- 주문 도메인의 마지막 서비스 로직입니다. 이번 글에서는 사용자의 역할(Role)에 따라 완전히 다른 데이터를 안전하게 내려주는 조회(Read) 로직과, 무결성을 지키기 위해 조건부로 작동하는 삭제(Soft Delete) 로직을 회고해 봅니다.
💻 실제 코드 (OrderServiceV1.java - 조회, 수정, 삭제)
/**
* 주문 상세 조회
*/
public ResGetOrderDtoV1 getOrder(UUID orderId, UUID userId, UserRole role) {
// 실시간 권한 재검증 및 차단 유저 접근 방어
validateUserRoleFromDB(userId, role);
OrderEntity order = orderRepository.findById(orderId)
.filter(o -> !o.getIsDeleted())
.orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
// 역할(Role)별 상세 조회 인가(Authorization) 처리 로직
boolean isAuthorized = false;
if (role == UserRole.MASTER || role == UserRole.MANAGER) {
isAuthorized = true; // 관리자는 조건 없이 통과 처리
} else if (role == UserRole.CUSTOMER && order.getUserId().equals(userId)) {
isAuthorized = true; // 고객은 본인 주문일 경우에만 통과 처리
} else if (role == UserRole.OWNER) {
StoreEntity store = storeRepository.findById(order.getStoreId())
.orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND));
if (store.getUser() != null && store.getUser().getId().equals(userId)) {
isAuthorized = true; // 사장님은 본인 소유의 가게 주문일 경우에만 통과 처리
}
}
// 인가에 실패한 제3자의 접근은 원천 차단
if (!isAuthorized) {
throw new CustomException(ErrorCode.ORDER_NOT_OWNER);
}
return ResGetOrderDtoV1.from(order);
}
/**
* 주문 목록 조회
*/
public Page<ResGetOrderListDtoV1> getOrders(UUID storeId, Boolean isHidden, Pageable pageable, UUID userId, UserRole role) {
validateUserRoleFromDB(userId, role); // DB 권한 재검증 실행
// 역할(Role)에 따라 완전히 분리된 Repository 쿼리 메서드를 호출하여 데이터 격리함
if (role == UserRole.CUSTOMER) {
// 고객은 본인의 주문 데이터만 가져오는 전용 쿼리 호출
return orderRepository.findAllByUserIdAndIsDeletedFalse(userId, pageable).map(ResGetOrderListDtoV1::from);
}
if (role == UserRole.OWNER) {
if (storeId == null) { throw new CustomException(ErrorCode.VALIDATION_ERROR); }
StoreEntity store = storeRepository.findById(storeId)
.orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND));
// 가게 소유권 검증 후, 해당 가게의 주문 데이터만 가져오는 전용 쿼리 호출
if (store.getUser() == null || !store.getUser().getId().equals(userId)) {
throw new CustomException(ErrorCode.ORDER_FORBIDDEN_FOR_OWNER);
}
return orderRepository.findAllByStoreIdAndIsDeletedFalse(storeId, pageable).map(ResGetOrderListDtoV1::from);
}
// MASTER/MANAGER용 필터링 다형성 처리 (숨김 조건에 따른 동적 쿼리 대용 로직)
if (storeId != null && isHidden != null) {
return orderRepository.findAllByStoreIdAndIsHiddenAndIsDeletedFalse(storeId, isHidden, pageable).map(ResGetOrderListDtoV1::from);
} else if (storeId != null) {
return orderRepository.findAllByStoreIdAndIsDeletedFalse(storeId, pageable).map(ResGetOrderListDtoV1::from);
} else if (isHidden != null) {
return orderRepository.findAllByIsHiddenAndIsDeletedFalse(isHidden, pageable).map(ResGetOrderListDtoV1::from);
}
return orderRepository.findAllByIsDeletedFalse(pageable).map(ResGetOrderListDtoV1::from);
}
/**
* 주문 요청사항 수정
*/
@Transactional
public ResGetOrderDtoV1 updateOrderRequest(UUID orderId, String newRequest, UUID userId, UserRole role) {
validateUserRoleFromDB(userId, role); // DB 권한 재검증 실행
OrderEntity order = orderRepository.findById(orderId)
.filter(o -> !o.getIsDeleted())
.orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
// 타인이 요청사항을 수정하지 못하도록 소유권을 검증
if (!order.getUserId().equals(userId)) {
throw new CustomException(ErrorCode.ORDER_NOT_OWNER);
}
try {
// 서비스에서 PENDING 상태를 직접 체크하지 않고, Entity 내부 비즈니스 로직을 호출하여 상태 검증
책임을 도메인 객체에 위임
order.updateRequest(newRequest);
order.markUpdatedBy(userId);
} catch (IllegalStateException e) {
// Entity가 발생시킨 순수 자바 예외를 캐치하여 HTTP API 규격에 맞는 CustomException으로
변환(Exception Translation)함
throw new CustomException(ErrorCode.ORDER_REQUEST_UPDATE_FAILED);
}
return ResGetOrderDtoV1.from(order, "요청사항이 성공적으로 수정되었습니다.");
}
/**
* 주문 삭제
*/
@Transactional
public void deleteOrder(UUID orderId, UUID userId, UserRole role) {
validateUserRoleFromDB(userId, role); // DB 권한 재검증 실행
OrderEntity order = orderRepository.findById(orderId)
.filter(o -> !o.getIsDeleted())
.orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
// 이미 배달 중이거나 완료된 주문은 데이터 무결성을 위해 삭제를 차단
if (order.getStatus() == OrderStatus.DELIVERED || order.getStatus() == OrderStatus.COMPLETED) {
throw new CustomException(ErrorCode.ORDER_CANNOT_DELETE_DELIVERED);
}
order.softDelete(userId); // 물리적 삭제(Hard Delete) 대신 상태 플래그 변경 처리
}
}
🤔 왜 이렇게 짰는가? (설계 의도와 이유)
역할(Role)에 따른 쿼리 분리와 데이터 격리를 통한 인가 처리 및 성능 최적화
- 주문 목록 조회(getOrders) 로직에서는 데이터를 애플리케이션 단으로 모두 가져온 뒤 if문으로 필터링하는 방식을 지양했습니다. 대신, 요청을 보낸 사용자의 역할(Customer, Owner)을 먼저 판단하고 해당 역할에 맞는 Repository 쿼리를 물리적으로 다르게 호출하도록 구성했습니다.
- 이를 통해 타인의 데이터가 애플리케이션 메모리로 불필요한 데이터가 올라오는 상황을 줄이고자 하였으며, 대규모 트래픽 환경에서의 조회 성능과 데이터 인가(Authorization) 처리의 안전성을 동시에 확보할 수 있었습니다.
데이터 무결성을 위한 조건부 논리 삭제(Soft Delete)
- 주문 데이터를 삭제(deleteOrder)할 때 단순히 isDeleted 상태만 변경하여 덮어쓰지 않았습니다. 삭제 로직 수행 직전에 현재 주문의 상태를 확인하여, 이미 정산 프로세스로 넘어간 DELIVERED나 COMPLETED 상태일 경우 예외를 발생시켜 삭제를 강제로 차단했습니다.
- 이는 단순한 CRUD 구현을 넘어, 서비스 운영 시 발생할 수 있는 주요 결제 및 통계 데이터의 유실을 도메인 관점에서
선제적으로 방어하기 위한 목적이었습니다.
엔티티 내부 비즈니스 로직 위임
- 요청사항 수정(updateOrderRequest) 시, 서비스 계층에서 현재 주문 상태가 PENDING인지 검사하는 로직을 작성하지 않았습니다. 대신 엔티티 내부의 updateRequest 메서드를 호출하여, 엔티티 스스로가 자신의 상태를 검증하도록 책임을 위임했습니다.
- 이를 통해 서비스 계층은 도메인 로직을 알 필요 없이 인가 처리와 트랜잭션 관리에만 집중하도록 역할을 분리했습니다.
💡 정리
- 다형적 쿼리 설계를 통한 안전한 데이터 격리: 사용자의 역할(Role)에 따라 호출하는 DB 쿼리를 분리하여 애플리케이션 메모리 낭비를 막고, 잘못된 객체 접근 권한(BOLA) 취약점을 쿼리 레벨에서 방어했습니다.
- 도메인 생명주기를 고려한 방어적 삭제 로직: 이미 배달이 완료되어 정산 대상이 된 주문의 삭제를 예외 처리로 차단하는 조건부 Soft Delete를 적용하여, 결제 데이터의 무결성을 지켜냈습니다.
- 응집도 높은 도메인 객체 설계: 상태 검증 로직을 서비스 계층에 흩뿌리지 않고 엔티티 객체에 책임을 위임함으로써, 객체 지향적인 코드 작성을 통해 유지보수성을 높였습니다.