본문 바로가기

기술 블로그 (Tech Blog)/Project-coopang

repository 설계

 

Repository 패턴QueryDSL 기반 동적 쿼리 구현을 결합했습니다.

  1. 유지보수성: 기본 CRUD와 커스텀 로직을 분리.
  2. 확장성: 새로운 기능 추가가 용이.
  3. 성능 최적화: 페이징과 카운트 쿼리를 분리하여 성능 최적화.
  4. 타입 안정성: QueryDSL을 사용해 컴파일 타임 검증 가능.
  5. 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);
}

 

왜 필요한가?

  1. 도메인 계층의 독립성 유지
    • HubRepository는 도메인 계층에서 사용하기 위해 설계된 추상화된 인터페이스입니다.
    • 실제 구현은 Spring Data JPA, QueryDSL 등의 기술을 사용하지만, 도메인 계층은 이를 알 필요가 없습니다.
  2. 비즈니스 로직과 데이터 접근 분리
    • 도메인 계층의 비즈니스 로직과 데이터 접근 로직을 분리하여 코드의 관심사를 명확히 합니다.
  3. 유지보수성과 확장성 확보
    • 저장소 구현체(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);
    }
}

 

주요 기능

  1. QueryDSL의 페이징 처리:
    • QueryDSL에서 직접적으로 제공하지 않는 Spring Data Pageable과의 통합 기능을 추가했습니다.
  2. 동적 쿼리 간소화:
    • JPAQueryFactoryQuerydsl 객체를 직접적으로 사용할 필요 없이 간단한 메서드 호출로 동적 쿼리 작성.
  3. 재사용성:
    • 여러 엔티티에 대해 동일한 방식으로 동작하도록 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;
    }
}

 

카운트 쿼리를 분리하지 않을 경우의 문제점

카운트 쿼리를 분리하지 않고 컨텐츠 쿼리만 사용하면 다음과 같은 문제가 발생할 수 있습니다:

  1. 총 레코드 수를 정확히 계산할 수 없음
    • 페이징 연산이 포함된 쿼리로는 총 레코드 수를 계산할 수 없습니다.
  2. 불필요한 데이터 조회
    • 현재 페이지의 데이터만 필요하지만, 모든 데이터를 가져온 후 개수를 세는 방식이 될 수 있습니다.

 

페이징 처리의 핵심

Spring Data의 Page 객체는 다음 두 가지를 포함해야 합니다:

  1. 컨텐츠 데이터 (Content): 현재 페이지에 해당하는 데이터 리스트.
  2. 총 레코드 수 (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 메서드를 한 곳에서 사용할 수 있음.