Skip to main content

Command Palette

Search for a command to run...

복잡한 Native 쿼리를 JOOQ로 치환하여 유지보수성을 높인 사례

JOOQ 도입 배경과 적용 결과 공유

Updated
3 min read
복잡한 Native 쿼리를 JOOQ로 치환하여 유지보수성을 높인 사례

배경

재고이관 에픽을 긴급하게 진행하게 되면서, 일반적으로 지켜야 할 개발 규칙이나 코드 품질 기준을 지키지 못한 채 기능 구현이 이루어진 상황이 있었습니다. 특히 재고이관 목록 조회 기능의 경우, 쿼리 자체가 복잡하여 Querydsl로는 기능 구현이 불가능했기 때문에, 부득이하게 Native Query로 기능을 작성하게 되었습니다.

이러한 방식은 단기적으로는 문제 해결에 도움이 되었지만, 결과적으로는 해당 코드를 작성한 사람 외에는 이해하거나 유지보수하기 어려운 구조로 이어졌습니다. 쿼리 복잡성과 DSL의 표현 한계로 인해, 코드의 재사용성과 확장성 모두 저하된 상태였습니다.

목적

이러한 상황을 개선하기 위해 다음과 같은 목표를 수립하였습니다.

  • 기존에 작성된 Native Query 기반의 코드를 제거하고, 유지보수가 가능한 구조로 리팩토링하고자 하였습니다.

  • 앞으로도 복잡한 쿼리를 요하는 기능 개발이 예상되므로, Querydsl 외에도 다양한 DSL을 사용할 수 있는 기반 환경을 구성하고자 하였습니다.

기술적 해결 방안

위 문제를 해결하기 위한 방법으로 **JOOQ(Java Object Oriented Querying)**를 도입하였습니다. JOOQ는 SQL 쿼리를 타입 세이프하게 Java 코드로 작성할 수 있는 DSL 도구로, 다음과 같은 이유로 적합하다고 판단하였습니다.

Querydsl의 현황

2024년 6월을 마지막으로 공식적인 Querydsl 릴리즈는 더 이상 제공되지 않고 있으며, 현재는 OpenFeign에서 별도로 포크하여 유지 중인 것으로 파악됩니다. 그러나 해당 저장소는 활동성이나 별(star) 수를 기준으로 볼 때, 활발하게 유지되고 있다고 보기 어렵습니다.

JOOQ 선정 기준

다음의 조건을 만족하는 DSL 도구를 찾고자 했습니다:

  • Querydsl처럼 타입 세이프한 문법을 제공할 것

  • array_agg, && 등과 같이 데이터베이스 종속적인 SQL 문법도 지원할 것

이러한 조건을 만족한 도구는 자바 진영에서 사실상 JOOQ 하나뿐이었습니다.

JOOQ의 장단점 분석

장점

  • 기존 Querydsl과 문법 구조가 유사하여, 러닝 커브가 낮습니다.

  • Querydsl보다 더 복잡한 SQL 구문을 표현할 수 있어 유연합니다.

  • 오픈소스로 제공되므로, 별도의 비용 없이 도입이 가능합니다.

단점

  • JPA와의 연계 사용이 쉽지 않으며, 영속성 관리가 어렵습니다.

  • 쿼리 결과를 매핑하거나 트랜잭션을 관리하기 위해서는 별도 설정과 구현이 필요합니다.

도입 결과

기존 재고이관 목록 조회 기능에서 사용하던 Native Query를 JOOQ로 전면 교체하였고, 테스트 결과 정상적으로 동작함을 확인하였습니다. 특히, 커스텀 로직 없이도 쿼리를 유연하게 작성할 수 있었기 때문에, 학습 시간을 제외하면 개발에 소요된 실제 시간은 오히려 단축되는 결과를 얻었습니다.

작업 시간 비교

방식총 소요 시간주요 이슈
Native Query1일 + 약 0.25~0.5일포맷팅, 디버깅 등에 많은 시간 소요
JOOQ약 0.75일DSL 적응 외에는 빠르게 개발 가능

작업 코드

수정 전

