[Basic-9] 객체지향 쿼리 언어(JPQL)

2026. 1. 6. 15:36·Spring/JPA

1. 객체지향 쿼리 언어 소개

1.1. JPA의 다양한 쿼리 방법

JPA는 여러 가지 쿼리 방법을 제공하여 다양한 상황에 대응할 수 있도록 설계되었다:

// 1. JPQL (Java Persistence Query Language)
String jpql = "SELECT m FROM Member m WHERE m.age > 20";
List<Member> result = em.createQuery(jpql, Member.class).getResultList();

// 2. Criteria API (타입 안전 쿼리)
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> cq = cb.createQuery(Member.class);
Root<Member> m = cq.from(Member.class);
cq.select(m).where(cb.gt(m.get("age"), 20));
List<Member> result = em.createQuery(cq).getResultList();

// 3. QueryDSL (실무 권장)
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember m = QMember.member;
List<Member> result = queryFactory.selectFrom(m)
                                   .where(m.age.gt(20))
                                   .fetch();

// 4. 네이티브 SQL
String sql = "SELECT * FROM MEMBER WHERE AGE > 20";
List<Member> result = em.createNativeQuery(sql, Member.class).getResultList();

// 5. JDBC 직접 사용 또는 MyBatis, SpringJdbcTemplate
Connection conn = em.unwrap(Connection.class);
// JDBC 직접 사용 또는 다른 매퍼와 연동

1.2. JPQL 소개와 필요성

단순 조회의 한계

// 1. 기본 조회 방법
Member member = em.find(Member.class, 1L);  // 단일 조회

// 2. 객체 그래프 탐색
Member member = em.find(Member.class, 1L);
Team team = member.getTeam();  // 연관 엔티티 조회

// 문제: 나이가 18살 이상인 모든 회원을 조회하려면?
// -> SQL이 필요하지만, 객체를 대상으로 하는 쿼리가 필요

JPQL의 등장

// JPQL: 객체지향 쿼리 언어
String jpql = "SELECT m FROM Member m WHERE m.age >= 18";
List<Member> adults = em.createQuery(jpql, Member.class).getResultList();

// 실행된 SQL (추상화됨)
// SELECT m.id, m.name, m.age, m.team_id
// FROM member m
// WHERE m.age >= 18

JPQL vs SQL

  • JPQL: 객체(엔티티)를 대상으로 쿼리, SELECT m FROM Member m
  • SQL: 테이블을 대상으로 쿼리, SELECT * FROM member
  • 공통점: SELECT, FROM, WHERE, GROUP BY, HAVING, JOIN 지원
  • 차이점: JPQL은 데이터베이스 독립적

2. JPQL 기본 문법과 기능

2.1. JPQL 문법 구조

기본 문법

-- SELECT 문 구조
SELECT [DISTINCT] {프로젝션}
FROM {엔티티} [AS] {별칭}
[WHERE {조건식}]
[GROUP BY {그룹화} [HAVING {그룹조건}]]
[ORDER BY {정렬}]

-- UPDATE 문 구조
UPDATE {엔티티} [AS] {별칭}
SET {수정할_속성} = {값}
[WHERE {조건식}]

-- DELETE 문 구조
DELETE FROM {엔티티} [AS] {별칭}
[WHERE {조건식}]

대소문자 구분 규칙

// 엔티티와 속성: 대소문자 구분 O
String jpql1 = "SELECT m FROM Member m WHERE m.age > 18";  // 정상
String jpql2 = "SELECT m FROM member m WHERE m.age > 18";  // 오류: member (소문자)

// JPQL 키워드: 대소문자 구분 X
String jpql3 = "select m from Member m where m.age > 18";  // 정상 (소문자)
String jpql4 = "SELECT m FROM Member m WHERE m.age > 18";  // 정상 (대문자)

// 별칭: 필수 (AS는 생략 가능)
String jpql5 = "SELECT m FROM Member AS m";  // AS 명시적
String jpql6 = "SELECT m FROM Member m";     // AS 생략 가능

 

2.2. 집합 함수와 정렬

집합 함수

// 다양한 집합 함수 사용
String jpql = """
    SELECT
        COUNT(m),           -- 회원 수
        SUM(m.age),         -- 나이 합계
        AVG(m.age),         -- 평균 나이
        MAX(m.age),         -- 최대 나이
        MIN(m.age)          -- 최소 나이
    FROM Member m
""";

TypedQuery<Object[]> query = em.createQuery(jpql, Object[].class);
Object[] result = query.getSingleResult();

Long count = (Long) result[0];
Long sum = (Long) result[1];
Double avg = (Double) result[2];
Integer max = (Integer) result[3];
Integer min = (Integer) result[4];

GROUP BY와 HAVING

// 팀별 통계
String jpql = """
    SELECT t.name, COUNT(m), AVG(m.age)
    FROM Member m
    JOIN m.team t
    GROUP BY t.name
    HAVING AVG(m.age) >= 20
""";

List<Object[]> results = em.createQuery(jpql, Object[].class).getResultList();

for (Object[] row : results) {
    String teamName = (String) row[0];
    Long count = (Long) row[1];
    Double avgAge = (Double) row[2];
    System.out.println(teamName + ": " + count + "명, 평균나이: " + avgAge);
}

// ORDER BY 정렬
String jpql2 = """
    SELECT m
    FROM Member m
    ORDER BY
        m.age DESC,         -- 나이 내림차순
        m.name ASC NULLS FIRST  -- 이름 오름차순, NULL 먼저
""";

2.3. 결과 조회 API

TypeQuery와 Query

// TypeQuery: 반환 타입이 명확할 때
TypedQuery<Member> typedQuery =
    em.createQuery("SELECT m FROM Member m", Member.class);
List<Member> members = typedQuery.getResultList();

