JPA - JPQL

Agida·2025년 9월 10일

JPA

목록 보기
3/8
post-thumbnail

🔍 JPQL - 객체지향 쿼리언어

JPQL 기본 문법

// 엔티티와 속성은 대소문자 구분 (Member, username)
// JPQL 키워드는 대소문자 구분 안함 (SELECT, FROM, WHERE)
// 테이블 이름이 아닌 엔티티 이름 사용 (Member)
// 별칭은 필수 (m) - AS는 생략 가능

String jpql = "SELECT m FROM Member m WHERE m.username = 'kim'";
List<Member> result = em.createQuery(jpql, Member.class).getResultList();

TypedQuery, Query

// TypedQuery: 반환 타입이 명확할 때 사용
TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m", Member.class);
TypedQuery<String> query2 = em.createQuery("SELECT m.username FROM Member m", String.class);

// Query: 반환 타입이 명확하지 않을 때 사용
Query query3 = em.createQuery("SELECT m.username, m.age FROM Member m");

결과 조회 API

// getResultList(): 결과가 하나 이상일 때, 리스트 반환
// 결과가 없으면 빈 리스트 반환
List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
                        .getResultList();

// getSingleResult(): 결과가 정확히 하나, 단일 객체 반환
// 결과가 없으면: NoResultException
// 둘 이상이면: NonUniqueResultException
Member member = em.createQuery("SELECT m FROM Member m WHERE m.id = 1", Member.class)
                  .getSingleResult();

파라미터 바인딩

// 이름 기준 파라미터 (권장)
String jpql = "SELECT m FROM Member m WHERE m.username = :username";
List<Member> members = em.createQuery(jpql, Member.class)
                        .setParameter("username", "kim")
                        .getResultList();

// 위치 기준 파라미터 (권장하지 않음)
String jpql2 = "SELECT m FROM Member m WHERE m.username = ?1";
List<Member> members2 = em.createQuery(jpql2, Member.class)
                         .setParameter(1, "kim")
                         .getResultList();

프로젝션 (SELECT 절에 조회할 대상 지정)

엔티티 프로젝션

// 엔티티 프로젝션 - 영속성 컨텍스트에서 관리됨
List<Member> result = em.createQuery("SELECT m FROM Member m", Member.class)
                       .getResultList();

// 연관된 엔티티 프로젝션 - 이렇게 하지 말고
List<Team> result = em.createQuery("SELECT m.team FROM Member m", Team.class)
                     .getResultList();

// 명시적 조인을 사용하자
List<Team> result = em.createQuery("SELECT t FROM Member m JOIN m.team t", Team.class)
                     .getResultList();

임베디드 타입 프로젝션

em.createQuery("SELECT m.address FROM Member m", Address.class)
  .getResultList();

스칼라 타입 프로젝션

// 여러 값 조회 - Object[] 반환
List<Object[]> result = em.createQuery("SELECT m.username, m.age FROM Member m")
                         .getResultList();

for (Object[] row : result) {
    String username = (String) row[0];
    Integer age = (Integer) row[1];
}

// DTO로 바로 조회 (new 명령어 사용)
List<MemberDTO> result = em.createQuery(
    "SELECT new jpql.MemberDTO(m.username, m.age) FROM Member m", MemberDTO.class)
    .getResultList();

// MemberDTO 클래스
public class MemberDTO {
    private String username;
    private int age;
    
