easy-query: Java용 가장 강력한 ORM 서브쿼리

발행: (2025년 12월 28일 오전 11:27 GMT+9)
10 min read
원문: Dev.to

Source: Dev.to

서브쿼리가 왜 이렇게 중요한가?

실제 비즈니스 개발에서 서브쿼리는 어디에나 존재합니다:

  • “주문이 있는 사용자” 조회
  • “10개 이상의 기사(글)를 가진 저자” 조회
  • “지난 30일 동안 구매한 회원” 조회
  • 각 사용자의 주문, 댓글, 즐겨찾기 수 계산

기존 ORM은 이 기능을 지원하지 않거나, 원시 SQL을 요구하거나, 성능이 낮은 SQL을 생성합니다.

easy‑query의 목표: 서브쿼리를 일반 쿼리만큼 간단하게 만들면서 고성능 SQL을 생성하는 것.

Source:

1. 암시적 서브쿼리: 말하듯 코딩하기

1.1 존재 여부 확인: any / none

// 게시물이 있는 사용자 조회
List users = easyEntityQuery.queryable(User.class)
    .where(u -> u.posts().any())
    .toList();

// Generated SQL:
// SELECT * FROM t_user t
// WHERE EXISTS (SELECT 1 FROM t_post t1 WHERE t1.user_id = t.id)
// 게시물이 없는 사용자 조회
List users = easyEntityQuery.queryable(User.class)
    .where(u -> u.posts().none())
    .toList();

// Generated SQL:
// SELECT * FROM t_user t
// WHERE NOT EXISTS (SELECT 1 FROM t_post t1 WHERE t1.user_id = t.id)

1.2 조건부 존재 여부 확인

// 공개된 게시물이 있는 사용자 조회
List users = easyEntityQuery.queryable(User.class)
    .where(u -> u.posts()
        .where(p -> p.status().eq(1))
        .any())
    .toList();

// Generated SQL:
// SELECT * FROM t_user t
// WHERE EXISTS (
//     SELECT 1 FROM t_post t1 
//     WHERE t1.user_id = t.id AND t1.status = 1
// )

1.3 전칭량자: all / notEmptyAll

// 모든 은행 카드가 코드가 "622"로 시작하는 저축 카드인 사용자 조회
List users = easyEntityQuery.queryable(User.class)
    .where(u -> u.bankCards()
        .where(bc -> bc.type().eq("savings"))
        .all(bc -> bc.code().startsWith("622")))
    .toList();

// Generated SQL (NOT EXISTS + NOT 논리 사용):
// SELECT * FROM t_user t
// WHERE NOT EXISTS (
//     SELECT 1 FROM t_bank_card t1 
//     WHERE t1.user_id = t.id AND t1.type = 'savings' 
//       AND NOT (t1.code LIKE '622%')
//     LIMIT 1
// )

allnotEmptyAll 의 차이점:

메서드컬렉션이 비어 있음모든 요소가 일치일부 요소가 일치하지 않음
all✅ 통과✅ 통과❌ 실패
notEmptyAll❌ 실패✅ 통과❌ 실패
// notEmptyAll: 최소 하나의 저축 카드가 존재하고, 모든 저축 카드가 "622"로 시작함
List users = easyEntityQuery.queryable(User.class)
    .where(u -> u.bankCards()
        .where(bc -> bc.type().eq("savings"))
        .notEmptyAll(bc -> bc.code().startsWith("622")))
    .toList();

// notEmptyAll = any() + all(), 즉: 존재 AND 모두 일치

1.4 집계 서브쿼리: count / sum / avg / max / min

// 게시물이 5개 초과인 사용자 조회
List users = easyEntityQuery.queryable(User.class)
    .where(u -> u.posts().count().gt(5L))
    .toList();

// Generated SQL:
// SELECT * FROM t_user t
// WHERE (SELECT COUNT(*) FROM t_post t1 WHERE t1.user_id = t.id) > 5
// 총 주문 금액이 10000 초과인 사용자 조회
List users = easyEntityQuery.queryable(User.class)
    .where(u -> u.orders()
        .sum(o -> o.amount())
        .gt(new BigDecimal("10000")))
    .toList();