// Query: 반환 타입이 명확하지 않을 때
Query query = em.createQuery("SELECT m.name, m.age FROM Member m");
List<Object[]> results = query.getResultList();
for (Object[] row : results) {
    String name = (String) row[0];
    Integer age = (Integer) row[1];
}

// 단일 결과 조회
try {
    Member member = em.createQuery(
        "SELECT m FROM Member m WHERE m.id = :id", Member.class)
        .setParameter("id", 1L)
        .getSingleResult();  // 결과가 없으면 NoResultException 발생
} catch (NoResultException e) {
    // 결과가 없을 때 처리
}

결과 조회 메서드 비교

  • getResultList(): 결과가 0개 이상일 때 사용, 결과 없으면 빈 리스트 반환
  • getSingleResult(): 결과가 정확히 1개일 때 사용
    • 결과 없음: NoResultException
    • 결과 2개 이상: NonUniqueResultException

2.4. 파라미터 바인딩

이름 기준 파라미터 (권장)

// 이름 기준 파라미터
String jpql = "SELECT m FROM Member m WHERE m.username = :username AND m.age > :age";

List<Member> members = em.createQuery(jpql, Member.class)
    .setParameter("username", "kim")
    .setParameter("age", 20)
    .getResultList();

// IN 절 파라미터
String jpql2 = "SELECT m FROM Member m WHERE m.username IN :names";
List<Member> members2 = em.createQuery(jpql2, Member.class)
    .setParameter("names", Arrays.asList("kim", "lee", "park"))
    .getResultList();

// 날짜/시간 파라미터
String jpql3 = "SELECT m FROM Member m WHERE m.createdDate > :date";
List<Member> members3 = em.createQuery(jpql3, Member.class)
    .setParameter("date", LocalDateTime.now().minusDays(7))
    .getResultList();

위치 기준 파라미터 (비권장)

// 위치 기준 파라미터 (가독성 떨어짐)
String jpql = "SELECT m FROM Member m WHERE m.username = ?1 AND m.age > ?2";
List<Member> members = em.createQuery(jpql, Member.class)
    .setParameter(1, "kim")
    .setParameter(2, 20)
    .getResultList();

파라미터 바인딩 장점

  1. SQL 인젝션 방지: 쿼리와 데이터 분리
  2. 성능 최적화: 같은 쿼리에 대해 재사용 가능
  3. 가독성 향상: 이름 기준 파라미터 사용 시

2.5. 프로젝션

프로젝션 대상 종류

// 1. 엔티티 프로젝션
String jpql1 = "SELECT m FROM Member m";  // 전체 엔티티
String jpql2 = "SELECT m.team FROM Member m";  // 연관 엔티티

// 2. 임베디드 타입 프로젝션
String jpql3 = "SELECT m.address FROM Member m";

// 3. 스칼라 타입 프로젝션
String jpql4 = "SELECT m.username, m.age FROM Member m";

// 4. DISTINCT로 중복 제거
String jpql5 = "SELECT DISTINCT m.team FROM Member m";

여러 값 조회 방법

String jpql = "SELECT m.username, m.age FROM Member m";

// 방법 1: Query 타입으로 조회 (Object[])
List<Object[]> results = em.createQuery(jpql).getResultList();
for (Object[] row : results) {
    String username = (String) row[0];
    Integer age = (Integer) row[1];
}

// 방법 2: TypeQuery 사용 (명시적 타입)
TypedQuery<Object[]> query = em.createQuery(jpql, Object[].class);
List<Object[]> results = query.getResultList();

// 방법 3: DTO로 조회 (가장 권장)
public class MemberDto {
    private String username;
    private int age;

    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
    // getters
}

String jpql2 = "SELECT new com.example.MemberDto(m.username, m.age) FROM Member m";
TypedQuery<MemberDto> query2 = em.createQuery(jpql2, MemberDto.class);
List<MemberDto> dtos = query2.getResultList();

2.6. 페이징 API

표준 페이징 API

// 페이징 쿼리
String jpql = "SELECT m FROM Member m ORDER BY m.name DESC";

List<Member> resultPage1 = em.createQuery(jpql, Member.class)
    .setFirstResult(0)    // 시작 위치 (0-based)
    .setMaxResults(10)    // 페이지 크기
    .getResultList();

List<Member> resultPage2 = em.createQuery(jpql, Member.class)
    .setFirstResult(10)   // 두번째 페이지
    .setMaxResults(10)
    .getResultList();

// 전체 카운트 쿼리
Long totalCount = em.createQuery("SELECT COUNT(m) FROM Member m", Long.class)
    .getSingleResult();

int totalPages = (int) Math.ceil((double) totalCount / 10);

데이터베이스별 페이징 SQL 변환

-- MySQL
SELECT m.* FROM MEMBER m ORDER BY m.name DESC LIMIT 0, 10

-- PostgreSQL
SELECT m.* FROM MEMBER m ORDER BY m.name DESC LIMIT 10 OFFSET 0

-- Oracle (11g 이전)
SELECT * FROM (
    SELECT ROWNUM AS rn, m.* FROM (
        SELECT * FROM MEMBER ORDER BY name DESC
    ) m WHERE ROWNUM <= 20
) WHERE rn > 10

-- SQL Server
SELECT * FROM MEMBER
ORDER BY name DESC
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY

실무 페이징 패턴

public class Page<T> {
    private List<T> content;
    private int page;
    private int size;
    private long totalElements;
    private int totalPages;

    // 생성자, getters
}

public Page<Member> findMembers(int page, int size) {
    // 데이터 조회
    List<Member> content = em.createQuery(
        "SELECT m FROM Member m ORDER BY m.createdDate DESC", Member.class)
        .setFirstResult(page * size)
        .setMaxResults(size)
        .getResultList();

    // 전체 카운트 조회 (성능 최적화 필요시 별도 최적화)
    Long total = em.createQuery("SELECT COUNT(m) FROM Member m", Long.class)
        .getSingleResult();

    return new Page<>(content, page, size, total);
}

