BOOK 2 - 스프링부트와 AWS로 혼자 구현하는 웹 서비스(3)
스프링부트와 AWS로 혼자 구현하는 웹 서비스 - 3
등록 / 수정 / 조회 API 생성
Spring 각 Layer의 역할

- 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와 동일한 효과를 볼 수 있다. -
위에서는
@RequiredArgsConstructorannotation이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 기능까지 한번에 테스트할 때는@SpringBootTest와TestRestTemplate을 사용한다.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 쿼리가 자동으로 수행되는 것을 쿼리 로그에서 확인할 수 있다.
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 {
...
}