[Java/JPA] JPQL - JOIN, 서브쿼리, 타입 표현식, 조건식, 함수

daheenamic·2025년 12월 2일

Java

목록 보기
45/48

JOIN

JPQL은 SQL과 유사하게 세 가지 조인을 지원한다.

  1. 내부 조인 (INNER JOIN)
  2. 외부 조인 (LEFT/RIGHT OUTER JOIN)
  3. 세타 조인 (연관관계 없는 조인)

내부 조인 (INNER JOIN)

SELECT m FROM Member m [INNER] JOIN m.team t

INNER 키워드는 생략 가능하다. 보통은 생략하고 JOIN만 쓴다.
내부 조인은 양쪽 테이블 모두에 데이터가 있을 때만 결과가 나온다. Member와 Team이 매칭되는 경우에만 조회된다는 의미이다.

// 팀에 소속된 회원만 조회
List<Member> result = em.createQuery(
    "select m from Member m join m.team t", 
    Member.class
).getResultList();

팀이 없는 회원은 결과에서 제외된다.


외부 조인 (LEFT/RIGHT OUTER JOIN)

SELECT m FROM Member m LEFT [OUTER] JOIN m.team t

OUTER 키워드도 생략 가능하다. 보통 LEFT JOIN으로만 쓴다.
외부 조인은 왼쪽 테이블(Member)의 데이터는 모두 가져오고, 오른쪽 테이블(Team)은 매칭 되는 데이터만 가져온다. Team이 없는 Member도 결과에 포함된다. (Team 정보는 null)

// 팀이 없는 회원도 함께 조회
List<Member> result = em.createQuery(
    "select m from Member m left join m.team t", 
    Member.class
).getResultList();

실제로는 외부 조인을 더 많이 사용한다. 팀이 없는 회원도 보고싶다는 요구사항이 훨씬 많기 때문이다.


세타 조인 (Theta Join)

select count(m) from Member m, Team t where m.username = t.name

세타 조인은 연관관계가 없는 엔티티를 조인할 때 사용한다. Member와 Team 사이에 FK 관계가 없어도 특정 조건으로 연결할 수 있다.

세타 조인의 특징

  • FROM 절에 여러 엔티티를 나열하고, WHERE 절에서 조건을 건다.
  • 내부적으로 CROSS JOIN (카테시안 곱)이 발생한다.
  • 모든 Member와 모든 Team을 일단 조합한 후, WHERE 조건으로 필터링
-- 실제 실행되는 SQL
SELECT COUNT(m) 
FROM Member m CROSS JOIN Team t 
WHERE m.username = t.name

세타 조인을 쓰는 이유
카테시안 곱이 발생하는지 왜 쓰는지에 대한 의문이 들었다. 실무에서는 다음과 같은 경우에 사용한다고 한다.

연관관계 없이 특정 조건으로 데이터를 비교해야 할 때가 있다.
예를 들어 회원 이름과 팀 이름이 같은 케이스를 찾거나, 두 테이블의 코드 값을 매칭해서 통계를 낼 때와 같은 경우이다.

다만 성능 이슈가 있을 수 있어서, 데이터가 많으면 주의해야 한다. 요즘은 ON 절을 활용한 조인으로 대체하는 경우가 많다.


ON 절을 활용한 조인

JPA 2.1부터 ON절을 지원하면서 조인이 훨씬 유연해졌다. 두 가지 주용 사용 케이스가 있다.

1. 조인 대상 필터링

조인하는 대상 자체를 필터링 할 수 있다.

예) 회원과 팀을 조인하되, 팀 이름이 'A'인 팀만 조인

// JPQL
SELECT m, t 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'

WHERE절과의 차이는, ON절은 조인 시점에 필터링 한다는 점이다.

// WHERE 절 사용 - 조인 후 필터링
SELECT m, t FROM Member m LEFT JOIN m.team t WHERE t.name = 'A'

// ON 절 사용 - 조인 시점에 필터링
SELECT m, t FROM Member m LEFT JOIN m.team t ON t.name = 'A'

LEFT JOIN의 경우 차이가 명확하다.

  • WHERE 절: Team이 null이면 제외됨 (팀 없는 회원도 제외)
  • ON 절: Team이 null이어도 Member는 조회됨 (팀 없는 회원도 포함)