// Generated SQL:
// SELECT * FROM t_user t
// WHERE (SELECT SUM(amount) FROM t_order t1 WHERE t1.user_id = t.id) > 10000

1.5 ORDER BY 에서의 서브쿼리

// 게시물 수 기준 정렬
List users = easyEntityQuery.queryable(User.class)
    .orderBy(u -> u.posts().count().desc())
    .toList();

// Generated SQL:
// SELECT * FROM t_user t
// ORDER BY (SELECT COUNT(*) FROM t_post t1 WHERE t1.user_id = t.id) DESC

1.6 SELECT 에서의 서브쿼리

// 사용자와 그들의 게시물 수 조회
List users = easyEntityQuery.queryable(User.class)
    .select(u -> new UserDTOProxy()
        .id().set(u.id())
        .username().set(u.username())
        .postCount().set(u.posts().count()))
    .toList();

// Generated SQL:
// SELECT t.id, t.username, 
//        (SELECT COUNT(*) FROM t_post t1 WHERE t1.user_id = t.id) AS post_count
// FROM t_user t

Source:

2. Subquery Merge Optimization: From N Scans to 1 Scan

이것은 easy‑query의 서브쿼리 처리에서 가장 강력한 기능입니다.

2.1 문제점: 다중 서브쿼리로 인한 성능 재앙

각 사용자의 게시물 수 댓글 수를 조회하고 싶다고 가정해 보겠습니다:

// 표준 접근법
List users = easyEntityQuery.queryable(User.class)
    .select(u -> new UserDTOProxy()
        .id().set(u.id())
        .postCount().set(u.posts().count())
        .commentCount().set(u.comments().count()))
    .toList();

일반적인 ORM은 각 집계마다 별도의 서브쿼리를 생성하므로 자식 테이블을 여러 번 스캔하게 됩니다.

문제 정의

대용량 데이터셋(예: 100만 명의 사용자, 500만 개의 게시물, 1000만 개의 댓글)에서 각 사용자를 위해 별도의 서브쿼리를 사용하는 순진한 접근법은 200만 번의 자식 테이블 스캔을 초래합니다.

SELECT t.id,
       (SELECT COUNT(*) FROM t_post WHERE user_id = t.id) AS post_count,
       (SELECT COUNT(*) FROM t_comment WHERE user_id = t.id) AS comment_count
FROM t_user t;

2.2 easy‑query 최적화

Subquery → GROUP JOIN

easy‑query는 여러 서브쿼리를 자동으로 하나의 GROUP BY 조인으로 병합합니다.

SELECT t.id,
       IFNULL(t1.post_count, 0)      AS post_count,
       IFNULL(t2.comment_count, 0)   AS comment_count
FROM t_user t
LEFT JOIN (
    SELECT user_id, COUNT(*) AS post_count
    FROM t_post
    GROUP BY user_id
) t1 ON t.id = t1.user_id
LEFT JOIN (
    SELECT user_id, COUNT(*) AS comment_count
    FROM t_comment
    GROUP BY user_id
) t2 ON t.id = t2.user_id;

성능 비교

접근법t_post 스캔 횟수t_comment 스캔 횟수
Subquery1 M1 M
GROUP JOIN11

잠재적인 속도 향상: 100× – 1000×.

Conditional Aggregation → CASE WHEN

Java (easy‑query)

// 각 사용자의 공개 게시물과 임시 저장 게시물 수를 셈
List users = easyEntityQuery.queryable(User.class)
    .select(u -> new UserDTOProxy()
        .id().set(u.id())
        .publishedCount().set(u.posts()
            .where(p -> p.status().eq(1)).count())
        .draftCount().set(u.posts()
            .where(p -> p.status().eq(0)).count()))
    .toList();

일반 ORM이 생성하는 SQL(두 개의 서브쿼리):

SELECT t.id,
       (SELECT COUNT(*) FROM t_post WHERE user_id = t.id AND status = 1),
       (SELECT COUNT(*) FROM t_post WHERE user_id = t.id AND status = 0)
FROM t_user t;

easy‑query가 최적화한 SQL(단일 GROUP BY + CASE WHEN):

