[Spring Boot] 6. 블로그 기획하고 API 만들기

김민경·2024년 7월 6일
post-thumbnail

'스프링 부트3 백엔드 개발자 되기' 책을 참고하며 작성 중 입니다.


프로젝트 기획


사전 지식

API?

API는 프로그램 간에 상호작용하기 위한 매개체

클라이언트의 요청을 서버에 절 전달하고, 서버에 결과물을 클라이언트에게 잘 돌려주는 역할 담당

REST API?

웹의 장점을 최대한 활용하는 API
REpresentational State Transfer 을 줄인 표현으로
자원을 이름으로 구분해 자원의 상태를 주고받는 API 방식

😣 너무 어려운 정의라면..

쉽게 말해서 그냥 명확하고 이해하기 쉬운 API로,
URL의 설계 방식을 말한다.

특징

  1. 서버 / 클라이언트 구조
  2. 무상태
  3. 캐시 처리 가능
  4. 계층화
  5. 인터페이스 일관성

장단점

장점

URL만 보고도 무슨 행동을 하는 API인지 명확하게 알 수 있음!
무상태 특성으로 클라이언트와 서버의 역할이 명확하게 분리
HTTP 표준을 사용하는 모든 플랫폼에서 사용 가능

단점

HTTP 메서드 방식에 개수 제한이 있고, 표준 규약이 없다

하지만 REST API는
주소와 메서드만 보고 요청의 내용을 파악할 수 있다는 강력한 장점이 있어 많은 개발자가 사용 중

사용하는 방법

규칙 1. URL에는 동사 X, 자원을 표시해야 함

/articles/1
동사 없음, 1번 글을 가져온다는 의미가 명확, 적합

/articles/show/1
/show/articles/1
동사가 있음 (show), 부적합

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

HTTP 메서드

POST : Create (추가)
GET : Read (조회)
PUT : Update (수정)
DELETE : Delete (삭제)
-> CRUD , 크루드


Entity 구성하기

테이블 구조

칼럼명자료형null 허용설명
idBIGINTN기본키일련번호, 기본키
titleVARCHAR(255)N게시물의 제목
contentVARCHAR(255)N내용

Article

@Entity //엔티티로 지정
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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 Article() {
//
//    }
-> @NoArgsConstructor

//    public Long getId() {
//        return id;
//    }
//
//    public String getTitle() {
//        return title;
//    }
//
//    public String getContent() {
//        return content;
//    }
-> @Getter

}

@Builder : lombok에서 지원하는 애너테이션
생성자 위에 입력하여 빌더 패턴 방식으로 객체 생성
@Getter : getter 메서드 생성
@NoArgsConstructor : 기본 생성자 생성

lombok의 애너테이션을 사용하여 코드의 반복을 줄여 가독성 향상

빌더 패던 ?
어느 필드에 어떤 값이 들어가는지 명식적으로 파악할 수 있어
객체를 유연하고 직관적으로 생성할 수 있음
가독성 UP

//빌더 패턴 X : 어떤 것이 title? content?
new Article("abc", "def");

//빌더 패턴 O : title, content 명시적으로 파악 가능
Article.builder()
	.title("abc")
	.content("def")
	.build();

Repository 생성

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

JpaRepository를 상속받을 때 Entity (Article)과 Entity의 PK 타입 (Long)을 인수로 넣는다.

API 구현

Service 메서드 구현

DTO?

Data Transfer Object
계층끼리 데이터를 교환하기 위해 사용하는 객체

DAO는 데이터베이스와 연결, 데이터 조회, 수정 하는데 사용하는 객체
DTO는 단순하게 데이터를 옮기기 위해 사용하는 전달자 역할을 하는 객체

서비스 계층에서 요청을 받을 객체인 AddArticleRequest 구현

AddArticleRequest

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

  private String title;
  private String content;

  //생성자를 이용해 객체 생성
  public Article toEntity(){
      return Article.builder()
              .title(title)
              .content(content)
              .build();
  }
  //빌더 패턴을 사용해 DTO를 엔티티로 만들어주는 메서드
}

BlogService