// Count 최적화 (복잡한 쿼리일 때)
public Long countMembers() {
    return em.createQuery(
        "SELECT COUNT(m) FROM Member m WHERE m.status = :status", Long.class)
        .setParameter("status", MemberStatus.ACTIVE)
        .getSingleResult();
}

2.7. 조인

조인 종류

// 1. 내부 조인 (INNER JOIN)
String jpql1 = "SELECT m FROM Member m JOIN m.team t";
String jpql2 = "SELECT m FROM Member m INNER JOIN m.team t";

// 2. 외부 조인 (LEFT OUTER JOIN)
String jpql3 = "SELECT m FROM Member m LEFT JOIN m.team t";
String jpql4 = "SELECT m FROM Member m LEFT OUTER JOIN m.team t";

// 3. 세타 조인 (연관관계 없는 조인)
String jpql5 = """
    SELECT COUNT(m)
    FROM Member m, Team t
    WHERE m.username = t.name
""";

// 4. 페치 조인 (성능 최적화)
String jpql6 = "SELECT m FROM Member m JOIN FETCH m.team";

// 5. 다중 조인
String jpql7 = """
    SELECT m
    FROM Member m
    JOIN m.team t
    LEFT JOIN t.company c
""";

ON 절 조인 (JPA 2.1+)

// 1. 조인 대상 필터링
String jpql1 = """
    SELECT m, t
    FROM Member m
    LEFT JOIN m.team t ON t.name = '개발팀'
""";
// SQL: ... LEFT JOIN Team t ON m.team_id = t.id AND t.name = '개발팀'

// 2. 연관관계 없는 엔티티 외부 조인 (하이버네이트 5.1+)
String jpql2 = """
    SELECT m, t
    FROM Member m
    LEFT JOIN Team t ON m.username = t.name
""";
// SQL: ... LEFT JOIN Team t ON m.username = t.name

// 3. 복잡한 ON 조건
String jpql3 = """
    SELECT m, t
    FROM Member m
    JOIN m.team t ON t.active = true AND t.createdDate > :date
""";

2.8. 서브쿼리

서브쿼리 지원 함수

// 1. 기본 서브쿼리
String jpql1 = """
    SELECT m
    FROM Member m
    WHERE m.age > (SELECT AVG(m2.age) FROM Member m2)
""";

// 2. EXISTS
String jpql2 = """
    SELECT m
    FROM Member m
    WHERE EXISTS (
        SELECT t
        FROM m.team t
        WHERE t.name = '팀A'
    )
""";

// 3. IN
String jpql3 = """
    SELECT m
    FROM Member m
    WHERE m.team IN (
        SELECT t
        FROM Team t
        WHERE t.active = true
    )
""";

// 4. ALL
String jpql4 = """
    SELECT o
    FROM Order o
    WHERE o.amount > ALL (
        SELECT p.stock
        FROM Product p
    )
""";

// 5. ANY/SOME
String jpql5 = """
    SELECT m
    FROM Member m
    WHERE m.age > ANY (
        SELECT m2.age
        FROM Member m2
        WHERE m2.team.name = '개발팀'
    )
""";

JPA 서브쿼리 제한사항

  • 지원: WHERE, HAVING 절
  • 조건부 지원: SELECT 절 (하이버네이트 지원)
  • 미지원: FROM 절의 서브쿼리 (하이버네이트 6부터 지원)
    • 대안: 조인 또는 네이티브 SQL 사용

2.9. 조건식과 함수

CASE 식

// 1. 기본 CASE 식
String jpql1 = """
    SELECT
        CASE
            WHEN m.age <= 18 THEN '청소년'
            WHEN m.age <= 30 THEN '청년'
            WHEN m.age <= 65 THEN '중장년'
            ELSE '노년'
        END
    FROM Member m
""";

// 2. 단순 CASE 식
String jpql2 = """
    SELECT
        CASE m.grade
            WHEN 'VIP' THEN '우수회원'
            WHEN 'GOLD' THEN '골드회원'
            ELSE '일반회원'
        END
    FROM Member m
""";

// 3. COALESCE (첫번째 null 아닌 값)
String jpql3 = "SELECT COALESCE(m.nickname, m.username, '이름없음') FROM Member m";

// 4. NULLIF (두 값이 같으면 null)
String jpql4 = "SELECT NULLIF(m.username, 'admin') FROM Member m";

JPQL 기본 함수

// 문자열 함수
String jpql1 = """
    SELECT
        CONCAT(m.firstName, ' ', m.lastName),
        SUBSTRING(m.email, 1, 10),
        TRIM(m.username),
        LOWER(m.username),
        UPPER(m.username),
        LENGTH(m.username),
        LOCATE('@', m.email)
    FROM Member m
""";

// 수학 함수
String jpql2 = """
    SELECT
        ABS(m.score),
        SQRT(m.score),
        MOD(m.age, 10)
    FROM Member m
""";

// JPA 용도 함수
String jpql3 = """
    SELECT
        SIZE(t.members),  -- 컬렉션 크기
        TYPE(e)           -- 엔티티 타입 (상속에서 사용)
    FROM Team t, Employee e
""";

사용자 정의 함수

// 1. 방언 확장 (사용자 정의 함수 등록)
public class MyH2Dialect extends H2Dialect {
    public MyH2Dialect() {
        registerFunction(
            "group_concat",
            new StandardSQLFunction("group_concat", StandardBasicTypes.STRING)
        );
    }
}

// persistence.xml
<property name="hibernate.dialect" value="com.example.MyH2Dialect"/>

// 2. JPQL에서 사용
String jpql = "SELECT function('group_concat', m.username) FROM Member m";