2. 연관관계 없는 엔티티 외부 조인

세타 조인은 내부 조인만 가능했는데, Hibernate 5.1 버전 이후부터 ON 절을 쓰면 연관관게 없이도 외부 조인을 할 수 있다.

예) 회원 이름과 팀 이름이 같은 대상을 외부 조인으로 찾기

// JPQL
SELECT m, t 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

FK가 없어도 특정 컬럼끼리 매칭해서 조인할 수 있다. 조인으로는 불가능했던 연관관계 없는 외부 조인이 가능해진 것이다.


서브 쿼리

서브 쿼리는 쿼리 안에 또 다른 쿼리를 넣는 것이다. 복잡한 조건이나 집계 결과를 바탕으로 데이터를 조회할 때 사용한다.

나이가 평균보다 많은 회원 조회

select m from Member m
where m.age > (select avg(m2.age) from Member m2)

서브쿼리로 전체 회원의 평균 나이를 구한 후, 그보다 나이가 많은 회원만 필터링한다.

한 건이라도 주문한 고객 조회

select m from Member m
where (select count(o) from Order o where m = o.member) > 0

각 회원마다 주문 건수를 세서 1건 이상인 회원만 가져온다.


서브 쿼리 지원 함수

JPQL은 서브쿼리를 더 유연하게 사용할 수 있도록 여러 함수를 제공한다.

EXISTS - 결과가 존재하는지 확인

// 팀A에 소속된 회원 조회
select m from Member m
where exists (select t from m.team t where t.name = '팀A')

서브쿼리 결과가 하나라도 있으면 참이다. NOT EXIST를 쓰면 결과가 없을 때 참이 된다.

ALL - 모든 조건을 만족

// 전체 상품 각각의 재고보다 주문량이 많은 주문들
select o from Order o
where o.orderAmount > ALL (select p.stockAmount from Product p)

서브쿼리 결과의 모든 값보다 커야 한다. 위 예제에서는 모든 상품의 재고량보다 주문량이 많아야 조회된다.

ANY / SOME - 하나라도 만족

// 어떤 팀이든 팀에 소속된 회원
select m from Member m
where m.team = ANY (select t from Team t)

ANYSOME은 같은 의미다. 서브쿼리 결과 중 하나라도 만족하면 참이다.

IN - 결과 중 하나와 일치

// 특정 팀 목록에 속한 회원
select m from Member m
where m.team in (select t from Team t where t.name in ('팀A', '팀B'))

서브쿼리 결과 중 하나와 같으면 참이다. NOT IN은 결과에 없으면 참이다.


JPA 서브 쿼리의 한계

JPQL의 서브쿼리는 표준 SQL보다 제약이 많다.

1. WHERE, HAVING 절에서만 사용 가능

// 가능 - WHERE 절
select m from Member m
where m.age > (select avg(m2.age) from Member m2)

// 가능 - HAVING 절
select m.team, count(m) from Member m
group by m.team
having count(m) > (select avg(cnt) from (select count(m2) as cnt from Member m2 group by m2.team))

2. SELECT 절은 Hibernate에서 지원

표준 JPQL은 SELECT 절 서브쿼리를 지원하지 않지만, 하이버네이트는 지원한다.

// 각 회원의 주문 건수를 함께 조회
select m.username, (select count(o) from Order o where o.member = m)
from Member m

회원 이름과 함께 각 회원의 주문 건수를 서브쿼리로 가져온다. 이런 방식은 통계성 데이터를 함께 보여줄 때 유용하다.

3. FROM 절 서브쿼리는 불가능 (하이버네이트 5 기준)

// 불가능 - FROM 절 서브쿼리
select m from (select m2 from Member m2 where m2.age > 20) m

표준 JPQL과 하이버네이트 5까지는 FROM 절에 서브쿼리를 쓸 수 없다.


Hibernate 6의 변경사항

Hibernate 6부터는 FROM 절 서브쿼리를 지원한다.

// 하이버네이트 6에서 가능
select m from (select m2 from Member m2 where m2.age > 20) m
where m.username like 'user%'

20세 이상 회원을 먼저 필터링한 후, 그 중에서 이름이 'user'로 시작하는 회원만 조회한다.

