본문 바로가기
이슈와해결

리팩토링 회고 - QueryDsl 검색 로직을 좀 더 클린하게 만들어보기

by Renechoi 2023. 12. 13.

0. 목차

  1. 목차
  2. 개요
  3. 첫 번째 구현 및 문제점
  4. 두 번째 구현 및 문제점
  5. 세 번째 구현 및 문제점
  6. 네 번째 구현 및 문제점
  7. 결론

1. 개요

사내 한 도메인 서비스에 새로운 API 를 추가 개발해야 하는 업무가 있었다. 검색 api를 제공해야 하는 부분에서 queryDsl을 사용면서 좀 더 클린한 방식의 코드 작성을 고민해보았다.

*실제 코드가 아닌 컨셉 코드로 대체하였습니다.

 

2. 첫 번째 구현 및 문제점

구현

 @RequiredArgsConstructor
@Component
public class DomainRepositoryImpl implements DomainCustomRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<DomainEntity> retrieve(String searchKeyword, String searchType, Pageable pageable) {
        QDomainEntity qDomainEntity = QDomainEntity.domainEntity;
        BooleanExpression expression = searchKeyword(searchType, searchKeyword);

        List<DomainEntity> results = queryFactory
                .selectFrom(qDomainEntity)
                .where(expression)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        long total = queryFactory
                .selectFrom(qDomainEntity)
                .where(expression)
                .fetchCount();

        return PageableExecutionUtils.getPage(results, pageable, () -> total);
    }

    private BooleanExpression searchKeyword(String searchType, String searchKeyword) {
        if (searchKeyword == null || searchKeyword.isEmpty()) {
            return null;
        }

        QDomainEntity qDomainEntity = QDomainEntity.domainEntity;

        if ("id".equalsIgnoreCase(searchType)) {
            return qDomainEntity.domainId.eq(Long.valueOf(searchKeyword));
        } else {
            return qDomainEntity.domainName.containsIgnoreCase(searchKeyword);
        }
    }
}

 

위의 자바 코드는 Querydsl를 활용한 DomainRepositoryImpl 클래스에 대한 구현다. 여기서는 searchKeywordsearchType을 파라미터로 받아, 각각에 맞는 검색 쿼리를 생성하고 있다. 이러한 방식은 코드가 단순하고 직관적으로 보이지만, 앞으로의 확장성을 고려하면 문제가 될 수 있다.

 

문제점

  1. 유연성 부족: 현재 구현은 'id'와 '이름'에 대한 검색만 가능합니다. 만약에 다른 검색 조건이 추가된다면, 메소드 수정이 불가피하다.
  2. 코드 수정 필요성: 새로운 요구사항이 생길 때마다 서버 코드를 변경해야 하기 때문에 유지 보수가 어렵다.

 

3. 두 번째 구현 및 문제점

구현

첫 번째 구현에서 발견된 유연성과 확장성의 문제를 해결하기 위해 DtoSearchFilter를 도입했다. ItemSearchRequest라는 Dto 클래스를 만들어 searchKeywordsearchType을 묶고, ItemSearchFilter 클래스에서 검색 조건을 처리한다.

 

 

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ItemSearchRequest implements SearchRequest {
    private String searchKeyword;
    private String searchType;
}

 

 

 

@Component
public class ItemSearchFilter {

    private static final QItem qItem = QItem.item;

    public Predicate build(ItemSearchRequest searchRequest) {
        String searchType = searchRequest.getSearchType();
        String searchKeyword = searchRequest.getSearchKeyword();

        BooleanExpression baseExpression = qItem.isNotNull();

        switch (searchType.toLowerCase()) {
            case "id":
                return baseExpression.and(qItem.itemId.containsIgnoreCase(searchKeyword));
            case "name":
                return baseExpression.and(qItem.itemName.containsIgnoreCase(searchKeyword));
            case "category":
                return baseExpression.and(qItem.category.containsIgnoreCase(searchKeyword));
            case "price":
                return baseExpression.and(qItem.price.eq(new BigDecimal(searchKeyword)));
            case "available":
                return baseExpression.and(qItem.isAvailable.eq(Boolean.parseBoolean(searchKeyword)));
            case "createdat":
                return baseExpression.and(qItem.createdAt.eq(LocalDateTime.parse(searchKeyword)));
            default:
                return baseExpression;
        }
    }
}

 