// 3. 하이버네이트의 표준 함수 사용
String jpql2 = "SELECT group_concat(m.username) FROM Member m";  // Hibernate 6+

3. JPQL 고급 기능

3.1. 경로 표현식

경로 표현식 종류

@Entity
public class Member {
    @Id private Long id;
    private String username;          // 상태 필드
    @ManyToOne private Team team;     // 단일 값 연관 필드
    @OneToMany private List<Order> orders;  // 컬렉션 값 연관 필드
}

// 1. 상태 필드 경로 탐색 (탐색 끝)
String jpql1 = "SELECT m.username FROM Member m";  // 정상
// m.username.username (X) - 상태 필드는 더 이상 탐색 불가

// 2. 단일 값 연관 경로 탐색 (묵시적 내부 조인 발생)
String jpql2 = "SELECT m.team.name FROM Member m";  // 정상, 탐색 가능
// SQL: SELECT t.name FROM Member m INNER JOIN Team t ON m.team_id = t.id

// 3. 컬렉션 값 연관 경로 탐색 (묵시적 내부 조인 발생, 탐색 불가)
String jpql3 = "SELECT t.members FROM Team t";  // 정상
String jpql4 = "SELECT t.members.username FROM Team t";  // 오류! 탐색 불가

// 4. 명시적 조인으로 탐색 가능
String jpql5 = """
    SELECT m.username
    FROM Team t
    JOIN t.members m  -- 명시적 조인
""";  // 정상

묵시적 조인 vs 명시적 조인

// 묵시적 조인 (가급적 사용하지 말 것)
String jpql1 = "SELECT m.team.name FROM Member m";
// 문제점: 조인이 발생하는지 한눈에 파악 어려움, 항상 내부 조인만 가능

// 명시적 조인 (권장)
String jpql2 = """
    SELECT t.name
    FROM Member m
    JOIN m.team t
    WHERE t.name = '개발팀'
""";
// 장점: 조인 종류 명확, 외부 조인 가능, 쿼리 튜닝 용이

// 복잡한 조인
String jpql3 = """
    SELECT o
    FROM Order o
    LEFT JOIN o.member m
    LEFT JOIN FETCH o.items i  -- 페치 조인
    INNER JOIN i.product p
    WHERE m.username = :username
""";

경로 표현식 실무 조언

  1. 가급적 묵시적 조인 대신 명시적 조인 사용
  2. 컬렉션은 경로 탐색 불가 → 명시적 조인으로 별칭 획득
  3. SELECT, WHERE 절에서 경로 탐색 가능
  4. 묵시적 조인은 SQL의 FROM 절에 영향을 줌

3.2. 페치 조인 (Fetch Join) ⭐

엔티티 페치 조인

// N+1 문제 발생 예시
String jpql = "SELECT m FROM Member m";
List<Member> members = em.createQuery(jpql, Member.class).getResultList();

for (Member member : members) {
    System.out.println(member.getTeam().getName());  // N+1 문제!
    // member.getTeam() 호출 시마다 추가 쿼리 실행
}

// 페치 조인으로 해결
String jpql2 = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members2 = em.createQuery(jpql2, Member.class).getResultList();

for (Member member : members2) {
    System.out.println(member.getTeam().getName());  // 추가 쿼리 없음
    // 이미 team이 로딩됨
}

컬렉션 페치 조인

// 1:N 관계에서의 페치 조인 (주의: 데이터 뻥튀기 가능성)
String jpql = "SELECT t FROM Team t JOIN FETCH t.members";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();

for (Team team : teams) {
    System.out.println("팀: " + team.getName());
    for (Member member : team.getMembers()) {
        System.out.println("  -> 회원: " + member.getUsername());
    }
}

DISTINCT로 중복 제거

// 데이터 뻥튀기 문제
// 팀A: 회원1, 회원2
// 팀B: 회원3
// 페치 조인 결과: 팀A, 팀A, 팀B (팀A가 2번 나옴)

// DISTINCT로 해결
String jpql = "SELECT DISTINCT t FROM Team t JOIN FETCH t.members";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();

// DISTINCT 동작 방식
// 1. SQL에 DISTINCT 추가 (데이터가 다르면 효과 없음)
// 2. 애플리케이션에서 같은 식별자를 가진 엔티티 제거 (JPA 레벨)

페치 조인 vs 일반 조인

// 일반 조인: 연관 엔티티를 함께 조회하지 않음
String jpql1 = "SELECT t FROM Team t JOIN t.members m";
// SQL: SELECT t.* FROM Team t INNER JOIN Member m ON t.id = m.team_id
// 결과: Team만 조회, Member는 조회 안됨

// 페치 조인: 연관 엔티티를 함께 조회
String jpql2 = "SELECT t FROM Team t JOIN FETCH t.members";
// SQL: SELECT t.*, m.* FROM Team t INNER JOIN Member m ON t.id = m.team_id
// 결과: Team과 Member 함께 조회

페치 조인의 특징과 한계

// 1. 별칭 사용 주의 (가급적 사용하지 말 것)
String jpql1 = "SELECT t FROM Team t JOIN FETCH t.members m WHERE m.age > 20";
// 비권장: 페치 조인 대상에 별칭을 사용하여 필터링

// 2. 둘 이상의 컬렉션 페치 조인 불가
String jpql2 = """
    SELECT t
    FROM Team t
    JOIN FETCH t.members
    JOIN FETCH t.projects  // 오류!
""";

// 3. 컬렉션 페치 조인과 페이징 API 사용 불가
String jpql3 = "SELECT t FROM Team t JOIN FETCH t.members";
List<Team> teams = em.createQuery(jpql3, Team.class)
    .setFirstResult(0)
    .setMaxResults(10)  // 경고: 메모리에서 페이징 (매우 위험!)
    .getResultList();

