BOOK 2 - 스프링부트와 AWS로 혼자 구현하는 웹 서비스(3)

스프링부트와 AWS로 혼자 구현하는 웹 서비스 - 3

등록 / 수정 / 조회 API 생성

Spring 각 Layer의 역할

image

  • Web Layer
    • Controller와 JSP 등의 view template 영역
    • 이외에도 Filter, Intercepter, ControllerAdvice 등 외부 요청과 응답에 대한 전반적인 영역
  • Service Layer
    • @Service 에 사용되는 서비스 영역
    • 일반적으로 Controller와 Dao의 중간 영역에서 사용되며 @Transactional 이 사용되는 영역
    • 트랜잭션, 도메인 간 순서 보장의 역할 수행
  • Repository Layer
    • DB와 같은 데이터 저장소에 접근하는 영역
  • DTOs
    • DTO(Data Transfer Object) : 계층 간에 데이터 교환을 위한 객체
    • 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이에 속한다.
  • Domain Model
    • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것
    • @Entity 객체가 사용된 영역이나 VO 같은 값 객체들이 사용된 영역
    • 비즈니스 처리를 담당

Review 등록 API 생성

ReviewController 클래스

@RequiredArgsConstructor
@RestController
@RequestMapping("api/v1/reviews")
public class ReviewController {

    private final ReviewService reviewService;

    @PostMapping("")
    public ResponseEntity<Long> saveReview(@RequestBody ReviewRequestDto requestDto) {
        return new ResponseEntity<Long>(reviewService.save(requestDto), HttpStatus.OK);
    }
}

ReviewService 클래스

@RequiredArgsConstructor
@Service
public class ReviewService {

    private final ReviewRepository reviewRepository;

    @Transactional
    public Long save(ReviewRequestDto requestDto) {
        return reviewRepository.save(requestDto.toEntity()).getId();
    }
}

Spring의 Bean 주입 방식

  • @Autowired
  • Setter
  • Constructor

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

    • @Autowired 는 권장하지 않는다.

    • 생성자로 Bean 객체를 받도록 하면 @Autowired 와 동일한 효과를 볼 수 있다.

    • 위에서는 @RequiredArgsConstructor annotation이 final 로 선언된 모든 필드를 인자값으로 하는 생성자를 생성해주기 때문에 Bean을 주입받을 수 있다.

      • 생성자를 직접 쓰지 않고 annotation을 활용하는 이유는 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 수정할 필요가 없기 때문이다.

ReviewRequestDto 클래스

@Getter
@NoArgsConstructor
public class ReviewRequestDto {

    private String title;
    private String content;
    private Long memberId;

    @Builder
    public ReviewRequestDto(String title, String content, Long memberId) {
        this.title = title;
        this.content = content;
        this.memberId = memberId;
    }

    public Review toEntity() {
        return Review.builder()
          .title(title)
          .content(content)
          .memberId(memberId)
          .build();
    }
}
  • Entity 클래스와 거의 유사한 형태임에도 RequestDto를 따로 생성한다.
    • Entity 클래스를 Request / Response 클래스로 사용해서는 안된다.
    • DB 테이블과 연결되어 있는 핵심이 되는 Entity 클래스를 사소한 변경이 자주 일어나는 Request / Response 클래스로 사용했을 때 그 변경으로 인해 여러 비즈니스 로직에 영향을 끼치게 될 수 있다.
    • 즉, View Layer와 DB Layer의 역할 분리를 철저히 하는 것이 좋다.
  • @RequestBody 로 받는 Request 클래스는 Setter 나 Constructor가 있어야 한다.

    • Setter와 Constructor 둘 다 없는 경우 요청을 받았을 때 JSON 데이터를 해당 RequestDto로 매핑할 수 없어 오류가 발생한다.

ReviewControllerTest 클래스

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

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private ReviewRepository reviewRepository;

    @After
    public void cleanUp() throws Exception {
        reviewRepository.deleteAll();
    }

    @Test
    public void save_review() throws Exception {
        // given
        String title = "title";
        String content = "content";
        Long memberId = 1L;

        ReviewRequestDto requestDto = ReviewRequestDto.builder()
          .title(title)
          .content(content)
          .memberId(memberId)
          .build();

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

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

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

        List<Review> reviewList = reviewRepository.findAll();
        assertThat(reviewList.get(0).getTitle()).isEqualTo(title);
        assertThat(reviewList.get(0).getContent()).isEqualTo(content);
        assertThat(reviewList.get(0).getMemberId()).isEqualTo(memberId);
    }
}
  • @WebMvcTest 의 경우 JPA 기능이 작동하지 않기 때문에 JPA 기능까지 한번에 테스트할 때는 @SpringBootTestTestRestTemplate 을 사용한다.
    • WebEnvironment.RANDOM_PORT : 랜덤 포트 실행

Review 수정 / 조회 API 생성

