easy-query: Java용 가장 강력한 ORM 서브쿼리
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
// )
all 과 notEmptyAll 의 차이점:
| 메서드 | 컬렉션이 비어 있음 | 모든 요소가 일치 | 일부 요소가 일치하지 않음 |
|---|---|---|---|
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 스캔 횟수 |
|---|---|---|
| Subquery | 1 M | 1 M |
| GROUP JOIN | 1 | 1 |
잠재적인 속도 향상: 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‑query | Hibernate | MyBatis | 기타 |
|---|---|---|---|---|
| 서브쿼리 자동 병합 | ✅ | ❌ | ❌ | — |
| 조건부 집계 자동 변환 | ✅ | ❌ | ❌ | — |
| 다단계 중첩 서브쿼리 지원 | ✅ | 제한적 | 제한적 | — |
| 명시적 조인 + 서브쿼리 혼합 | ✅ | 제한적 | 제한적 | — |
(표는 예시이며 실제 구현에 따라 차이가 있을 수 있습니다.)
| ure | easy‑query | MyBatis‑Plus | JPA/Hibernate | jOOQ |
|---|---|---|---|---|
암시적 서브쿼리 (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의 서브쿼리 설계 철학:
- 복잡함을 단순하게 – 복잡한 서브쿼리 로직을 간결한 람다로 표현합니다.
- 느린 것을 빠르게 – 비효율적인 SQL을 자동으로 최적화하여 성능 좋은 문장으로 변환합니다.
- 수동을 자동으로 – 개발자는 무엇을 원하는지 기술하고, 프레임워크가 어떻게 달성할지 결정합니다.
애플리케이션이 관계형 통계, 존재 여부 확인, 혹은 집계 쿼리에 많이 의존한다면 easy‑query를 사용해 보세요.
관련 링크
- GitHub:
- 문서: