[03] 스프링 부트에서 JPA로 데이터베이스 다루기(04)

04. 등록/수정/조회 API 만들기


API를 만들기 위해 필요한 클래스 3가지

  • request 데이터를 받을 Dto
  • API 요청을 받을 Controller
  • 트랜잭션, 도메인 기능 간의 순서를 보장하는 service

4-1) 등록 기능 구현 : PostsApiController, PostsSaveRequestDto, PostsService 클래스 생성

  • PostsApiController는 web 패키지에,
  • PostsSaveRequestDto는 web.dto 패키지에,
  • PostsService는 service.posts 패지키에 생성

PostsApiController 소스 코드

import com.cutehuman.springboot.service.posts.PostsService;
import com.cutehuman.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor //선언된 모든 final 필드가 포함된 생성자 생성
@RestController //컨트롤러가 JSON을 반환하도록 
public class PostsApiController {
    private final PostsService postsService;

    @PostMapping("/api/v1/posts") //POST 요청을 받을 수 있는 API 만들어 줌
    public Long save(@RequestBody PostsSaveRequestDto requestDto){
        return postsService.save(requestDto);
    }
}

PostsSaveRequestDto 소스 코드

import com.cutehuman.springboot.domain.posts.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter //선언된 모든 필드의 get 메소드 생성
@NoArgsConstructor //기본 생성자 자동 추가
public class PostsSaveRequestDto {
    private String title;
    private String content;
    private String author;
    
    @Builder //해당 클래스의 빌더 패턴 클래스 생성
    //생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함
    public PostsSaveRequestDto(String title, String content, String author){
        this.title = title;
        this.content = content;
        this.author = author;
    }

    public Posts toEntity(){
        return Posts.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();
    }
}

PostsService 소스 코드

import com.cutehuman.springboot.domain.posts.PostsRepository;
import com.cutehuman.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor //선언된 모든 final 필드가 포함된 생성자 생성
@Service 
public class PostsService {
    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto){
        return postsRepository.save(requestDto.toEntity()).getId();
    }
}

스프링에서 Bean 객체 받는 방식

  • @Autowired
  • setter
  • 생성자

이 중 가장 권장하는 방식은 생성자로 주입받는 방식

주의

  • 절대로 Entity 클래스를 Request/Response 클래스로 사용x
  • Request/Response용으로는 Dto 클래스를 따로 생성해서 사용하도록 함
    => DB 레이어와 View 레이어의 역할 분리를 철저하게 할 것

4-2) 등록 기능 테스트 코드로 검증하기

1. web패키지에 PostsApiControllerTest 생성

import com.cutehuman.springboot.domain.posts.Posts;
import com.cutehuman.springboot.domain.posts.PostsRepository;
import com.cutehuman.springboot.web.dto.PostsSaveRequestDto;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @After //메소드 실행 후 Advice 실행
    public void tearDown() throws Exception{
        postsRepository.deleteAll();
    }

    @Test
    public void Posts_등록된다() throws Exception{
        //given
        String title = "title";
        String content = "content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts";

        //when
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        //then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

}
  • API를 테스트하는데 @WebMvcTest를 사용하지 않은 이유
    : @WebMvcTest는 JPA 기능이 작동하지 않기 때문.
    (Controller, ControllerAdvice 등 외부 연동과 관련된 부분만 활성화)
  • JPA 기능까지 한번에 테스트 할 때는 @SpringBootTestTestRestTemplate을 사용함

2. 테스트 수행

  • WebEnvironment.RANDOM_PORT로 인한 랜덤포트 실행
  • insert 쿼리 실행

4-3) 수정/조회 기능 구현

PostsApiController

@RequiredArgsConstructor
@RestController
public class PostsApiController {
   
   ...

    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto){
        return postsService.update(id, requestDto);
    }

    @GetMapping("/api/v1/posts/{id}")
    public PostsResponseDto findById(@PathVariable Long id){
        return postsService.findById(id);
    }
}

PostsResponseDto
web.dto 패키지에 생성

import com.cutehuman.springboot.domain.posts.Posts;
import lombok.Getter;

@Getter
public class PostsResponseDto {
   private Long id;
   private String title;
   private String content;
   private String author;

   // Entity의 필드 중 일부만 사용하므로
   // 생성자로 Entity를 받아 필드에 값을 넣음
   public PostsResponseDto(Posts entity){
       this.id = entity.getId();
       this.title = entity.getTitle();
       this.content = entity.getContent();
       this.author = entity.getAuthor();
   }
}

PostsUpdateRequestDto
web.dto 패키지에 생성

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
   private String title;
   private String content;

   @Builder
   public PostsUpdateRequestDto(String title, String content){
       this.title = title;
       this.content = content;
   }
}

Posts

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

PostsService

@RequiredArgsConstructor
@Service
public class PostsService {

    ...
    
    @Transactional
    public Long update(Long id, PostsUpdateRequestDto requestDto){
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

        posts.update(requestDto.getTitle(), requestDto.getContent());

        return id;
    }

    public PostsResponseDto findById (Long id){
        Posts entity = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));

        return new PostsResponseDto(entity);
    }
}

update 기능에서 DB에 쿼리를 날리는 부분이 없음
: JPA의 영속성 컨텍스트 때문

영속성 컨텍스트는 엔티티를 영구 저장하는 환경으로, 일종의 논리적 개념. 엔티티가 영속성 컨텍스트에 포함되어 있는지 여부는 JPA에서 핵심 내용.

JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 DB에서 데이터를 가져오면, 이 데이터는 영속성 컨텍스트가 유지된 상태.

이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분 반영함
-> 엔티티 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요 X ==> 더티체킹의 개념


4-4) 수정 기능 테스트 코드로 검증하기

1. PostsApiControllerTest에 코드 작성

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

  @Test
      public void Posts_수정된다() throws Exception{
          //given
          Posts savedPosts = postsRepository.save(Posts.builder()
                  .title("title")
                  .content("content")
                  .author("author")
                  .build());

          Long updateId = savedPosts.getId();
          String expectedTitle = "title2";
          String expectedContent = "content2";

          PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                  .title(expectedTitle)
                  .content(expectedContent)
                  .build();

          String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
          HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);

          //when
          ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);

          //then
          assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
          assertThat(responseEntity.getBody()).isGreaterThan(0L);
          List<Posts> all = postsRepository.findAll();
          assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
          assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }

}

2. 테스트 수행
update 쿼리가 수행되는 것을 확인할 수 있음


4-5) 조회 기능 톰캣으로 확인하기

로컬 환경에서는 DB로 H2를 사용함. 메모리에 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야 함

1. 웹 콘솔 활성화
application.propertis에 옵션 추가

spring.h2.console.enabled=true

2. Application 클래스의 main 메소드 실행

  • 정상적으로 실행됐다면 톰캣이 8080 포트로 실행됨


  • 웹 브라우저에 http://localhost:8080/h2-console 로 접속: JDBC URL을 사진과 같이 작성해야 함


  • connect 버튼 클릭 > 현재 프로젝트의 H2를 관리할 수 있는 관리 페이지로 이동함(다음과 같이 POSTS 테이블이 정상적으로 노출되어야 함)


  • 간단한 쿼리 실행
SELECT * FROM posts;



  • 현재는 등록된 데이터가 없으므로 insert 쿼리 실행
insert into posts (author, content, title) values ('author', 'content', 'title');



좋은 웹페이지 즐겨찾기