// 4. 단일 값 연관 필드는 페이징 가능
String jpql4 = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = em.createQuery(jpql4, Member.class)
    .setFirstResult(0)
    .setMaxResults(10)  // 정상
    .getResultList();

페치 조인 실무 적용 패턴

// 글로벌 로딩 전략: 모두 지연 로딩
@Entity
public class Member {
    @ManyToOne(fetch = FetchType.LAZY)  // 지연 로딩
    private Team team;
}

// 필요할 때만 페치 조인 사용
public Member findMemberWithTeam(Long memberId) {
    String jpql = """
        SELECT m
        FROM Member m
        JOIN FETCH m.team
        WHERE m.id = :id
    """;
    return em.createQuery(jpql, Member.class)
        .setParameter("id", memberId)
        .getSingleResult();
}

public List<Order> findOrdersWithItems(Long memberId) {
    String jpql = """
        SELECT DISTINCT o
        FROM Order o
        JOIN FETCH o.member m
        JOIN FETCH o.items i
        JOIN FETCH i.product
        WHERE m.id = :memberId
        ORDER BY o.orderDate DESC
    """;
    return em.createQuery(jpql, Order.class)
        .setParameter("memberId", memberId)
        .getResultList();
}

// 페치 조인 대신 DTO 조회 (복잡한 요구사항)
public List<OrderSummaryDto> findOrderSummaries(Long memberId) {
    String jpql = """
        SELECT new com.example.OrderSummaryDto(
            o.id, o.orderDate, o.status,
            m.username,
            COUNT(i), SUM(i.totalPrice)
        )
        FROM Order o
        JOIN o.member m
        JOIN o.items i
        WHERE m.id = :memberId
        GROUP BY o.id, o.orderDate, o.status, m.username
    """;
    return em.createQuery(jpql, OrderSummaryDto.class)
        .setParameter("memberId", memberId)
        .getResultList();
}

3.3. 다형성 쿼리

TYPE (상속 관계에서 타입 필터링)

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {
    @Id private Long id;
    private String name;
    private int price;
}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
    private String author;
    private String isbn;
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
    private String director;
    private String actor;
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item {
    private String artist;
}

// TYPE 사용
String jpql1 = "SELECT i FROM Item i WHERE TYPE(i) IN (Book, Movie)";
// SQL: SELECT i.* FROM Item i WHERE i.DTYPE IN ('B', 'M')

// 모든 자식 타입 포함 조회
String jpql2 = "SELECT i FROM Item i";
// 자동으로 모든 자식 타입 포함하여 조회

TREAT (타입 캐스팅)

// 부모 타입을 특정 자식 타입으로 다룸
String jpql1 = """
    SELECT i
    FROM Item i
    WHERE TREAT(i AS Book).author = '김영한'
""";
// SQL: SELECT i.* FROM Item i WHERE i.DTYPE = 'B' AND i.author = '김영한'

// 여러 자식 타입으로 TREAT
String jpql2 = """
    SELECT i
    FROM Item i
    WHERE
        (TREAT(i AS Book).author = '김영한') OR
        (TREAT(i AS Movie).director = '크리스토퍼 놀란')
""";

// FROM 절에서 TREAT
String jpql3 = """
    SELECT b
    FROM Item i, TREAT(i AS Book) b
    WHERE b.price > 10000
""";

3.4. 엔티티 직접 사용

기본 키 값으로 비교

// 엔티티 직접 사용
String jpql1 = "SELECT m FROM Member m WHERE m = :member";
List<Member> result1 = em.createQuery(jpql1, Member.class)
    .setParameter("member", member)
    .getResultList();
// SQL: SELECT m.* FROM Member m WHERE m.id = ?

// 기본 키 값 사용
String jpql2 = "SELECT m FROM Member m WHERE m.id = :memberId";
List<Member> result2 = em.createQuery(jpql2, Member.class)
    .setParameter("memberId", member.getId())
    .getResultList();
// SQL: SELECT m.* FROM Member m WHERE m.id = ?

외래 키 값으로 비교

// 엔티티 직접 사용
Team team = em.find(Team.class, 1L);
String jpql1 = "SELECT m FROM Member m WHERE m.team = :team";
List<Member> result1 = em.createQuery(jpql1, Member.class)
    .setParameter("team", team)
    .getResultList();
// SQL: SELECT m.* FROM Member m WHERE m.team_id = ?

// 외래 키 값 사용
String jpql2 = "SELECT m FROM Member m WHERE m.team.id = :teamId";
List<Member> result2 = em.createQuery(jpql2, Member.class)
    .setParameter("teamId", 1L)
    .getResultList();
// SQL: SELECT m.* FROM Member m WHERE m.team_id = ?

엔티티 직접 사용 장점

  1. 가독성 향상: 객체지향적 표현
  2. 타입 안전성: 컴파일 타임 체크 가능
  3. 리팩토링 용이: 필드명 변경 시 자동 반영

3.5. Named 쿼리

어노테이션 기반 Named 쿼리

// 엔티티에 정의
@Entity
@NamedQueries({
    @NamedQuery(
        name = "Member.findByUsername",
        query = "SELECT m FROM Member m WHERE m.username = :username",
        hints = @QueryHint(name = "org.hibernate.readOnly", value = "true")
    ),
    @NamedQuery(
        name = "Member.count",
        query = "SELECT COUNT(m) FROM Member m"
    ),
    @NamedQuery(
        name = "Member.findByAgeGreaterThan",
        query = "SELECT m FROM Member m WHERE m.age > :age ORDER BY m.age"
    )
})
public class Member {
    // ...
}

// 사용
public Member findByUsername(String username) {
    return em.createNamedQuery("Member.findByUsername", Member.class)
        .setParameter("username", username)
        .setHint("javax.persistence.cache.storeMode", "REFRESH")
        .getSingleResult();
}

