- 주문 생성(createOrder) 로직은 다양한 도메인(가게, 배송지, 메뉴)의 데이터가 한곳에 모이는 핵심 기능입니다. 단순히 데이터를 DB에 넣는 것을 넘어, 클라이언트의 비정상적인 요청을 어떻게 서버 단에서 차단했는지 회고해 봅니다.
💻 실제 코드 (OrderServiceV1.java - 주문 생성)
// 매 요청 시 DB 권한 재검증
private void validateUserRoleFromDB(UUID userId, UserRole tokenRole) {
UserEntity user = userRepository.findById(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
if (user.getIsDeleted()) {
log.warn("[Security] Deleted user access attempt. User: {}", userId);
throw new CustomException(ErrorCode.USER_NOT_FOUND);
}
if (user.getUserRole() != tokenRole) {
log.warn("[Security] Role mismatch detected! User: {}, TokenRole: {}, DBRole: {}",
userId, tokenRole, user.getUserRole());
throw new CustomException(ErrorCode.ACCESS_DENIED); // 실시간 권한 변경 시 차단
}
}
/**
* 주문 생성 (CUSTOMER 전용)
*/
@Transactional
public ResCreateOrderDtoV1 createOrder(ReqCreateOrderDtoV1 request, UUID userId, UserRole role) {
// 실시간 권한 재검증 및 차단 유저 접근 방어
validateUserRoleFromDB(userId, role);
log.info("[Order] Creating order. User: {}, Store: {}", userId, request.getStoreId());
// 가게 유효성 검증 (숨김 처리, 영업 상태)
StoreEntity store = storeRepository.findById(request.getStoreId())
.orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND));
if (Boolean.TRUE.equals(store.getIsHidden())) {
throw new CustomException(ErrorCode.STORE_NOT_FOUND);
}
if (store.getStatus() != StoreStatus.OPEN) {
throw new CustomException(ErrorCode.STORE_CLOSED);
}
// 현재 서버 시간 기준 영업 시간 내 주문인지 검증
LocalTime nowTime = LocalTime.now();
if (nowTime.isBefore(store.getOpenTime()) || nowTime.isAfter(store.getCloseTime())) {
throw new CustomException(ErrorCode.STORE_CLOSED);
}
// 배송지 유효성 및 소유권 검증
AddressEntity address = addressRepository.findByIdAndIsDeletedFalse(request.getAddressId())
.orElseThrow(() -> new CustomException(ErrorCode.ADDRESS_NOT_FOUND));
if (Boolean.TRUE.equals(address.getIsDeleted())) {
throw new CustomException(ErrorCode.ADDRESS_NOT_FOUND);
}
if (!address.getUserId().equals(userId)) {
throw new CustomException(ErrorCode.ADDRESS_NOT_OWNER);
}
// 메뉴 유효성 검증 및 총액 계산
int calculatedItemTotalPrice = 0; // 총합 금액을 누적해서 담아둘 변수 선언
// 클라이언트가 요청한 여러 개의 주문 아이템 리스트를 하나씩 꺼내어 검사
for (ReqCreateOrderDtoV1.OrderItemRequest itemRequest : request.getOrderItems()) {
// 해당 메뉴가 DB에 실제로 존재하는지 조회
MenuEntity menu = menuRepository.findById(itemRequest.getMenuId())
.orElseThrow(() -> new CustomException(ErrorCode.MENU_NOT_FOUND));
// 메뉴를 삭제했거나, 숨김 처리해 둔 메뉴라면 주문을 막음
if (Boolean.TRUE.equals(menu.getIsDeleted()) || Boolean.TRUE.equals(menu.getIsHidden())) {
throw new CustomException(ErrorCode.MENU_NOT_FOUND);
}
// 클라이언트 요청 가격과 DB 메뉴 가격 일치 여부 검증 (가격 변조 방지)
if (!menu.getPrice().equals(itemRequest.getPriceAtOrder())) {
throw new CustomException(ErrorCode.PRICE_MISMATCH);
}
// 누적 금액 계산
calculatedItemTotalPrice += menu.getPrice() * itemRequest.getQuantity();
}
// 최소 주문 금액 달성 여부 검증
if (store.getMinOrderPrice() != null && calculatedItemTotalPrice < store.getMinOrderPrice()) {
throw new CustomException(ErrorCode.ORDER_MIN_PRICE_NOT_MET);
}
int deliveryFee = 3000;
int finalTotalPrice = calculatedItemTotalPrice + deliveryFee;
// 주문(Order) 엔티티 생성
OrderEntity order = OrderEntity.builder()
.userId(userId)
.storeId(request.getStoreId())
.addressId(request.getAddressId())
.request(request.getRequest())
.totalPrice(finalTotalPrice)
.deliveryFee(deliveryFee)
.status(OrderStatus.PENDING)
.build();
order.markCreatedBy(userId);
// 주문 아이템(OrderItem) 자식 엔티티 조립 및 스냅샷 적용
List<OrderItemEntity> orderItems = request.getOrderItems().stream()
// DTO 리스트를 순회하며 DB 저장을 위한 Entity로 변환함
.map(itemRequest -> OrderItemEntity.builder()
.order(order) // 자식 객체에 부모 객체를 매핑함 (양방향 연관관계)
.menuId(itemRequest.getMenuId())
.quantity(itemRequest.getQuantity())
// 주문 시점의 가격을 스냅샷으로 저장하여 향후 메뉴 가격 변동 시 데이터 무결성 유지
.priceAtOrder(itemRequest.getPriceAtOrder())
.createdBy(userId)
.build())
.toList();
// 생성된 자식 엔티티 리스트를 부모(Order) 엔티티에 일괄 세팅함
order.getOrderItems().addAll(orderItems);
// OrderEntity의 CascadeType.ALL 옵션에 의해 자식 엔티티들도 자동으로 DB에 INSERT 됨
OrderEntity savedOrder = orderRepository.save(order);
log.info("[Order] Successfully created. OrderId: {}", savedOrder.getOrderId());
return ResCreateOrderDtoV1.from(savedOrder, "주문이 성공적으로 생성되었습니다.");
}
🤔 왜 이렇게 짰는가? (설계 의도와 이유)
주문 생성은 여러 도메인의 데이터가 교차하는 기능이므로, 비정상적인 데이터가 DB에 적재되는 것을 막기 위한 순차적인 검증이 필요했습니다.
순차적인 데이터 교차 검증
- 가게 상태(영업시간, 숨김 처리), 배송지 소유권, 메뉴 유효성을 차례대로 확인했습니다. 특히 클라이언트에서 전달받은 메뉴 가격을 그대로 신뢰하지 않고, DB에 존재하는 실제 메뉴 가격과 비교하는 교차 검증 로직을 넣어 가격 변조를 방지했습니다.
스냅샷 패턴과 Stream API를 활용한 객체 조립:
- 메뉴 테이블의 가격은 향후 상점 주인에 의해 변경될 수 있습니다. 주문 생성 시 확인된 최종 가격을 OrderItemEntity의 priceAtOrder 필드에 스냅샷으로 복사하여 저장했습니다. 이 변환 과정은 Java Stream API를 활용하여 여러 개의 요청 DTO를 안전하고 가독성 높게 엔티티 리스트로 매핑했습니다.
영속성 전이(Cascade)를 통한 데이터 저장
- 생성된 다건의 자식 아이템(OrderItem)을 별도의 Repository로 개별 저장하지 않았습니다. 부모와 자식 간의 생명주기 의존성을 고려하여, 부모(Order) 엔티티 저장 시 CascadeType.ALL 옵션에 의해 자식 엔티티들도 일괄적으로 DB에 INSERT 되도록 트랜잭션을 깔끔하게 관리했습니다.
DB 기반 권한 재검증을 통한 보안 강화
- 서비스 로직의 시작 지점에서 validateUserRoleFromDB(userId, role)을 호출하여 요청으로 전달된 권한 정보를 그대로 신뢰하지 않고, DB 기준으로 한 번 더 검증하도록 구성했습니다.
- JWT나 요청 파라미터에 포함된 권한 정보는 클라이언트 요청 흐름을 거쳐 들어오기 때문에, 중요한 비즈니스 로직에서는 서버 내부의 신뢰 가능한 데이터 기준으로 재검증하는 것이 안전합니다. 이를 통해 권한 위조나 잘못된 접근 가능성을 줄일 수 있었습니다.
💡 정리
- 교차 검증을 통한 데이터 조작 차단: 클라이언트가 요청한 가격 정보를 서버 단에서 데이터베이스의 실제 메뉴 가격과 교차 검증하여, 비정상적인 가격 변조 요청이 처리되는 것을 원천 차단했습니다.
- 스냅샷 패턴으로 과거 데이터 무결성 보장: 주문 생성 시점의 가격을 주문 상세 내역에 고정 복사하는 스냅샷 설계를 적용하여, 상점의 메뉴 가격 변경 등 외부 요인에도 기존 결제 금액 데이터의 무결성이 훼손되지 않도록 구현했습니다.
- JPA 영속성 전이(Cascade)를 활용한 최적화: 부모-자식 도메인 간의 애그리거트 관계를 파악하고 영속성 전이를 적용하여, 불필요한 Repository 메서드 호출을 줄이고 데이터베이스 INSERT 작업을 원자적으로 처리했습니다.
- 민감한 트랜잭션을 위한 이중 권한 검증: 클라이언트로부터 전달된 토큰의 권한을 맹신하지 않고 DB 기준으로 실시간 재검증을 수행하여 비즈니스 로직의 보안 수준을 높였습니다.