ReviewController 클래스

public class ReviewController {
  ...
  
  @GetMapping("/{reviewId}")
  public ResponseEntity<ReviewResponseDto> findReviewById(@PathVariable Long reviewId) {
      return new ResponseEntity<ReviewResponseDto>(reviewService.findById(reviewId), HttpStatus.OK);
  }

  @PostMapping("/{reviewId}")
  public ResponseEntity<Long> updateReview(@PathVariable Long reviewId, @RequestBody ReviewRequestDto requestDto) {
      return new ResponseEntity<Long>(reviewService.update(reviewId, requestDto), HttpStatus.OK);
  }
}

ReviewService 클래스

public class ReviewService {
  ...
    
  @Transactional
  public Long update(Long reviewId, ReviewRequestDto requestDto) {
      Review review = reviewRepository.findById(reviewId)
        .orElseThrow(()-> new IllegalArgumentException("해당 리뷰가 없습니다. review Id = " + reviewId));

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

      return reviewId;
  }

  public ReviewResponseDto findById(Long reviewId) {
      Review review = reviewRepository.findById(reviewId)
        .orElseThrow(()-> new IllegalArgumentException("해당 리뷰가 없습니다. review Id = " + reviewId));

      return new ReviewResponseDto(review);
  }
}
  • update 메서드에서 Repository를 사용하여 update 쿼리를 수행하는 부분이 없다.
    • JPA의 영속성 컨텍스트 때문에 update 쿼리를 직접 수행하지 않고도 데이터를 업데이트 할 수 있다.
    • 영속성 컨텍스트 : Entity를 영구 저장하는 환경
    • JPA의 EntityManager가 활성화된 상태로 하나의 Transaction 안에서 DB에서 데이터를 조회하면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
      • 이 상태에서 해당 데이터의 값을 변경하면 Transaction이 끝나는 시점에 해당 테이블에 데이터 변경분을 반영한다.
      • 이러한 개념을 Dirty Checking 이라고 한다.

ReviewResponseDto 클래스

@Getter
public class ReviewResponseDto {

    private Long id;
    private String title;
    private String content;
    private Long memberId;

    public ReviewResponseDto(Review review) {
        this.id = review.getId();
        this.title = review.getTitle();
        this.content = review.getContent();
        this.memberId = review.getMemberId();
    }
}

ReviewControllerTest 클래스

public class ReviewControllerTest {
  ...
  
  @Test
  public void update_review() throws Exception {
      // given
      Review review = reviewRepository.save(Review.builder()
      .title("title")
      .content("content")
      .memberId(1L)
      .build());

      Long reviewId = review.getId();
      String updatedTitle = "UpdateTitle";
      String updatedContent = "UpdateContent";

      ReviewRequestDto requestDto = ReviewRequestDto.builder()
        .title(updatedTitle)
        .content(updatedContent)
        .build();

      String url = LOCALHOST_URL_PREFIX + port + "/api/v1/reviews/" + reviewId;

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

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

      List<Review> reviewList = reviewRepository.findAll();
      assertThat(reviewList.get(0).getTitle()).isEqualTo(updatedTitle);
      assertThat(reviewList.get(0).getContent()).isEqualTo(updatedContent);
  }
}
  • update_review 단위 테스트를 실행했을 때 다음과 같이 review id에 해당하는 데이터를 조회하는 select 쿼리 다음에 update 쿼리가 자동으로 수행되는 것을 쿼리 로그에서 확인할 수 있다.

    image

JPA Auditing 설정

  • 일반적으로 Entity에는 해당 데이터의 생성시간과 수정시간을 포함한다.
    • 각 Entity에서 데이터를 DB에 삽입하거나 갱신할 때마다 날짜 데이터를 수정하는 코드가 반복해서 들어가게 된다.
    • 이러한 코드 중복을 피하기 위해 JPA Auditing 기능을 사용한다.

BaseEntity 생성 및 JPA Auditing annotation 설정

BaseEntity 클래스

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime modifiedAt;
}
  • BaseEntity 클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 생성시간, 수정시간을 자동으로 관리하는 역할을 한다.
  • @MappedSuperClass : JPA Entity 클래스들이 BaseEntity 클래스를 상속할 경우 필드들도 column으로 인식하도록 설정
  • @EntityListeners(AuditingEntityListener.class) : 해당 클래스에 Auditing 기능을 포함
  • @CreatedDate : Entity가 생성되어 저장될 때 시간이 자동 저장된다.
  • @LastModifiedDate : 조회한 Entity의 값을 변경할 때 시간이 자동 저장된다.

Review Entity 클래스의 상속 지정

public class Review extends BaseEntity {
  ...
}

Application 클래스에 JPA Auditing annotation 활성화 설정

@EnableJpaAuditing   // JPA Auditing 활성화
@SpringBootApplication
public class AllReviewApplication {
  ...
}