본문 바로가기

Spring공부

스프링 부트3로 블로그 만들기

REST API

REST API는 웹의 장점을 최대한 활용하는 API이다. 쉽게 말해 명확하고 이해하기 쉬운 API를 말한다. RESR API는 URL의 설계 방식을 말한다. 

 

REST API의 특징

REST API는 서버/클라이언트 구조, 무상태, 캐시 처리기능, 계층화, 인터페이스 일관성과 같은 특징이 있다.

 

REST API의 장점과 단점

REST API의 장점은 URL만 보고도 무슨 행동을 하는 API인지 명확하게 알 수 있다는 것이다. 그리고 상태가 없다는 특징이 있어서 클라이언트와 서버의 역할이 명확하게 분리된다. HTTP 표준을 사용하는 모든 플랫폼에서 사용 할 수 있다. 단점으로는 HTTP 메서드, 즉 GET, POST와 같은 방식의 개수에 제한이 있고, 설계를 하기 위해 공식적으로 제공되는 표준 규약이 없다는 것이다. 그럼에도 REST API는 주소와 메서드만 보고 요청의 내용을 파악할 수 있다는 강력한 장점이 있어서 개발자들이 많이 사용한다. 심지어 'REST 하게 디자인한 API'를 RESTful API라 부른다.

 

규칙 1. URL에는 동사를 쓰지 말고, 자원을 표시해야 한다.

예문 적합성 설명
/article/1 적합 동사 없음, 1번 글을 가져온다ㅏ는 의미가 명확, 적합
/article/show/1
/show/article/1
부적합 show라는 동사가 있음, 부적합

 

규칙 2. 동사는 HTTP 메서드로

설명 적합한 HTTP 메서드와 URL
id가 1인 블로그 글을 조회하는 API GET/articles/1
블로그 글을 추가하는 API POST/article
블로그 글을 수정한느 API PUT/article
블로그 글을 삭제하는 API DELETE/article/1

 

 

빌더 패턴의 장점

  1. 가독성 향상:
    • 복잡한 객체를 생성할 때, 어떤 값이 어떤 필드에 해당하는지 명확히 알 수 있어 가독성이 높아집니다.
    • 예: Article article = Article.builder().title("제목").content("내용").build();
  2. 불변성 (Immutability):
    • 객체를 불변으로 만들 수 있습니다. 빌더 패턴을 사용하면 객체 생성 후에 필드를 변경할 수 없게 설계할 수 있습니다.
  3. 유연한 객체 생성:
    • 필수 필드와 선택 필드를 쉽게 구분할 수 있습니다. 필수 필드는 빌더 메서드로 강제하고, 선택 필드는 필요에 따라 추가할 수 있습니다.
  4. 안전한 객체 생성:
    • 빌더 패턴을 사용하면 생성자에 전달되는 매개변수의 순서나 타입에 대한 오류를 방지할 수 있습니다.
  5. 메서드 체이닝:
    • 메서드 체이닝을 통해 객체 생성 시 일관된 형태로 코드를 작성할 수 있습니다.

@Getter의 장점

  1. 코드 간소화:
    • 각 필드에 대한 getter 메서드를 자동으로 생성하여 코드 중복을 줄여줍니다.
  2. 가독성:
    • 코드가 간결해져 클래스의 의도가 명확히 드러납니다. 코드를 읽는 사람이 핵심 로직에 집중할 수 있습니다.
  3. 유지보수 용이성:
    • Lombok이 자동으로 생성해주는 메서드를 사용하여 수동으로 작성하는 실수를 줄이고, 유지보수를 더 쉽게 할 수 있습니다.

@NoArgsConstructor의 장점

  1. 간편한 객체 생성:
    • 매개변수가 없는 기본 생성자를 자동으로 생성하여, JPA와 같은 프레임워크에서 프록시 객체를 생성하거나 리플렉션을 통해 객체를 생성할 때 유용합니다.
  2. 호환성:
    • JPA 엔티티나 스프링 빈과 같은 일부 프레임워크에서는 기본 생성자가 필요합니다. @NoArgsConstructor를 사용하면 이러한 요구사항을 쉽게 충족할 수 있습니다.
  3. 테스트 편의성:
    • 테스트 코드에서 객체를 쉽게 생성하여 테스트할 수 있습니다. 기본 생성자를 사용해 기본값으로 객체를 생성한 후, 필요한 값만 setter로 설정할 수 있습니다.

엔티티 작성

@Entity //엔티티로 지정
@Getter
@NoArgsConstructor
public class Article {
    @Id //id 필드를 기본키로 지정
    @GeneratedValue(strategy = GenerationType.IDENTITY) //기본키를 자동으로 1씩 증가
    @Column(name = "id", updatable = false)
    private Long id;
    
    @Column(name = "title" , nullable = false) //'title' 이라는 not null 컬럼과 매핑
    private String title;
    
    @Column(name = "content" , nullable = false)
    private String content;
    
    @Builder //빌더 패턴으로 객체 생성
    public Article(String title, String content){
        this.title=title;
        this.content=content;
    }