public Long countAll() {
    return em.createNamedQuery("Member.count", Long.class)
        .getSingleResult();
}

public List<Member> findAdults(int minAge) {
    return em.createNamedQuery("Member.findByAgeGreaterThan", Member.class)
        .setParameter("age", minAge)
        .getResultList();
}

 

 

Named 쿼리 장점

  1. 애플리케이션 로딩 시점에 쿼리 검증: 오류 조기 발견
  2. 재사용성: 동일 쿼리 여러 곳에서 사용
  3. 캐싱 가능: 하이버네이트 쿼리 캐시 활용
  4. 관리 용이성: 쿼리 중앙 집중 관리

3.6. 벌크 연산

벌크 업데이트

// 재고 10개 미만 상품 가격 10% 인상
String jpql = """
    UPDATE Product p
    SET p.price = p.price * 1.1
    WHERE p.stock < :stock
""";

int updatedCount = em.createQuery(jpql)
    .setParameter("stock", 10)
    .executeUpdate();

System.out.println(updatedCount + "개의 상품 가격이 인상되었습니다.");

// 조건이 복잡한 벌크 업데이트
String jpql2 = """
    UPDATE Order o
    SET o.status = :newStatus
    WHERE o.status = :oldStatus
    AND o.orderDate < :date
""";

int cancelledCount = em.createQuery(jpql2)
    .setParameter("newStatus", OrderStatus.CANCELLED)
    .setParameter("oldStatus", OrderStatus.PENDING)
    .setParameter("date", LocalDateTime.now().minusDays(30))
    .executeUpdate();

벌크 삭제

// 30일 이상된 로그 삭제
String jpql = """
    DELETE FROM AccessLog l
    WHERE l.accessTime < :date
""";

int deletedCount = em.createQuery(jpql)
    .setParameter("date", LocalDateTime.now().minusDays(30))
    .executeUpdate();

// 연관관계 없는 데이터 삭제
String jpql2 = """
    DELETE FROM TemporaryData t
    WHERE NOT EXISTS (
        SELECT 1 FROM User u WHERE u.id = t.userId
    )
""";

int orphanCount = em.createQuery(jpql2).executeUpdate();

벌크 연산 주의사항

// 문제: 영속성 컨텍스트와 데이터베이스 불일치
Member member = em.find(Member.class, 1L);
member.setAge(25);  // 영속성 컨텍스트에만 반영

// 벌크 연산 실행
String jpql = "UPDATE Member m SET m.age = m.age + 1 WHERE m.team.id = :teamId";
int updated = em.createQuery(jpql)
    .setParameter("teamId", 1L)
    .executeUpdate();
// SQL: UPDATE member SET age = age + 1 WHERE team_id = 1
// 주의: 영속성 컨텍스트의 member.age는 여전히 25

// 해결 방법 1: 벌크 연산 먼저 실행
@Transactional
public void bulkUpdateThenProcess(Long teamId) {
    // 1. 벌크 연산 먼저 실행
    String jpql = "UPDATE Member m SET m.age = m.age + 1 WHERE m.team.id = :teamId";
    em.createQuery(jpql)
        .setParameter("teamId", teamId)
        .executeUpdate();

    // 2. 영속성 컨텍스트 초기화
    em.clear();

    // 3. 이후 작업 수행
    List<Member> members = em.createQuery(
        "SELECT m FROM Member m WHERE m.team.id = :teamId", Member.class)
        .setParameter("teamId", teamId)
        .getResultList();
}

// 해결 방법 2: 벌크 연산 후 플러시와 초기화
@Transactional
public void processThenBulkUpdate(Long teamId) {
    // 1. 필요한 작업 먼저 수행
    List<Member> members = em.createQuery(
        "SELECT m FROM Member m WHERE m.team.id = :teamId", Member.class)
        .setParameter("teamId", teamId)
        .getResultList();

    // 2. 작업 후 플러시
    em.flush();

    // 3. 벌크 연산 실행
    String jpql = "UPDATE Member m SET m.age = m.age + 1 WHERE m.team.id = :teamId";
    em.createQuery(jpql)
        .setParameter("teamId", teamId)
        .executeUpdate();

    // 4. 영속성 컨텍스트 초기화
    em.clear();
}

벌크 INSERT (하이버네이트)

// INSERT INTO ... SELECT (하이버네이트 지원)
String jpql = """
    INSERT INTO MemberHistory(id, username, age, createdDate)
    SELECT m.id, m.username, m.age, CURRENT_TIMESTAMP
    FROM Member m
    WHERE m.createdDate < :date
""";

int insertedCount = em.createQuery(jpql)
    .setParameter("date", LocalDateTime.now().minusYears(1))
    .executeUpdate();

4. JPQL 성능 최적화

4.1. N+1 문제와 해결책

N+1 문제 예시

// 문제: 각 회원의 팀을 조회할 때마다 추가 쿼리 발생
List<Member> members = em.createQuery(
    "SELECT m FROM Member m", Member.class)
    .getResultList();  // 쿼리 1번: 회원 조회

for (Member member : members) {
    System.out.println(member.getTeam().getName());  // N번의 추가 쿼리!
    // member.getTeam() 호출 시마다 SELECT * FROM team WHERE id = ?
}

해결책 1: 페치 조인

// 페치 조인으로 한 번에 조회
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = em.createQuery(jpql, Member.class)
    .getResultList();  // 쿼리 1번: 회원과 팀 함께 조회

for (Member member : members) {
    System.out.println(member.getTeam().getName());  // 추가 쿼리 없음
}

해결책 2: 배치 사이즈 조정

// 엔티티에 배치 사이즈 설정
@Entity
@BatchSize(size = 100)  // 팀을 100개씩 모아서 조회
public class Member {
    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;
}

