Repository 패턴과 QueryDSL 기반 동적 쿼리 구현을 결합했습니다.
- 유지보수성: 기본 CRUD와 커스텀 로직을 분리.
- 확장성: 새로운 기능 추가가 용이.
- 성능 최적화: 페이징과 카운트 쿼리를 분리하여 성능 최적화.
- 타입 안정성: QueryDSL을 사용해 컴파일 타임 검증 가능.
- DDD 원칙 준수: 도메인 계층과 인프라스트럭처 계층의 책임 분리.
Repository 구조
- HubRepository (도메인 인터페이스):
- DDD에서 "도메인 계층"에 속하며, 도메인 요구사항에 따른 저장소 인터페이스를 정의.
- 핵심 비즈니스 로직과 연계된 메서드를 제공 (e.g., findByHubIdAndIsDeletedFalse).
- HubJpaRepository (구현체):
- Spring Data JPA를 사용하여 기본적인 CRUD 및 JpaRepository를 확장.
- HubRepository와 HubRepositoryCustom을 결합하여 기본 JPA 메서드 + 커스텀 QueryDSL 메서드를 통합.
- HubRepositoryCustom (QueryDSL Custom 인터페이스):
- QueryDSL 기반으로 복잡한 동적 쿼리를 처리하기 위한 인터페이스.
- HubRepositoryCustomImpl (QueryDSL Custom 구현체):
- QueryDSL을 사용하여 복잡한 쿼리를 구현.
- 도메인 계층의 요구사항을 충족시키기 위한 구체적인 로직 작성.
코드
domain repository interface
package com.coopang.hub.domain.repository.hub;
import com.coopang.hub.application.request.hub.HubSearchConditionDto;
import com.coopang.hub.domain.entity.hub.HubEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface HubRepository {
Optional<HubEntity> findByHubId(UUID hubId);
Optional<HubEntity> findByHubIdAndIsDeletedFalse(UUID hubId);
List<HubEntity> findHubList(HubSearchConditionDto condition);
Page<HubEntity> search(HubSearchConditionDto condition, Pageable pageable);
}
왜 필요한가?
- 도메인 계층의 독립성 유지
- HubRepository는 도메인 계층에서 사용하기 위해 설계된 추상화된 인터페이스입니다.
- 실제 구현은 Spring Data JPA, QueryDSL 등의 기술을 사용하지만, 도메인 계층은 이를 알 필요가 없습니다.
- 비즈니스 로직과 데이터 접근 분리
- 도메인 계층의 비즈니스 로직과 데이터 접근 로직을 분리하여 코드의 관심사를 명확히 합니다.
- 유지보수성과 확장성 확보
- 저장소 구현체(JPA, MongoDB 등)를 변경해도 HubRepository 인터페이스는 변경되지 않으므로 도메인 계층에는 영향을 주지 않습니다.
의존성 역전 원칙(DIP)
- 고수준(High-level) 모듈은 저수준(Low-level) 모듈에 의존하지 말아야 한다.
- 대신, **둘 다 추상화(인터페이스)**에 의존해야 한다.
- **"고수준이 저수준에 의존하지 않는다"**는 것은 레이어드 아키텍처의 기본 원칙입니다.
고수준 (High-Level)
- 고수준 모듈은 시스템의 비즈니스 로직이나 애플리케이션의 주요 목적을 나타냅니다.
- 이 모듈은 애플리케이션의 핵심 "의도"와 관련된 복잡한 로직을 처리하며, 다른 계층(특히 기술적 세부 사항)에 의존하지 않습니다.
- 예: 도메인 계층 (Domain Layer)
- 비즈니스 규칙, 엔티티(Entity), 값 객체(Value Object), 도메인 서비스(Domain Service) 등이 포함됩니다.
저수준 (Low-Level)
- 저수준 모듈은 시스템의 기술적 세부 사항을 처리합니다.
- 데이터베이스, 파일 시스템, 네트워크 통신 등과 같은 인프라 작업을 수행하며, 일반적으로 고수준 모듈에 의해 호출됩니다.
- 예: 인프라 계층 (Infrastructure Layer)
- JPA Repository, QueryDSL, HTTP 클라이언트, Kafka와 같은 기술적 구현이 포함됩니다.
DDD에서의 Repository 역할
DDD에서 Repository는 다음과 같은 역할을 합니다:
1. Aggregate 루트 관리
- Repository는 Aggregate 루트를 저장하고 조회하는 역할을 합니다.
- HubEntity가 Aggregate 루트라면, Repository를 통해 해당 Aggregate에 대한 모든 저장소 작업을 수행합니다.
2. 데이터 저장 및 조회
- Aggregate를 저장(save), 조회(findById), 삭제(delete)하는 작업을 담당합니다.
- HubRepository는 Aggregate 루트(HubEntity)를 다루는 메서드를 제공합니다.
3. 비즈니스 규칙에 따른 데이터 접근
- Repository는 단순히 데이터를 가져오는 것이 아니라, 도메인 로직에 맞는 데이터를 가져오기 위한 메서드를 정의합니다.
- 예: 삭제되지 않은 허브만 조회 (findByHubIdAndIsDeletedFalse).
- 특정 검색 조건을 만족하는 허브를 조회 (findHubList, search).
HubRepository의 역할
1. 도메인 계층의 데이터 접근 추상화
- HubRepository는 도메인 계층에서 사용할 저장소 인터페이스를 정의합니다.
- 데이터 저장소가 어떤 기술(JPA, MongoDB, MyBatis 등)을 사용하는지 알 필요가 없도록 추상화합니다.
- 이로써 도메인 계층은 기술 세부사항(인프라스트럭처)에 의존하지 않고 독립성을 유지할 수 있습니다
2. 도메인 특화 데이터 접근 메서드 제공
- HubRepository는 허브(HubEntity)와 관련된 도메인 특화 데이터 접근 메서드를 정의합니다.
- 일반적인 CRUD 외에 비즈니스 요구사항에 따라 필요한 메서드를 추가할 수 있습니다.
예를 들어:
- findByHubId(UUID hubId):
- 허브 ID로 특정 허브를 조회.
- findByHubIdAndIsDeletedFalse(UUID hubId):
- 삭제되지 않은 허브만 조회.
- findHubList(HubSearchConditionDto condition):
- 검색 조건에 따라 허브 리스트 조회.
- search(HubSearchConditionDto condition, Pageable pageable):
- 페이징 처리가 필요한 검색 쿼리.
3. 기술 세부 사항과 도메인의 분리
- HubRepository는 도메인 계층에서 사용하는 저장소의 인터페이스만 제공하며, 실제 구현은 인프라스트럭처 계층에서 처리됩니다.
- 이를 통해 도메인 계층은 저장소의 구현체(JPA, QueryDSL 등)를 몰라도 되고, 저장소가 변경되어도 도메인 계층에는 영향을 주지 않습니다.
infrastructure repository interface
package com.coopang.hub.infrastructure.repository.hub;
import com.coopang.hub.domain.entity.hub.HubEntity;
import com.coopang.hub.domain.repository.hub.HubRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface HubJpaRepository extends JpaRepository<HubEntity, UUID>, HubRepository, HubRepositoryCustom {
Optional<HubEntity> findByHubId(UUID hubId);
}
extends JpaRepository<HubEntity, UUID> 상속 받았기에
Spring Data JPA가 내부적으로 SimpleJpaRepository라는 기본 구현체를 사용해 필요한 메서드를 동적으로 구현합니다.
HubJpaRepository의 실제 구현 위치
Spring Data JPA가 생성한 구현체는 **실행 시(runtime)**에 생성되므로, 코드로 작성된 구현체를 직접 확인할 수는 없습니다.
대신, Spring은 다음과 같은 방식으로 구현체를 등록합니다:
- Spring이 @EnableJpaRepositories 또는 @SpringBootApplication을 통해 애플리케이션 시작 시 JpaRepository를 확장한 모든 인터페이스를 검색.
- 인터페이스에 대해 프록시 객체를 생성하여 동적으로 메서드를 처리.
- 기본적으로 SimpleJpaRepository가 구현체로 사용됩니다.
infrastructure QueryDSL repository interface
package com.coopang.hub.infrastructure.repository.hub;
import com.coopang.hub.application.request.hub.HubSearchConditionDto;
import com.coopang.hub.domain.entity.hub.HubEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
public interface HubRepositoryCustom {
Page<HubEntity> search(HubSearchConditionDto condition, Pageable pageable);
List<HubEntity> findHubList(HubSearchConditionDto condition);
}
apiConfig 공통 abstact class
package com.coopang.apiconfig.querydsl;
import com.querydsl.core.types.EntityPath;
import com.querydsl.core.types.Expression;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.support.JpaEntityInformation;
import org.springframework.data.jpa.repository.support.JpaEntityInformationSupport;
import org.springframework.data.jpa.repository.support.Querydsl;
import org.springframework.data.querydsl.SimpleEntityPathResolver;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.stereotype.Repository;
import org.springframework.util.Assert;
import java.util.List;
import java.util.function.Function;
@Repository
@ConditionalOnBean(EntityManager.class)
public abstract class Querydsl4RepositorySupport {
private final Class domainClass;
private Querydsl querydsl;
private EntityManager entityManager;
private JPAQueryFactory queryFactory;
public Querydsl4RepositorySupport(Class<?> domainClass) {
Assert.notNull(domainClass, "Domain class must not be null!");
this.domainClass = domainClass;
}
@Autowired(required = false)
public void setEntityManager(EntityManager entityManager) {
Assert.notNull(entityManager, "EntityManager must not be null!");
JpaEntityInformation entityInformation = JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);
SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE;
EntityPath path = resolver.createPath(entityInformation.getJavaType());
this.entityManager = entityManager;
this.querydsl = new Querydsl(entityManager, new PathBuilder<>(path.getType(), path.getMetadata()));
this.queryFactory = new JPAQueryFactory(entityManager);
}
@PostConstruct
public void validate() {
Assert.notNull(entityManager, "EntityManager must not be null!");
Assert.notNull(querydsl, "Querydsl must not be null!");
Assert.notNull(queryFactory, "QueryFactory must not be null!");
}
protected JPAQueryFactory getQueryFactory() {
return queryFactory;
}
protected Querydsl getQuerydsl() {
return querydsl;
}
protected EntityManager getEntityManager() {
return entityManager;
}
protected <T> JPAQuery<T> select(Expression<T> expr) {
return getQueryFactory().select(expr);
}
protected <T> JPAQuery<T> selectFrom(EntityPath<T> from) {
return getQueryFactory().selectFrom(from);
}
protected <T> Page<T> applyPagination(Pageable pageable, Function<JPAQueryFactory, JPAQuery> contentQuery) {
JPAQuery jpaQuery = contentQuery.apply(getQueryFactory());
List<T> content = getQuerydsl().applyPagination(pageable, jpaQuery).fetch();
return PageableExecutionUtils.getPage(content, pageable, jpaQuery::fetchCount);
}
protected <T> Page<T> applyPagination(Pageable pageable, Function<JPAQueryFactory, JPAQuery> contentQuery, Function<JPAQueryFactory, JPAQuery> countQuery) {
JPAQuery jpaContentQuery = contentQuery.apply(getQueryFactory());
List<T> content = getQuerydsl().applyPagination(pageable, jpaContentQuery).fetch();
JPAQuery countResult = countQuery.apply(getQueryFactory());
return PageableExecutionUtils.getPage(content, pageable, countResult::fetchCount);
}
}
주요 기능
- QueryDSL의 페이징 처리:
- QueryDSL에서 직접적으로 제공하지 않는 Spring Data Pageable과의 통합 기능을 추가했습니다.
- 동적 쿼리 간소화:
- JPAQueryFactory와 Querydsl 객체를 직접적으로 사용할 필요 없이 간단한 메서드 호출로 동적 쿼리 작성.
- 재사용성:
- 여러 엔티티에 대해 동일한 방식으로 동작하도록 domainClass를 기반으로 한 추상화
QueryDSL 및 EntityManager 초기화
- setEntityManager: EntityManager와 Querydsl, JPAQueryFactory를 초기화합니다.
- Querydsl 객체는 QueryDSL이 제공하는 페이징 처리 및 동적 쿼리 빌더를 지원합니다.
- JPAQueryFactory는 QueryDSL로 JPA 쿼리를 작성할 때 사용됩니다.
- @PostConstruct의 validate: 모든 의존성을 초기화했는지 확인합니다
QueryDSL 기반의 동적 쿼리 지원
- 동적 쿼리 생성 메서드
- select(Expression<T> expr): 특정 컬럼이나 표현식에 대해 QueryDSL select 쿼리를 시작합니다.
- selectFrom(EntityPath<T> from): 특정 엔티티에 대해 QueryDSL selectFrom 쿼리를 시작합니다.
- 이 메서드들은 QueryDSL 쿼리를 간결하게 생성할 수 있도록 돕습니다.
페이징 처리 기능
- applyPagination 메서드는 Spring Data의 Pageable을 사용하여 QueryDSL 기반의 페이징 쿼리를 처리합니다.
- 첫 번째 메서드:
- **컨텐츠 쿼리(content query)**만을 받아서 페이징 처리.
- 두 번째 메서드:
- **컨텐츠 쿼리(content query)**와 **카운트 쿼리(count query)**를 별도로 받아서 페이징 처리.
- 첫 번째 메서드:
- 페이징 처리 방식
- contentQuery를 실행하여 데이터를 가져오고, countQuery를 실행하여 총 레코드 수를 계산합니다.
- PageableExecutionUtils.getPage를 사용하여 Page 객체를 반환합니다.
추상 클래스로 재사용 가능
- 이 클래스는 특정 도메인 클래스에 종속되지 않으며, 다양한 엔티티를 다룰 수 있도록 domainClass를 일반화(generic)했습니다.
- 이를 상속받아 구체적인 리포지토리를 구현하면, QueryDSL 기반의 동적 쿼리 작성 및 페이징 기능을 손쉽게 사용할 수 있습니다.
infrastructure QueryDSL repository Concreate Class
package com.coopang.hub.infrastructure.repository.hub;
import static com.coopang.hub.domain.entity.hub.QHubEntity.hubEntity;
import com.coopang.apiconfig.querydsl.Querydsl4RepositorySupport;
import com.coopang.hub.application.request.hub.HubSearchConditionDto;
import com.coopang.hub.domain.entity.hub.HubEntity;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Predicate;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.UUID;
@Repository
public class HubRepositoryCustomImpl extends Querydsl4RepositorySupport implements HubRepositoryCustom {
public HubRepositoryCustomImpl() {
super(HubEntity.class);
}
@Override
public Page<HubEntity> search(HubSearchConditionDto condition, Pageable pageable) {
final BooleanBuilder whereClause = generateWhereClause(condition);
return applyPagination(pageable, contentQuery -> contentQuery
.selectFrom(hubEntity)
.where(
whereClause
),
countQuery -> countQuery
.selectFrom(hubEntity)
.where(
whereClause
)
);
}
@Override
public List<HubEntity> findHubList(HubSearchConditionDto condition) {
final BooleanBuilder whereClause = generateWhereClause(condition);
return selectFrom(hubEntity)
.where(
whereClause
)
.fetch();
}
private BooleanBuilder generateWhereClause(HubSearchConditionDto condition) {
BooleanBuilder whereClause = new BooleanBuilder();
whereClause.and(hubIdEq(condition.getHubId()));
whereClause.and(hubNameStarsWith(condition.getHubName()));
whereClause.and(hubManagerIdEq(condition.getHubManagerId()));
whereClause.and(hubEntity.isDeleted.eq(condition.isDeleted()));
return whereClause;
}
private Predicate hubIdEq(UUID hubId) {
return !ObjectUtils.isEmpty(hubId) ? hubEntity.hubId.eq(hubId) : null;
}
private Predicate hubNameStarsWith(String hubName) {
return StringUtils.hasText(hubName) ? hubEntity.hubName.startsWith(hubName) : null;
}
private Predicate hubManagerIdEq(UUID hubManagerId) {
return !ObjectUtils.isEmpty(hubManagerId) ? hubEntity.hubManagerId.eq(hubManagerId) : null;
}
}
카운트 쿼리를 분리하지 않을 경우의 문제점
카운트 쿼리를 분리하지 않고 컨텐츠 쿼리만 사용하면 다음과 같은 문제가 발생할 수 있습니다:
- 총 레코드 수를 정확히 계산할 수 없음
- 페이징 연산이 포함된 쿼리로는 총 레코드 수를 계산할 수 없습니다.
- 불필요한 데이터 조회
- 현재 페이지의 데이터만 필요하지만, 모든 데이터를 가져온 후 개수를 세는 방식이 될 수 있습니다.
페이징 처리의 핵심
Spring Data의 Page 객체는 다음 두 가지를 포함해야 합니다:
- 컨텐츠 데이터 (Content): 현재 페이지에 해당하는 데이터 리스트.
- 총 레코드 수 (Total Count): 데이터베이스에 저장된 전체 데이터의 개수.
카운트 쿼리는 Total Count를 계산하는 데 사용됩니다.
이를 통해 **전체 페이지 수(totalPages)**와 현재 페이지 여부(hasNext, hasPrevious) 같은 정보를 제공할 수 있습니다.
카운트 쿼리를 분리하는 이유
(1) 성능 최적화
카운트 쿼리는 단순히 총 레코드 수를 계산하기 때문에, 데이터 전체를 가져올 필요 없이 COUNT(*) 만 수행합니다.
- 예를 들어, 데이터가 1,000,000건이 있는 경우, 카운트 쿼리는 1,000,000건의 데이터를 모두 가져오는 대신, 단순히 레코드 개수만 계산합니다.
- 컨텐츠 쿼리는 현재 페이지에 필요한 데이터만 가져오므로, 두 쿼리가 각각 최적화됩니다.
(2) 복잡한 쿼리 상황
컨텐츠를 가져오는 쿼리와 카운트 쿼리는 서로 다른 방식으로 최적화될 수 있습니다.
- 예: LIMIT, OFFSET 같은 페이징 연산은 카운트 쿼리에는 불필요합니다.
- 컨텐츠 쿼리: SELECT * FROM table LIMIT 10 OFFSET 0
- 카운트 쿼리: SELECT COUNT(*) FROM table
QueryDSL에서 분리 처리
QueryDSL은 두 가지를 분리해서 처리할 수 있도록 설계되어 있습니다:
- 컨텐츠 쿼리(content query): 페이징된 데이터를 가져옵니다.
- 카운트 쿼리(count query): 총 레코드 수를 계산합니다.
applyPagination 메서드에서 다음과 같이 처리됩니다:
return applyPagination(pageable,
contentQuery -> contentQuery
.selectFrom(hubEntity)
.where(whereClause), // 현재 페이지 데이터를 가져오는 쿼리
countQuery -> countQuery
.selectFrom(hubEntity)
.where(whereClause) // 전체 레코드 수를 계산하는 쿼리
);
카운트 쿼리의 동작 원리
PageableExecutionUtils.getPage 메서드가 두 쿼리 결과를 결합합니다:
- 컨텐츠 데이터는 fetch()로 가져옵니다.
- 카운트는 countQuery::fetchCount를 호출하여 총 레코드 수를 계산합니다.
PageableExecutionUtils.getPage(content, pageable, countResult::fetchCount);
왜 이렇게 설계했는가?
유지보수성
- JpaRepository에서 제공하는 기본 CRUD 메서드와, QueryDSL을 사용한 커스텀 동적 쿼리를 분리하여 책임을 명확히 했습니다.
- 복잡한 쿼리가 필요한 경우 HubRepositoryCustom에만 집중하면 되므로, 유지보수가 용이.
확장성
- 새로운 비즈니스 요구사항(예: 복잡한 쿼리)이 추가되더라도 HubRepositoryCustom에 메서드를 추가하고, 이를 HubRepositoryCustomImpl에서 구현하면 됩니다.
- 도메인 계층과 인프라스트럭처 계층이 분리되어 있으므로, 데이터 접근 방식이 변경되더라도 도메인 로직에 영향을 최소화.
페이징 및 동적 쿼리의 효율성
- 페이징을 처리하기 위해 QueryDSL의 applyPagination 메서드를 활용:
- 데이터가 많은 경우에도 성능 저하를 최소화하기 위해 카운트 쿼리를 분리.
- BooleanBuilder를 활용해 동적으로 조건을 추가:
- 조건이 복잡해도 코드가 깔끔하고 읽기 쉬움.
타입 안정성과 컴파일 타임 검증
- QueryDSL은 자바 코드로 쿼리를 작성하기 때문에, SQL 쿼리 문자열 기반의 오류를 방지.
- 컴파일 타임에 잘못된 필드나 메서드 사용을 검출.
Spring Data JPA와 QueryDSL의 결합
- Spring Data JPA의 기본 기능을 활용하면서도, QueryDSL을 사용해 복잡한 요구사항을 충족.
- HubJpaRepository가 HubRepository와 HubRepositoryCustom을 모두 구현함으로써 JPA 메서드와 QueryDSL 메서드를 한 곳에서 사용할 수 있음.
'기술 블로그 (Tech Blog) > Project-coopang' 카테고리의 다른 글
Coopang 프로젝트: 멀티 모듈 구조와 역할 정리 (0) | 2024.11.19 |
---|---|
Coopang 프로젝트: Stateless 구조와 코레오그래피 기반 SAGA 패턴 적용 사례Stateless 구조코레오그래피 기반 SAGA 패턴 적용 사례 (0) | 2024.11.19 |
local 통합 테스트 (0) | 2024.10.25 |
슬랙으로 메세지 보내기 (0) | 2024.10.23 |
mapper (0) | 2024.10.23 |