String sql = "SELECT CASE WHEN ... THEN ... ELSE ... END AS location_type, " +
             "       MIN(workplace_id), " +
             "       ARRAY_AGG(location_code), " +
             "       ... " +
             "FROM ... " +
             "WHERE workplace_id = ? AND ... " +
             "GROUP BY location_type, parent_space_id " +
             "HAVING SUM(picking_quantity) > 0 AND ...";

List<Object[]> result = entityManager.createNativeQuery(sql).getResultList();

return result.stream()
    .map(row -> ExternalStockMoveOrderSearchDTO.createFrom(row))
    .collect(Collectors.toList());

수정 후

Field<String> locationType = DSL
    .when(space.SPACE_NAME.in(DANPLA_SPACE_NAMES).and(attr.PURPOSE.eq("STOW")), "DANPLA")
    .otherwise(space.LOCATION_CODE);

return dslContext.select(
        locationType,
        DSL.min(stock.WORKPLACE_ID),
        DSL.arrayAggDistinct(space.LOCATION_CODE),
        ...
    )
    .from(space)
    .join(attr).on(space.SPACE_ID.eq(attr.SPACE_ID))
    .join(stock).on(stock.SPACE_ID.eq(space.SPACE_ID))
    .where(
        stock.WORKPLACE_ID.eq(param.getOutboundWorkplaceId()),
        space.SPACE_TYPE.eq("CELL"),
        stock.QUANTITY.gt(0),
        stock.SPACE_ID.notIn(param.getExcludedSpaceIds())
    )
    .groupBy(locationType, space.PARENT_SPACE_ID)
    .having(
        hasPickingCondition(param),
        hasIssueCondition(param),
        ...
    )
    .fetchInto(ExternalStockMoveOrderSearchDTO.class);
항목Before (Native SQL)After (JOOQ DSL)
쿼리 작성 방식문자열 직접 조합DSL 기반 타입 안전 방식
유지보수성낮음높음
조건 분리불가가능 (메서드 단위 분리)
테스트 용이성어려움단위 테스트 가능
DTO 매핑수동 캐스팅자동 매핑 (fetchInto)
DANPLA 등 특수 케이스 처리CASE WHEN SQL 직접 작성.when().otherwise() 사용

수정 전과 후를 비교해보았을 때 엔지니어의 입장에서 수정 후가 훨씬 유지보수하기 쉽다는 것을 직관적으로 느낄 수 있었습니다.

참고 자료

More from this blog

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

Git merge 내부 동작 — 3-way merge, merge base, 그리고 recursive에서 ort로

git merge를 매일 쓰지만, 그 한 줄이 안에서 무슨 일을 하는지 들여다본 적은 드물어요. 이 글은 merge가 두 갈래의 변경을 어떻게 합치는지, merge base가 왜 필요한지, 그리고 Git이 기본 전략을 recursive에서 ort로 갈아치운 이유를 따라가요. Git을 쓰는 백엔드 개발자를 대상으로 해요. 브랜치 두 개를 합치는 일은 겉보기

May 15, 202612 min read

Java NIO ByteBuffer 내부 구조 — Direct vs Heap, Cleaner, 그리고 off-heap 메모리가 GC를 우회하는 방법

Netty가 빠른 이유, Kafka 클라이언트가 직렬화에 신경 쓰는 이유, MappedByteBuffer로 수 GB짜리 파일을 다루는 이유. 그 한가운데에는 ByteBuffer가 있어요. 이번 글에서는 ByteBuffer의 두 얼굴 — heap과 direct — 가 어떻게 다른지, off-heap 메모리는 어떻게 잡고 어떻게 풀리는지, JVM과 운영체제 사

May 15, 202612 min read

Java Flight Recorder 내부 구조 — Thread-Local Buffer부터 Disk Repository까지

JFR을 켜면 1% 미만 오버헤드로 JVM 내부가 그대로 기록돼요. 어떻게 이렇게 가벼울 수 있는지, 그리고 그 데이터가 어떤 경로를 거쳐 디스크에 쌓이는지 한 번 따라가 봐요. 이 글은 JFR을 "그냥 잘 쓰는 도구"에서 "내부 동작을 아는 도구"로 끌어올리고 싶은 분을 위한 글이에요. 운영 중인 서버에서 갑자기 응답 시간이 튀어요. 메트릭 그래프는 분

May 15, 202614 min read

끄적끄적 테크 블로그

162 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