하이버네이트 6의 FROM 절 서브쿼리 지원에 대한 자세한 내용은 공식문서를 참고하면 된다.


FROM 절 서브쿼리를 쓰는 이유

FROM 절 서브쿼리는 보통 데이터를 먼저 필터링하고, 바깥 쿼리에서 가공하는 패턴으로 사용된다.

예를 들어
1. 서브쿼리(안쪽): 대량의 데이터를 여러 조건으로 필터링해서 줄인다.
2. 메인쿼리(바깥쪽): 필터링된 데이터의 타입을 변경하거나, 문자열을 가공하거나, 뷰에 맞게 포맷팅한다.

-- 전형적인 FROM 절 서브쿼리 패턴
SELECT 
    sub.username,
    CONCAT('회원:', sub.username) as display_name,  -- 문자열 가공
    CASE WHEN sub.age >= 20 THEN '성인' ELSE '미성년' END as age_group  -- 타입 변환
FROM 
    (SELECT m.username, m.age FROM Member m WHERE m.team_id = 1) sub  -- 필터링

이런 경우 JPQL에서는 데이터를 애플리케이션으로 가져온 후 로직을 처리해야 한다. 그러면 FROM 절 서브쿼리 사용 케이스 자체가 많이 줄어든다.

// DB에서는 필터링만
List<Member> members = em.createQuery(
    "select m from Member m where m.team.id = 1", 
    Member.class
).getResultList();

// 애플리케이션에서 가공
List<MemberDTO> result = members.stream()
    .map(m -> new MemberDTO(
        m.getUsername(),
        "회원:" + m.getUsername(),  // 문자열 가공
        m.getAge() >= 20 ? "성인" : "미성년"  // 타입 변환
    ))
    .collect(Collectors.toList());

SQL에서 모든 걸 처리하려고 하지 말고 DB는 데이터 필터링만, 애플리케이션은 로직 처리로 역할을 나누면 FROM 절 서브쿼리가 필요한 경우가 줄어든다.

FROM절 서브쿼리에 대한 전략은 다음과 같이 세우는게 이상적이다.
1. 조인으로 풀 수 있으면 조인으로 해결 (가장 권장)
2. 조인으로 안 되면 쿼리를 두 번 날려서 해결
3. 데이터가 너무 많으면 네이티브 SQL 사용
4. 가공 로직은 애플리케이션 레벨로 이동 (FROM 절 서브쿼리 필요성 감소)
5. 하이버네이트 6 이상이면 FROM 절 서브쿼리 직접 사용 가능


JPQL 타입 표현

JPQL에서 리터럴 값을 표현하는 방법은 SQL과 거의 같지만 몇 가지 JPA만의 특징이 있다.

기본 타입 표현

// 문자: 작은따옴표 사용
'HELLO'

// 문자 안에 작은따옴표: 두 번 연속으로
'She''s'  // She's

// 숫자: 타입 접미사
10L      // Long
10D      // Double
10F      // Float

// Boolean
TRUE, FALSE

ENUM 타입 표현

ENUM 타입을 JPQL에서 사용할 때는 패키지명을 포함한 전체 경로를 적어야 한다.

// MemberType ENUM 정의
public enum MemberType {
    ADMIN, USER
}

// JPQL에서 ENUM 사용
String query = "select m.username, 'HELLO', TRUE from Member m " +
               "where m.type = jpabook.MemberType.ADMIN";

이렇게 패키지명까지 다 쓰면 너무 길고 복잡하기 때문에 파라미터 바인등을 사용 하면 된다.

String query = "select m.username, 'HELLO', TRUE from Member m " +
               "where m.type = :type";
               
List<Object[]> result = em.createQuery(query)
        .setParameter("type", MemberType.ADMIN)  // 파라미터로 전달
        .getResultList();

파라미터 바인딩을 쓰면 패키지명을 안 써도 되고, 코드도 훨씬 깔끔하다

QueryDSL는 자바 코드 쿼리를 작성하기 때문에 ENUM을 import해서 바로 쓸 수 있다. 이런 불편함이 완전히 사라진다.


엔티티 타입 표현 (상속 관계)

엔티티 타입 표현은 상속 관계에서 특정 자식 타입만 조회할 때 사용한다.