@RequiredArgsConstructor //Bean을 생성자로 생성
// final이 붙거나 @NotNull 이 붙은 필드의 생성자 추가
@Service //해당 클래스를 Bean으로 서블릿 컨테이너에 등록
public class BlogService {
    private final BlogRepository blogRepository;

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

@RequiredArgsConstructor : Bean을 생성자로 생성하는 lombok 지원 애너테이션
final 키워드나 @NotNull이 붙은 필드로 생성자 생성
@Service : 클래스를 빈으로 서블릿 컨테이너에 등록
save() : JpaRepository에서 지원하는 저장 메서드,]
AddArticleRequest 클래스에 저장된 값들을 article 데이터베이스에 저장

Controller

URL Mapping 애너테이션 tkdyd

@GetMapping, @PostMapping, @PutMapping, @DeleteMapping
@RequiredArgsConstructor
@RestController //HTTP 응답으로 객체 데이터를 JSON 형식으로 반환
public class BlogApiController {
  private final BlogService blogService;

  @PostMapping("/api/articles") //HTTP 메서드가 POST 일 때 전달받은 URL과 동일하면 메서드로 매핑
  public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request){ //@ResponseBody로 요청 본문 값 매핑
      Article savedArticle = blogService.save(request);
      	return ResponseEntity.status(HttpStatus.CREATED) //응답코드로 201, Created 응답
              .body(savedArticle);
  }
}

@RestController : HTTP 응답으로 객체 데이터를 JSON 형식으로 반환
@PostMapping : HTTP 메서드가 POST일 때 요청받은 URL과 동일한 메서드로 매핑
@RequestBody : HTTP를 요청할 때 응답에 해당하는 값을 AddArticleRequest에 매핑
ResponseEntity.status().body() : 응답코드로 201, Created 응답하고 테이블에 저장된 객체 반환

알아놓으면 좋은 응답 코드

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

API 실행 테스트

application.yml

spring:
#  sql:
#    init:
#      mode: never
    #data.sql 실행 X
jpa:
  show-sql: true
  properties:
    hibernate:
      format_sql: true
  defer-datasource-initialization: true
datasource:
  url : jdbc:h2:mem:testdb #데이터베이스 연결
h2:
  console:
    enabled: true

Postman 테스트

HTTP 메서드 : POST
URL : http://localhost:8080/api/articles
ContentType : APPLICATION_JSON

request

{
  "title" : "제목",
  "content" : "내용"
}

response

{
  "id": 1,
  "title": "제목",
  "content": "내용"
}

DataBase 확인

Test Code

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

  @Autowired
  protected ObjectMapper objectMapper;
  //자바 객체를 JSON 데이터로 변환하는 직렬화 , JSON 데이터를 자바 객체로 변환하는 역직렬화를 위한 클래스

  @Autowired
  private WebApplicationContext context;

  @Autowired
  BlogRepository blogRepository;

  @BeforeEach
  public void mockMvcSetUp(){
      this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
              .build();
      blogRepository.deleteAll();
  }

  /*
  Given : 블로그 글 추가에 필요한 요청 객체를 만든다.
  When : 블로그 글 추가 API에 요청을 보낸다. 요청 타입 : JSON / given에서 만들어 둔 객체를 요청 본문에 함께 보낸다.
  Then : 요청 코드가 201 Created 인지 확인, Blog를 전체 조회하여 크기가 1인지 확인, 실제로 저장된 데이터와 요청 값 비교
   */

  @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)
              .content(requestBody));

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

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

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

조회, 삭제, 수정까지

Service

@RequiredArgsConstructor //Bean을 생성자로 생성
// final이 붙거나 @NotNull 이 붙은 필드의 생성자 추가
@Service //해당 클래스를 Bean으로 서블릿 컨테이너에 등록
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 응답으로 객체 데이터를 JSON 형식으로 반환
public class BlogApiController {
    private final BlogService blogService;