    public void update(String title, String content){
        this.title=title;
        this.content=content;
    }
    
}

 

리포지터리 작성

public interface BlogRepository extends JpaRepository<Article, Long> {
}

 

dto작성

@NoArgsConstructor//기본 생성자 추가
@Getter
@AllArgsConstructor//모든 필드 값을 파라미터로 받는 생성자 추가
public class AddArticleRequest {

    private String title;
    private String content;

    public Article toEntity(){ // 생성자를 사용해 객체 생성
        return Article.builder()
                .title(title)
                .content(content)
                .build();
         }
}
@Getter
public class ArticleResponse {
    private final String title;
    private final String content;

    public ArticleResponse(Article article){
        this.title=article.getTitle();
        this.content=article.getContent();
    }
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UpdateArticleRequest {
    private String title;
    private String content;
}

 

서비스 작성

@RequiredArgsConstructor // final이 붙거나 @Not Null이 붙은 필드의 생성자 추가
@Service // 빈으로 둥록
public class BlogService {

    private final BlogRepository blogRepository;

    //블로그 글 추가 메서드
    public Article save(AddArticleRequest request){
        return blogRepository.save(request.toEntity());
    }

    public List<Article> findAll(){
        return blogRepository.findAll();
    }

    public Article findById(long id){
        return blogRepository.findById(id).orElseThrow(()->new IllegalArgumentException("not found: " + id));
    }

    public void delete(long id){
        blogRepository.deleteById(id);
    }

    @Transactional //트렌젝션 메서드
    public Article update(long id, UpdateArticleRequest request){
        Article article = blogRepository.findById(id).orElseThrow(()->new IllegalArgumentException("not found: " + id));

        article.update(request.getTitle(),request.getContent());

        return article;
    }
}

 

controller 작성

@RequiredArgsConstructor
@RestController // HTTP Response Body에 객체 데이터를 JSON형식으로 반환하는 컨트롤러
public class BlogApiController {

    private final BlogService blogService;

    //HTTP 메서드가 POST일 때 전달받은 URL과 동일하면 메서드로 매핑
    @PostMapping("/api/articles")
    //@RequestBody로 요청 본문 값과 매핑
    public ResponseEntity<Article> addArticle(
            @RequestBody AddArticleRequest request){
        Article savedArticle = blogService.save(request);

        //요청한 자원이 성공적응로 생성되었으며 저장된 블로그 글 정보를 응답 객체에 담아 전송
        return ResponseEntity.status(HttpStatus.CREATED).body(savedArticle);
    }

    @GetMapping("/api/articles")
    public ResponseEntity<List<ArticleResponse>> findAllArticles(){
        List<ArticleResponse>articles = blogService.findAll()
                .stream()
                .map(ArticleResponse::new)
                .toList();

        return ResponseEntity.ok().body(articles);
    }

    @GetMapping("/api/articles/{id}")
    //URL 경로에서 값 추출
    public ResponseEntity<ArticleResponse> findArticles(@PathVariable long id){
        Article article = blogService.findById(id);

        return ResponseEntity.ok().body(new ArticleResponse(article));
    }

    @DeleteMapping("/api/articles/{id}")
    public ResponseEntity<Void> delete(@PathVariable long id){
        blogService.delete(id);

        return ResponseEntity.ok().build();
    }

    @PutMapping("/api/articles/{id}")
    public ResponseEntity<Article> update(@PathVariable long id,
                                          @RequestBody UpdateArticleRequest request){

        Article updateArticle = blogService.update(id, request);

        return ResponseEntity.ok().body(updateArticle);
    }
}

 

 

꼭 알아두면 좋은 응답 코드

 

200 OK 요청이 성공적으로 수행되었음
201 Created 요청이 성곡적으로 수행되었고, 새로운 리소스가 생성되었음
400 Bad Request 요청 값이 잘못되어 요청에 실패했음
403 Forbidden 권한이 없어 요청에 실패했음
404 Not Found 요청 값으로 찾은 리소스가 없어 요청에 실패했음
500 Internal Server Error 서버 상에 문제가 있어 요청에 실패했음

 

테스트코드 작성

@SpringBootTest //테스트용 애플리케이션 컨텍스트
@AutoConfigureMockMvc //MockMvc 생성 및 자동 구성
class BlogApiControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper; // 직렬화, 역직렬화를 위한 클래스

    @Autowired
    private WebApplicationContext context;

    @Autowired
    BlogRepository blogRepository;

    @BeforeEach //테스트 실행 전 실행하는 메서드
    public void mockMvcSetUp(){
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
        blogRepository.deleteAll();;
    }

    @DisplayName("addArticle: 블로그 글 추가에 성공한다. ")
    @Test
    public void addArticle() throws Exception{
        //given
        final String url = "/api/articles";
        final String title = "title";
        final String content = "content";
        final AddArticleRequest userRequest = new AddArticleRequest(title,content);

        //객체 JSON으로 직렬화
        final String requestBody = objectMapper.writeValueAsString(userRequest);

        //when
        //설정한 내용을 바탕으로 요청 전송
        ResultActions result = mockMvc.perform(post(url).contentType(MediaType.APPLICATION_JSON_VALUE).content(requestBody));

        //then
        result.andExpect(status().isCreated());

        List<Article> articles = blogRepository.findAll();

        assertThat(articles.size()).isEqualTo(1);
        assertThat(articles.get(0).getTitle()).isEqualTo(title);
        assertThat(articles.get(0).getContent()).isEqualTo(content);
    }

