17.3 연동(Integration)을 통한 전체 API 동작 완성
앞서 17.1~17.2 장에서 설계한 도메인 엔티티(Entity) 리포지토리(Repository), 그리고 비즈니스 로직(Service)을 웹(Web) 계층인 컨트롤러(Controller) 와 조작하여 완성된 API로 연동(Integration)하는 단계입니다.
여기서는 클라이언트의 요청이 어떻게 계층을 통과하여 데이터를 조작하고 결과를 반환하는지 전체 흐름을 완성합니다.
🔀 1. 프레젠테이션 계층 (Controller 및 DTO) 연동 전략
클라이언트에게 엔티티(Entity) 객체를 그대로 노출하는 것은 절대 금물입니다. 엔티티 구조 변경이 API 스펙 변경으로 이어지고, 지연 로딩 문제(LAZY Loading Exception) 등을 유발할 수 있기 때문입니다.
반드시 Dto(Data Transfer Object) 로 변환하여 요청(Request)을 받고 응답(Response)을 처리해야 합니다.
// --- Request DTO ---
@Getter
@NoArgsConstructor
public class OrderRequestDto {
private Long memberId;
private Long itemId;
private int count;
}
// --- Response DTO ---
@Getter
@AllArgsConstructor
public class OrderResponseDto {
private Long orderId;
private String status;
private int totalPrice;
// 도메인 엔티티를 Response DTO로 안전하게 매핑
public static OrderResponseDto from(Order order) {
return new OrderResponseDto(
order.getId(),
order.getStatus().name(),
order.getTotalPrice()
);
}
}
📌 Controller 구현 (REST API)
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
// 서비스 레이어 의존성 주입
private final OrderService orderService;
/**
* 주문 생성 API
*/
@PostMapping
public ResponseEntity<OrderResponseDto> createOrder(
@RequestBody @Valid OrderRequestDto requestDto) {
// 1. Service에 로직을 위임 (트랜잭션 시작점)
Order savedOrder = orderService.order(
requestDto.getMemberId(),
requestDto.getItemId(),
requestDto.getCount()
);
// 2. 응답 DTO로 변환하여 반환
return ResponseEntity.status(HttpStatus.CREATED)
.body(OrderResponseDto.from(savedOrder));
}
/**
* 주문 취소 API
*/
@PostMapping("/{orderId}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long orderId) {
orderService.cancelOrder(orderId);
return ResponseEntity.noContent().build();
}
}
⚙️ 2. 유스케이스 구현 (Service 계층 연동)
Controller에서 호출받는 Service 계층은 복잡한 비즈니스 로직은 이미 구현된 17.2장의 도메인 엔티티 안의 메서드로 완전히 위임(Delegate) 하고, 본인은 여러 엔티티들을 하나로 묶고 트랜잭션(@Transactional)을 관리하는 파사드(Facade) 역할을 수행합니다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final MemberRepository memberRepository;
private final ItemRepository itemRepository;
/**
* 주문 실행
*/
@Transactional
public Order order(Long memberId, Long itemId, int count) {
// 1. 데이터베이스에서 값 가져오기
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("회원 없음"));
Item item = itemRepository.findById(itemId)
.orElseThrow(() -> new IllegalArgumentException("상품 없음"));
// 2. OrderItem 도메인 객체 생성
OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
// 3. Order 도메인 객체 생성 (비즈니스 로직 위임)
Order order = Order.createOrder(member, orderItem);
// 4. 데이터베이스 저장 (영속성 전이 CASCADE에 의해 orderItem들도 함께 저장됨)
return orderRepository.save(order);
}
/**
* 주문 취소
*/
@Transactional
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("주문 없음"));
// 엔티티가 스스로 취소 로직 및 재고 복구 로직을 수행함
// 별도의 JPA save() 호출 및 UPDATE 쿼리 작성이 필요 없음. (Dirty Checking에 의해 자동 변경 감지)
order.cancel();
}
}
✅ 3. Postman 및 통합 테스트(Integration Testing) 연동 시나리오
개발이 완료되면 통합 테스트(Integration Test)를 사용해 실제 데이터베이스에 값을 넣어가며 웹에서 컨트롤러 응답까지 잘 오는지 전체 흐름을 확인합니다.
@SpringBootTest // 스프링 전체 컨텍스트를 띄우고 테스트
@AutoConfigureMockMvc
@Transactional // 테스트 종료 시 데이터 롤백
public class OrderIntegrationTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@Autowired MemberRepository memberRepository;
@Autowired ItemRepository itemRepository;
@Test
@DisplayName("주문 생성이 컨트롤러부터 DB까지 정상 작동해야 한다")
void createOrderTest() throws Exception {
// 1. Dummy 데이터 세팅
Member member = memberRepository.save(new Member("Alice"));
Item item = itemRepository.save(new Item("MacBook", 2000000, 10)); // 재고 10개
OrderRequestDto requestDto = new OrderRequestDto(member.getId(), item.getId(), 2); // 2개 주문
// 2. 통합 API 동작 시뮬레이션
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestDto)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("ORDER"))
.andExpect(jsonPath("$.totalPrice").value(4000000));
// 3. 재고 감소가 실제로 DB에 반영되었는지 재고 8개 확인
Item itemAfterOrder = itemRepository.findById(item.getId()).get();
assertThat(itemAfterOrder.getStockQuantity()).isEqualTo(8);
}
}
🎯 고수 팁 (Pro Tips)
💡 DTO 변환 시점과 레이어 간 의존성 문제 DTO ↔ Entity 캐스팅 변환을 어느 계층(Layer)에서 하는 것이 좋을까요? 정답:
Controller에서 하는 것이 가장 우수합니다.Service에서Response DTO를 뱉게 만들면 Service 계층이 특정 API 스펙(Controller)에 결합되는 문제가 생깁니다. 서비스는 가급적 불변성과 타입이 확실한Entity(혹은 DTO지만 웹 관련이 없는 내부용 전송 객체)를 넘겨주고, 컨트롤러가 이를 받아 API 응답용Response DTO로 변환하여 출력하는 것이 컴포넌트 간 재사용성과 의존성 관리 차원에서 깔끔합니다.