Dto를 도입하면서 다음과 같은 장점을 얻었다:

  1. 코드의 중복 감소: 검색 관련 정보가 하나의 Dto에 묶여 있으므로, 각 기능에서 이 Dto만 사용하면 된다.
  2. 확장성 증가: 모든 검색 타입과 검색 키워드를 해당 Dto를 통해 받을 수 있게 되었으므로, 유연한 검색 api를 제공한다. 또한 새로운 검색 조건이나 기능이 추가되더라도 DtoSearchFilter만 수정하면 되므로 확장성이 향상된다.

 

문제점

그러나 이런 구현 방식에는 다음과 같은 문제들이 따라왔다.

 

너무 많은 분기문과 가독성 저하

ItemSearchFilter에서 switch문을 사용해 여러 검색 조건을 처리하고 있습니다. 이로 인해 코드가 길어지고, 분기문이 많아져 가독성이 저하다.

 

이 문제를 해결하기 위한 다음 사항들을 고려했다.

  1. Strategy Pattern 적용: 각 검색 조건을 별도의 전략으로 분리하여, 런타임에서 동적으로 전략을 선택할 수 있게 하는 방법.
  2. Reflection 활용: 검색 조건의 이름과 필드 이름을 일치시켜, 리플렉션을 사용해 동적으로 검색 조건을 적용하는 방법.

 

각각의 장단점이 있었는데, 전략 패턴을 사용하는 경우 클래스가 필드 수 만큼 늘어난다는 단점이 있고, 리플렉션의 검색 속도의 저하라는 우려점이 있었다.

 

따라서 전략 패턴을 사용하는 방식은 배제하기로 했고, 리플렉션 사용은 차선으로 미뤄두고 다른 방식을 모색한다.

 

4. 세 번째 구현 및 문제점

구현

세 번째 구현에서는 OptionalBooleanBuilder라는 클래스를 도입하여 기존에 구현되어 있던 코드를 재사용하고, 동적으로 쿼리를 구성하는 방식을 선택했다.

 

작동 원리 및 이점

  1. OptionalBooleanBuilder는 Querydsl의 BooleanExpression을 래핑하여 더 유연한 쿼리 구성을 가능하게 한다.
  2. 검색 유형(searchType)과 검색 키워드(searchKeyword)에 따라 동적으로 쿼리를 생성한다.
  3. validate 메소드를 통해 불필요한 검색 유형이 들어오면 예외 처리를 해준다.

 

이러한 방식은 다음과 같은 이점을 가졌다.

  1. 더 깔끔한 API: 이전 방법보다 더 명확하고 직관적인 API를 제공한다.
  2. 코드의 재사용: OptionalBooleanBuilder를 다른 부분에서도 쉽게 활용할 수 있다.
  3. 확장성: 새로운 필드나 검색 조건이 추가될 경우, OptionalBooleanBuilder만 수정하면 된다.

 

@Component
@RequiredArgsConstructor
public class ADomainSearchFilter {

    private static final QADomain qADomain = QADomain.aDomain;
    private final ADomainFieldResolver fieldResolver;

    public void validate(String searchType){
        if (fieldResolver.isNotAllowedField(searchType)) {
            throw new IllegalArgumentException("검색 타입 없음");
        }
    }

