[기본] Spring Data JDBC를 통한 데이터 액세스 계층 구현(2) - 서비스, 리포지토리 구현
기타 이번 챕터에서 수정된 코드
서비스 클래스와 리포지토리 클래스 이외에도 서비스 클래스를 사용하는 Controller 클래스, 이와 연관된 DTO 클래스, Mapper 인터페이스 등도 수정된 부분이 존재합니다.
✔ CoffeePostDto 코드
@Getter
public class CoffeePostDto {
@NotBlank
private String korName;
@NotBlank
@Pattern(regexp = "^([A-Za-z])(\\\\s?[A-Za-z])*$",
message = "커피명(영문)은 영문이어야 합니다(단어 사이 공백 한 칸 포함). 예) Cafe Latte")
private String engName;
@Range(min= 100, max= 50000)
private int price;
@NotBlank
@Pattern(regexp = "^([A-Za-z]){3}$",
message = "커피 코드는 3자리 영문이어야 합니다.")
private String coffeeCode;
}
[코드 3-95-1] 커피 코드가 추가된 CoffeePostDto(OrderPostDto.java)
커피 코드가 추가되었습니다. 커피 코드는 커피라는 상품의 고유 식별 코드를 의미합니다.
✔ OrderController 코드
package com.springboot.order.controller;
import com.springboot.coffee.service.CoffeeService;
import com.springboot.order.dto.OrderPostDto;
import com.springboot.order.dto.OrderResponseDto;
import com.springboot.order.entity.Order;
import com.springboot.order.mapper.OrderMapper;
import com.springboot.order.service.OrderService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;
import javax.validation.Valid;
import javax.validation.constraints.Positive;
import java.net.URI;
import java.util.List;
import java.util.stream.Collectors;
import static java.util.Comparator.comparing;
@RestController
@RequestMapping("/v10/orders")
@Validated
public class OrderController {
private final static String ORDER_DEFAULT_URL = "/v10/orders"; // (1) Default URL 경로
private final OrderService orderService;
private final OrderMapper mapper;
private final CoffeeService coffeeService;
public OrderController(OrderService orderService,
OrderMapper mapper,
CoffeeService coffeeService) {
this.orderService = orderService;
this.mapper = mapper;
this.coffeeService = coffeeService;
}
@PostMapping
public ResponseEntity postOrder(@Valid @RequestBody OrderPostDto orderPostDto) {
Order order = orderService.createOrder(mapper.orderPostDtoToOrder(orderPostDto));
// (2) 등록된 주문(Resource)에 해당하는 URI 객체
URI location =
UriComponentsBuilder
.newInstance()
.path(ORDER_DEFAULT_URL + "/{order-id}")
.buildAndExpand(order.getOrderId())
.toUri(); // "/v10/orders/{order-id}"
return ResponseEntity.created(location).build(); // (3) HTTP 201 Created status
}
@GetMapping("/{order-id}")
public ResponseEntity getOrder(@PathVariable("order-id") @Positive long orderId){
Order order = orderService.findOrder(orderId);
// (4) 주문한 커피 정보를 가져오도록 수정
return new ResponseEntity<>(
mapper.orderToOrderResponseDto(coffeeService, order),
HttpStatus.OK);
}
@GetMapping
public ResponseEntity getOrders() {
List<Order> orders = orderService.findOrders();
// (5) 주문한 커피 정보를 가져오도록 수정
List<OrderResponseDto> response =
orders
.stream()
.map(order -> mapper.orderToOrderResponseDto(coffeeService, order))
.collect(Collectors.toList());
return new ResponseEntity<>(response, HttpStatus.OK);
}
@DeleteMapping("/{order-id}")
public ResponseEntity cancelOrder(@PathVariable("order-id") @Positive long orderId){
orderService.cancelOrder(orderId);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
}
[코드 3-96] Spring Data JDBC 적용으로 인해 수정된 OrderController(OrderController.java)
- postOrder() 핸들러 메서드의 수정 내용 설명
- 우리가 이 전 유닛까지는 데이터베이스를 연동하지 않았기 때문에 주문 정보 등록 시, OrderController에서 OrderService의 createOrder(order)를 호출해 등록할 주문을 전달하고, 이렇게 전달한 Order 객체를 createOrder(order)의 리턴 값으로 그대로 리턴하도록 했습니다.
- 그런데 이제는 상황이 다릅니다. 등록할 주문 정보는 데이터베이스에 저장이 되고, ORDER 테이블에 하나의 row로 저장이 됩니다. 즉, ORDER_ID라는 고유한 식별자(기본키)를 가지는 진정한 주문 정보로써의 역할을 하게 됩니다.
- 일반적으로 클라이언트 측에서 백엔드 애플리케이션 측에 어떤 리소스(회원 정보, 커피 정보, 주문 정보 등)의 등록을 요청할 경우, 백엔드 애플리케이션은 해당 리소스를 데이터베이스에 저장한 후 요청한 리소스가 성공적으로 저장되었음을 알리는 201 Created HTTP Status를 response header에 추가해서 클라이언트 측에 응답으로 전달합니다.
- 클라이언트 측에서는 response header에 포함된 리소스의 위치 정보(Location)를 얻은 후에 해당 리소스의 URI로 다시 요청을 전송해서 리소스의 정보를 얻어옵니다.
- (1)에서 리소스(주문 정보)의 디폴트 URL을 정의합니다.
- (2)에서 UriComponentsBuilder를 이용해 등록된 리소스(주문 정보)의 위치 정보인 URI 객체를 생성합니다.
- (3)에서 ResponseEntity.*created*(location).build();를 이용해 응답 객체를 리턴합니다.
- ResponseEntity.*created*(location) 메서드는 내부적으로 201 Created HTTP Status를 response header에 추가하고, 별도의 response body는 포함하지 않습니다.
[그림 3-49-1] 등록된 주문 정보의 Location 정보가 response header에 포함된 모습
[그림 3-49-1]은 postOrder() request를 전송할 경우의 response의 모습입니다. 보다시피 201 Created HTTP Status이고, 등록된 주문 정보의 위치 정보가 Location header에 포함되어 있습니다.
⭐ 백엔드 애플리케이션 측에 리소스를 등록할 경우에는 등록된 리소스의 정보를 응답으로 리턴할 필요가 없다는 사실을 기억하기 바랍니다.
- 그 외 (4), (5)에서는 주문한 커피 정보가 OrderResponseDto에 포함되도록 수정되었습니다.
- CoffeeService 객체를 CoffeeMapper 매핑 메서드의 파라미터로 넘겨줌으로써 내부적으로 주문한 커피 정보를 OrderResponseDto에 포함시킬 수 있습니다.
MemberController와 CoffeeController의 수정된 코드는 OrderController와 로직 자체는 동일하므로, 자세한 코드 변경 사항은 레퍼런스 코드(https://github.com/Lucky-kor/be-reference-spring-data-jdbc)를 확인 바랍니다.
✔ OrderPostDto 코드
@Getter
@AllArgsConstructor
public class OrderPostDto {
@Positive
private long memberId;
// (1) 여러 잔의 커피를 주문할 수 있도록 수정
@Valid
private List<OrderCoffeeDto> orderCoffees;
}
[코드 3-97] Spring Data JDBC 적용으로 인해 수정된 OrderPostDto(OrderPostDto.java)
(1)과 같이 여러 잔의 커피를 주문할 수 있도록 수정되었습니다. List안에 포함된 객체에 대한 유효성 검증을 위해서는 (1)과 같이 @Valid 애너테이션을 추가해 주면 됩니다.
✔ OrderCoffeeDto 클래스 추가
@Getter
@AllArgsConstructor
public class OrderCoffeeDto {
@Positive
private long coffeeId;
@Positive
private int quantity;
}
[코드 3-98] Spring Data JDBC 적용으로 인해 추가된 OrderCoffeeDto(OrderCoffeeDto.java)
OrderCoffeeDto 클래스는 여러 잔의 커피 정보를 주문하기 위해 추가된 DTO 클래스입니다.
✔ OrderCoffeeResponseDto 코드 추가
@Getter
@AllArgsConstructor
public class OrderCoffeeResponseDto {
private long coffeeId;
private String korName;
private String engName;
private int price;
private int quantity;
}
[코드 3-98-1] Spring Data JDBC 적용으로 인해 추가된 OrderCoffeeResponseDto(OrderCoffeeResponseDto.java)
OrderCoffeeResponseDto 클래스는 주문한 여러 잔의 커피 정보를 응답으로 제공하기 위해 추가된 DTO 클래스입니다.
✔ OrderResponseDto 코드
import com.springboot.coffee.dto.CoffeeResponseDto;
import com.springboot.order.entity.Order;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
@Getter
@Setter
public class OrderResponseDto {
private long orderId;
private long memberId;
private Order.OrderStatus orderStatus;
private List<OrderCoffeeResponseDto> orderCoffees;
private LocalDateTime createdAt;
}
[코드 3-99] Spring Data JDBC 적용으로 인해 수정된 OrderResponseDto(OrderResponseDto.java)
OrderResponseDto 클래스는 아래와 같은 기능이 추가되어 수정되었습니다.
- 주문한 여러 건의 커피 정보를 응답으로 전송할 수 있도록 변경
- 주문 시간과 주문 상태를 응답으로 전송할 수 있도록 변경
✔ OrderMapper 코드
/**
* 람다식이 없는, for-each문을 사용한 코드입니다.
*/
package com.springboot.order.mapper;
import com.springboot.coffee.entity.Coffee;
import com.springboot.coffee.service.CoffeeService;
import com.springboot.order.dto.OrderCoffeeDto;
import com.springboot.order.dto.OrderCoffeeResponseDto;
import com.springboot.order.entity.Order;
import com.springboot.order.dto.OrderPostDto;
import com.springboot.order.dto.OrderResponseDto;
import com.springboot.order.entity.OrderCoffee;
import org.mapstruct.Mapper;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Mapper(componentModel = "spring")
public interface OrderMapper {
default Order orderPostDtoToOrder(OrderPostDto orderPostDto) {
Order order = new Order();
order.setMemberId(orderPostDto.getMemberId());
Set<OrderCoffee> orderCoffees = orderPostDto.getOrderCoffees()
.stream()
.map(orderCoffeeDto ->
OrderCoffee.builder()
.coffeeId(orderCoffeeDto.getCoffeeId())
.quantity(orderCoffeeDto.getQuantity())
.build())
.collect(Collectors.toSet());
order.setOrderCoffees(orderCoffees);
order.setCreatedAt(LocalDateTime.now());
order.setOrderStatus(Order.OrderStatus.ORDER_REQUEST);
return order;
}
default OrderResponseDto orderToOrderResponseDto(CoffeeService coffeeService, Order order) {
List<OrderCoffeeResponseDto> orderCoffees =
orderCoffeesToOrderCoffeeResponseDtos(coffeeService, order.getOrderCoffees());
OrderResponseDto orderResponseDto = new OrderResponseDto(
order.getOrderId(),
order.getMemberId(),
order.getOrderStatus(),
orderCoffees,
order.getCreatedAt()
);
return orderResponseDto;
}
default List<OrderCoffeeResponseDto> orderCoffeesToOrderCoffeeResponseDtos
(CoffeeService coffeeService
,Set<OrderCoffee> orderCoffees) {
List<OrderCoffeeResponseDto> result = new ArrayList<>();
for(OrderCoffee orderCoffee: orderCoffees) {
Coffee coffee = coffeeService.findVerifiedCoffee(orderCoffee.getCoffeeId());
OrderCoffeeResponseDto dto = new OrderCoffeeResponseDto(
coffee.getCoffeeId(),
coffee.getKorName(),
coffee.getEngName(),
coffee.getPrice(),
orderCoffee.getQuantity()
);
result.add(dto);
}
return result;
}
}
import com.springboot.coffee.entity.Coffee;
import com.springboot.coffee.entity.OrderCoffee;
import com.springboot.coffee.service.CoffeeService;
import com.springboot.order.dto.OrderCoffeeResponseDto;
import com.springboot.order.entity.Order;
import com.springboot.order.dto.OrderPostDto;
import com.springboot.order.dto.OrderResponseDto;
import org.mapstruct.Mapper;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Mapper(componentModel = "spring")
public interface OrderMapper {
// 수정되었음.
default Order orderPostDtoToOrder(OrderPostDto orderPostDto) {
Order order = new Order();
// (1)
order.setMemberId(orderPostDto.getMemberId());
// (2)
Set<OrderCoffee> orderCoffees = orderPostDto.getOrderCoffees()
.stream()
.map(orderCoffeeDto ->
// (2-1)
OrderCoffee.builder()
.coffeeId(orderCoffeeDto.getCoffeeId())
.quantity(orderCoffeeDto.getQuantity())
.build())
.collect(Collectors.toSet());
order.setOrderCoffees(orderCoffees);
return order;
}
default OrderResponseDto orderToOrderResponseDto(CoffeeService coffeeService,
Order order) {
// (3)
long memberId = order.getMemberId();
// (4)
List<OrderCoffeeResponseDto> orderCoffees =
orderCoffeesToOrderCoffeeResponseDtos(coffeeService, order.getOrderCoffees());
OrderResponseDto orderResponseDto = new OrderResponseDto();
orderResponseDto.setOrderCoffees(orderCoffees);
orderResponseDto.setMemberId(memberId);
orderResponseDto.setCreatedAt(order.getCreatedAt());
orderResponseDto.setOrderId(order.getOrderId());
orderResponseDto.setOrderStatus(order.getOrderStatus());
// TODO 주문에 대한 더 자세한 정보로의 변환은 요구 사항에 따라 다를 수 있습니다.
return orderResponseDto;
}
default List<OrderCoffeeResponseDto> orderCoffeesToOrderCoffeeResponseDtos(
CoffeeService coffeeService,
Set<OrderCoffee> orderCoffees) {
// (5)
return orderCoffees.stream()
.map(orderCoffee -> {
// (5-1)
Coffee coffee = coffeeService.findCoffee(orderCoffee.getCoffeeId());
return new OrderCoffeeResponseDto(coffee.getCoffeeId(),
coffee.getKorName(),
coffee.getEngName(),
coffee.getPrice(),
orderCoffee.getQuantity());
}).collect(Collectors.toList());
}
}
[코드 3-100] Spring Data JDBC 적용으로 인해 수정된 OrderMapper(OrderMapper.java)
OrderMapper 인터페이스는 DTO와 Entity 클래스 간의 복잡한 매핑 절차로 인해 기존에 MapStruct가 엔티티 클래스와 DTO 클래스를 대신 매핑해 주던 방식에서 개발자가 직접 매핑 작업 코드를 구현하는 것으로 변경되었습니다.
✔ OrderMapper 코드 설명
- OrderMapper의 경우, DTO와 Entity 간의 매핑 작업이 복잡하기 때문에 우리가 서비스 계층에서 배웠던 Mapsturct의 매핑 방식 중에서 개발자가 직접 매핑 로직을 구현하는 직접 매핑 방식을 사용하고 있습니다.
- orderPostDtoToOrder(OrderPostDto orderPostDto)
- orderPostDtoToOrder() 메서드는 등록하고자 하는 커피 주문 정보(OrderPostDto)를 Order 엔티티 클래스의 객체로 변환하는 역할을 합니다.
- (1) 에서는 orderPostDto에 포함된 memberId를 Order 클래스의 memberId에 할당해 줍니다.
- (2)에서는 orderPostDto에 포함된 주문한 커피 정보인 List<OrderCoffeeDto> orderCoffees를 Java의 Stream을 이용해 Order 클래스의 Set<OrderCoffee> orderCoffees으로 변환하고 있습니다.
- (2-1)에서는 OrderCoffee 클래스에 @Builder 애너테이션이 적용되어 있으므로 lombok에서 지원하는 빌더 패턴을 사용할 수 있습니다.
- 따라서 빌더 패턴을 이용해 List<OrderCoffeeDto> orderCoffees에 포함된 주문한 커피 정보를 OrderCoffee의 필드에 추가하고 있습니다.
- 빌더 패턴을 사용하는 것은 필수는 아닙니다. 빌더 패턴을 사용하든 new 키워드로 객체를 생성하든 상관없지만 빌더 패턴의 사용 예를 보여주기 위해 빌더 패턴을 사용했다는 것을 참고하기 바랍니다.
- 빌더 패턴에 대한 내용은 아래 [심화 학습]을 참고해 주세요.
- orderToOrderResponseDto(CoffeeService coffeeService, Order order)
- orderToOrderResponseDto()는 데이터베이스에서 조회한 Order 객체를 OrderResponseDto 객체로 변환해 주는 역할을 합니다.
- 먼저 (3)에서는 Order의 memberId 필드 값을 얻습니다.
- (4)에서는 주문한 커피의 구체적인 정보를 조회하기 위해 orderToOrderCoffeeResponseDto(coffeeService, order.getOrderCoffees());를 호출합니다.
- order.getOrderCoffees()의 리턴 값은 Set<OrderCoffee> orderCoffees이고, 이 orderCoffees에는 커피명이나 가격 같은 구체적인 커피 정보가 포함된 것이 아니기 때문에 데이터베이스에서 구체적인 커피 정보를 조회하는 추가 작업을 수행해야 합니다.
- orderCoffeesToOrderCoffeeResponseDtos(CoffeeService coffeeService, Set<OrderCoffee> orderCoffees)
- orderCoffeesToOrderCoffeeResponseDtos()는 데이터베이스에서 커피의 구체적인 정보를 조회한 후, OrderCoffeeResponseDto에 커피 정보를 채워 넣는 역할을 합니다.
- (5)에서는 파라미터로 전달받은 orderCoffees를 Java의 Stream을 이용해 데이터베이스에서 구체적인 커피 정보를 조회한 후, OrderCoffeeResponseDto로 변환하는 작업을 하고 있습니다.
- (5-1)에서 파라미터로 전달받은 coffeeService 객체를 이용해 coffeeId에 해당하는 Coffee를 조회하고 있습니다.
✔ ExceptionCode 클래스
public enum ExceptionCode {
MEMBER_NOT_FOUND(404, "Member not found"),
MEMBER_EXISTS(409, "Member exists"),
COFFEE_NOT_FOUND(404, "Coffee not found"),
COFFEE_CODE_EXISTS(409, "Coffee Code exists"),
ORDER_NOT_FOUND(404, "Order not found"),
CANNOT_CHANGE_ORDER(403, "Order can not change"),
NOT_IMPLEMENTATION(501, "Not Implementation");
@Getter
private int status;
@Getter
private String message;
ExceptionCode(int code, String message) {
this.status = code;
this.message = message;
}
}
[코드 3-101] Spring Data JDBC 적용으로 인해 수정된 ExceptionCode(ExceptionCode.java)
구현한 커피 주문 샘플 애플리케이션 실행
여러분들이 서비스 클래스와 리포지토리 클래스를 모두 작성했다면 커피 주문 샘플 애플리케이션을 직접 실행해서 데이터베이스에 저장이 잘 되는지 확인해 볼 수 있습니다.
샘플 애플리케이션 실행 후, 아래와 같은 순서대로 테스트를 진행합니다.
- 회원 정보를 등록합니다.
{
"email": "hgd@gmail.com",
"name": "홍길동",
"phone": "010-1234-5555"
}
[코드 3-102] 등록할 샘플 회원 정보
코드 3-102의 회원 정보를 Postman을 이용해서 데이터베이스에 등록하세요.
- 커피 정보를 등록합니다.
{
"korName": "카라멜 라떼",
"engName": "Caramel Latte",
"price": 4500,
"coffeeCode": "CRL"
}
[코드 3-103] 등록할 샘플 커피 정보1
{
"korName": "바닐라 라떼",
"engName": "Vanilla Latte",
"price": 5000,
"coffeeCode": "VNL"
}
[코드 3-104] 등록할 샘플 커피 정보2
코드 3-103과 코드 3-104의 커피 정보를 Postman을 이용해서 데이터베이스에 등록하세요.
- 등록한 회원 정보로 등록한 커피 정보를 주문합니다.
앞에서 회원 정보와 커피 정보를 등록했다면 응답 데이터에서 등록한 회원의 memberId와 등록한 커피의 coffeeId를 각각 확인할 수 있습니다.
{
"memberId": 1,
"orderCoffees":[
{
"coffeeId": 1,
"quantity": 2
},
{
"coffeeId": 2,
"quantity": 1
}
]
}
[코드 3-105] 등록한 회원으로 등록한 커피를 주문하는 샘플 주문 정보
코드 3-105의 커피 주문 정보를 Postman을 이용해서 데이터베이스에 등록하세요.
정상적으로 주문이 되었다면 [그림 3-49-2]와 같이 headers 탭에서 Location 정보를 확인할 수 있습니다.
[그림 3-49-2] 등록된 주문 정보의 Location 정보가 response header에 포함된 모습
커피 주문 시, memberId나 coffeeId 같은 단순한 숫자를 요청으로 보낸다는 것이 어색할지도 모르겠습니다.
하지만 실제 프로젝트라면 사용자가 화면이 있는 프론트엔드(Frontend) 애플리케이션을 이용해서 여러분들이 만든 샘플 애플리케이션의 요청 URI를 내부적으로 이용하여 회원 가입, 카페 주인의 커피 등록, 손님의 커피 목록 조회 및 커피 주문 등의 작업에 coffeeId, memberId 같은 식별자가 대부분 사용된다는 사실을 기억하면 좋을 것 같습니다.
핵심 포인트
- Spring Data JDBC의 CrudRepository 인터페이스를 상속하면 CrudRepository에서 제공하는 CRUD 메서드를 사용할 수 있다.
- Spring Data JDBC에서는 SQL 쿼리를 대신하는 다양한 쿼리 메서드(Query Method) 작성을 지원한다.
- Spring Data JDBC에서 지원하는 쿼리 메서드의 정의가 어렵다면 @Query 애너테이션을 이용해서 SQL 쿼리를 직접 작성할 수 있다.
- 회원 정보, 커피 정보 등의 리소스를 데이터베이스에 Insert할 경우 이미 Insert 된 리소스인지 여부를 검증하는 로직이 필요하다.
- Optional을 이용하면 데이터 검증에 대한 로직을 간결하게 작성할 수 있다.
- 복잡한 DTO 클래스와 엔티티 클래스의 매핑은 Mapper 인터페이스에 default 메서드를 직접 구현해서 개발자가 직접 매핑 로직을 작성해 줄 수 있다.
심화 학습
- Spring Data JDBC에서 사용할 수 있는 쿼리 메서드를 더 알아보고 싶다면 아래 링크를 참고하세요.
- 빌더 패턴과 lombok에서 지원하는 @Builder 애너테이션에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.