상속 관계 예시

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn
public abstract class Item {
    @Id @GeneratedValue
    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("A")
public class Album extends Item {
    private String artist;
}

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

Item을 상속받는 Book, Album, Movie가 있다고 가정하자.

TYPE() 함수로 특정 타입만 조회

// Book 타입만 조회
String query = "select i from Item i where TYPE(i) = Book";
List<Item> books = em.createQuery(query, Item.class)
                     .getResultList();

// Book 또는 Movie만 조회
String query = "select i from Item i where TYPE(i) IN (Book, Movie)";
List<Item> result = em.createQuery(query, Item.class)
                      .getResultList();

TYPE(i) 함수는 엔티티의 실제 타입을 반환한다. 상속 관계에서 특정 자식 클래스만 필터링할 때 유용하다.

실행되는 SQL

-- Single Table 전략일 경우
SELECT i.* 
FROM Item i 
WHERE i.DTYPE = 'B'  -- DTYPE은 구분 컬럼

-- Joined 전략일 경우  
SELECT i.* 
FROM Item i 
WHERE i.DTYPE IN ('B', 'M')

상속 전략에 따라 적절한 SQL로 변환된다.

사용 예시

// 책과 영화만 10% 할인
String query = "select i from Item i " +
               "where TYPE(i) IN (Book, Movie) and i.price > 10000";
List<Item> discountItems = em.createQuery(query, Item.class)
                             .getResultList();

for (Item item : discountItems) {
    item.setPrice((int)(item.getPrice() * 0.9));
}

특정 타입의 상품에만 할인을 적용하는 등, 상속 관계에서 타입별로 다른 로직을 적용할 때 사용한다.


JPQL 기본 연산자

JPQL은 SQL과 거의 동일한 연산자를 지원한다.

논리 연산자

  • AND, OR, NOT: 조건 조합
select m from Member m 
where m.age > 20 AND m.username like 'user%'

비교 연산자

  • =, >, >=, <, <=, <>: 값 비교
select m from Member m 
where m.age >= 18 AND m.age <> 65

<>는 "같지 않다"를 의미한다. !=도 사용 가능하다.

범위 연산자

  • BETWEEN: 범위 조건
  • IN: 여러 값 중 하나와 일치
  • LIKE: 패턴 매칭
  • IS NULL / IS NOT NULL: NULL 체크
// BETWEEN
select m from Member m 
where m.age between 20 and 30

// IN
select m from Member m 
where m.username in ('user1', 'user2', 'user3')

// LIKE
select m from Member m 
where m.username like '%admin%'

// IS NULL
select m from Member m 
where m.team is null

서브 쿼리 연산자

  • EXISTS: 서브쿼리 결과 존재 여부
select m from Member m 
where exists (select t from m.team t where t.name = '개발팀')

JPQL은 SQL을 아는 사람이라면 거의 직관적으로 사용할 수 있다. 다만 ENUM이나 엔티티 타입 같은 JPA만의 특수한 표현은 알아두면 실무에서 유용하게 쓸 수 있다.


조건식 (CASE)

CASE 식은 조건에 따라 다른 값을 반환할 때 사용한다. SQL의 CASE 문과 동일하게 동작한다.

JPQL은 두 가지 형태의 CASE 식을 지원한다.