    public MemberDTO(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

페이징

// JPA는 페이징을 다음 두 API로 추상화
String jpql = "SELECT m FROM Member m ORDER BY m.age DESC";
List<Member> resultList = em.createQuery(jpql, Member.class)
                           .setFirstResult(10)    // 조회 시작 위치 (0부터 시작)
                           .setMaxResults(20)     // 조회할 데이터 수
                           .getResultList();

// 각 데이터베이스마다 다른 페이징 처리를 JPA가 처리해줌
// MySQL: LIMIT
// Oracle: ROWNUM
// SQL Server: TOP

조인

내부 조인

String jpql = "SELECT m FROM Member m INNER JOIN m.team t";
List<Member> result = em.createQuery(jpql, Member.class).getResultList();

외부 조인

String jpql = "SELECT m FROM Member m LEFT JOIN m.team t";
List<Member> result = em.createQuery(jpql, Member.class).getResultList();

세타 조인

String jpql = "SELECT m FROM Member m, Team t WHERE m.username = t.name";
List<Member> result = em.createQuery(jpql, Member.class).getResultList();

조인 - ON 절

조인 대상 필터링:

// JPQL: 회원과 팀을 조인하면서, 팀 이름이 'A'인 팀만 조인
String jpql = "SELECT m FROM Member m LEFT JOIN m.team t ON t.name = 'A'";
// SQL: SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.TEAM_ID = t.id AND t.name = 'A'

연관관계 없는 엔티티 외부 조인:

// JPQL: 회원의 이름과 팀의 이름이 같은 대상 외부 조인
String jpql = "SELECT m FROM Member m LEFT JOIN Team t ON m.username = t.name";
// SQL: SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.username = t.name

서브 쿼리

// 나이가 평균보다 많은 회원
String jpql = "SELECT m FROM Member m WHERE m.age > (SELECT AVG(m2.age) FROM Member m2)";

// 한 건이라도 주문한 고객
String jpql = "SELECT m FROM Member m WHERE (SELECT COUNT(o) FROM Order o WHERE m = o.member) > 0";

서브 쿼리 지원 함수

  • EXISTS (subquery): 서브쿼리에 결과가 존재하면 참
  • ALL (subquery): 모두 만족하면 참
  • ANY/SOME (subquery): 같은 의미, 조건을 하나라도 만족하면 참
  • IN (subquery): 서브쿼리의 결과 중 하나라도 같은 것이 있으면 참
// 팀 A 소속인 회원
String jpql = "SELECT m FROM Member m WHERE EXISTS (SELECT t FROM m.team t WHERE t.name = '팀A')";

// 전체 상품 각각의 재고보다 주문량이 많은 주문들
String jpql = "SELECT o FROM Order o WHERE o.orderAmount > ALL (SELECT p.stockAmount FROM Product p)";

// 어떤 팀이든 팀에 소속된 회원
String jpql = "SELECT m FROM Member m WHERE m.team = ANY (SELECT t FROM Team t)";

JPA 서브 쿼리 한계

  • WHERE, HAVING 절에서만 서브 쿼리 사용 가능
  • SELECT 절도 가능 (하이버네이트에서 지원)
  • FROM 절의 서브 쿼리는 현재 JPQL에서 불가능
    • 조인으로 풀 수 있으면 풀어서 해결
    • 네이티브 SQL을 사용
    • 애플리케이션에서 쿼리를 2번 분리해서 실행

JPQL 타입 표현과 기타식

  • 문자: ‘HELLO’, ‘She’‘s’
  • 숫자: 10L(Long), 10D(Double), 10F(Float)
  • Boolean: TRUE, FALSE
  • ENUM: jpabook.MemberType.Admin (패키지명 포함)
  • 엔티티 타입: TYPE(m) = Member (상속 관계에서 사용)
// ENUM 사용 예시
String jpql = "SELECT m FROM Member m WHERE m.type = jpql.MemberType.ADMIN";

// 엔티티 타입 사용 예시 (상속)
String jpql = "SELECT i FROM Item i WHERE TYPE(i) = Book";

조건식 (CASE 등등)

기본 CASE식

String jpql = 
    "SELECT " +
        "CASE WHEN m.age <= 10 THEN '학생요금' " +
        "     WHEN m.age >= 60 THEN '경로요금' " +
        "     ELSE '일반요금' " +
        "END " +
    "FROM Member m";

단순 CASE식

String jpql = 
    "SELECT " +
        "CASE t.name " +
        "    WHEN '팀A' THEN '인센티브110%' " +
        "    WHEN '팀B' THEN '인센티브120%' " +
        "    ELSE '인센티브105%' " +
        "END " +
    "FROM Team t";

COALESCE, NULLIF

// COALESCE: 하나씩 조회해서 null이 아니면 반환
// 사용자 이름이 없으면 이름 없는 회원을 반환
String jpql = "SELECT COALESCE(m.username, '이름 없는 회원') FROM Member m";

// NULLIF: 두 값이 같으면 null 반환, 다르면 첫 번째 값 반환
// 사용자 이름이 '관리자'면 null을 반환하고 나머지는 본인의 이름을 반환
String jpql = "SELECT NULLIF(m.username, '관리자') FROM Member m";

JPQL 기본 함수

문자 함수

  • CONCAT(문자1, 문자2): 문자 합치기
  • SUBSTRING(문자, 위치, [길이]): 부분 문자열
  • TRIM(문자): 공백 제거
  • LOWER, UPPER(문자): 대소문자 변경
  • LENGTH(문자): 문자 길이
  • LOCATE(찾을 문자, 원본 문자, [검색 시작 위치]): 문자 위치
String jpql = "SELECT CONCAT('a', 'b') FROM Member m"; // ab
String jpql = "SELECT SUBSTRING(m.username, 2, 3) FROM Member m";
String jpql = "SELECT LOCATE('de', 'abcdefg') FROM Member m"; // 4

수학 함수

  • ABS(숫자): 절대값
  • SQRT(숫자): 제곱근
  • MOD(숫자, 나눌 수): 나머지
  • SIZE(컬렉션): 컬렉션의 크기
String jpql = "SELECT SIZE(t.members) FROM Team t"; // 팀의 멤버 수

날짜 함수

  • CURRENT_DATE: 현재 날짜
  • CURRENT_TIME: 현재 시간
  • CURRENT_TIMESTAMP: 현재 날짜 시간

사용자 정의 함수

하이버네이트는 사용자 정의 함수를 호출할 수 있다.

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

// 2. persistence.xml에 dialect 등록
// <property name="hibernate.dialect" value="dialect.MyH2Dialect"/>

// 3. 사용
String jpql = "SELECT FUNCTION('group_concat', m.username) FROM Member m";

경로 표현식

JPQL에서 .(점)을 찍어 객체 그래프를 탐색하는 것

SELECT m.username -> 상태 필드
FROM Member m
    JOIN m.team t -> 단일 값 연관 필드
    JOIN m.orders o -> 컬렉션 값 연관 필드
WHERE t.name = '팀A'

경로 표현식 용어 정리

  • 상태 필드(state field): 단순히 값을 저장하기 위한 필드 (m.username)
  • 연관 필드(association field): 연관관계를 위한 필드
    • 단일 값 연관 필드: @ManyToOne, @OneToOne, 대상이 엔티티 (m.team)
    • 컬렉션 값 연관 필드: @OneToMany, @ManyToMany, 대상이 컬렉션 (m.orders)

경로 표현식 특징

  • 상태 필드: 경로 탐색의 끝, 탐색X
  • 단일 값 연관 경로: 묵시적 내부 조인 발생, 탐색O
  • 컬렉션 값 연관 경로: 묵시적 내부 조인 발생, 탐색X
// 상태 필드 경로 탐색
String jpql = "SELECT m.username, m.age FROM Member m"; // OK

// 단일 값 연관 경로 탐색
String jpql = "SELECT m.team FROM Member m"; // OK
String jpql = "SELECT m.team.name FROM Member m"; // OK, 묵시적 조인 발생

// 컬렉션 값 연관 경로 탐색
String jpql = "SELECT t.members FROM Team t"; // OK
String jpql = "SELECT t.members.username FROM Team t"; // 실패!

// 컬렉션 탐색을 하려면 명시적 조인 사용
String jpql = "SELECT m.username FROM Team t JOIN t.members m"; // OK

묵시적 조인 vs 명시적 조인

// 묵시적 조인: 경로 표현식에 의해 묵시적으로 SQL 조인 발생 (내부 조인만 가능)
String jpql = "SELECT m.team.name FROM Member m";

// 명시적 조인: join 키워드 직접 사용
String jpql = "SELECT t.name FROM Member m JOIN m.team t";

실무 조언: 묵시적 조인은 사용하지 말자

  • 조인이 일어나는 상황을 한눈에 파악하기 어려움
  • 성능 튜닝이 어려움
  • 항상 명시적 조인 사용

페치 조인 (fetch join)

SQL 조인 종류가 아니고, JPQL에서 성능 최적화를 위해 제공하는 기능. 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능.

엔티티 페치 조인

// JPQL
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = em.createQuery(jpql, Member.class).getResultList();

// 실행된 SQL
// SELECT M.*, T.* FROM MEMBER M INNER JOIN TEAM T ON M.TEAM_ID = T.ID

for (Member member : members) {
    // 페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩X
    System.out.println("username = " + member.getUsername() + 
                      ", teamName = " + member.getTeam().getName()); 
}

컬렉션 페치 조인

// JPQL
String jpql = "SELECT t FROM Team t JOIN FETCH t.members WHERE t.name = '팀A'";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();

// 실행된 SQL  
// SELECT T.*, M.* FROM TEAM T INNER JOIN MEMBER M ON T.ID = M.TEAM_ID WHERE T.NAME = '팀A'

for(Team team : teams) {
    System.out.println("teamname = " + team.getName() + ", team = " + team);
    for (Member member : team.getMembers()) {
        // 페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생 안함
        System.out.println("-> username = " + member.getUsername()+ ", member = " + member);
    }
}

주의: 컬렉션 페치 조인에서 일대다 조인은 결과가 증가할 수 있다

팀A에 회원이 2명 있으면 같은 팀A가 2번 조회됨.

페치 조인과 DISTINCT

// JPQL의 DISTINCT는 2가지 기능 제공
// 1. SQL에 DISTINCT 추가
// 2. 애플리케이션에서 엔티티 중복 제거

String jpql = "SELECT DISTINCT t FROM Team t JOIN FETCH t.members WHERE t.name = '팀A'";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();

System.out.println("teams = " + teams.size()); // 1 (중복 제거됨)

하이버네이트6부터는 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용됨.

일반 조인 vs 페치 조인

// 일반 조인 실행시 연관된 엔티티를 함께 조회하지 않음
String jpql = "SELECT t FROM Team t JOIN t.members m WHERE t.name = '팀A'";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();

// 실행된 SQL
// SELECT T.* FROM TEAM T INNER JOIN MEMBER M ON T.ID = M.TEAM_ID WHERE T.NAME = '팀A'

for(Team team : teams) {
    System.out.println("teamname = " + team.getName());
    for (Member member : team.getMembers()) {
        // 회원 컬렉션을 실제 사용할 때 별도의 SQL 실행 (지연 로딩)
        System.out.println("-> username = " + member.getUsername());
    }
}

페치 조인의 특징과 한계

  • 페치 조인 대상에는 별칭을 줄 수 없다
  • 둘 이상의 컬렉션은 페치 조인 할 수 없다
  • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다
    • 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징 가능
    • 하이버네이트는 경고 로그를 남기고 메모리에서 페이징(매우 위험)

컬렉션 페치 조인에서 페이징 해결책

// 방법 1: 배치 사이즈로 해결
@BatchSize(size = 100) // 개별 최적화
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();

// 또는 글로벌 설정
// hibernate.default_batch_fetch_size: 100

// 방법 2: 방향을 뒤집어서 해결
String jpql = "SELECT m FROM Member m JOIN FETCH m.team t";
List<Member> members = em.createQuery(jpql, Member.class)
                        .setFirstResult(0)
                        .setMaxResults(10)
                        .getResultList();

페치 조인 정리

  • 모든 것을 페치 조인으로 해결할 수는 없음
  • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적
  • 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면, 페치 조인보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적

엔티티 직접 사용

JPQL에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기본키값을 사용

// JPQL
String jpql = "SELECT m FROM Member m WHERE m = :member";
List<Member> members = em.createQuery(jpql)
                        .setParameter("member", member)
                        .getResultList();

// 실행된 SQL
// SELECT m.* FROM Member m WHERE m.id = ?

// 엔티티의 아이디를 사용
String jpql = "SELECT m FROM Member m WHERE m.id = :memberId";
List<Member> members = em.createQuery(jpql)
                        .setParameter("memberId", memberId)
                        .getResultList();

외래키 값

Team team = em.find(Team.class, 1L);

String qlString = "SELECT m FROM Member m WHERE m.team = :team";
List<Member> members = em.createQuery(qlString)
                        .setParameter("team", team)
                        .getResultList();

// 실행된 SQL
// SELECT m.* FROM Member m WHERE m.team_id = ?

Named 쿼리

미리 정의해서 이름을 부여해두고 사용하는 JPQL. 정적 쿼리만 가능.

@Entity
@NamedQuery(
        name = "Member.findByUsername",
        query = "SELECT m FROM Member m WHERE m.username = :username")
public class Member {
    // ...
}

// 사용
List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
                           .setParameter("username", "회원1")
                           .getResultList();

Named 쿼리의 장점

  • 애플리케이션 로딩 시점에 초기화 후 재사용
  • 애플리케이션 로딩 시점에 쿼리를 검증

XML에 정의

<named-query name="Member.findByUsername">
    <query><![CDATA[
        select m
        from Member m
        where m.username = :username
        ]]>
    </query>
</named-query>

벌크 연산

재고가 10개 미만인 모든 상품의 가격을 10% 상승하려면?

  • JPA 변경 감지 기능으로 실행하려면 너무 많은 SQL 실행
    • 재고가 10개 미만인 상품을 리스트로 조회
    • 상품 엔티티의 가격을 10% 증가
    • 트랜잭션 커밋 시점에 변경감지가 동작
  • 변경된 데이터가 100건이라면 100번의 UPDATE SQL 실행

벌크 연산 예제

String qlString = "update Product p " +
                  "set p.price = p.price * 1.1 " +
                  "where p.stockAmount < :stockAmount";

int resultCount = em.createQuery(qlString)
                   .setParameter("stockAmount", 10)
                   .executeUpdate();

벌크 연산 주의사항

  • 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리
    • 벌크 연산을 먼저 실행
    • 벌크 연산 수행 후 영속성 컨텍스트 초기화
// 회원1의 나이를 20으로 변경하고 싶다.
Member member1 = em.find(Member.class, 1L);
System.out.println("member1 = " + member1.getAge()); // 0

// 벌크 연산 수행: 모든 회원의 나이를 20으로 변경
em.createQuery("update Member m set m.age = 20")
  .executeUpdate();

System.out.println("member1 = " + member1.getAge()); // 0 (영속성 컨텍스트에는 여전히 0)

// 해결책 1: 벌크 연산을 먼저 실행
// 해결책 2: 벌크 연산 수행 후 영속성 컨텍스트 초기화
em.clear();

Member findMember = em.find(Member.class, 1L);
System.out.println("findMember = " + findMember.getAge()); // 20

JPQL은 SQL과 문법이 유사하면서도 객체지향적 특성을 유지하는 강력한 쿼리 언어다. 특히 페치 조인은 성능 최적화에 있어서 핵심적인 기능이므로 반드시 숙지해야 한다. 하지만 복잡한 쿼리나 통계성 쿼리의 경우 네이티브 SQL이나 QueryDSL 같은 대안을 고려해보는 것도 좋다.

profile
백엔드

0개의 댓글