// 또는 어노테이션으로 설정
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 100)
private Team team;

// XML 설정
<property name="hibernate.default_batch_fetch_size" value="100"/>

해결책 3: 엔티티 그래프

// 엔티티 그래프 정의
@Entity
@NamedEntityGraph(
    name = "Member.withTeam",
    attributeNodes = @NamedAttributeNode("team")
)
public class Member {
    // ...
}

// 사용
EntityGraph<Member> graph = em.createEntityGraph(Member.class);
graph.addAttributeNodes("team");

Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.fetchgraph", graph);

Member member = em.find(Member.class, 1L, hints);

4.2. 쿼리 힌트

JPA 표준 힌트

// 읽기 전용 쿼리
String jpql = "SELECT m FROM Member m WHERE m.team.id = :teamId";
List<Member> members = em.createQuery(jpql, Member.class)
    .setParameter("teamId", 1L)
    .setHint("javax.persistence.readOnly", true)  // 읽기 전용
    .setHint("javax.persistence.query.timeout", 5000)  // 타임아웃 5초
    .getResultList();

// 캐시 관련 힌트
List<Member> cached = em.createQuery(jpql, Member.class)
    .setParameter("teamId", 1L)
    .setHint("javax.persistence.cache.retrieveMode", CacheRetrieveMode.USE)
    .setHint("javax.persistence.cache.storeMode", CacheStoreMode.REFRESH)
    .getResultList();

하이버네이트 힌트

// 페치 사이즈
String jpql = "SELECT m FROM Member m";
List<Member> members = em.createQuery(jpql, Member.class)
    .setHint("org.hibernate.fetchSize", 100)  // JDBC 페치 사이즈
    .setHint("org.hibernate.readOnly", true)  // 읽기 전용
    .setHint("org.hibernate.comment", "회원 전체 조회")  // 쿼리 코멘트
    .getResultList();

// 캐시 모드
List<Member> cached = em.createQuery(jpql, Member.class)
    .setHint("org.hibernate.cacheable", true)  // 쿼리 캐시 사용
    .setHint("org.hibernate.cacheRegion", "memberQueries")  // 캐시 영역
    .getResultList();

// 플러시 모드
List<Member> members2 = em.createQuery(jpql, Member.class)
    .setHint("org.hibernate.flushMode", "MANUAL")  // 플러시 수동 모드
    .getResultList();

4.3. 네이티브 SQL 최적화

네이티브 SQL 사용

// 복잡한 통계 쿼리
String sql = """
    SELECT
        DATE_FORMAT(o.order_date, '%Y-%m') as month,
        COUNT(DISTINCT o.member_id) as active_members,
        SUM(oi.quantity * oi.price) as total_sales,
        AVG(oi.quantity * oi.price) as avg_order_value
    FROM orders o
    JOIN order_items oi ON o.id = oi.order_id
    WHERE o.order_date >= :startDate
    GROUP BY DATE_FORMAT(o.order_date, '%Y-%m')
    ORDER BY month DESC
""";

Query nativeQuery = em.createNativeQuery(sql);
nativeQuery.setParameter("startDate", LocalDateTime.now().minusMonths(6));

List<Object[]> results = nativeQuery.getResultList();

// 결과 매핑
String sql2 = """
    SELECT
        m.id as member_id,
        m.username,
        COUNT(o.id) as order_count,
        SUM(oi.quantity * oi.price) as total_spent
    FROM member m
    LEFT JOIN orders o ON m.id = o.member_id
    LEFT JOIN order_items oi ON o.id = oi.order_id
    GROUP BY m.id, m.username
""";

Query query = em.createNativeQuery(sql2, "MemberStatisticsMapping");
List<MemberStatistics> stats = query.getResultList();

// SQL 결과 세트 매핑 정의
@SqlResultSetMapping(
    name = "MemberStatisticsMapping",
    classes = @ConstructorResult(
        targetClass = MemberStatistics.class,
        columns = {
            @ColumnResult(name = "member_id", type = Long.class),
            @ColumnResult(name = "username", type = String.class),
            @ColumnResult(name = "order_count", type = Long.class),
            @ColumnResult(name = "total_spent", type = BigDecimal.class)
        }
    )
)

5. 실무 적용 패턴

5.1. 동적 쿼리 빌더

QueryDSL 기반 동적 쿼리

public List<Member> searchMembers(MemberSearchCondition condition) {
    JPAQueryFactory queryFactory = new JPAQueryFactory(em);
    QMember m = QMember.member;

    BooleanBuilder builder = new BooleanBuilder();

    if (condition.getUsername() != null) {
        builder.and(m.username.contains(condition.getUsername()));
    }

    if (condition.getAgeGoe() != null) {
        builder.and(m.age.goe(condition.getAgeGoe()));
    }

    if (condition.getAgeLoe() != null) {
        builder.and(m.age.loe(condition.getAgeLoe()));
    }

    if (condition.getTeamName() != null) {
        builder.and(m.team.name.eq(condition.getTeamName()));
    }

    return queryFactory.selectFrom(m)
        .where(builder)
        .orderBy(m.createdDate.desc())
        .fetch();
}

Criteria API 기반 동적 쿼리

public List<Member> searchMembers(MemberSearchCondition condition) {
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Member> cq = cb.createQuery(Member.class);
    Root<Member> m = cq.from(Member.class);

    List<Predicate> predicates = new ArrayList<>();

    if (condition.getUsername() != null) {
        predicates.add(cb.like(m.get("username"), "%" + condition.getUsername() + "%"));
    }

    if (condition.getAgeGoe() != null) {
        predicates.add(cb.ge(m.get("age"), condition.getAgeGoe()));
    }

    if (condition.getAgeLoe() != null) {
        predicates.add(cb.le(m.get("age"), condition.getAgeLoe()));
    }

    if (condition.getTeamName() != null) {
        predicates.add(cb.equal(m.get("team").get("name"), condition.getTeamName()));
    }

    cq.where(cb.and(predicates.toArray(new Predicate[0])));
    cq.orderBy(cb.desc(m.get("createdDate")));

    return em.createQuery(cq).getResultList();
}