    @PostMapping("/api/articles") //HTTP 메서드가 POST 일 때 전달받은 URL과 동일하면 메서드로 매핑
    public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request){ //@ResponseBody로 요청 본문 값 매핑
        Article savedArticle = blogService.save(request);
        return ResponseEntity.status(HttpStatus.CREATED) //응답코드로 201, 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}")
    public ResponseEntity<ArticleResponse> findArticle(@PathVariable long id){ //@PathvVariable URL에서 값을 가져오는 애너테이션
        Article article = blogService.findById(id);

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

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

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

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

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

🌟Transational?

트랜잭션은 데이터베이스의 데이터를 바꾸기 위해 묶은 작업의 단

ex) 계좌 이체
1. A 계좌에서 출금
2. B 계좌에 입금
하지만 이 과정에서 오류 -> 출금은 됐는데 입금이 안됐다?!

따라서 출금과 입금을 하나의 작업 단위로 묶어서, 즉 트랜잭션으로 묶어서 두 작업을 한 단위로 실행
중간에 실패한다면 트랜잭션의 처음 상태로 모두 되돌린다.

DTO

ArticleResponse

응답을 위한 DTO

@Getter
public class ArticleResponse {
  private final String title;
  private final String content;

  public ArticleResponse(Article article){
      this.title = article.getTitle();
      this.content = article.getContent();
  }
}

UpdateArticleRequest

Update 요청을 받을 DTO

@AllArgsConstructor
@NoArgsConstructor
@Getter
public class UpdateArticleRequest {
  private String title;
  private String content;
}

ControllerTest

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

  @Autowired
  protected ObjectMapper objectMapper;
  //자바 객체를 JSON 데이터로 변환하는 직렬화 , JSON 데이터를 자바 객체로 변환하는 역직렬화를 위한 클래스

  @Autowired
  private WebApplicationContext context;

  @Autowired
  BlogRepository blogRepository;

  @BeforeEach
  public void mockMvcSetUp(){
      this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
              .build();
      blogRepository.deleteAll();
  }

  /*
  Given : 블로그 글 추가에 필요한 요청 객체를 만든다.
  When : 블로그 글 추가 API에 요청을 보낸다. 요청 타입 : JSON / given에서 만들어 둔 객체를 요청 본문에 함께 보낸다.
  Then : 요청 코드가 201 Created 인지 확인, Blog를 전체 조회하여 크기가 1인지 확인, 실제로 저장된 데이터와 요청 값 비교
   */

  @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)
              .content(requestBody));

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

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

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

  /*
  Given : 블로그 글을 저장
  When : 목록 조회 API를 호출
  Then : 응답코드가 200 OK? 반환받은 값 중에 0번째 요소의 content 와 title이 저장된 값과 같은지 확인
   */

  @DisplayName("findAllArticles : 블로그 글 목록 조회에 성공한다.")
  @Test
  public void findAllArticles() 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
      ResultActions result = mockMvc.perform(get(url)
              .accept(MediaType.APPLICATION_JSON));

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

  /*
  Given : 블로그 글 저장
  When : 저장한 블로그 글의 id 값으로 API 호출
  Then : 응답코드가 200 Ok, 반환받은 content와 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 savedArticle = blogRepository.save(Article.builder()
              .title(title)
              .content(content)
              .build());

      //when
      final ResultActions result = mockMvc.perform(get(url, savedArticle.getId()));

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

  /*
  Given : 블로그 글 저장
  When : 저장한 블로그 글의 id 값으로 삭제 API 호출
  Then : 응답 코드 200 OK, 블로그 글 리스트를 전체 조회해 조회한 배열 크기가 0인지 확인
   */
  @DisplayName("deleteArticle : 블로그 글 삭제에 성공한다.")
  @Test
  public void deleteArticle() throws Exception{
      //given
      final String url = "/api/articles/{id}";
      final String title = "title";
      final String content = "content";

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

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

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

      assertThat(articles.size()).isZero();
      assertThat(articles).isEmpty();
      //같은 동작
  }

  /*
  Given : 블로그 글 저장
  When : UPDATE API로 수정 요청을 보냄, 요청 타입 : JSON, given에서 만들어둔 객체를 요청 본문으로 함께 보냄
  Then : 200 OK , id로 조회한 후에 값이 수정되었는 지 확인
   */
  @DisplayName("updateArticle : 블로그 글 수정에 성공한다.")
  @Test
  public void updateArticle() throws Exception{
      //given
      final String url = "/api/articles/{id}";
      final String title = "title";
      final String content = "content";

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

      final String newTitle = "newTitle";
      final String newContent = "newContent";

      UpdateArticleRequest request = new UpdateArticleRequest(newTitle, newContent);

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

      //then
      result.andExpect(status().isOk());
      Article article = blogRepository.findById(savedArticle.getId()).get();

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

}

마무리

CRUD를 작성하는 방법과 테스트 코드를 작성하는 방법을 알아보았습니다.

REST API, JpaRepository, Lombok, Test Code
profile
뭐든 기록할 수 있도록

0개의 댓글