  1. 기본 CASE 식: 복잡한 조건 표현 가능
  2. 단순 CASE 식: 정확한 값 매칭만 가능

기본 CASE 식

조건을 자유롭게 작성할 수 있는 방식이다.

select
    case 
        when m.age <= 10 then '학생요금'
        when m.age >= 60 then '경로요금'
        else '일반요금'
    end
from Member m

나이에 따라 요금 등급을 다르게 표시한다. when 조건을 여러 개 연결할 수 있고, else로 기본값을 지정한다.


단순 CASE 식

특정 값과 정확히 일치하는지만 비교하는 간단한 방식이다.

select
    case t.name
        when '팀A' then '인센티브110%'
        when '팀B' then '인센티브120%'
        else '인센티브105%'
    end
from Team t

t.name의 값이 '팀A'면 첫 번째, '팀B'면 두 번째, 둘 다 아니면 else의 값을 반환한다.


COALESCE - NULL 대체

COALESCE는 여러 값을 순서대로 확인해서 NULL이 아닌 첫 번째 값을 반환한다.

// 사용자 이름이 없으면 '이름 없는 회원' 반환
select coalesce(m.username, '이름 없는 회원') 
from Member m

m.username이 NULL이면 '이름 없는 회원'을 반환하고, NULL이 아니면 m.username을 그대로 반환한다.

여러 값 체크

COALESCE는 여러 개의 인자를 받을 수 있다.

select coalesce(m.nickname, m.username, m.email, '익명')
from Member m
  1. nickname이 NULL이 아니면 반환
  2. NULL이면 username 확인
  3. 그것도 NULL이면 email 확인
  4. 다 NULL이면 '익명' 반환

예시

// 주소가 없는 회원 처리
String query = "select m.username, " +
               "coalesce(m.address, '주소 미등록') as address " +
               "from Member m";

List<Object[]> result = em.createQuery(query).getResultList();

for (Object[] row : result) {
    System.out.println(row[0] + " - " + row[1]);
    // 출력: "홍길동 - 서울시 강남구"
    // 출력: "김철수 - 주소 미등록"
}

NULL 값을 처리해야 하는 통계나 리포트 화면에서 유용하다.


NULLIF - 특정 값을 NULL로 반환

NULLIF는 두 값이 같으면 NULL을 반환하고, 다르면 첫 번째 값을 반환한다.

// 사용자 이름이 '관리자'면 NULL 반환, 아니면 본인 이름 반환
select NULLIF(m.username, '관리자') 
from Member m

예를 들어

  • m.username이 '관리자'면 -> NULL 반환
  • m.username이 '홍길동'이면 -> '홍길동' 반환

쓰는 이유?
특정 값을 제외하고 싶을 때 사용한다. 주로 통계나 집계에서 특정 데이터를 제외할 때 유용하다.

// '관리자' 계정을 제외한 평균 나이 계산
select avg(case when m.username = '관리자' then null else m.age end)
from Member m

// NULLIF로 더 간결하게
select avg(NULLIF(m.username, '관리자'))
from Member m

조건식 조합 활용

실제로 여러 조건식을 조합해서 사용하는 경우가 많다.

// 회원 등급과 표시 이름 한 번에 처리
String query = "select " +
               "    coalesce(m.nickname, m.username, '익명') as displayName, " +
               "    case " +
               "        when m.age < 20 then '일반' " +
               "        when m.age >= 20 and m.age < 30 then '우수' " +
               "        else 'VIP' " +
               "    end as grade, " +
               "    NULLIF(m.email, 'no-reply@example.com') as email " +
               "from Member m";

List<Object[]> result = em.createQuery(query).getResultList();

for (Object[] row : result) {
    String displayName = (String) row[0];  // 표시 이름 (NULL 없음)
    String grade = (String) row[1];        // 등급
    String email = (String) row[2];        // 시스템 이메일은 NULL 처리
    
    System.out.printf("%s [%s] - %s%n", 
        displayName, 
        grade, 
        email != null ? email : "이메일 없음"
    );
}
  1. CASE는 DTO 변환 전에 사용: 데이터베이스에서 가공해서 가져오면 애플리케이션 로직이 간단해진다
  2. COALESCE로 안전한 기본값 제공: NULL로 인한 NPE 방지
  3. NULLIF로 특수 데이터 제외: 테스트 계정이나 시스템 데이터를 집계에서 제외

이 세 가지 조건식만 잘 활용해도 복잡한 쿼리 로직을 데이터베이스 레벨에서 깔끔하게 처리할 수 있다.


JPQL 함수 (Hibername 6+)

JPQL에서 사용할 수 있는 함수는 크게 세 가지로 나뉜다.

