[Java/JPA] JPQL - 다형성 쿼리, Named 쿼리, 벌크 연산

daheenamic·2025년 12월 5일

Java

목록 보기
48/48

실무에서 자주 쓰이지는 않지만 알아두면 유용한 기능들을 알아보자. 특히 벌크 연산은 성능 최적화에 필수적이니 꼭 이해하고 넘어가야 한다.


1. 다형성 쿼리

상속 관계에 있는 엔티티를 조회할 때 사용하는 기능이다.

TYPE - 특정 자식 타입만 조회

상속 관계에서 특정 자식 타입만 필터링할 때 사용한다.

// Item 중에서 Book과 Movie만 조회
// JPQL
select i from Item i
where type(i) IN (Book, Movie)

// 실행되는 SQL
select i.* from Item i
where i.DTYPE in ('B', 'M')

DTYPE은 상속 전략에서 사용되는 구분 컬럼이다. @DiscriminatorColumn으로 지정한 컬럼값으로 필터링된다.

실무 활용 예시

// 책과 영화만 할인 대상으로 조회
String jpql = "select i from Item i " +
              "where type(i) in (Book, Movie) and i.price > 10000";
List<Item> discountItems = em.createQuery(jpql, Item.class)
                             .getResultList();

TREAT - 자식 타입으로 다운캐스팅

자바의 타입 캐스팅과 유사하다. 부모 타입을 특정 자식 타입으로 취급해서 자식의 필드에 접근할 수 있다.

// Book의 author 필드로 필터링
// JPQL
select i from Item i
where treat(i as Book).author = 'kim'

// 실행되는 SQL
select i.* from Item i
where i.DTYPE = 'B' and i.author = 'kim'

Item 타입이지만 Book으로 취급해서 author 필드를 사용할 수 있다.

사용 가능한 절

  • FROM 절: from treat(Item as Book) b
  • WHERE 절: where treat(i as Book).author = 'kim'
  • SELECT 절: select treat(i as Book).author (하이버네이트 지원)

실무 활용 예시

// 저자가 'kim'인 책만 조회하고, 저자 정보도 함께 가져오기
String jpql = "select treat(i as Book).author, treat(i as Book).isbn " +
              "from Item i " +
              "where treat(i as Book).author = 'kim'";
List<Object[]> result = em.createQuery(jpql).getResultList();

2. 엔티티 직접 사용

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

기본 키 값

// 두 JPQL은 동일한 SQL을 생성
select count(m.id) from Member m  // ID를 직접 사용
select count(m) from Member m     // 엔티티를 직접 사용

// 실행되는 SQL
select count(m.id) as cnt from Member m

엔티티를 직접 쓰든, ID를 쓰든 결과는 같다. JPA가 자동으로 기본 키로 변환한다.

파라미터로 엔티티 전달

// 엔티티를 파라미터로 전달
String jpql = "select m from Member m where m = :member";
List<Member> result = em.createQuery(jpql, Member.class)
                        .setParameter("member", member)
                        .getResultList();

// 식별자를 직접 전달 (위와 동일한 결과)
String jpql = "select m from Member m where m.id = :memberId";
List<Member> result = em.createQuery(jpql, Member.class)
                        .setParameter("memberId", member.getId())
                        .getResultList();

// 실행되는 SQL
select m.* from Member m where m.id = ?

두 방식 모두 같은 SQL이 실행된다. 엔티티를 넘기면 JPA가 자동으로 ID를 추출해서 사용한다.

외래키 값

외래 키도 마찬가지다. 엔티티를 직접 사용하면 외래 키 값으로 변환된다.

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

// 엔티티를 직접 사용
String jpql = "select m from Member m where m.team = :team";
List<Member> result = em.createQuery(jpql, Member.class)
                        .setParameter("team", team)
                        .getResultList();

// 외래 키를 직접 사용 (위와 동일한 결과)
String jpql = "select m from Member m where m.team.id = :teamId";
List<Member> result = em.createQuery(jpql, Member.class)
                        .setParameter("teamId", 1L)
                        .getResultList();

// 실행되는 SQL
select m.* from Member m where m.team_id = ?

m.team을 쓰든 m.team.id를 쓰든 같은 SQL이 나간다.


3. Named 쿼리

Named 쿼리는 미리 정의해서 이름을 부여해두고 사용하는 JPQL이다.

Named 쿼리의 특징

  • 정적 쿼리: 동적으로 변경 불가능
  • 애플리케이션 로딩 시점에 초기화: 쿼리를 미리 파싱하고 캐싱
  • 애플리케이션 로딩 시점에 검증: 문법 오류를 즉시 발견
  • 어노테이션 또는 XML로 정의 가능

어노테이션 방식

@Entity
@NamedQuery(
    name = "Member.findByUsername",
    query = "select m from Member m where m.username = :username"
)
public class Member {
    // ...
}
// 사용
List<Member> result = em.createNamedQuery("Member.findByUsername", Member.class)
                        .setParameter("username", "회원1")
                        .getResultList();

XML 방식

persistence.xml에 매핑 파일 등록

<persistence-unit name="jpabook">
    <mapping-file>META-INF/ormMember.xml</mapping-file>
</persistence-unit>

ormMember.xml 작성

<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm" version="2.1">
    
    <named-query name="Member.findByUsername">
        <query><![CDATA[
            select m
            from Member m
            where m.username = :username
        ]]></query>
    </named-query>
    
    <named-query name="Member.count">
        <query>select count(m) from Member m</query>
    </named-query>
    
</entity-mappings>

XML이 항상 우선권을 가진다. 운영 환경에 따라 다른 XML을 배포할 수 있다.