    public Predicate createQuery(SearchRequest searchRequest) {
        String searchType = searchRequest.getSearchType().toLowerCase();
        String searchKeyword = searchRequest.getSearchKeyword();
        return new OptionalBooleanBuilder(qADomain.isNotNull())
                .notEmptyAnd(qADomain.domainId::containsIgnoreCase, searchType.equals("id") || searchType.equals("domainId") ? searchKeyword : null)
                .notEmptyAnd(qADomain.domainName::containsIgnoreCase, searchType.equals("name") || searchType.equals("domainName") ? searchKeyword : null)
                .notEmptyAnd(qADomain.createdBy::containsIgnoreCase, searchType.equals("createdby") ? searchKeyword : null)
                .notEmptyAnd(qADomain.createdIp::containsIgnoreCase, searchType.equals("createdip") ? searchKeyword : null)
                .notEmptyAnd(qADomain.modifiedBy::containsIgnoreCase, searchType.equals("modifiedby") ? searchKeyword : null)
                .notEmptyAnd(qADomain.modifiedIp::containsIgnoreCase, searchType.equals("modifiedip") ? searchKeyword : null)
                .notNullAnd(qADomain.value::eq, searchType.equals("value") ? new BigDecimal(searchKeyword) : null)
                .notNullAnd(qADomain.isActive::eq, searchType.equals("isactive") ? ("1".equals(searchKeyword) || "true".equalsIgnoreCase(searchKeyword)) : null)
                .notNullAnd(qADomain.createdAt::eq, searchType.equals("createdat") ? LocalDateTime.parse(searchKeyword) : null)
                .build();
    }

}

 

 

public class OptionalBooleanBuilder {
    private final BooleanExpression booleanExpression;

    public OptionalBooleanBuilder(BooleanExpression booleanExpression) {
        this.booleanExpression = booleanExpression;
    }

    public <T> OptionalBooleanBuilder notNullAnd(Function<T, BooleanExpression> expressionFunction,
                                                 T value) {
        if (value != null) {
            return new OptionalBooleanBuilder(
                    this.booleanExpression.and(expressionFunction.apply(value)));
        }
        return this;
    }

    public OptionalBooleanBuilder notEmptyAnd(
            Function<String, BooleanExpression> expressionFunction, String value) {
        if (!StringUtils.isEmpty(value)) {
            return new OptionalBooleanBuilder(
                    this.booleanExpression.and(expressionFunction.apply(value)));
        }
        return this;
    }
}

 

문제점

 

그러나 이 구현 방식에도 몇 가지 문제점이 있었다.

 

가독성 문제

조건문이 많아져서 가독성이 저하됩니다. 코드를 처음 보는 사람이 이해하기 어려울 수 있다.

 

하드코딩 문제

검색 유형(searchType)이 코드 내부에 하드코딩 되어 있다. 유지보수를 어렵게 하고, 코드의 유연성을 떨어뜨린다.

 

5. 네 번째 구현 및 문제점

구현

이번 구현에서는 리플렉션을 사용하여 SearchCommand 객체 내부에서 검색 유형(searchType)과 검색 키워드(searchKeyword)를 자동으로 매핑하는 방법을 적용했다. 클라이언트로부터 들어오는 DtoFacade 레벨에서 SearchCommand 객체로 변환합니다. 이 객체 내부에서 검색 유형과 키워드를 필드와 매핑시켜 주고, 이렇게 매핑된 값을 필터(SearchFilter)에서 사용했다.

 

 

작동 원리

  1. SearchCommand 객체는 SearchRequest DTO로부터 생성된다.
  2. 이 객체의 mapSearchKeyword 메서드가 호출되면, 검색 유형에 맞는 필드를 찾아 해당 필드의 타입에 맞게 검색 키워드를 변환한다.
  3. 이렇게 변환된 검색 키워드는 SearchFilter에서 쿼리를 생성할 때 사용된다.

이러한 접근 방법은 검색 유형과 키워드의 매핑 로직을 SearchCommand 객체 내부로 숨김으로써, 필터의 복잡성을 줄이고 책임을 분산시켰다.

 

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GenericSearchCommand {
    private String id;
    private String name;
    private BigDecimal amount;
    private Boolean isActive;
    private Boolean isDeleted;
    private LocalDateTime createdAt;
    private String createdBy;
    private String createdIp;
    private LocalDateTime modifiedAt;
    private String modifiedBy;
    private String modifiedIp;

    private String searchKeyword;
    private String searchType = "name";
    private Map<String, Object> searchMapping = new HashMap<>();

    public static GenericSearchCommand from(SearchRequest searchRequest) {
        return VoMapper.convert(searchRequest, GenericSearchCommand.class);
    }

    public void mapSearchKeyword() {
        Field field;
        try {
            field = GenericEntity.class.getDeclaredField(searchType);
            searchMapping.put(field.getName(), convertValue(field.getType(), searchKeyword));
        } catch (NoSuchFieldException | IllegalAccessException | InstantiationException e) {
            throw new IllegalArgumentException("잘못된 검색 타입");
        }
    }

    private Object convertValue(Class<?> fieldType, String value) throws IllegalAccessException, InstantiationException {
        // 변환 로직은 그대로 유지
    }
}

 

 

 

