- 이번에는 데이터를 꺼내오는 Repository 계층입니다. 여기서는 단순히 쿼리를 날리는 것을 넘어, '보안(인가)'과 '도메인 주도 설계(DDD)' 관점에서 제가 어떤 구조적 고민을 했는지 회고해보려 합니다.
💻 실제 코드 (OrderRepository.java & OrderItemRepository.java)
OrderRepository.java
public interface OrderRepository extends JpaRepository<OrderEntity, UUID> {
/**
* 기본 페이징 조회 (삭제된 데이터 제외)
*/
Page<OrderEntity> findAllByIsDeletedFalse(Pageable pageable);
/**
* 특정 가게의 활성 주문 목록 조회
*/
Page<OrderEntity> findAllByStoreIdAndIsDeletedFalse(@Param("storeId") UUID storeId, Pageable pageable);
/**
* 가게 ID 및 숨김 여부에 따른 필터링 조회 (삭제된 데이터 제외)
*/
Page<OrderEntity> findAllByStoreIdAndIsHiddenAndIsDeletedFalse(@Param("storeId") UUID storeId, @Param("isHidden") Boolean isHidden, Pageable pageable);
/**
* 전체 주문 중 숨김 여부에 따른 필터링 조회 (삭제된 데이터 제외)
*/
Page<OrderEntity> findAllByIsHidden(@Param("isHidden") Boolean isHidden, Pageable pageable);
/**
* 결제를 위한 주문 존재 여부 조회
*/
Optional<OrderEntity> findByOrderIdAndUserId(@Param("orderId") UUID orderId, @Param("userId") UUID userId);
Page<OrderEntity> findAllByIsHiddenAndIsDeletedFalse(@Param("isHidden") Boolean isHidden, Pageable pageable);
/**
* Soft Delete가 false인지 검사
*/
Optional<OrderEntity> findByOrderIdAndIsDeletedFalse(@Param("orderId") UUID orderId);
// [RBAC] CUSTOMER: 본인의 주문만 조회
Page<OrderEntity> findAllByUserIdAndIsDeletedFalse(@Param("userId") UUID userId, Pageable pageable);
}
OrderItemRepository.java
public interface OrderItemRepository extends JpaRepository<OrderItemEntity, UUID> {
// 텅 비어있습니다!
}
🤔 왜 이렇게 짰는가? (설계 의도와 이유)
자바 코드가 아닌 '쿼리 레벨'에서 권한(RBAC)을 격리한 이유
- 처음에는 OrderService에서 findAll()로 주문을 다 가져온 다음, "고객이면 본인 것만 필터링하고, 사장님이면 자기 가게 것만 필터링하자"라고 생각할 수도 있습니다. 하지만 트래픽이 많아지면 서버 메모리가 터져버립니다.
- 그래서 데이터베이스 조회 시점부터 철저하게 데이터를 격리하기로 했습니다. 고객용 API는 무조건 findAllByUserId... 메서드만 타게 만들고, 사장님용 API는 무조건 findAllByStoreId... 메서드만 타게 하여, 타인의 데이터가 애플리케이션 메모리에 아예 올라오지 못하도록 성능과 보안(인가)을 동시에 해결했습니다.
결제 검증용 쿼리에 userId를 함께 넘긴 이유 (findByOrderIdAndUserId)
- 결제 모듈에서 "이 주문 번호(orderId) 결제할게요"라고 넘어올 때, 단순히 주문 번호만으로 DB를 조회하면 심각한 보안 구멍이 생깁니다. 악의적인 유저가 남의 주문 번호를 가로채서 결제를 시도할 수 있기 때문입니다.
- 그래서 현재 요청을 보낸 사람의 userId를 쿼리 조건(WHERE 절)에 포함시켰습니다. 이렇게 하면 쿼리 한 번으로 "이 주문이 실제로 존재하는가?"와 "이 주문이 진짜 네 것이 맞느냐?"라는 두 가지 소유권 검증을 안전하게 끝낼 수 있습니다.
텅 비어있는 OrderItemRepository (애그리거트 루트 패턴)
- 코드를 짜다 보니 OrderItemRepository 안에는 아무런 쿼리 메서드도 작성할 필요가 없다는 걸 깨달았습니다. 왜냐하면, 도메인 주도 설계(DDD) 관점에서 주문(Order)이 부모(애그리거트 루트)이고, 개별 아이템(OrderItem)은 부모에 종속된 부품이기 때문입니다.
- 이전 엔티티 설계에서 CascadeType.ALL을 걸어두었기 때문에, 개별 아이템을 DB에서 따로 찾고 저장할 필요 없이 오직 부모인 Order Repository 하나만을 통해서 모든 영속성 작업이 일어나도록 일관성을 강제한 결과입니다.
- 💡 애그리거트 루트(Aggregate Root)란?
- 관련된 객체들을 하나의 군집(애그리거트)으로 묶었을 때, 그 군집을 대표하는 대장 객체를 말합니다. 외부에서는 오직 이 '대장'을 통해서만 내부 데이터에 접근하고 수정할 수 있습니다.
- 적용된 로직: 우리 도메인에서는 주문(Order)이 대장(루트)이고, 개별 아이템(OrderItem)은 부하(자식)입니다. 이전 엔티티 설계에서 Order 쪽에 CascadeType.ALL을 걸어두었기 때문에, 개별 아이템을 DB에서 따로 찾고 저장할 필요 없이 오직 부모인 Order Repository 하나만을 통해서 모든 영속성(저장/삭제) 작업이 일어나도록 일관성을 강제했습니다.
- 🛡️ 그럼 빈 파일은 왜 만들어 두었을까? (확장성 대비)
- 비즈니스 로직(Service) 상에서는 Order를 통해서만 상태를 변경하는 것이 원칙이 맞습니다. 하지만, 추후 백오피스(관리자 페이지) 등에서 '특정 메뉴의 누적 판매량' 같은 통계를 내기 위해 OrderItem을 단독으로 대량 조회(Bulk Read)해야 하는 특수한 상황이 올 수 있습니다.
- 이러한 실무적인 확장성(Scalability)과 JPA 프레임워크의 관례를 고려하여 뼈대(Interface)만 남겨두되, 현재 비즈니스 로직에서는 일절 사용하지 않음으로써 DDD 원칙을 지켜냈습니다.
isHidden을 활용한 복합 필터링 쿼리의 필요성
- 코드를 보면 isHidden과 isDeleted를 조합한 필터링 쿼리들이 존재합니다. 이는 배달 플랫폼의 특성상 관리자(MANAGER/MASTER)나 사장님(OWNER)이 '숨김 처리된 주문(혹은 가게)'의 주문 내역을 따로 조회하거나 통계를 내야 하는 비즈니스 요구사항을 반영한 것입니다.
- 서비스 계층에서 if문으로 걸러내는 대신, JPA Naming Method를 통해 데이터베이스에서부터 정확하게 필요한 데이터만 페이징(Pageable)하여 가져오도록 설계하여 서버 메모리 낭비를 막았습니다.
💡 정리
- 쿼리 성능과 인가(Authorization) 동시 해결: 애플리케이션(Service) 메모리 단에서 필터링하는 실수를 범하지 않고, 사용자 역할(Customer, Owner)에 따라 특화된 JPA Naming Method를 만들어 쿼리 레벨부터 물리적으로 데이터를 격리했습니다.
- 보안 방어적 쿼리 설계: 결제 도메인 연동 시 타인의 주문을 탈취하지 못하도록 findByOrderIdAndUserId 메서드를 제공하여, DB 조회 단계부터 철저하게 데이터 소유권(Ownership)을 검증했습니다.
- DDD 애그리거트 루트(Aggregate Root)의 실천: OrderItem에 대한 불필요한 Repository 메서드를 만들지 않았습니다. Order를 애그리거트 루트로 삼아 엔티티 간의 의존성을 정리하고, 시스템의 복잡도를 낮췄습니다.