SELECT t.id,
       SUM(CASE WHEN t1.status = 1 THEN 1 ELSE 0 END) AS published_count,
       SUM(CASE WHEN t1.status = 0 THEN 1 ELSE 0 END) AS draft_count
FROM t_user t
LEFT JOIN t_post t1 ON t.id = t1.user_id
GROUP BY t.id;

같은 테이블에 대한 여러 조건부 집계가 하나의 조인과 여러 CASE WHEN 식으로 병합됩니다.

2.3 다단계 중첩 서브쿼리

중첩된 탐색 속성

// 좋아요가 달린 댓글이 있는 게시물을 가진 사용자
List users = easyEntityQuery.queryable(User.class)
    .where(u -> u.posts()
        .any(p -> p.comments()
            .any(c -> c.likes().any())))
    .toList();

교차 레벨 집계

// 게시물의 총 댓글 수가 100개를 초과하는 사용자
List users = easyEntityQuery.queryable(User.class)
    .where(u -> u.posts()
        .flatElement()
        .comments()
        .count()
        .gt(100L))
    .toList();

2.4 명시적 조인과 서브쿼리 결합

// 주문 금액이 1000을 초과하고 동시에 댓글이 있는 사용자
List users = easyEntityQuery.queryable(User.class)
    .innerJoin(Order.class, (u, o) -> u.id().eq(o.userId()))
    .where((u, o) -> {
        o.amount().gt(new BigDecimal("1000"));
        u.comments().any();               // 조인과 결합된 서브쿼리
    })
    .select((u, o) -> u)
    .distinct()
    .toList();

2.5 다른 ORM과의 기능 비교

기능easy‑queryHibernateMyBatis기타
서브쿼리 자동 병합
조건부 집계 자동 변환
다단계 중첩 서브쿼리 지원제한적제한적
명시적 조인 + 서브쿼리 혼합제한적제한적

(표는 예시이며 실제 구현에 따라 차이가 있을 수 있습니다.)

ureeasy‑queryMyBatis‑PlusJPA/HibernatejOOQ
암시적 서브쿼리 (any/count)✅ 람다 구문❌ 원시 SQL❌ JPQL/Criteria❌ 수동
WHERE에 서브쿼리부분 지원✅ 수동
ORDER BY에 서브쿼리✅ 수동
SELECT에 서브쿼리✅ 수동
서브쿼리 → GROUP JOIN✅ 자동 최적화
조건부 집계 병합✅ 자동 최적화

2.6 왜 “가장 강력한가”?

  • 가장 간단한 구문u.posts().count().gt(5L)는 자연어처럼 읽힙니다.
  • 자동 최적화 – 서브쿼리 → GROUP JOIN, 조건부 집계 → CASE WHEN.
  • 전체 시나리오 지원WHERE, ORDER BY, SELECT에 서브쿼리.
  • 강력한 타입 – 컴파일 시점 검사, 리팩터링 친화적.
  • 최고 성능 – 수동 튜닝 없이 고성능 SQL을 생성합니다.

요약

easy‑query의 서브쿼리 설계 철학:

  1. 복잡함을 단순하게 – 복잡한 서브쿼리 로직을 간결한 람다로 표현합니다.
  2. 느린 것을 빠르게 – 비효율적인 SQL을 자동으로 최적화하여 성능 좋은 문장으로 변환합니다.
  3. 수동을 자동으로 – 개발자는 무엇을 원하는지 기술하고, 프레임워크가 어떻게 달성할지 결정합니다.

애플리케이션이 관계형 통계, 존재 여부 확인, 혹은 집계 쿼리에 많이 의존한다면 easy‑query를 사용해 보세요.

관련 링크

  • GitHub:
  • 문서:
Back to Blog

관련 글

더 보기 »

벤치마크: easy-query vs jOOQ

JMH 벤치마크 비교: easy‑query vs jOOQ vs Hibernate !Lihttps://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A...

Spring Data JPA 관계

소개 새해 복 많이 받으세요! 풀스택 여정의 지난 10일 동안, 입사 직후 프로젝트를 진행해 왔습니다. 처음에는 Re...