  1. JPQL 표준 함수: 데이터베이스와 무관하게 사용 가능
  2. 데이터베이스 벤더별 함수: 하이버네이트가 방언에 미리 등록해놓은 함수
  3. 사용자 정의 함수: 직접 등록해서 사용하는 함수

JPQL 표준 함수

JPQL이 기본으로 제공하는 함수들이다. 어떤 데이터베이스를 쓰든 그냥 사용할 수 있다.

문자 함수

// CONCAT - 문자열 합치기
select concat(m.username, ' 님') from Member m
// 결과: "홍길동 님"

// SUBSTRING - 문자열 자르기 (시작 위치, 길이)
select substring(m.username, 1, 3) from Member m
// 결과: "홍길동" -> "홍길"

// TRIM - 공백 제거
select trim(m.username) from Member m

// UPPER, LOWER - 대소문자 변환
select upper(m.username) from Member m
select lower(m.email) from Member m

// LENGTH - 문자열 길이
select length(m.username) from Member m
// 결과: 3

// LOCATE - 특정 문자열의 위치 찾기
select locate('길', m.username) from Member m
// "홍길동"에서 '길'의 위치 -> 2

수학 함수

// ABS - 절댓값
select abs(m.balance) from Member m

// SQRT - 제곱근
select sqrt(m.score) from Member m

// MOD - 나머지 연산
select mod(m.age, 10) from Member m
// 나이를 10으로 나눈 나머지

JPA 전용 함수

// SIZE - 컬렉션 크기
select size(t.members) from Team t
// 팀에 속한 회원 수

// INDEX - 컬렉션 인덱스 (거의 안 씀)

INDEX@OrderColumn 사용 시에만 의미가 있고, 중간에 데이터가 빠지면 NULL이 되는 등 문제가 많아서 실무에서는 거의 사용하지 않는다.


사용자 정의 함수

애플리케이션을 개발하다 보면 데이터베이스에 등록된 특정 함수를 사용해야 할 때가 있다. 예를 들어 MySQL의 GROUP_CONCAT, Oracle의 LISTAGG 같은 함수들이다.

JPQL은 이런 함수를 바로 알 수 없기 때문에, 하이버네이트 방언(Dialect)에 함수를 등록해야 한다.

기본적으로 하이버네이트는 각 데이터베이스의 주요 함수들을 이미 등록해놓았다. 하지만 등록되지 않은 함수는 직접 추가해야 한다.


Hibernate 6에서 사용자 정의 함수 등록하기.

Hibernate 6부터는 함수 등록 방법이 변경되었기 때문에 강의에 나온 registerFunction() 메서드 대신 새로운 방식을 사용한다.

방법1: 커스텀 Dialect 생성 (권장)

MySQL을 사용한다고 가정하고 GROUP_CONCAT 함수를 등록해보자.

1. 커스텀 Dialect 클래스 생성

package com.example.dialect;

import org.hibernate.boot.model.FunctionContributions;
import org.hibernate.dialect.MySQLDialect;
import org.hibernate.type.StandardBasicTypes;

public class MyMySQLDialect extends MySQLDialect {
    
    @Override
    public void initializeFunctionRegistry(FunctionContributions functionContributions) {
        super.initializeFunctionRegistry(functionContributions);
        
        // GROUP_CONCAT 함수 등록
        functionContributions.getFunctionRegistry().registerPattern(
            "group_concat",
            "group_concat(?1)",
            functionContributions.getTypeConfiguration()
                .getBasicTypeRegistry()
                .resolve(StandardBasicTypes.STRING)
        );
    }
}

2. Gradle 의존성 설정

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'com.mysql:mysql-connector-j:8.0.33'
    implementation 'org.hibernate:hibernate-core:6.2.0.Final' // 또는 최신 버전
}

3. application.yml 또는 persistence.xml에 Dialect 지정
application.yml (Spring Boot)

spring:
  jpa:
    properties:
      hibernate:
        dialect: com.example.dialect.MyMySQLDialect

persistence.xml (순수 JPA)

<property name="hibernate.dialect" value="com.example.dialect.MyMySQLDialect"/>

방법 2: FunctionContributor 사용

더 유연한 방법으로, 여러 함수를 한 번에 등록할 수 있다.

1. FunctionContributor 구현

package com.example.function;

import org.hibernate.boot.model.FunctionContributions;
import org.hibernate.boot.model.FunctionContributor;
import org.hibernate.type.StandardBasicTypes;

public class MyFunctionContributor implements FunctionContributor {
    
