MySQL 공부 8 - JPQL 내부 동작 원리, QueryDSL, Native Query

Chu Sang Yoon·2026년 3월 20일

MySQL

목록 보기
8/9

MySQL 공부 8 - JPQL 내부 동작 원리, QueryDSL, Native Query

7편에서 엔티티 생명주기를 다뤘다. 이번 편에서는 실제로 쿼리가 어떻게 처리되는지 JPQL 내부 동작부터 파고든다. 그리고 JPQL의 한계를 보완하는 QueryDSL과 Native Query를 함께 정리한다.


JPQL이 존재하는 이유

SQL을 직접 쓰면 되는데 왜 JPQL이 필요한가.

// SQL 직접 사용
String sql = "SELECT * FROM users WHERE user_id = 1";

// JPQL 사용
String jpql = "SELECT u FROM User u WHERE u.id = 1";
SQLJPQL
대상테이블, 컬럼엔티티, 필드
종속성DB 스키마 종속객체 모델 종속
DB 호환성DB마다 문법 다름DB 독립적
결과ResultSet엔티티 객체

JPQL은 객체 지향 쿼리 언어다. 테이블이 아닌 엔티티를 대상으로 쿼리한다.


JPQL → SQL 변환 파이프라인

"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 > ?"

1. Lexical Analysis — 어휘 분석

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"

2. Syntax Analysis — 구문 분석 (AST 생성)

토큰들을 트리 구조로 변환한다.

              ┌─────────────────┐
              │ SelectStatement │
              └────────┬────────┘
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ SelectClause │ │FromClause│ │ WhereClause  │
│    "u"       │ │          │ │              │
└──────────────┘ └────┬─────┘ └──────┬───────┘
                      ▼              ▼
               ┌────────────┐ ┌─────────────────┐
               │ FromElement│ │ ComparisonExpr  │
               │ entity:User│ │   op: ">"       │
               │ alias: "u" │ └───────┬─────────┘
               └────────────┘         │
                              ┌───────┴───────┐
                              ▼               ▼
                        ┌──────────┐   ┌──────────┐
                        │ PathExpr │   │ Literal  │
                        │ "u.age"  │   │   25     │
                        └──────────┘   └──────────┘

3. Semantic Analysis — 의미 분석

AST가 실제 엔티티 매핑과 일치하는지 검증한다.

  • User 엔티티가 users 테이블에 매핑되어 있는지
  • u.age 필드가 users.age 컬럼에 매핑되어 있는지
  • 타입 정보(Integer, String 등)가 맞는지

4. SQL Generation — SQL 생성

검증된 AST를 실제 SQL로 변환한다. DB Dialect에 따라 MySQL이면 MySQL 문법, PostgreSQL이면 PostgreSQL 문법으로 각각 다르게 생성한다.


Query Plan Cache와의 연계

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로 파싱을 생략한다. 파라미터 바인딩은 캐시와 별개로 매번 수행된다.


최적화 포인트

SELECT 절 최소화

이름만 필요한데 전체 엔티티를 조회하면 낭비다.

// 나쁜 예
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());

JOIN vs 서브쿼리

같은 결과를 내지만 성능이 다르다.

// 방법 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

서브쿼리가 느린 이유:

  • 서브쿼리 전체를 실행해서 결과 집합 메모리에 저장
  • 외부 쿼리에서 각 행마다 IN 리스트 검색
  • 결과 집합이 크면 메모리 사용 증가 + 인덱스 사용 제한

JOIN을 사용하면:

  • 인덱스 기반 조인
  • 스트리밍 처리 가능
  • 옵티마이저가 Nested Loop, Hash Join 등을 선택해서 최적화

💡 : 서브쿼리 결과가 작으면 서브쿼리도 괜찮다. 하지만 결과가 크면 JOIN으로 바꾸는 게 유리하다. EXPLAIN으로 실행 계획을 확인하면 명확하게 차이가 보인다.


EXISTS vs IN 성능 차이

주문이 있는 사용자를 찾는 시나리오:

// 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 동작:

  • 서브쿼리 전체 실행 → 결과 ID 10만 건 메모리에 저장
  • 메인 쿼리의 각 행마다 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() 없이 이후에 엔티티를 조회하면 캐시에 있는 이전 값을 가져온다.


QueryDSL

JPQL 동적 쿼리의 한계

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();
}
  • 문자열 기반 → 컴파일 타임 검증 불가
  • 오타가 있으면 런타임에 터짐
  • 조건이 많아질수록 유지보수 지옥

QueryDSL로 해결

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 전달 시 자동 무시

Q-Class 내부 구조

APT(Annotation Processing Tool)로 컴파일 타임에 자동 생성

@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   ← 자동 생성

Q-Class가 필요한 이유

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>

QueryDSL 쿼리 실행 구조

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을 타입 안전하게 생성하는 빌더다.


Native Query

JPQL/QueryDSL의 한계

-- 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[]로 받기: 읽기 전용 + 빠르게 + 유연하게.

  • 통계 쿼리, 조인 많은 복잡 쿼리, DB 벤더 종속 SQL
  • 영속성 컨텍스트 완전 무관 → flush/dirty checking 걱정 없음
  • 엔티티 생성 없이 가장 빠름
  • SQL 자유도 최대 (윈도우 함수, DB 전용 문법 모두 OK)
  • 단, 타입 안정성 없음 → 컬럼 순서 바뀌면 런타임 에러 → Repository 내부에서만 쓰고 DTO로 바로 변환할 것

@SqlResultSetMapping: 조회 결과 구조가 고정된 API/화면 전용 쿼리.

  • 컴파일 타임 타입 안정성, 의도가 명확
  • 영속성 컨텍스트 영향 없음
  • 단, 엔티티 클래스에 어노테이션 + 쿼리마다 매핑 설정이 무거움
  • SQL 변경 시 매핑도 같이 수정 필요 → 재사용성 낮음

주의사항 — 영속성 컨텍스트 동기화 문제

@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(...);

Native Query로 성능을 높이는 경우들

옵티마이저 힌트:

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 사용:

  • 일반적인 CRUD
  • 단순 조건 검색 (LIKE, BETWEEN, IN)
  • 엔티티 수정이 필요한 경우
  • DB 이식성이 중요한 경우
  • 동적 쿼리 → QueryDSL

Native Query 사용:

  • 옵티마이저 힌트 (인덱스 강제 지정)
  • DB 전용 함수 (전문 검색, JSON, 공간 쿼리)
  • INSERT ... SELECT 대량 삽입
  • FROM 절 서브쿼리
  • 윈도우 함수 (PARTITION BY, OVER)
  • 레거시 SQL 재사용
  • 복잡한 CASE / 조건부 집계

마치며

JPQL은 4단계 파이프라인(어휘 분석 → 구문 분석 → 의미 분석 → SQL 생성)을 거쳐서 DB 방언에 맞는 SQL을 생성한다. Query Plan Cache와 파라미터 바인딩을 올바르게 쓰면 파싱 비용을 아낄 수 있다.

QueryDSL은 이 JPQL을 타입 안전하게 생성하는 빌더다. 동적 쿼리에서 문자열 조합 JPQL보다 훨씬 안전하고 유지보수하기 좋다. 내부적으로 JPQL을 생성하고 EntityManager.createQuery()를 호출한다는 걸 알면 QueryDSL이 왜 JPQL의 모든 제약을 그대로 갖는지 이해된다.

Native Query는 Hibernate를 우회해서 SQL을 직접 실행한다. 영속성 컨텍스트와 동기화 문제가 있으니 사용 시 flush 타이밍을 반드시 고려해야 한다.

0개의 댓글