@Component
@RequiredArgsConstructor
public class GenericSearchFilter {

    private static final QGenericEntity qGenericEntity = QGenericEntity.genericEntity;
    private final GenericEntityFieldResolver fieldResolver;

    public void validate(String searchType){
        if (fieldResolver.isNotAllowedField(searchType)) {
            throw new IllegalArgumentException("검색 타입 없음");
        }
    }

    public Predicate build2(GenericSearchCommand command) {
        OptionalBooleanBuilder booleanBuilder = new OptionalBooleanBuilder(qGenericEntity.isNotNull());

        // 검색 로직의 나머지 부분은 동일
    }
}

 

문제점

역시 문제는 속도였다. 속도가 중요한 검색 로직에서 리플렉션을 사용하는 것이 오버엔지니어링이 되는 것은 아닌지, 적절한 선택인지 고민할 필요가 있다.

 

효율성과 코드 복잡성의 트레이드 오프

리플렉션을 사용하면 코드가 복잡해질 수 있고, 성능에도 영향을 미친다. 당연히 사용하지 않는 것보다 성능이 크게 떨어질 것이다. 그럼에도 사용하고 싶다면 해당 방식을 적용한 경우와 아닌 경우를 나누어 테스트가 필요하다.

 

타입 안정성 문제

리플렉션을 사용하면 컴파일 타임에서의 타입 체크가 약화된다. 이로 인해 런타임에서 예상치 못한 에러가 발생할 가능성도 있다(고 한다).

 

6. 결론

이러한 점들을 고려할 때 리플렉션을 사용해 코드 레벨을 단순화하는 구현 방식이 최선일지를 고민했다. 최종적으로는 OptionalBooleanBuilder를 이용한 세 번째 구현 방식을 채택했다.

 

사실 검색이라는 기능에서 성능은 이슈의 여지가 크기 때문에 4번의 방안과 같이 리플렉션을 사용하는 것은 애초에 선택지가 아닐 것이다.

내가 했던 리팩토링에서 정말 추구하는 바가 무엇이었을까를 생각해보았다. 첫번째는 클린 코드다. 그런데 클린 코드가 필요한 이유가 무엇일까? 사실 한번 써놓고 다시 안 볼꺼면 (그러기를 기대하지만) 굳이 클린한 코드를 고집할 이유도 없다. 언젠간 다시 봐야할 때 폼이 들지 않고 싶어서이다. 즉 유지보수성이다. 그런데 이번 케이스는 유지보수성 + 코드 작성에서도 이슈가 있었다.

 

가만히 생각 해 보니 필드를 매핑하는 것을 직접 쓰기 싫었던 것이 두 번째 이유였다. 결국 문제는 자동화였던 것이다. 필드가 자동으로 매핑되었으면 하는 욕망이 너무 컸다.

 

4번의 방안은 반쯤은 자동화를 성공시킨다. 앞서 말했듯 동적으로 자동화가 되면서 조금이라도 성능적인 손해가 발생한다는 것이 문제다. 다른 기능이라면 그냥 넘어갈 법한 것도 검색에서는 허용되지 않는다.

 

그래서 이 문제는 개인적인 기술부채로 남은 것 같다. 생각해 본 방법 중 하나는 롬복 처럼 애노테이션 프로세서와 자동 코드 생성 도구를 사용해서 컴파일 시점에 필요한 쿼리를 만들어주는 것이다. 몇 차례 시도해보았지만 완성시키진 못했다. 기회와 시간이 주어진다면 다시 도전해보고 싶은 과제다.

반응형