    @DisplayName("findAllArticles : 블로그 글 목록 조회에 성공한다.")
    @Test
    public void findAllArticle() throws Exception{
        //given
        final String url = "/api/articles";
        final String title = "title";
        final String content = "content";

        blogRepository.save(Article.builder()
                .title(title)
                .content(content)
                .build());

        //when
        final ResultActions resultActions = mockMvc.perform(get(url).accept(MediaType.APPLICATION_JSON));

        //then
        resultActions.andExpect(status().isOk())
                .andExpect(jsonPath("$[0].content").value(content))
                .andExpect(jsonPath("$[0].title").value(title));
    }

    @DisplayName("findArticle : 블로그 글 조회 성공")
    @Test
    public void findArticle() throws Exception{
        //given
        final String url = "/api/articles/{id}";
        final String title = "title";
        final String content = "content";

        Article saveArticle = blogRepository.save(Article.builder()
                .content(content)
                .title(title)
                .build());

        //when
        final ResultActions resultActions = mockMvc.perform(get(url,saveArticle.getId()).accept(MediaType.APPLICATION_JSON));

        //then
        resultActions.andExpect(status().isOk())
                .andExpect(jsonPath("$.content").value(content))
                .andExpect(jsonPath("$.title").value(title));
    }

    @DisplayName("deleteArticle : 블로그 글 삭제에 성공한다.")
    @Test
    public void deleteArticle() throws Exception{
        //given
        final String url = "/api/articles/{id}";
        final String title = "title";
        final String content = "content";

        Article saveArticle = blogRepository.save(Article.builder()
                .title(title)
                .content(content)
                .build());

        //when
        mockMvc.perform(delete(url,saveArticle.getId())).andExpect(status().isOk());

        //then
        List<Article> articles =blogRepository.findAll();

        assertThat(articles).isEmpty();

    }

    @DisplayName("updateArticle : 블로그 글 수정에 성공한다.")
    @Test
    public void updateArticle()throws Exception{
        //given
        final String url ="/api/articles/{id}";
        final String title = "title";
        final String content ="content";

        Article saveArticle = blogRepository.save(Article.builder()
                .content(content)
                .title(title)
                .build());

        final String newTitle = "new title";
        final String newContent = "new Content";

        UpdateArticleRequest request = new UpdateArticleRequest(newTitle,newContent);

        //when
        ResultActions result = mockMvc.perform(put(url,saveArticle.getId())
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(objectMapper.writeValueAsString(request)));

        //then
        result.andExpect(status().isOk());

        Article article = blogRepository.findById(saveArticle.getId()).get();

        assertThat(article.getTitle()).isEqualTo(newTitle);
        assertThat(article.getContent()).isEqualTo(newContent);


    }

}
  1. 직렬화 (Serialization): 자바 객체를 JSON 형식의 문자열로 변환하여 파일이나 네트워크로 전송하기 쉽게 합니다.
  2. 역직렬화 (Deserialization): JSON 형식의 문자열을 다시 자바 객체로 변환하여 사용합니다.
  3. 목적: 객체 상태를 저장하거나 전송하고, 이를 다시 원래 객체로 복원하는 데 사용됩니다.
given 블로그 글 추가에 필요한 요청 객체를 만든다. 
when 블로그 글 추가 API에 요청을 보낸다. 이때 요청 타입은 JSON이며, given절에서 미리 만들어둔 객체를 요청 본문으로 함꼐 보낸다.
then 응답 코드가 201 Created인지 확인 한다. Blog를 전체 조회해 크기가 1인지 확인하고, 실제로 저장된 데이터와 요청 값을 비교한다.

 

 

코드 성명
assertThat(articles.size()).isEqualTo(1); 블로그 글 크기가 1이어야 한다.
assertThat(articles.size()).isgreaterThan(2); 블로그 글 크기가 2보다 커야 한다.
assertThat(articles.size()).isLessThan(5); 블로그 글 크기가 5보다 작아야 한다.
assertThat(articles.size()).isZero(); 블로그 글 크기가 0이어야 한다.
assertThat(article.title()).isEqualTo("제목") 블로그 글의 title값이 "제목"이어야 한다.
assertThat(article.title()).isNotEmpty(); 블로그 글의 title값이 비어 있지 않아야 한다.
assertThat(article.title()).contains("제"); 블로그 글의 title값이 "제"를 포함해야 한다.

 

'Spring공부' 카테고리의 다른 글

블로그의 인증, 인가 쿠키&세션  (0) 2024.05.23
240520_TIL  (0) 2024.05.20
데이터베이스  (0) 2024.05.17
테스트 코드  (0) 2024.05.16
스프링 부트 구조 살펴보기  (0) 2024.05.14