📕 목차
1. 개요
2. ExtendedRepository
3. QueryDslUtil
4. Test Case
5. 실제 사용 사례
1. 개요
📌 기존 방식의 문제점
JPA method의 네이밍 룰을 사용하면 원하는 쿼리를 실행할 수 있다는 장점이 있다.
하지만 조건식이 많아질 수록 메서드명도 필연적으로 길어질 수밖에 없다는 단점이 존재하는데, 이에 대한 해결책으로 보통은 @Query를 사용한다.
하지만 @Query를 사용한 단점은 크게 두 가지가 있다고 생각하는데,
- JPQL 문법을 문자열로 입력하기에 컴파일 시점에서 에러를 잡을 수 없다.
- 하위 Repository가 업무 규칙에 종속된다.
(2)번의 경우 싱글 모듈에서는 크게 체감할 수 없지만, 멀티 모듈 환경을 고려하면 명백히 인지할 수 있다.
DomainService가 Repository를 주입받아 어느정도 완화할 수는 있겠지만, 비지니스 로직을 몰라야 할 Repository가 api의 업무 정책에 따라 메서드가 추가되어야 하는 문제가 발생한다.
심지어 하나의 Domain 모듈에 여러 Api가 관여하는 경우, 이 문제는 더 심각해진다.
고작 한 곳에서만 특수하게 사용되는 경우를 위해 Repository에 무분별하게 Query를 추가하는 것이 맞을까?
이 문제는 설령 query dsl을 사용한다고 해도 동일하다.
📌 JPA Specification
위의 문제를 개선하기 위해서 JPA Specification을 사용할 수도 있다.
간단히 설명하면 조건에 포함될 부분을 Specification 정보에 담는 방식인데, 이렇게 되면 도메인은 해당 메서드를 열어주기만 하고 호출자 측에서 쿼리를 관리할 수 있다는 장점이 있다.
물론 data jpa는 QueryDsl을 위한 Predicate Executor도 제공한다.
하지만 Jpa method부터 거슬리는 게 하나 있다면, 명시적 join을 표현하진 못한다.
Jpa method에서 Foo가 Bar를 참조하는 bar_id를 가질 때, findByBar_Id를 하면 Foo 테이블만 조회하는 게 아니라 Bar 테이블과 join하여 쿼리가 나간다. (보고 나면 알지만, 보기 전엔 어떤 쿼리가 나갈지 예측이 틀릴 때가 존재함)
그리고 위 방식은 QueryDsl의 가장 큰 장점인 결과를 Dto에 쉽게 담는 작업을 사용할 수 없다는 점이다.
어찌저찌 하면 가능은 하겠지만 번거로움..
📌 QueryDsl도 비슷하게 사용할 수 있지 않을까?
Specification을 사용하려고 찾아보다가 QueryDsl에서도 비슷하게 적용할 수 있지 않을까 싶었는데,
아니나 다를까 이미 이를 적용하신 분이 계셨다. 👍
다만 위 방식은 몇 가지 문제가 있었다.
- 함수 하나가 너무 많은 작업을 수행하고 있어서 로직을 이해하기가 어렵다.
- 범용성을 가지는 코드를 재활용할 수 없다.
- Dto에 정보를 담는 경우 Projections.bean()을 사용하기 때문에 setter, 기본 생성자를 허용해야 하며 따라서 필드에 final 한정자를 사용할 수도 없어 불변식이 깨진다.
그래서 위 문제들을 개선하여 리팩토링한 코드를 작성해보았다.
전체 코드는 아래에서 확인할 수 있습니다.
(PR 승인 대기 중이라 블로그랑 코드가 조금 다를 수 있습니다.)
2. ExtendedRepository
📌 QueryHandler
/**
* QueryDsl의 명시적 조인을 위한 함수형 인터페이스
*/
@FunctionalInterface
public interface QueryHandler {
JPAQuery<?> apply(JPAQuery<?> query);
}
QueryHandler라는 함수형 인터페이스를 사용하여, 조인되는 Entity를 명시적으로 표현하기 위해 사용할 것이다.
📌 QueryDsl Search Repository
public interface QueryDslSearchRepository<T> {
/**
* 검색 조건에 해당하는 도메인 리스트를 조회하는 메서드
*
* @param predicate : 검색 조건
* @param queryHandler : 검색 조건에 추가적으로 적용할 조건
* @param sort : 정렬 조건
*
* @see Predicate
* @see QueryHandler
* @see org.springframework.data.domain.PageRequest
*/
List<T> findList(Predicate predicate, QueryHandler queryHandler, Sort sort);
/**
* 검색 조건에 해당하는 도메인 페이지를 조회하는 메서드
*
* @param predicate : 검색 조건
* @param queryHandler : 검색 조건에 추가적으로 적용할 조건
* @param pageable : 페이지 정보
*
* @see Predicate
* @see QueryHandler
* @see org.springframework.data.domain.PageRequest
*/
Page<T> findPage(Predicate predicate, QueryHandler queryHandler, Pageable pageable);
/**
* 검색 조건에 해당하는 DTO 리스트를 조회하는 메서드 <br/>
* bindings가 {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다. <br/>
* 만약 bindings가 삽입 순서를 보장하지 않을 경우, Dto는 기본 생성자와 setter 메서드를 제공해야 하며, 모든 필드의 final 키워드를 제거해야 한다.
*
* @param predicate : 검색 조건
* @param type : 조회할 도메인(혹은 DTO) 타입
* @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드. {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입해야 한다.
* @param queryHandler : 검색 조건에 추가적으로 적용할 조건
* @param sort : 정렬 조건
*
* @see Predicate
* @see QueryHandler
* @see org.springframework.data.domain.PageRequest
*/
<P> List<P> selectList(Predicate predicate, Class<P> type, Map<String, Expression<?>> bindings, QueryHandler queryHandler, Sort sort);
/**
* 검색 조건에 해당하는 DTO 페이지를 조회하는 메서드
* bindings가 {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다. <br/>
* 만약 bindings가 삽입 순서를 보장하지 않을 경우, Dto는 기본 생성자와 setter 메서드를 제공해야 하며, 모든 필드의 final 키워드를 제거해야 한다.
*
* @param predicate : 검색 조건
* @param type : 조회할 도메인(혹은 DTO) 타입
* @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드. {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입해야 한다.
* @param queryHandler : 검색 조건에 추가적으로 적용할 조건
* @param pageable : 페이지 정보
*
* @see Predicate
* @see QueryHandler
* @see org.springframework.data.domain.PageRequest
*/
<P> Page<P> selectPage(Predicate predicate, Class<P> type, Map<String, Expression<?>> bindings, QueryHandler queryHandler, Pageable pageable);
}
메서드는 총 4가지로 다음과 같은 역할을 한다.
- Entity 반환
- findList()
- findPage()
- Dto 반환
- selectList()
- selectPage()
즉, query의 결과를 entity에 담을 지, dto에 담을지를 find와 select로 구분 가능하다.
🟡 구현체
public class QueryDslSearchRepositoryImpl<T> implements QueryDslSearchRepository<T> {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
private final EntityPath<T> path;
public QueryDslSearchRepositoryImpl(JpaEntityInformation<T, ?> entityInformation, EntityManager entityManager) {
this.em = entityManager;
this.queryFactory = new JPAQueryFactory(entityManager);
this.path = SimpleEntityPathResolver.INSTANCE.createPath(entityInformation.getJavaType());
}
public QueryDslSearchRepositoryImpl(Class<T> type, EntityManager entityManager) {
this.em = entityManager;
this.queryFactory = new JPAQueryFactory(entityManager);
this.path = new EntityPathBase<>(type, "entity");
}
@Override
public List<T> findList(Predicate predicate, QueryHandler queryHandler, Sort sort) {
return this.buildWithoutSelect(predicate, null, queryHandler, sort).select(path).fetch();
}
@Override
public Page<T> findPage(Predicate predicate, QueryHandler queryHandler, Pageable pageable) {
Assert.notNull(pageable, "pageable must not be null!");
JPAQuery<?> query = this.buildWithoutSelect(predicate, null, queryHandler, pageable.getSort()).select(path);
int totalSize = query.fetch().size();
query = query.offset(pageable.getOffset()).limit(pageable.getPageSize());
return new PageImpl<>(query.select(path).fetch(), pageable, totalSize);
}
@Override
public <P> List<P> selectList(Predicate predicate, Class<P> type, Map<String, Expression<?>> bindings, QueryHandler queryHandler, Sort sort) {
JPAQuery<?> query = this.buildWithoutSelect(predicate, bindings, queryHandler, sort);
if (bindings instanceof LinkedHashMap) {
return query.select(Projections.constructor(type, bindings.values().toArray(new Expression<?>[0]))).fetch();
}
return query.select(Projections.bean(type, bindings)).fetch();
}
@Override
public <P> Page<P> selectPage(Predicate predicate, Class<P> type, Map<String, Expression<?>> bindings, QueryHandler queryHandler, Pageable pageable) {
Assert.notNull(pageable, "pageable must not be null!");
JPAQuery<?> query = this.buildWithoutSelect(predicate, bindings, queryHandler, pageable.getSort()).select(path);
int totalSize = query.fetch().size();
query = query.offset(pageable.getOffset()).limit(pageable.getPageSize());
if (bindings instanceof LinkedHashMap) {
return new PageImpl<>(query.select(Projections.constructor(type, bindings.values().toArray(new Expression<?>[0]))).fetch(), pageable, totalSize);
}
return new PageImpl<>(query.select(Projections.bean(type, bindings)).fetch(), pageable, totalSize);
}
/**
* 파라미터를 기반으로 Querydsl의 JPAQuery를 생성하는 메서드
*/
private JPAQuery<?> buildWithoutSelect(Predicate predicate, Map<String, Expression<?>> bindings, QueryHandler queryHandler, Sort sort) {
JPAQuery<?> query = queryFactory.from(path);
applyPredicate(predicate, query);
applyQueryHandler(queryHandler, query);
applySort(query, sort, bindings);
return query;
}
/**
* Querydsl의 JPAQuery에 Predicate를 적용하는 메서드 <br/>
* Predicate가 null이 아닐 경우에만 적용
*/
private void applyPredicate(Predicate predicate, JPAQuery<?> query) {
if (predicate != null) query.where(predicate);
}
/**
* Querydsl의 JPAQuery에 QueryHandler를 적용하는 메서드 <br/>
* QueryHandler가 null이 아닐 경우에만 적용
*/
private void applyQueryHandler(QueryHandler queryHandler, JPAQuery<?> query) {
if (queryHandler != null) queryHandler.apply(query);
}
/**
* Querydsl의 JPAQuery에 Sort를 적용하는 메서드 <br/>
* Sort가 null이 아닐 경우에만 적용 <br/>
* Sort가 QSort일 경우에는 OrderSpecifier를 적용하고, 그 외의 경우에는 OrderSpecifier를 생성하여 적용
*/
private void applySort(JPAQuery<?> query, Sort sort, Map<String, Expression<?>> bindings) {
if (sort != null) {
if (sort instanceof QSort qSort) {
query.orderBy(qSort.getOrderSpecifiers().toArray(new OrderSpecifier[0]));
} else {
applySortOrders(query, sort, bindings);
}
}
}
private void applySortOrders(JPAQuery<?> query, Sort sort, Map<String, Expression<?>> bindings) {
for (Sort.Order order : sort) {
OrderSpecifier.NullHandling queryDslNullHandling = QueryDslUtil.getQueryDslNullHandling(order);
OrderSpecifier<?> os = QueryDslUtil.getOrderSpecifier(order, bindings, queryDslNullHandling);
query.orderBy(os);
}
}
}
QueryUtil에 내용은 목차 (3)에서 다시 이야기 할 거고, 메서드를 최대한 직관적으로 표현할 수 있도록 정리했다.
여기서 중요한 점은 결과를 Dto에 매핑하기 위한 ~select 메서드 내부의 LinkedHashMap 분기 처리 조건이다.
if (bindings instanceof LinkedHashMap) {
return query.select(Projections.constructor(type, bindings.values().toArray(new Expression<?>[0]))).fetch();
}
Dto의 불변성을 유지하려면 Projections.bean()이 아니라 Projections.constructor를 활용해야 했는데, 해당 메서드는 Expression<?> 가변 변수를 통해 인자를 받는다.
그렇다면 Map 자료형을 넘길 때, 입력 순서를 보장하는 LinkedHashMap을 사용한다면 생성자 매개변수 순서와 동일하게 값을 입력한 경우 constructor()를 사용할 수 있게 되는 것이다.
만약, 그 외의 HashMap 등을 사용한 경우엔 (같을 수도 있겠지만)생성자 매개변수와 value 순서가 동일함을 개발자가 보장하기 매우 어려우므로 Projections.bean()을 활용하며
이 경우엔 Dto에 setter, 기본 생성자를 정의하고 필드에 final 한정자를 제거해야만 한다.
📌 Extended Repository
@NoRepositoryBean
public interface ExtendedRepository<T, ID extends Serializable> extends JpaRepository<T, ID>, QueryDslSearchRepository<T> {
}
앞으로 Repository 인터페이스는 JpaRepository가 아닌 ExtendedRepository를 확장하도록 만들 것이다.
하지만 JpaRepository의 기능도 사용하고 싶으므로 함께 확장해준다.
Extended Repository는 인터페이스일 뿐 실제 구현체가 아니므로 @NoRepositoryBean을 통해 Proxy Bean 스캔 대상에서 제외시킨다.
📌 JpaRepositoryFactoryBean
@EnableJpaRepositories(basePackageClasses = DomainPackageLocation.class, repositoryBaseClass = ExtendedRepositoryFactory.class)
public class JpaConfig {
}
위에서 만든 Extended Repository 구현체의 존재를 Jpa에게 알려주어야 하는데, 위 스니펫으로 처리할 수 있다고 한다.
사실 나는 이렇게 안 해서 될 지 안 될지는 모르겠지만, 스택 오버플로우에서 그렇다고 함..
만약 해보고 안 되면 아래 방법으로 진행하면 된다.
public class ExtendedRepositoryFactory<T extends Repository<E, ID>, E, ID> extends JpaRepositoryFactoryBean<T, E, ID> {
/**
* Creates a new {@link JpaRepositoryFactoryBean} for the given repository interface.
*
* @param repositoryInterface must not be {@literal null}.
*/
public ExtendedRepositoryFactory(Class<? extends T> repositoryInterface) {
super(repositoryInterface);
}
@Override
@NonNull
protected RepositoryFactorySupport createRepositoryFactory(@NonNull EntityManager em) {
return new InnerRepositoryFactory(em);
}
private static class InnerRepositoryFactory extends JpaRepositoryFactory {
private final EntityManager em;
public InnerRepositoryFactory(EntityManager em) {
super(em);
this.em = em;
}
@Override
@NonNull
protected RepositoryComposition.RepositoryFragments getRepositoryFragments(@NonNull RepositoryMetadata metadata) {
RepositoryComposition.RepositoryFragments fragments = super.getRepositoryFragments(metadata);
if (QueryDslSearchRepository.class.isAssignableFrom(metadata.getRepositoryInterface())) {
var implExtendedJpa = super.instantiateClass(
QueryDslSearchRepositoryImpl.class,
this.getEntityInformation(metadata.getDomainType()),
this.em
);
fragments = fragments.append(RepositoryComposition.RepositoryFragments.just(implExtendedJpa));
}
return fragments;
}
}
}
@EnableJpaRepositories(basePackageClasses = DomainPackageLocation.class, repositoryFactoryBeanClass = ExtendedRepositoryFactory.class)
public class JpaConfig {
}
Jpa에게 구현체를 알려주는 게 아니라, Factory를 정의해서 넘겨주는 것.
공식 문서에 워낙 친절하게 설명을 해놔서 그대로 따라만 써도 된다.
3. QueryDslUtil
📌 왜 Util로 분리했는가?
OrderSpecifier를 반환하는 로직을 Util로 분리한 이유는 이게 생각보다 유용하게 쓰일 때가 많다.
ExtendedRepository에서 적용할 때 뿐만 아니라, 어쩔 수 없이 Pagenation을 사용한 무한 스크롤 등을 처리할 때 Repository에 메서드를 추가하게 되는 경우가 있을 텐데
그 때 Pageable 인터페이스의 OrderSpecifier를 queryDsl에 매핑해주는 작업 등에도 사용된다.
📌 구현
@Slf4j
public class QueryDslUtil {
private static final Function<Sort.NullHandling, OrderSpecifier.NullHandling> castToQueryDsl = nullHandling -> switch (nullHandling) {
case NATIVE -> OrderSpecifier.NullHandling.Default;
case NULLS_FIRST -> OrderSpecifier.NullHandling.NullsFirst;
case NULLS_LAST -> OrderSpecifier.NullHandling.NullsLast;
};
/**
* Sort.Order의 정보를 이용하여 OrderSpecifier.NullHandling을 반환하는 메서드
*
* @param order : {@link Sort.Order}
* @return {@link OrderSpecifier.NullHandling}
*/
public static OrderSpecifier.NullHandling getQueryDslNullHandling(Sort.Order order) {
return castToQueryDsl.apply(order.getNullHandling());
}
/**
* OrderSpecifier를 생성할 때, Sort.Order의 정보를 이용하여 OrderSpecifier.NullHandling을 적용하는 메서드
*
* @param order : {@link Sort.Order}
* @param nullHandling : {@link OrderSpecifier.NullHandling}
* @return {@link OrderSpecifier}
*/
public static OrderSpecifier<?> getOrderSpecifier(Sort.Order order, OrderSpecifier.NullHandling nullHandling) {
Order orderBy = order.isAscending() ? Order.ASC : Order.DESC;
return createOrderSpecifier(orderBy, Expressions.stringPath(order.getProperty()), nullHandling);
}
/**
* Expression이 Operation이고 Operator가 ALIAS일 경우, OrderSpecifier를 생성할 때, Expression을 StringPath로 변환하여 생성한다. <br/>
* 그 외의 경우에는 OrderSpecifier를 생성한다.
*
* @param order : {@link Sort.Order}
* @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드 정보. {@code binding}은 Map<String, Expression<?>> 형태로 전달된다.
* @param queryDslNullHandling : {@link OrderSpecifier.NullHandling}
* @return {@link OrderSpecifier}
*/
public static OrderSpecifier<?> getOrderSpecifier(Sort.Order order, Map<String, Expression<?>> bindings, OrderSpecifier.NullHandling queryDslNullHandling) {
Order orderBy = order.isAscending() ? Order.ASC : Order.DESC;
if (bindings != null && bindings.containsKey(order.getProperty())) {
Expression<?> expression = bindings.get(order.getProperty());
return createOrderSpecifier(orderBy, expression, queryDslNullHandling);
} else {
return createOrderSpecifier(orderBy, Expressions.stringPath(order.getProperty()), queryDslNullHandling);
}
}
@SuppressWarnings({"rawtypes", "unchecked"})
private static OrderSpecifier<?> createOrderSpecifier(Order orderBy, Expression<?> expression, OrderSpecifier.NullHandling queryDslNullHandling) {
if (expression instanceof Operation && ((Operation<?>) expression).getOperator() == Ops.ALIAS) {
return new OrderSpecifier<>(orderBy, Expressions.stringPath(((Operation<?>) expression).getArg(1).toString()), queryDslNullHandling);
} else {
return new OrderSpecifier(orderBy, expression, queryDslNullHandling);
}
}
}
OrderSpecifier.NullHandling을 여기서 처음 봤는데, 컬럼에 null이 들어왔을 때 순서를 어떻게 처리할 지 결정한다.
이 부분도 다름 명시적으로 보이게끔 리팩토링에 신경을 많이 써서, 설명보다 코드 보는 게 쉬울 것이다.
4. Test Case
💡 테스트 케이스면서 사용 방법을 설명해주는 코드라 참고하면 됩니다.
1️⃣ findList 테스트
@Test
@DisplayName("""
Entity findList 테스트: 이름이 양재서고, 일반 회원가입 이력이 존재하면서, lock이 걸려있지 않은 사용자 정보를 조회한다.
이때, 결과는 id 내림차순으로 정렬한다.
""")
@Transactional
public void findList() {
// given
Predicate predicate = qUser.name.eq("양재서")
.and(qUser.password.isNotNull())
.and(qUser.locked.isFalse());
QueryHandler queryHandler = null; // queryHandler는 사용하지 않으므로 null로 설정
Sort sort = Sort.by(Sort.Order.desc("id"));
// when
List<User> users = userRepository.findList(predicate, queryHandler, sort);
// then
Long maxValue = 100000L;
for (User user : users) {
log.info("user: {}", user);
assertTrue("id는 내림차순 정렬되어야 한다.", user.getId() <= maxValue);
assertTrue("일반 회원가입 이력이 존재해야 한다.", user.isGeneralSignedUpUser());
assertFalse("lock이 걸려있지 않아야 한다.", user.getLocked());
maxValue = user.getId();
}
}
Hibernate: select u1_0.id,u1_0.created_at,u1_0.deleted_at,u1_0.locked,u1_0.name,u1_0.account_book_notify,u1_0.chat_notify,u1_0.feed_notify,u1_0.password,u1_0.password_updated_at,u1_0.phone,u1_0.profile_image_url,u1_0.profile_visibility,u1_0.role,u1_0.updated_at,u1_0.username from user u1_0 where (u1_0.deleted_at IS NULL) and u1_0.name=? and u1_0.password is not null and u1_0.locked=? order by u1_0.id desc
(u1_0.deleted_at IS NULL)은 User Entity에 @SQLRestriction 걸어놔서 같이 나왔을 뿐이라 무시해도 된다. (아래에서도 동일)
2️⃣ findPage 테스트
@Test
@DisplayName("""
Entity findPage 테스트: 이름이 양재서고, Kakao로 가입한 Oauth 정보를 조회한다.
단, 결과는 처음 5개만 조회하며, id 내림차순으로 정렬한다.
""")
@Transactional
public void findPage() {
// given
Predicate predicate = qUser.name.eq("양재서")
.and(qOauth.provider.eq(Provider.KAKAO));
QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id));
Sort sort = Sort.by(Sort.Order.desc("user.id"));
int pageNumber = 0, pageSize = 5;
Pageable pageable = PageRequest.of(pageNumber, pageSize, sort);
// when
Page<User> users = userRepository.findPage(predicate, queryHandler, pageable);
// then
assertEquals("users의 크기는 5여야 한다.", 5, users.getSize());
Long maxValue = 100000L;
for (User user : users.getContent()) {
log.debug("user: {}", user);
assertTrue("id는 내림차순 정렬되어야 한다.", user.getId() <= maxValue);
assertEquals("이름이 양재서여야 한다.", "양재서", user.getName());
maxValue = user.getId();
}
}
Hibernate: select u1_0.id,u1_0.created_at,u1_0.deleted_at,u1_0.locked,u1_0.name,u1_0.account_book_notify,u1_0.chat_notify,u1_0.feed_notify,u1_0.password,u1_0.password_updated_at,u1_0.phone,u1_0.profile_image_url,u1_0.profile_visibility,u1_0.role,u1_0.updated_at,u1_0.username from user u1_0 left join oauth o1_0 on u1_0.id=o1_0.user_id where (u1_0.deleted_at IS NULL) and u1_0.name=? and o1_0.provider=? order by u1_0.id desc
Hibernate: select u1_0.id,u1_0.created_at,u1_0.deleted_at,u1_0.locked,u1_0.name,u1_0.account_book_notify,u1_0.chat_notify,u1_0.feed_notify,u1_0.password,u1_0.password_updated_at,u1_0.phone,u1_0.profile_image_url,u1_0.profile_visibility,u1_0.role,u1_0.updated_at,u1_0.username from user u1_0 left join oauth o1_0 on u1_0.id=o1_0.user_id where (u1_0.deleted_at IS NULL) and u1_0.name=? and o1_0.provider=? order by u1_0.id desc limit ?,?
3️⃣ selectList 불변성 테스트 (feat. LinkedHashMap)
@Test
@DisplayName("""
Dto selectList 테스트: 사용자 이름이 양재서인 사용자의 username, name, phone 그리고 연동된 Oauth 정보를 조회한다.
LinkedHashMap을 사용하여 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다.
""")
@Transactional
public void selectListUseLinkedHashMap() {
// given
Predicate predicate = qUser.name.eq("양재서");
QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id));
Sort sort = null;
Map<String, Expression<?>> bindings = new LinkedHashMap<>();
bindings.put("userId", qUser.id); // 반드시 생성자 매개변수 순서로 삽입해야 한다.
bindings.put("username", qUser.username);
bindings.put("name", qUser.name);
bindings.put("phone", qUser.phone);
bindings.put("oauthId", qOauth.id);
bindings.put("provider", qOauth.provider);
// when
List<UserAndOauthInfo> userAndOauthInfos = userRepository.selectList(predicate, UserAndOauthInfo.class, bindings, queryHandler, sort);
// then
userAndOauthInfos.forEach(userAndOauthInfo -> {
log.debug("userAndOauthInfo: {}", userAndOauthInfo);
assertEquals("이름이 양재서인 사용자만 조회되어야 한다.", "양재서", userAndOauthInfo.name());
assertEquals("provider는 KAKAO여야 한다.", Provider.KAKAO, userAndOauthInfo.provider());
});
}
public record UserAndOauthInfo(Long userId, String username, String name, String phone, Long oauthId, Provider provider) {
}
Hibernate: select o1_0.id,u1_0.phone,o1_0.provider,u1_0.name,u1_0.id,u1_0.username from user u1_0 left join oauth o1_0 on u1_0.id=o1_0.user_id where (u1_0.deleted_at IS NULL) and u1_0.name=?
4️⃣ selectList 일반 테스트 (feat. HashMap)
@Test
@DisplayName("""
Dto selectList 테스트: 사용자 이름이 양재서인 사용자의 username, name, phone 그리고 연동된 Oauth 정보를 조회한다.
HashMap을 사용하더라도 Dto의 setter를 명시하고 final 키워드를 제거하면 결과를 조회할 수 있다.
""")
@Transactional
public void selectListUseHashMap() {
// given
Predicate predicate = qUser.name.eq("양재서");
QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id));
Sort sort = null;
Map<String, Expression<?>> bindings = new HashMap<>();
bindings.put("userId", qUser.id);
bindings.put("username", qUser.username);
bindings.put("name", qUser.name);
bindings.put("phone", qUser.phone);
bindings.put("oauthId", qOauth.id);
bindings.put("provider", qOauth.provider);
// when
List<UserAndOauthInfoNotImmutable> userAndOauthInfos = userRepository.selectList(predicate, UserAndOauthInfoNotImmutable.class, bindings, queryHandler, sort);
// then
userAndOauthInfos.forEach(userAndOauthInfo -> {
log.debug("userAndOauthInfo: {}", userAndOauthInfo);
assertEquals("이름이 양재서인 사용자만 조회되어야 한다.", "양재서", userAndOauthInfo.getName());
assertEquals("provider는 KAKAO여야 한다.", Provider.KAKAO, userAndOauthInfo.getProvider());
});
}
@Setter
@Getter
public static class UserAndOauthInfoNotImmutable {
private Long userId;
private String username;
private String name;
private String phone;
private Long oauthId;
private Provider provider;
public UserAndOauthInfoNotImmutable() {}
}
5. 실제 사용 사례
📌 저수준에서 비지니스 정책 관심사 탈피
한 번은 사용자의 월별 지출 내역을 조회하는 쿼리를 작성해야 했는데, 이 정도 되면 @Query를 사용하는 것도 살짝 고역이다.
기회는 이 때다 싶어서 만들어놓고 방치시켜두던 ExtendedRepository의 findList()를 사용했었다.
List<Spending> spendings = spendingSearchService.readSpendings(userId, year, month);
@Slf4j
@Service
@RequiredArgsConstructor
public class SpendingSearchService {
private final SpendingService spendingService;
private final QUser user = QUser.user;
private final QSpending spending = QSpending.spending;
/**
* 사용자의 해당 년/월 지출 내역을 조회하는 메서드
*/
@Transactional(readOnly = true)
public List<Spending> readSpendings(Long userId, int year, int month) {
Predicate predicate = spending.user.id.eq(userId)
.and(spending.spendAt.year().eq(year))
.and(spending.spendAt.month().eq(month));
QueryHandler queryHandler = query -> query.leftJoin(user).on(spending.user.eq(user));
Sort sort = Sort.by(Sort.Order.desc("spendAt"));
return spendingService.readSpendings(predicate, queryHandler, sort);
}
}
확실히 도메인 모듈의 서비스에서 무분별하게 search 메서드를 제공해줘야 할 이유가 사라져서 너무 편했다.
하지만 위처럼 우후죽순 늘어나게 될 쿼리에 대한 관리의 필요성에 대해서도 느끼는 참이라, 아마 별도로 또 분리하게 되지 않을까 싶다.
현재는 이유가 없으므로 딱히 고려하지 않고 있음.