    @Override
    public void contributeFunctions(FunctionContributions functionContributions) {
        // GROUP_CONCAT 함수 등록
        functionContributions.getFunctionRegistry().registerPattern(
            "group_concat",
            "group_concat(?1)",
            functionContributions.getTypeConfiguration()
                .getBasicTypeRegistry()
                .resolve(StandardBasicTypes.STRING)
        );
        
        // 구분자를 지정하는 GROUP_CONCAT
        functionContributions.getFunctionRegistry().registerPattern(
            "group_concat_separator",
            "group_concat(?1 separator ?2)",
            functionContributions.getTypeConfiguration()
                .getBasicTypeRegistry()
                .resolve(StandardBasicTypes.STRING)
        );
    }
}

2. META-INF/services 디렉토리에 설정 파일 추가
src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor 파일 생성

com.example.function.MyFunctionContributor

이렇게 하면 하이버네이트가 자동으로 감지해서 함수를 등록한다.


사용자 정의 함수 사용하기

등록한 함수는 function() 문법으로 호출한다.

// 각 팀별로 회원 이름을 쉼표로 연결
String query = "select t.name, function('group_concat', m.username) " +
               "from Team t " +
               "join t.members m " +
               "group by t.name";

List<Object[]> result = em.createQuery(query).getResultList();

for (Object[] row : result) {
    String teamName = (String) row[0];
    String memberNames = (String) row[1];
    System.out.println(teamName + ": " + memberNames);
    // 출력: "개발팀: 홍길동,김철수,이영희"
}

구분자를 지정하는 버전

String query = "select t.name, function('group_concat_separator', m.username, ' | ') " +
               "from Team t " +
               "join t.members m " +
               "group by t.name";

List<Object[]> result = em.createQuery(query).getResultList();
// 출력: "개발팀: 홍길동 | 김철수 | 이영희"

실무에서 자주 쓰이는 MySQL 함수 등록 예시

MySQL에서 자주 사용하는 함수들을 미리 등록해두면 편하다.

public class MyMySQLDialect extends MySQLDialect {
    
    @Override
    public void initializeFunctionRegistry(FunctionContributions functionContributions) {
        super.initializeFunctionRegistry(functionContributions);
        
        var registry = functionContributions.getFunctionRegistry();
        var typeConfig = functionContributions.getTypeConfiguration();
        var basicTypeRegistry = typeConfig.getBasicTypeRegistry();
        
        // GROUP_CONCAT
        registry.registerPattern(
            "group_concat",
            "group_concat(?1)",
            basicTypeRegistry.resolve(StandardBasicTypes.STRING)
        );
        
        // DATE_FORMAT
        registry.registerPattern(
            "date_format",
            "date_format(?1, ?2)",
            basicTypeRegistry.resolve(StandardBasicTypes.STRING)
        );
        
        // IFNULL (MySQL 전용, 표준은 COALESCE)
        registry.registerPattern(
            "ifnull",
            "ifnull(?1, ?2)",
            basicTypeRegistry.resolve(StandardBasicTypes.STRING)
        );
    }
}

사용 예시

// 날짜 포맷 변환
String query = "select function('date_format', m.createdDate, '%Y-%m-%d') from Member m";

// IFNULL 사용
String query = "select function('ifnull', m.nickname, m.username) from Member m";

주의사항

1. 데이터베이스 종속성

사용자 정의 함수는 데이터베이스에 종속적이다. MySQL에서 등록한 함수를 Oracle로 전환하면 동작하지 않는다.
가능하면 JPQL 표준 함수나 JPA 기능으로 해결하는 게 좋다.

2. 하이버네이트 버전 확인

Hibernate 5 이하는 registerFunction() 메서드를 사용하고, Hibernate 6 이상은 initializeFunctionRegistry()를 사용한다.

버전을 확인하고 맞는 방법을 사용해야 한다.

// build.gradle에서 확인
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    // Spring Boot 3.x는 Hibernate 6.x 사용
    // Spring Boot 2.x는 Hibernate 5.x 사용
}

3. 성능 고려

복잡한 함수는 성능 이슈가 발생할 수 있다. 특히 GROUP_CONCAT 같은 집계 함수는 데이터가 많으면 느려질 수 있다.

가능하면 애플리케이션 레벨에서 처리하거나, 인덱스를 적절히 활용해야 한다.


JPQL 함수를 잘 활용하면 쿼리를 더 간결하게 작성할 수 있지만, 과도하게 사용하면 데이터베이스 종속성이 높아진다. 적절한 균형을 유지하는 게 중요하다.

0개의 댓글