멀티캠퍼스에서 진행했던 프로젝트의 커머스 기능을 구현하면서 가장 많은 고민을 했던 부분 중 하나는 바로 '데이터베이스 조회'였습니다.
userEmail)로 장바구니에 담긴 상품을 가져오고, 판매자의 대시보드 통계를 출력하라"와 같은 비즈니스 요구사항이 늘어날수록, 다중 테이블을 넘나드는 조회가 필수적이었습니다.
이 글에서는 프로젝트 초기의 단순했던 엔티티 매핑을 객체 지향적으로 개선하여 얻은 이점과, 복잡한 쿼리 요구사항 앞에서 QueryDSL 도입을 시도하다 현실적인 일정 문제로 JPQL이라는 타협점을 찾기까지의 과정을 공유하고자 합니다.
1. JPA 연관관계 매핑의 도입과 활용
가장 먼저 선행된 작업은 관계형 데이터베이스의 패러다임을 객체 지향의 패러다임으로 맞추는 엔티티 리팩토링이었습니다.
1.1. 기존 외래 키(FK) 필드 직접 매핑의 한계
초기 엔티티 설계에서는 CartItems나 OrderItems 엔티티 내부에 userId, cartId, productId와 같이 RDB의 외래 키(FK) 컬럼을 원시 타입(UUID, String) 필드로 1:1 매칭하여 사용했습니다.
이 방식은 직관적이지만 결정적인 단점이 있었습니다. 객체 간의 참조(그래프 탐색)가 불가능했기 때문에, 연관된 테이블의 데이터를 가져오려면 서비스 계층에서 Repository를 여러 번 호출하여 직접 조립해야만 했습니다.
- 당시 예시:
1.2. 객체 지향적 연관관계 매핑으로의 진화
이러한 한계를 극복하고자 RDB의 외래 키 대신 @ManyToOne, @OneToOne 어노테이션을 사용하여 실제 엔티티 객체를 참조하도록 연관관계 매핑을 구성했습니다.
사진1:객체참조기반-엔티티-연관관계-매핑구조
이 변경은 엄청난 생산성 향상을 가져왔습니다. 연관관계가 없던 시절에는 불가능했던 JPA 쿼리 메서드(Derived Query Method)를 통한 객체 그래프 탐색이 가능해졌기 때문입니다. 실제로 프로젝트 곳곳에서 다음과 같이 메서드명만으로 깊은 계층의 조인(Join)을 깔끔하게 처리할 수 있었습니다.
- 장바구니 조회:
findByCart_User_UserEmail(...) - 주문 상품 조회:
findAllByProduct_Seller_User_UserEmail_AndOrderItemStatus_OrderByOrder_CreatedAtDesc(...)
1.3. 복잡한 Join과 조건에서의 JPQL 활용
하지만 만능일 것 같았던 쿼리 메서드도 비즈니스 로직이 복잡해지면서 한계를 드러냈습니다. GROUP BY를 통한 통계 추출이나, N+1 문제를 방지하기 위한 JOIN FETCH가 필요한 경우에는 메서드 이름만으로는 너무 길고 복잡했습니다.
따라서 우리 팀은 명확한 기준을 세웠습니다.
@Query)을 사용한다."실제 프로젝트의 OrderItemsRepository에 이 원칙이 잘 녹아있습니다.
2. 더 복잡한 요구사항과 QueryDSL 도입 실패기
연관관계 매핑과 JPQL의 조합으로 많은 문제를 해결했지만, 장바구니와 주문 도메인에서 동적 쿼리(사용자의 입력에 따라 조건이 달라지는 쿼리) 요구사항이 등장하면서 또 다른 위기가 찾아왔습니다.
문자열 기반의 JPQL은 동적 쿼리를 작성하기 매우 까다롭고, 컴파일 시점에 오타를 잡을 수 없다는 단점이 있었습니다. 이를 해결하기 위해 타입 세이프(Type-Safe)한 쿼리 빌더인 QueryDSL 도입을 결정했습니다.
2.1. 설정의 늪에 빠지다
당차게 build.gradle에 의존성을 추가했지만, 현실은 호락호락하지 않았습니다. 가장 먼저 Lombok과의 어노테이션 프로세서(Annotation Processor) 충돌 이 발생했습니다. QueryDSL의 Q-클래스를 생성하는 과정에서 Gradle 설정이 꼬여, 엔티티의 Getter조차 제대로 인식하지 못하는 빌드 에러가 쏟아졌습니다.
2.2. 마감 시간의 압박
프로젝트 내 다른 의존성과의 호환성 문제까지 겹치면서 원인을 알 수 없는 설정 지연이 계속되었습니다. 기능 구현 일정이 촉박한 상황에서, 완벽한 아키텍처를 세팅하는 데 더 이상 시간을 쏟을 수는 없었습니다. "당장 안정적으로 동작하는 코드를 만들고 기술 부채로 남겨두자" 는 현실적인 판단이 필요했습니다.
3. 현실적인 타협: JPQL 서브쿼리 및 조인 활용
결국 QueryDSL 도입을 잠정 보류하고, 기본적으로 제공되는 JPQL(@Query) 을 최대한 활용하는 방향으로 우회했습니다. 다소 쿼리 문자열을 직접 작성해야 하는 번거로움이 있지만, 이미 구성해 둔 탄탄한 엔티티 연관관계를 바탕으로 JOIN FETCH를 사용하여 N+1 문제를 깔끔하게 해결할 수 있었습니다.
3.1. CartItemRepository의 실제 적용 사례
장바구니에서 선택한 품목들을 주문서로 넘기거나 화면에 렌더링할 때 사용하기 위해 작성한 실제 CartItemRepository의 메서드입니다.
3.1.1. 쿼리 동작 원리 및 최적화 포인트
JOIN FETCH ci.product p: 장바구니 품목(CartItems)을 조회할 때 연관된 상품 정보(Product)를 즉시 로딩(Eager)으로 한 번에 가져옵니다.LEFT JOIN FETCH p.productImages pi: 상품에 등록된 이미지(ProductImages)가 없을 수도 있으므로(Null 가능성) 일반 조인이 아닌LEFT JOIN을 사용하되, 패치 조인을 결합하여 N+1 쿼리 발생을 원천 차단했습니다.DISTINCT의 사용: 1:N 관계인 이미지를JOIN FETCH할 경우 카테시안 곱(Cartesian Product)으로 인해 장바구니 품목 데이터가 이미지 수만큼 중복(뻥튀기)되어 반환됩니다. 이를 애플리케이션 단에서 걸러내기 위해DISTINCT키워드를 명시했습니다.IN절 활용: 한 번의 쿼리로 요청된 여러 개의cartItemIds데이터를 한 번에 조회하여 데이터베이스 I/O를 최소화했습니다.
4. 마무리하며: 남겨진 기술 부채
엔티티 연관관계 매핑을 통한 JPA 쿼리 메서드의 적극적인 활용, 그리고 N+1 문제 같은 성능 이슈가 발생하는 지점에서만 명시적인 JPQL(JOIN FETCH)을 혼용하는 전략 덕분에 마감 시간 내에 복잡한 커머스 기능들을 무사히 구현해 냈습니다.
하지만 QueryDSL을 적용하지 못하고 남겨둔 쿼리들은 여전히 기술 부채로 남아있습니다. 특히, 사용자 검색 조건에 따라 WHERE 절이 동적으로 변해야 하는 '동적 쿼리'가 필요한 화면에서는 문자열을 이어 붙이는 하드 코딩을 피하기가 어렵습니다.
비록 완벽하진 않지만, 주어진 제약 조건 속에서 JPA와 JPQL의 장단점을 명확히 이해하고 적재적소에 혼용하는 법을 배운 의미 있는 삽질(?)이었습니다. 기술 부채는 갚을 때 비로소 성장이 된다고 합니다. 조만간 이 쿼리들을 더 나은 구조로 리팩토링하며 다시 한번 성장한 모습으로 글을 이어가 보겠습니다.