Notice
Recent Posts
Recent Comments
Link
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Archives
Today
Total
관리 메뉴

참새의 이야기

Querydsl로 동적 쿼리 활용하기 본문

Spring

Querydsl로 동적 쿼리 활용하기

참새짹짹! 2024. 2. 14. 20:14

지난 글에서는 querydsl을 사용하기 위해 알아야 하는 아주 기본적인 부분들을 소개했다.

 

이번에는 최근에 프로젝트를 하면서 querydsl을 사용하면 유연하게 db 조회를 할 수 있다는 것을 실감했는데, 지난가을에 했던 뉴핏 프로젝트를 크게 개선할 수 있겠다는 생각이 들어서 글로도 기록해보려고 한다.

 

우선, 아래는 뉴핏에서 개선이 필요한 코드의 일부분이다.

@GetMapping
public ResponseEntity<EquipmentGymListResponse> getAllEquipment(
    @RequestHeader(name = "authority-id") Long authorityId,
    @RequestParam(name = "purposeType", required = false) PurposeType purposeType,
    @RequestParam(name = "equipment_id", required = false) Long equipmentId) {
    Gym gym = authorityService.getGymByAuthorityId(authorityId);
    EquipmentGymListResponse allInGym;

    if (purposeType == null && equipmentId == null) {
        allInGym = equipmentGymService.findAllInGym(gym);
    } else if (purposeType != null && equipmentId == null) {
        allInGym = equipmentGymService.findAllInGymByPurpose(gym, purposeType);
    } else if (purposeType == null && equipmentId != null) {
        Equipment equipment = equipmentService.findById(equipmentId);
        allInGym = equipmentGymService.findAllInGymByEquipment(gym, equipment);
    } else {
        return ResponseEntity.badRequest().build();
    }

    return ResponseEntity
        .ok(allInGym);
}

 

controller인데 굉장히 복잡하고 한눈에 의도가 들어오지 않는다.

 

더러운 코드를 굳이 해석할 필요는 없으니 요약하자면, purposeType이나 equipmentId를 이용해서 클라이언트가 이용 중인 gym의 기구를 조회하는 것이 목적이다.

🤔누가봐도 예쁜 코드는 아닌 것 같은데 애초에 왜 이렇게 코드를 작성했냐?

이런 의문이 들 수 있다.

하지만, 당시에는 querydsl을 도입하기 전이었기 때문에 각각에 대해 다른 repository 메서드를 사용할 수밖에 없었다.

 

물론, 이외에도 querydsl과는 무관하지만 controller에서 복수의 service를 호출한다는 문제점이 있다.

단순 조회의 목적이니 큰 문제는 없지만, service에서도 충분히 처리가 가능한 일인데 controller에서 처리하는 것은 바람직하지 않다.

 

이제 본격적으로 controller부터 하나씩 개선해 보자.

1. @RequestParam보다 dto를 사용하자

동적 쿼리에 대한 글이라더니 왜 갑자기 @RequestParam 을 dto로 바꾸는지 의아할 수 있다.

사실 이 모든 일이 querydsl로 동적 쿼리를 날리기 위한 빌드업이다.

 

기존 코드에서는 각각의 쿼리 파라미터를 @RequestParam을 사용해서 받고 있다.

필수는 아니기 때문에 required 옵션을 false로 설정한 것을 확인할 수 있다.

 

만약 쿼리 파라미터 종류가 하나만 존재했다면 이렇게 처리하는 것이 효율적일 수 있겠으나, 우리 코드의 경우에는 쿼리 파라미터의 종류가 두 가지이므로 dto를 만들어 사용하는 것이 더 간단하다.

public record EquipmentQueryRequest(PurposeType purpose, Long equipment_id) {}

 

특히 이 값들을 repository까지 가져가야 전달해야 하므로 하나로 묶어서 전달하는 편이 더 깔끔하다.

2. controller의 복잡한 if 제거

아래의 코드는 위의 controller 코드에서 if문을 발췌한 것이다.

EquipmentGymListResponse allInGym;    
if (purposeType == null && equipmentId == null) {
    allInGym = equipmentGymService.findAllInGym(gym);
} else if (purposeType != null && equipmentId == null) {
    allInGym = equipmentGymService.findAllInGymByPurpose(gym, purposeType);
} else if (purposeType == null && equipmentId != null) {
    Equipment equipment = equipmentService.findById(equipmentId);
    allInGym = equipmentGymService.findAllInGymByEquipment(gym, equipment);
} else {
    return ResponseEntity.badRequest().build();
}

 

결국 모든 케이스에서 equipmentGymService를 호출하고 EquipmentGymListResponse 을 반환받는다.

마지막 else에서는 badRequest를 반환하기는 하지만, 사실 이 경우도 badRequest보다는 두 가지 조건 모두 만족하는 것들을 조회해서 반환하는 것이 적절하다.

 

 

우리의 목표는 하나의 repository 메서드가 모든 케이스에 대응 가능한 코드를 짜는 것이기 때문에 if문으로 분기하는 일은 필요 없다.

하나의 repository 메서드가 모든 케이스에 대응할 것이기 때문에 service단의 메서드도 하나면 충분하다.