5.2. DTO 프로젝션 패턴

레코드 사용 (Java 16+)

// 레코드 정의
public record MemberDto(Long id, String username, int age, String teamName) {}

// JPQL에서 사용
String jpql = """
    SELECT new com.example.MemberDto(
        m.id, m.username, m.age, t.name
    )
    FROM Member m
    JOIN m.team t
""";

List<MemberDto> dtos = em.createQuery(jpql, MemberDto.class).getResultList();

인터페이스 기반 프로젝션

// 인터페이스 정의
public interface MemberProjection {
    Long getId();
    String getUsername();
    int getAge();
    String getTeamName();

    // 디폴트 메서드
    default String getInfo() {
        return getUsername() + " (" + getAge() + ") - " + getTeamName();
    }
}

// QueryDSL에서 사용
public List<MemberProjection> findMemberProjections() {
    QMember m = QMember.member;
    QTeam t = QTeam.team;

    return queryFactory
        .select(Projections.constructor(MemberProjection.class,
            m.id, m.username, m.age, t.name))
        .from(m)
        .join(m.team, t)
        .fetch();
}

5.3. 복잡한 통계 쿼리 패턴

CTE (Common Table Expressions) 사용

// 네이티브 SQL로 CTE 사용
String sql = """
    WITH monthly_sales AS (
        SELECT
            DATE_TRUNC('month', o.order_date) as month,
            SUM(oi.quantity * oi.price) as monthly_total
        FROM orders o
        JOIN order_items oi ON o.id = oi.order_id
        GROUP BY DATE_TRUNC('month', o.order_date)
    )
    SELECT
        month,
        monthly_total,
        SUM(monthly_total) OVER (ORDER BY month) as running_total,
        AVG(monthly_total) OVER (
            ORDER BY month
            ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
        ) as moving_avg
    FROM monthly_sales
    ORDER BY month
""";

List<Object[]> stats = em.createNativeQuery(sql).getResultList();

윈도우 함수 사용

String sql = """
    SELECT
        m.username,
        o.order_date,
        o.total_amount,
        RANK() OVER (
            PARTITION BY m.id
            ORDER BY o.total_amount DESC
        ) as rank_in_member,
        SUM(o.total_amount) OVER (
            PARTITION BY m.id
            ORDER BY o.order_date
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) as cumulative_total
    FROM member m
    JOIN orders o ON m.id = o.member_id
    ORDER BY m.username, o.order_date
""";

List<Object[]> results = em.createNativeQuery(sql).getResultList();

6. 정리

6.1. JPQL 사용 원칙

  1. 객체지향적으로 생각하라: 테이블이 아닌 엔티티를 대상으로 쿼리
  2. 페치 조인을 이해하라: N+1 문제 해결의 핵심
  3. 명시적 조인을 사용하라: 묵시적 조인보다 가독성과 성능이 좋음
  4. 파라미터 바인딩을 사용하라: SQL 인젝션 방지와 성능 최적화
  5. 페이징을 활용하라: 대량 데이터 조회 시 필수
  6. 적절한 조인 전략 선택: 상황에 맞는 조인 방식 선택

6.2. 성능 최적화 체크리스트

✅ 해야 할 것

  • N+1 문제는 페치 조인으로 해결
  • 필요 없는 컬럼은 SELECT 절에서 제외
  • 인덱스를 고려한 WHERE 절 작성
  • 파라미터 바인딩 사용
  • 적절한 페이징 적용
  • 읽기 전용 쿼리는 힌트 사용

❌ 하지 말 것

  • 엔티티를 DTO처럼 사용 (불필요한 컬럼 조회)
  • 컬렉션 페치 조인에 페이징 적용
  • 무분별한 즉시 로딩 사용
  • 네이티브 SQL 남용
  • 동적 쿼리 문자열 결합 (SQL 인젝션 위험)

6.3. 도구 선택 가이드

상황 권장 도구 이유
정적 쿼리 Named 쿼리 검증, 재사용, 캐싱
동적 쿼리 QueryDSL 타입 안전성, 가독성
복잡한 통계 네이티브 SQL 데이터베이스 기능 활용
간단한 조회 JPQL 간결함, 객체지향적
타입 안전성이 중요한 경우 QueryDSL/Criteria 컴파일 타임 체크

 JPQL은 JPA의 핵심 기능으로, 객체지향적인 사고로 데이터베이스를 조작할 수 있게 해준다. 복잡한 비즈니스 요구사항에 대응하기 위해 다양한 기능들을 조합하여 사용하는 것이 중요하다. 특히 페치 조인, 페이징, 벌크 연산 같은 고급 기능들을 올바르게 이해하고 활용하면 성능과 유지보수성을 크게 향상시킬 수 있다.

'Spring > JPA' 카테고리의 다른 글

[Advanced-2] QueryDSL(1): 기본  (0) 2026.01.06
[Advanced-1] Spring Data Jpa  (0) 2026.01.06
[Basic-8] 값 타입  (0) 2026.01.06
[Basic-7] 프록시와 연관관계 관리  (0) 2026.01.06
[Basic-6] 고급 관계 매핑  (0) 2026.01.06
'Spring/JPA' 카테고리의 다른 글
  • [Advanced-2] QueryDSL(1): 기본
  • [Advanced-1] Spring Data Jpa
  • [Basic-8] 값 타입
  • [Basic-7] 프록시와 연관관계 관리
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Basic-9] 객체지향 쿼리 언어(JPQL)
상단으로

티스토리툴바