지난번에 포스팅했던 Posts 엔티티 클래스와 JpaRepository를 이용하여 사용자가 게시글을 등록할 수 있도록 API를 만들어보자.
우선 API를 만들기 전에 API 클래스는 어떻게 구성할 수 있는지, 사용자가 API를 통해 DB에 데이터를 넣을 때 어떤 흐름으로 데이터가 이동하는지 살펴보자.
마지막으로 테스트 코드를 작성하여 API가 잘 동작하는지 확인해보자.
API를 만들기 위해 총 3개의 클래스가 필요하다.
spring 웹 계층의 영역을 간단히 소개하자면 다음과 같다.
그림[1] Spring 웹 계층
Web Layer
Service Layer
Repository Layer
Dtos
Domain Model
💡 VO (Value Object) 란?
- 변하지 않는 값을 저장하기 위해 사용하는 오브젝트
- 예를 들어, Color 클래스로 Red를 표현 하기 위해서는 Color.RED등 과 같이 값을 표현하기 위해 getter 기능만이 존재한다.
- VO vs DTO 비교 : https://mommoo.tistory.com/61
그림[2] 스프링 계층 간 데이터 흐름도
계층(Layer)는 3가지 계층인 Web Layer(Controller), Service Layer, Repository Layer 로 구분할 수 있고, 계층 간 통신하는 데이터 단위는 DTO, Domain 이 있다.
DTO는 View Layer(화면 단)을 위한 클래스이며, 도메인(Domain)은 DB Layer(데이터베이스)와 직접 연관되어 있는 클래스이다. DTO와 Domain을 구분하는 것이 궁금할 수도 있다. 뒤에서 자세히 설명하겠다.
클라이언트(웹에서는 브라우저)의 요청을 컨트롤러가 받아 서비스로 넘긴다. 서비스 계층에서는 정의된 순서대로 트랜젝션이나 도메인을 실행한다. 데이터에 접근(데이터의 CRUD가 필요할때)할 때는 리포지토리를 통해 접근하여, 데이터 처리는 도메인(= 엔티티 클래스)에서 처리한다. 도메인에서 처리된 데이터는 최종적으로 데이터베이스에 반영된다.
Web(Controller), Service, Repository, Dto, Domain, 이 5가지 레이어에서 비즈니스 처리를 담당해야 할 곳은 Domain 영역이다.
많은 사람들은 Service 영역에서 비즈니스 로직을 처리 해야한다고 오해하지만 전혀 그렇지 않다.
Service 는 트랜젝션, 도메인 간 순서 보장을 하는 역할만 한다.
주문 취소 로직을 통해 Service 영역의 권장하는 예시와 권장하지 않는 예시를 비교해보자.
@Transactional
public Order cancelOrder(int orderId){
1) 데이터베이스로부터 주문정보(Orders), 결제정보(Billing), 배송정보(Delivery) 조회
2) 배송 취소를 해야 하는지 확인
3) if(배송 중이라면) {
배송 취소로 변경
}
4) 각 테이블에 취소 상태 Update
}
모든 로직이 서비스 클래스 내부에서 처리된다. 그러다 보니 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 된다.
@Transactional
public Order cancelOrder(int orderId) {
//1)
OrderDto order = orderDao.selectOrders(orderId);
BillingDto billing = billingDao.selectBilling(orderId);
DeliveryDto delivery = deliveryDao.selectDelivery(orderId);
//2)
String deliveryStatus = delivery.getStatus();
//3)
if("IN_PROGRESS".equals(deliveryStatus)) {
delivery.setStatus("CANCEL");
deliveryDao.update(delivery);
}
//4)
order.setStatus("CANCEL");
orderDao.update(order);
billing.setStatus("CANCEL");
deliveryDao.update(billing);
return order;
}
반면 도메인 모델에서 처리할 경우 다음과 같은 코드가 될 수 있다.
order, billing, delivery 가 각자 본인의 취소 이벤트를 처리하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해 준다.
@Transactional
piblic Order cancelOrder(int orderId) {
//1)
Orders order = orderRepository.findById(orderId);
Billing billing = billing Repository.findByOrdderId(orderId);
Delivery delivery = deliveryRepository.findByOrderId(orderId);
//2-3)
delivery.cancel();
//4)
order.cancel();
billing.cancel();
return order;
}
클래스 파일은 각각 아래 패키지에 생성한다.
package com.spring.book.web;
import com.spring.book.service.posts.PostsService;
import com.spring.book.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
@RestController
public class PostsApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
}
PostsApiController 클래스는 @RestController 어노테이션을 지정하면서 컨트롤러 역할을 수행하게 되었다.
save 메소드는 /api/v1/posts 요청을 처리하며, 서비스 단을 호출하여 얻은 데이터를 반환한다.
위 코드에서는 @RequiredArgsConstructor가 final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해준다. 이 어노테이션은 롬복 라이브러리에서 제공하는 어노테이션이다.
스프링에서 Bean 객체를 주입받는 방식은 다음과 같다.
이 중에서 가장 권장하는 방식이 생성자로 주입받는 방식이다. (@Autowired는 권장하지 않는다.)
해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속 수정해야하는 번거로움을 해결하기 위함이다.
(롬복 어노테이션이 있으면 해당 컨트롤러에 새로운 서비스를 추가하거나, 기존 컴포넌트를 제거하는 등의 상황이 발생해도 생성자 코드는 전혀 손대지 않아도 된다.)
@RequestBody 어노테이션이 붙은 파라미터는 HTTP 요청의 본문(Body)내용을 통째로 자바 객체로 변환해서 매핑된 메소드 파라미터로 전달해준다.
일반적인 GET/POST의 요청 파라미터라면 @RequestBody를 사용할 일이 없을 것이다.
반면에 xml이나 json기반의 메시지를 사용하는 요청의 경우에 이 방법이 매우 유용하다.
비교해서 알아두면 좋을 어노테이션으로 @ResponseBody가 있다.
@RequestBody / @ResponseBody 정리.
클라이언트에서 서버로 필요한 데이터를 요청하기 위해 JSON 데이터를 요청 본문에 담아서 서버로 보내면, 서버에서는 @RequestBody 어노테이션을 사용하여 HTTP 요청 본문에 담긴 값들을 자바객체로 변환시켜, 객체에 저장한다.
서버에서 클라이언트로 응답 데이터를 전송하기 위해 @ResponseBody 어노테이션을 사용하여 자바 객체를 HTTP 응답 본문의 객체로 변환하여 클라이언트로 전송한다.
package com.spring.book.service.posts;
import com.spring.book.domain.posts.PostsRepository;
import com.spring.book.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity()).getId();
}
}
DTO의 데이터를 리포지토리를 통해 엔티티에 저장한다.
엔티티의 id값을 컨트롤러로 전달한다.
package com.spring.book.web.dto;
import com.spring.book.domain.posts.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@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();
}
}
Dto 클래스인 PostsSaveRequestDto는 Entity 클래스인 Posts와 거의 유사한 형태이다.
그럼에도 Dto 클래스를 따로 분리해야 하는 이유는 다음과 같다.
package com.spring.book.web;
import com.spring.book.domain.posts.Posts;
import com.spring.book.domain.posts.PostsRepository;
import com.spring.book.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
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);
}
}
@WebMvcTest는 JPA 기능이 작동하지 않으며, Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화된다.
JPA 기능까지 한번에 테스트할 때에는 @SpringBootTest와 TestRestTemplate를 사용하면 된다.
WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행과 insert 쿼리가 실행된 것을 모두 확인할 수 있다.