실무에서는 Spring Data JPA 사용

사실 Named Query를 위처럼 직접 정의해서 쓰는 경우는 거의 없다. Spring Data JPA의 @Query를 사용하는 게 일반적이다`

public interface UserRepository extends JpaRepository<User, Long> {
    
    @Query("select u from User u where u.emailAddress = ?1")
    User findByEmailAddress(String emailAddress);
}

이 방식을 이름 없는 Named 쿼리라고 한다. Spring Data JPA가 이걸 Named 쿼리로 인식해서 애플리케이션 로딩 시점에 문법 오류를 검증해준다.

실무에서는 이 방식이 가장 편하고 안전하다.


4. 벌크 연산

벌크 연산은 한 번의 쿼리로 여러 건을 수정하거나 삭제하는 기능이다.

왜 필요한가?

JPA의 변경 감지(Dirty Checking)는 건별로 UPDATE가 나간다.

// 재고가 10개 미만인 상품 가격을 10% 인상한다면?

// 1. 재고 10개 미만 상품 조회
List<Product> products = em.createQuery(
    "select p from Product p where p.stockAmount < 10", 
    Product.class
).getResultList();

// 2. 가격 10% 인상
for (Product product : products) {
    product.setPrice(product.getPrice() * 1.1);
}

// 3. 트랜잭션 커밋 시 변경 감지 동작
// → 100건이면 UPDATE 쿼리 100번 실행!

변경된 데이터가 100건이면 UPDATE SQL이 100번 실행된다. 성능에 큰 문제가 생긴다.

벌크 연산으로 해결

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

int resultCount = em.createQuery(jpql)
                    .setParameter("stockAmount", 10)
                    .executeUpdate();  // 벌크 연산 실행

System.out.println("영향받은 엔티티 수: " + resultCount);

단 한 번의 쿼리로 여러 건을 수정한다. executeUpdate()는 영향받은 엔티티 수를 반환한다.

지원하는 연산

  • UPDATE: 여러 건 수정
  • DELETE: 여러 건 삭제
  • INSERT: insert into ... select (하이버네이트 지원)
// DELETE 예시
String jpql = "delete from Member m where m.age < 18";
int deletedCount = em.createQuery(jpql).executeUpdate();

벌크 연산의 치명적인 함정

벌크 연산의 가장 큰 문제는 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다는 점이다.

문제 상황

Member member1 = new Member();
member1.setUsername("회원1");
member1.setAge(0);  // 나이 0
em.persist(member1);

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

// DB에는 20으로 변경되었지만...
Member findMember = em.find(Member.class, member1.getId());
System.out.println("findMember.getAge() = " + findMember.getAge());
// 출력: findMember.getAge() = 0

출력 결과가 0이 나와버린다..!

이유는 이렇다.
1. member1이 영속성 컨텍스트에 나이 0으로 저장됨
2. 벌크 연산이 DB에 직접 UPDATE (영속성 컨텍스트 무시)
3. DB에는 20으로 변경되었지만, 영속성 컨텍스트는 여전히 0
4. em.find()는 영속성 컨텍스트에서 먼저 찾읍 -> 0 반환

해결 방법

방법 1: 벌크 연산을 먼저 실행

// 벌크 연산을 가장 먼저 실행
int resultCount = em.createQuery("update Member m set m.age = 20")
                    .executeUpdate();

// 이후에 엔티티 작업
Member member1 = new Member();
member1.setUsername("회원1");
em.persist(member1);

영속성 컨텍스트에 아무것도 없을 때 벌크 연산을 하면 문제가 없다.

방법 2: 벌크 연산 후 영속성 컨텍스트 초기화

Member member1 = new Member();
member1.setUsername("회원1");
member1.setAge(0);
em.persist(member1);

// 벌크 연산 실행
int resultCount = em.createQuery("update Member m set m.age = 20")
                    .executeUpdate();

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

// 이제 DB에서 새로 조회
Member findMember = em.find(Member.class, member1.getId());
System.out.println("findMember.getAge() = " + findMember.getAge());
// 출력: findMember.getAge() = 20

em.clear()로 영속성 컨텍스트를 비우면, em.find()가 DB에서 새로 조회한다.

Spring Data JPA의 해결책

Spring Data JPA에서는 @Modifying(clearAutomatically = true) 옵션을 제공한다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    
    @Modifying(clearAutomatically = true)  // 자동으로 영속성 컨텍스트 초기화
    @Query("update Member m set m.age = 20")
    int bulkAgePlus();
}

벌크 연산 후 자동으로 영속성 컨텍스트를 초기화해준다.


실무 팁

  1. 다형성 쿼리는 상속 구조에서만: 일반적인 경우는 거의 안 씀
  2. 엔티티 직접 사용은 편리함: 파라미터 바인딩 시 엔티티 그대로 넘기면 됨
  3. Named 쿼리는 Spring Data JPA로: @Query 어노테이션이 훨씬 편함
  4. 벌크 연산은 조심: 영속성 컨텍스트 불일치 문제 반드시 해결할 것

특히 벌크 연산은 실무에서 성능 최적화에 필수적이지만, 영속성 컨텍스트 문제를 모르고 쓰면 버그가 발생한다. 반드시 em.clear()를 습관화하자.


이것으로 JPQL의 기초적인 기능은 모두 다뤘다! 이제 QueryDSL을 배워서 타입 안전하고 가독성 높은 쿼리를 작성하는 방법을 익힐 차례다. JPQL의 기초가 탄탄하면 QueryDSL은 금방 배울 수 있다고 하니 열심히 공부를 해보자..

0개의 댓글