7편에서 엔티티 생명주기를 다뤘다. 이번 편에서는 실제로 쿼리가 어떻게 처리되는지 JPQL 내부 동작부터 파고든다. 그리고 JPQL의 한계를 보완하는 QueryDSL과 Native Query를 함께 정리한다.
SQL을 직접 쓰면 되는데 왜 JPQL이 필요한가.
// SQL 직접 사용
String sql = "SELECT * FROM users WHERE user_id = 1";
// JPQL 사용
String jpql = "SELECT u FROM User u WHERE u.id = 1";
| SQL | JPQL | |
|---|---|---|
| 대상 | 테이블, 컬럼 | 엔티티, 필드 |
| 종속성 | DB 스키마 종속 | 객체 모델 종속 |
| DB 호환성 | DB마다 문법 다름 | DB 독립적 |
| 결과 | ResultSet | 엔티티 객체 |
JPQL은 객체 지향 쿼리 언어다. 테이블이 아닌 엔티티를 대상으로 쿼리한다.
"SELECT u FROM User u WHERE u.age > :age"
│
▼
┌──────────────────────────────────────────┐
│ 1. Lexical Analysis (어휘 분석) │
│ 토큰 분리 │
│ [SELECT][u][FROM][User][u] │
│ [WHERE][u.age][>][:age] │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ 2. Syntax Analysis (구문 분석) │
│ AST(Abstract Syntax Tree) 생성 │
│ 문법 검증 │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ 3. Semantic Analysis (의미 분석) │
│ 엔티티 매핑 확인 (User → users 테이블) │
│ 필드 매핑 확인 (u.age → users.age) │
│ 타입 검증 │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ 4. SQL Generation (SQL 생성) │
│ AST를 SQL로 변환 │
│ DB Dialect 적용 (MySQL, PostgreSQL 등) │
└──────────────────────────────────────────┘
│
▼
"SELECT u.id, u.name, u.age FROM users u WHERE u.age > ?"
JPQL을 토큰으로 분리한다.
입력: "SELECT u FROM User u WHERE u.age > 25"
Token[0]: KEYWORD_SELECT "SELECT"
Token[1]: IDENTIFIER "u"
Token[2]: KEYWORD_FROM "FROM"
Token[3]: IDENTIFIER "User" ← 엔티티명
Token[4]: IDENTIFIER "u" ← alias
Token[5]: KEYWORD_WHERE "WHERE"
Token[6]: PATH "u.age" ← 필드 경로
Token[7]: OPERATOR_GT ">"
Token[8]: LITERAL_INTEGER "25"
토큰들을 트리 구조로 변환한다.
┌─────────────────┐
│ SelectStatement │
└────────┬────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ SelectClause │ │FromClause│ │ WhereClause │
│ "u" │ │ │ │ │
└──────────────┘ └────┬─────┘ └──────┬───────┘
▼ ▼
┌────────────┐ ┌─────────────────┐
│ FromElement│ │ ComparisonExpr │
│ entity:User│ │ op: ">" │
│ alias: "u" │ └───────┬─────────┘
└────────────┘ │
┌───────┴───────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ PathExpr │ │ Literal │
│ "u.age" │ │ 25 │
└──────────┘ └──────────┘
AST가 실제 엔티티 매핑과 일치하는지 검증한다.
User 엔티티가 users 테이블에 매핑되어 있는지u.age 필드가 users.age 컬럼에 매핑되어 있는지검증된 AST를 실제 SQL로 변환한다. DB Dialect에 따라 MySQL이면 MySQL 문법, PostgreSQL이면 PostgreSQL 문법으로 각각 다르게 생성한다.
em.createQuery("SELECT u FROM User u WHERE u.id = :id")
│
▼
┌──────────────────┐
│ Query Plan Cache │
└────────┬─────────┘
│
┌──────────────┴──────────────┐
│ │
Cache Hit Cache Miss
│ │
▼ ▼
저장된 Plan 즉시 사용 1. Lexer → 2. Parser
3. Analyzer → 4. Generator
│
Cache에 저장
│
└──────────────┬─────────────┘
│
▼
파라미터 바인딩 (:id → 1L)
│
▼
SQL 실행
JPQL 파싱은 비용이 크다(문자열 처리, 트리 생성, 검증). 같은 JPQL은 Cache Hit로 파싱을 생략한다. 파라미터 바인딩은 캐시와 별개로 매번 수행된다.
이름만 필요한데 전체 엔티티를 조회하면 낭비다.
// 나쁜 예
List<User> users = em.createQuery(
"SELECT u FROM User u WHERE u.age > 25",
User.class
).getResultList();
users.forEach(u -> System.out.println(u.getName()));
생성되는 SQL:
SELECT u.id, u.name, u.email, u.age, u.created_at,
u.profile_image, u.bio, u.address, ... -- 20개 컬럼
FROM users u
WHERE u.age > 25
불필요한 네트워크 비용, 메모리 사용, 영속성 컨텍스트 스냅샷 부담이 생긴다.
// 방법 1: 필요한 필드만 Object[]로
List<Object[]> results = em.createQuery(
"SELECT u.id, u.name FROM User u WHERE u.age > 25"
).getResultList();
// 방법 2: DTO Projection
List<UserNameDto> results = em.createQuery(
"SELECT new com.example.UserNameDto(u.id, u.name) " +
"FROM User u WHERE u.age > 25",
UserNameDto.class
).getResultList();
문자열 조합 방식은 두 가지 문제를 동시에 일으킨다.
SQL Injection 취약점:
String name = "' OR 1=1 --";
String jpql = "SELECT u FROM User u WHERE u.name = '" + name + "'";
// 결과 JPQL: WHERE u.name = '' OR 1=1 --'
// → 조건 무력화, 모든 사용자 데이터 노출!
Query Plan Cache 오염:
// 값마다 다른 JPQL 문자열 → 매번 새로 파싱
"WHERE u.name = 'Alice'" → Cache Key 1
"WHERE u.name = 'Bob'" → Cache Key 2
"WHERE u.name = 'Charlie'" → Cache Key 3
캐시가 기본 2048개를 넘으면 오래된 것부터 제거되는데, 유효한 Plan도 날아간다.
// 올바른 방법: 파라미터 바인딩
String jpql = "SELECT u FROM User u WHERE u.name = :name";
em.createQuery(jpql).setParameter("name", "Alice");
JPQL: "SELECT u FROM User u WHERE u.name = :name"
│
▼
┌──────────────────┐
│ Cache Key: │ ← 파라미터 값과 무관하게
│ "...u.name = :name" │ 동일한 Key
└──────────────────┘
│
┌───────────────┼───────────────┐
│ │ │
:name="Alice" :name="Bob" :name="Charlie"
│
▼
모두 같은 Query Plan 재사용!
// WHERE 절 → 가능
SELECT u FROM User u
WHERE u.age > (SELECT AVG(u2.age) FROM User u2)
// HAVING 절 → 가능
SELECT u.department, COUNT(u) FROM User u
GROUP BY u.department
HAVING COUNT(u) > (SELECT AVG(...) FROM ...)
// FROM 절 → 불가능!
SELECT * FROM (SELECT u.name FROM User u) sub
// SELECT 절 서브쿼리 → Hibernate는 지원하지만 JPA 표준은 아님
SELECT u.name, (SELECT COUNT(*) FROM Order o WHERE o.user = u)
FROM User u
FROM 절 서브쿼리가 불가능한 이유: JPQL은 엔티티 그래프 기반이다. FROM 절은 엔티티 루트를 정의하는데, 서브쿼리가 들어오면 엔티티가 아니게 되어 영속성 컨텍스트와 연결이 불가능해진다.
FROM 절 서브쿼리가 필요하면:
// 방법 1: Native Query
String sql = """
SELECT sub.name, sub.order_count
FROM (
SELECT u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name
) sub
WHERE sub.order_count > 5
""";
em.createNativeQuery(sql);
// 방법 2: 쿼리 분리 (애플리케이션에서 처리)
List<Object[]> stats = em.createQuery(
"SELECT u.id, u.name, COUNT(o) " +
"FROM User u LEFT JOIN u.orders o " +
"GROUP BY u.id, u.name"
).getResultList();
List<Object[]> filtered = stats.stream()
.filter(row -> (Long) row[2] > 5)
.collect(toList());
같은 결과를 내지만 성능이 다르다.
// 방법 1: 서브쿼리
SELECT u FROM User u
WHERE u.id IN (SELECT o.user.id FROM Order o WHERE o.amount > 10000)
// 방법 2: JOIN
SELECT DISTINCT u FROM User u
JOIN u.orders o
WHERE o.amount > 10000
실행 계획 차이:
-- 서브쿼리 (MySQL이 최적화 못하는 경우)
EXPLAIN SELECT * FROM users u
WHERE u.id IN (SELECT o.user_id FROM orders o WHERE o.amount > 10000);
-- type: ALL (Full Table Scan 가능성)
-- Extra: Using where; Using subquery
-- JOIN
EXPLAIN SELECT DISTINCT u.* FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.amount > 10000;
-- type: ref (인덱스 사용)
-- Extra: Using where; Using index
서브쿼리가 느린 이유:
JOIN을 사용하면:
💡 팁: 서브쿼리 결과가 작으면 서브쿼리도 괜찮다. 하지만 결과가 크면 JOIN으로 바꾸는 게 유리하다.
EXPLAIN으로 실행 계획을 확인하면 명확하게 차이가 보인다.
주문이 있는 사용자를 찾는 시나리오:
// IN
SELECT u FROM User u
WHERE u.id IN (SELECT o.user.id FROM Order o)
// EXISTS
SELECT u FROM User u
WHERE EXISTS (SELECT 1 FROM Order o WHERE o.user = u)
IN 동작:
EXISTS 동작:
TRUE 반환 (Short-Circuit)주문이 많은 사용자를 찾을 때는 EXISTS가 유리하다. 반대로 서브쿼리 결과가 적다면 IN도 충분하다.
List<User> users = em.createQuery("SELECT u FROM User u", User.class)
.setFirstResult(10000) // OFFSET
.setMaxResults(20) // LIMIT
.getResultList();
생성 SQL:
SELECT * FROM users LIMIT 20 OFFSET 10000
OFFSET이 클수록 느려진다.
OFFSET 100 → 120개 읽고 100개 버림 → 빠름OFFSET 10000 → 10020개 읽고 10000개 버림 → 느림OFFSET 100000 → 100020개 읽고 100000개 버림 → 매우 느림커서 기반 페이징으로 해결:
public List<User> findUsersAfterId(Long lastId, int size) {
return em.createQuery(
"SELECT u FROM User u WHERE u.id > :lastId ORDER BY u.id",
User.class
)
.setParameter("lastId", lastId)
.setMaxResults(size)
.getResultList();
}
생성 SQL:
SELECT * FROM users WHERE id > 10000 ORDER BY id LIMIT 20
버리는 데이터 없이 인덱스를 타고 바로 원하는 위치로 점프한다.
전체 사용자 포인트 10% 증가가 필요할 때:
// 나쁜 예: 변경 감지 방식
List<User> users = em.createQuery("SELECT u FROM User u", User.class)
.getResultList();
for (User user : users) {
user.setPoint(user.getPoint() * 1.1); // 변경 감지
}
// 10만 건 → 10만 번 UPDATE
// 좋은 예: 벌크 연산
int updatedCount = em.createQuery(
"UPDATE User u SET u.point = u.point * 1.1"
).executeUpdate();
em.clear(); // 1차 캐시 초기화 (중요!)
생성 SQL:
UPDATE users SET point = point * 1.1
-- 한 번의 쿼리로 전체 수정
💡 팁: 벌크 연산 후
em.clear()를 반드시 호출해야 한다. 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 실행되기 때문에, 1차 캐시와 DB가 불일치 상태가 된다.clear()없이 이후에 엔티티를 조회하면 캐시에 있는 이전 값을 가져온다.
public List<User> searchUsers(String name, Integer minAge, String city) {
StringBuilder jpql = new StringBuilder("SELECT u FROM User u WHERE 1=1");
if (name != null) jpql.append(" AND u.name LIKE :name");
if (minAge != null) jpql.append(" AND u.age >= :minAge");
if (city != null) jpql.append(" AND u.city = :city");
TypedQuery<User> query = em.createQuery(jpql.toString(), User.class);
if (name != null) query.setParameter("name", "%" + name + "%");
if (minAge != null) query.setParameter("minAge", minAge);
if (city != null) query.setParameter("city", city);
return query.getResultList();
}
public List<User> searchUsers(String name, Integer minAge, String city) {
return queryFactory
.selectFrom(user)
.where(
nameContains(name),
ageGoe(minAge),
cityEq(city)
)
.fetch();
}
// 재사용 가능한 조건 메서드
private BooleanExpression nameContains(String name) {
return name != null ? user.name.contains(name) : null;
}
private BooleanExpression ageGoe(Integer minAge) {
return minAge != null ? user.age.goe(minAge) : null;
}
private BooleanExpression cityEq(String city) {
return city != null ? user.city.eq(city) : null;
}
user.nmae 오타 → 컴파일 에러 (빨간줄)user.age.gt("스물다섯") → Integer 필드에 String → 컴파일 에러where()에 null 전달 시 자동 무시@Entity
public class User {
private Long id;
private String name;
private Integer age;
}
│
│ 컴파일 시 APT(querydsl-apt)가 스캔
▼
@Generated("com.querydsl.codegen.EntitySerializer")
public class QUser extends EntityPathBase<User> {
public static final QUser user = new QUser("user");
public final NumberPath<Long> id = createNumber("id", Long.class);
public final StringPath name = createString("name");
public final NumberPath<Integer> age = createNumber("age", Integer.class);
}
빌드 과정:
mvn compile
│
▼
1. javac가 소스 컴파일 시작
2. APT 단계 실행
- JPAAnnotationProcessor 호출
- @Entity 클래스 스캔
- Q-Class 생성
3. 생성된 Q-Class도 함께 컴파일
결과:
target/generated-sources/java/com/example/entity/
├── QUser.java ← 자동 생성
├── QOrder.java ← 자동 생성
└── QProduct.java ← 자동 생성
Java의 기본 타입으로는 SQL 연산을 표현할 방법이 없다.
Long id = 1L;
id.gt(25) // ❌ Long에는 gt() 메서드 없음
name.contains() // ❌ String.contains()는 SQL LIKE가 아님
Path 클래스는 SQL 연산을 타입 안전하게 메서드로 제공한다:
Java 타입 Path 타입 SQL 연산 메서드
───────────────────────────────────────────────────────────
Long → NumberPath<Long> → gt(), lt(), goe(), between()
String → StringPath → eq(), contains(), like(), startsWith()
LocalDate → DatePath → before(), after(), between()
Boolean → BooleanPath → isTrue(), isFalse()
Q-Class는 엔티티의 구조를 타입 시스템으로 옮긴 것이다.
User.java (런타임 객체) QUser.java (컴파일 타임 메타데이터)
├── id: Long → ├── id: NumberPath<Long>
├── name: String → ├── name: StringPath
└── age: Integer → └── age: NumberPath<Integer>
List<User> result = queryFactory
.selectFrom(user)
.where(user.age.gt(25))
.orderBy(user.name.asc())
.fetch();
1. selectFrom(user)
- JPAQuery 객체 생성
- FROM 절 설정: user (QUser)
2. where(user.age.gt(25))
- user.age → NumberPath<Integer>
- .gt(25) → BooleanExpression 생성
- WHERE 절에 조건 추가
3. orderBy(user.name.asc())
- user.name → StringPath
- .asc() → OrderSpecifier 생성
- ORDER BY 절 설정
4. fetch()
- JPQL 문자열 생성
- EntityManager.createQuery() 호출
- 결과 반환
내부적으로 JPQL 문자열을 생성하고 EntityManager.createQuery()를 호출한다. QueryDSL은 JPQL을 타입 안전하게 생성하는 빌더다.
-- 1. DB 특화 함수
SELECT MATCH(title, content) AGAINST('검색어') FROM posts -- MySQL 전문 검색
-- 2. FROM 절 서브쿼리
SELECT * FROM (SELECT ...) sub WHERE ...
-- 3. 옵티마이저 힌트
SELECT /*+ INDEX(users idx_age) */ * FROM users
-- 4. 대량 삽입
INSERT INTO ... SELECT ...
Native Query 실행 흐름:
JPQL: JPQL → Parser → AST → Semantic Analysis → SQL Generation → 실행
Native Query: SQL → 바로 실행 (파싱/변환 없음)
Hibernate를 우회해서 SQL을 직접 실행한다.
// 1. 결과를 엔티티로 매핑
List<User> users = em.createNativeQuery(
"SELECT * FROM users WHERE age > ?",
User.class
)
.setParameter(1, 25)
.getResultList();
// 2. 결과를 Object[]로 받기
List<Object[]> results = em.createNativeQuery(
"SELECT id, name FROM users"
).getResultList();
// 3. DTO로 매핑 (@SqlResultSetMapping 사용)
@SqlResultSetMapping(
name = "UserDtoMapping",
classes = @ConstructorResult(
targetClass = UserDto.class,
columns = {
@ColumnResult(name = "id", type = Long.class),
@ColumnResult(name = "name", type = String.class)
}
)
)
엔티티로 매핑: 조회 후 수정이 필요한 경우. 영속 엔티티로 관리되어 변경 감지, 연관관계 탐색 가능. 단, 첫 접근 시 1차 캐시에 없지만 DB 조회 후 영속성 컨텍스트에 등록된다.
Object[]로 받기: 읽기 전용 + 빠르게 + 유연하게.
@SqlResultSetMapping: 조회 결과 구조가 고정된 API/화면 전용 쿼리.
@Test
public void nativeQuerySyncProblem() {
// 1. 엔티티 조회 → 1차 캐시에 저장
User user = em.find(User.class, 1L);
// 1차 캐시: {User#1: name="Alice"}
// 2. 엔티티 수정 → 메모리에서만 변경
user.setName("Bob");
// 1차 캐시: {User#1: name="Bob"} (Dirty)
// DB: name="Alice" (아직 flush 안 됨)
// 3. Native Query → DB 직접 조회
User result = (User) em.createNativeQuery(
"SELECT * FROM users WHERE id = 1", User.class
).getSingleResult();
System.out.println(result.getName()); // "Alice" ← DB 값!
// 문제: 같은 엔티티인데 값이 다름
// user.getName() = "Bob"
// result.getName() = "Alice"
}
JPQL은 실행 직전에 flush()를 자동 호출(FlushMode.AUTO)한다. Native Query는 기본적으로 flush를 호출하지 않는다.
해결 방법:
// 방법 1: 수동 flush
em.flush();
em.createNativeQuery(...);
// 방법 2: Native Query에 FlushMode 설정
Query query = em.createNativeQuery("SELECT * FROM users", User.class);
query.setFlushMode(FlushModeType.AUTO);
// 방법 3: flush + clear 후 조회
em.flush();
em.clear();
em.createNativeQuery(...);
옵티마이저 힌트:
SELECT /*+ INDEX(users idx_age) */ * FROM users WHERE age > 25
SELECT * FROM users USE INDEX (idx_name_age) WHERE name = 'Alice'
SELECT /*+ STRAIGHT_JOIN */ * FROM users u
JOIN orders o ON u.id = o.user_id
DB 특화 함수:
-- MySQL 전문 검색
SELECT *, MATCH(title, content) AGAINST('검색어' IN BOOLEAN MODE) as score
FROM posts
WHERE MATCH(title, content) AGAINST('검색어' IN BOOLEAN MODE)
ORDER BY score DESC
-- 윈도우 함수
SELECT
user_id,
order_date,
amount,
SUM(amount) OVER (PARTITION BY user_id ORDER BY order_date) as running_total
FROM orders
대량 삽입:
INSERT INTO user_statistics (user_id, total_orders, total_amount)
SELECT
user_id,
COUNT(*),
SUM(amount)
FROM orders
GROUP BY user_id
ON DUPLICATE KEY UPDATE
total_orders = VALUES(total_orders),
total_amount = VALUES(total_amount)
FROM 절 서브쿼리:
SELECT sub.user_id, sub.order_count
FROM (
SELECT user_id, COUNT(*) as order_count
FROM orders
GROUP BY user_id
) sub
WHERE sub.order_count > 10
복잡한 CASE/조건부 집계:
SELECT
user_id,
SUM(CASE WHEN status = 'COMPLETED' THEN amount ELSE 0 END) as completed_amount,
SUM(CASE WHEN status = 'PENDING' THEN amount ELSE 0 END) as pending_amount,
COUNT(DISTINCT DATE(order_date)) as active_days
FROM orders
WHERE order_date >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id
HAVING completed_amount > 100000
JPQL / QueryDSL 사용:
LIKE, BETWEEN, IN)Native Query 사용:
INSERT ... SELECT 대량 삽입FROM 절 서브쿼리PARTITION BY, OVER)CASE / 조건부 집계JPQL은 4단계 파이프라인(어휘 분석 → 구문 분석 → 의미 분석 → SQL 생성)을 거쳐서 DB 방언에 맞는 SQL을 생성한다. Query Plan Cache와 파라미터 바인딩을 올바르게 쓰면 파싱 비용을 아낄 수 있다.
QueryDSL은 이 JPQL을 타입 안전하게 생성하는 빌더다. 동적 쿼리에서 문자열 조합 JPQL보다 훨씬 안전하고 유지보수하기 좋다. 내부적으로 JPQL을 생성하고 EntityManager.createQuery()를 호출한다는 걸 알면 QueryDSL이 왜 JPQL의 모든 제약을 그대로 갖는지 이해된다.
Native Query는 Hibernate를 우회해서 SQL을 직접 실행한다. 영속성 컨텍스트와 동기화 문제가 있으니 사용 시 flush 타이밍을 반드시 고려해야 한다.