이를 반영해 controller에서는 복잡한 if문을 모두 지워버리고 service 단의 findAllByQuery 메서드를 호출하도록 개선하자.

@GetMapping
public ResponseEntity<EquipmentGymListResponse> getAllEquipment(
    @RequestHeader(name = "authority-id") Long authorityId, 
    EquipmentQueryRequest request) {

    EquipmentGymListResponse allInGym = equipmentGymService.findAllByQuery(authorityId, request);

    return ResponseEntity
        .ok(allInGym);
}

 

가장 위에서 봤던 controller와 같은 일을 한다고 믿기 어려울 정도로 간결하고 목적이 명확해졌다.

 

이제는 controller가 넘긴 공을 service, repository가 받아야 할 차례다.

기존의 controller에서 if문으로 분기하여 경우에 따라 다른 service 메서드를 호출하면, 각각의 service 메서드에서는 다른 repository의 메서드를 호출했다.

즉, 하나의 controller인데도 어떤 값이 null이고 어떤 값이 null이 아닌지에 따라 repository의 다른 메서드를 사용했다는 것이다.

결국 핵심은 repository에 있으니 service단은 생략하고 아래에서는 repository에서 querydsl을 어떻게 활용했는지 알아보자.

3. BooleanExpression으로 where 조건을 구성하자

마침내 querydsl이 등장할 시간이다.

이제 어떻게 세 개의 메서드가 하나로 합쳐질 수 있었는지를 확인해 보자.

 

아직도 개선된 버전에서는 어떤 값이 null인지를 확인하지 않았다.

아래의 repository 코드에서 이 작업을 처리할 것이다.

@Override
public List<EquipmentGym> findAllByQueryOption(Gym gym, EquipmentQueryRequest request) {
    return queryFactory.selectFrom(equipmentGym)
        .join(equipmentGym.equipment, equipment).fetchJoin()
        .where(eqId(id), eqEquipmentId(request.equipment_id()), eqPurpose(request.purpose()))
        .fetch();
}

private BooleanExpression eqId(Long id) {
    return equipmentGym.gym.id.eq(id);
}

private BooleanExpression eqEquipmentId(Long equipmentId) {
    return equipmentId == null ? null : equipmentGym.equipment.id.eq(equipmentId);
}

private BooleanExpression eqPurpose(PurposeType purpose) {
    return purpose == null ? null : equipment.purposeType.eq(purpose);
}

 

service에서 호출하는 repository의 findAllByQueryOption의 모습이다.

이 단 하나의 메서드가 모든 case에 대응할 수 있었던 것은 BooleanExpression의 역할이 크다.

where절 내부를 보면, 각 쿼리 조건들을 동적으로 처리하기 위해 BooleanExpression을 반환하는 메서드들을 호출한다.

 

BooleanExpression을 반환하는 eqEquipmentId, eqPurpose 메서드들을 보면 parameter로 들어온 값이 null이면 null을 반환하고 null이 아니라면 BooleanExpression을 반환한다.

 

동적 쿼리가 가능한 것은, BooleanExpression이 null을 반환하면, where에서 제거되기 때문에 가능한 일이다.

예를 들어, eqEquipmentId, eqPurpose 가 모두 null을 반환한다면 위의 where절은 아래와 같이 바뀐다.

// Before
.where(eqId(id), eqEquipmentId(request.equipment_id()), eqPurpose(request.purpose()))

// After
.where(eqId(id))

4. BooleanBuilder로 where절을 정리하자

BooleanExpression을 사용하는 것만으로도 코드가 충분히 깔끔해졌지만, where절 안에서 연달아 3개의 메서드가 호출되는 것이 마음에 들지 않는다면 BooleanBuilder로 조금 더 정리할 수 있다.

@Override
public List<EquipmentGym> findAllByQueryOption(Gym gym, EquipmentQueryRequest request) {
    return queryFactory.selectFrom(equipmentGym)
        .join(equipmentGym.equipment, equipment).fetchJoin()
        .where(eqId(gym.getId()), queryOption(request))
        .fetch();
}

private BooleanBuilder queryOption(EquipmentQueryRequest request) {
        BooleanBuilder booleanBuilder = new BooleanBuilder();

        return booleanBuilder
            .and(eqPurpose(request.purpose()))
            .and(eqEquipmentId(request.equipment_id()));
}

BooleanBuilder를 단독으로 사용하면 정적 쿼리를 만들게 된다는 것이 한계지만, BooleanExpression을 같이 사용함으로써 동적 쿼리를 만들 수 있게 되었다.

타입이 다른데도 이런 코드가 가능한 이유는 BooleanBuilderand()에서는 Predicate를 매개변수로 받는데, BooleanExpression이 Predicate를 implement 하고 있기 때문이다.

 

 

 

이 글의 코드는 Pull Request에서 확인할 수 있습니다.

'Spring' 카테고리의 다른 글

Querydsl 기본 세팅  (0) 2024.02.05
[Spring Security] Authentication Architecture  (0) 2023.11.05
[Spring Security] Spring security Architecture  (0) 2023.11.05
[MVC2] API 예외 처리  (2) 2023.08.15
[MVC2] MVC 예외 처리  (0) 2023.08.12