JPA 표준 예외란 jakarta.persistence.PersistenceException의 자식 클래스들을 통칭하는 것이다. 이 예외 클래스들은 RuntimeException을 상속받는 언체크 예외이다.
JPA 표준 예외는 크게 2가지로 분류된다.
트랜잭션 롤백을 표시하는 예외는 심각한 예외이므로 복구해선 안 되고 트랜잭션을 강제로 커밋하더라도 jakarta.persistence.RollbackException 예외가 발생한다. 반면 트랜잭션 롤백을 표시하지 않는 예외는 비교적 심각하지 않은 예외이므로 사용자가 트랜잭션의 커밋/롤백 여부를 판단할 수 있다.
| 트랜잭션 롤백을 표시하는 예외 | 설명 |
|---|---|
| jakarta.persistence.EntityExistsException | EntityManager.persist() 호출 시 중복된 엔터티가 있는 경우 |
| jakarta.persistence.EntityNotFoundException | EntityManager.getReference() 호출 이후 실제 사용 시엔터티가 존재하지 않는 경우 / refresh(), lock()에도 적용 |
| jakarta.persistence.OptimisticLockException | 낙관적 락 충돌 |
| jakarta.persistence.PessimisticLockException | 비관적 락 충돌 |
| jakarta.persistence.RollbackException | EntityTransaction.commit() 실패한 경우롤백이 표시되어 있는 트랜잭션을 커밋한 경우 |
| jakarta.persistence.TransactionRequiredException | 트랜잭션이 필요할 때 트랜잭션이 없는 경우 트랜잭션 없이 엔터티 변경 시 주로 발생 |
| 트랜잭션 롤백을 표시하지 않는 예외 | 설명 |
|---|---|
| jakarta.persistence.NoResultException | Query.getSingleResult() 호출 시 결과가 하나도 없는 경우 |
| jakarta.persistence.NonUniqueResultException | Query.getSingleResult() 호출 시 결과가 둘 이상인 경우 |
| jakarta.persistence.LockTimeoutException | 비관적 락 타임아웃 |
| jakarta.persistence.QueryTimeoutException | 쿼리 실행 타임아웃 |
서비스 계층에서 데이터 접근 계층의 구현에 직접 의존하는 것은 좋은 설계라 할 수 없고 이는 예외 처리에도 적용되는 사항이다. 서비스 계층에서 JPA 예외 클래스를 직접 지정하여 JPA에 의존하는 것을 방지하기 위해 스프링 프레임워크는 데이터 접근 계층의 예외를 추상화하여 사용자에게 제공한다.
| JPA 예외 | 스프링 변환 예외 |
|---|---|
| jakarta.persistence.PersistenceException | org.springframework.orm.jpa.JpaSystemException |
| jakarta.persistence.NoResultException | org.springframework.dao.EmptyResultDataAccessException |
| jakarta.persistence.NonUniqueResultException | org.springframework.dao.IncorrectResultSizeDataAccessException |
| jakarta.persistence.LockTimeoutException | org.springframework.dao.CannotAcquireLockException |
| jakarta.persistence.QueryTimeoutException | org.springframework.dao.QueryTimeoutException |
| jakarta.persistence.EntityExistsException | org.springframework.dao.DataIntegrityViolationException |
| jakarta.persistence.EntityNotFoundException | org.springframework.orm.jpa.JpaObjectRetrievalFailureException |
| jakarta.persistence.OptimisticLockExceptoin | org.springframework.orm.jpa.JpaOptimisticLockingFailureException |
| jakarta.persistence.PessimisticLockException | org.springframework.dao.PessimisticLockingFailureException |
| jakarta.persistence.TransactionRequiredException | org.springframework.dao.InvalidDataAccessApiUsageException |
| jakarta.persistence.RollbackException | org.springframework.transaction.TransactionSystemException |
| java.lang.IllegalStateException | org.springframework.dao.InvalidDataAccessApiUsageException |
| java.lang.IllegalArgumentException | org.springframework.dao.InvalidDataAccessApiUsageException |
JPA 예외를 스프링 프레임워크가 제공하는 추상화된 예외로 변환하려면 PersistenceExceptionTranslationPostProcessor를 스프링 빈으로 등록하면 된다. 이 빈은 @Repository 애너테이션을 사용한 클래스에 예외 변환 AOP를 적용해서 JPA 예외 발생 시 스프링 프레임워크가 추상화한 예외로 변환한다.
스프링 부트 프로젝트의 경우 구성보다 관례라는 스프링 부트의 철학 덕분에 애플리케이션 컨텍스트에 해당 빈이 자동으로 등록된다.
스프링 부트의 JPA 예외 변환기 빈 자동 등록
@Test
void persistenceExceptionTranslationPostProcessorIsLoaded() {
String[] beanNames = applicationContext.getBeanNamesForType(
PersistenceExceptionTranslationPostProcessor.class
);
Assertions.assertNotEquals(0, beanNames.length);
}
예제 15.1. 예외 변환 예제 코드
@Repository
public class NoResultExceptionTestRepository {
@PersistenceContext
private EntityManager em;
public Member findMember() {
// 조회된 데이터가 없음
return em.createQuery("SELECT m FROM Member m", Member.class).getSingleResult();
}
}
getSingleResult() 메서드는 jakarta.persistence.NoResultException을 발생시키고, 예외가 메서드를 빠져 나갈 때 PersistenceExceptionTranslationPostProcessor에서 등록한 AOP 인터셉터가 동작하여 해당 예외를 org.springframework.dao.EmptyResultDataAccessException 예외로 변환한다. 그러므로 사용자는 결과적으로 추상화된 예외를 받게 된다.
만약 예외 변환을 생략하고자 한다면 다음과 같이 throws 절에 반환할 JPA 예외를 직접 명시하면 된다.
예제 15.2. 예외를 변환하지 않는 코드
@Repository
public class NoResultExceptionTestRepository {
@PersistenceContext
private EntityManager em;
public Member findMember() throws jakarta.persistence.NoResultException{
// 조회된 데이터가 없음
return em.createQuery("SELECT m FROM Member m", Member.class).getSingleResult();
}
}
트랜잭션 롤백 시 데이터베이스의 데이터는 복구되지만 애플리케이션의 객체는 수정된 상태로 영속성 컨텍스트에 남아 있다. 이로 인해 발생하는 문제를 예방하기 위해 스프링 프레임워크는 영속성 컨텍스트의 범위에 따라 다른 해결 방법을 사용한다.
기본 전략은 트랜잭션당 영속성 컨텍스트 전략으로, 트랜잭션 AOP 종료 시점에 트랜잭션 롤백과 함께 영속성 컨텍스트도 함께 종료되므로 문제가 발생하지 않는다. 하지만 OSIV처럼 영속성 컨텍스트의 범위가 트랜잭션보다 넓어질 경우 스프링 프레임워크는 트랜잭션 롤백 시 영속성 컨텍스트를 초기화(EntityManager.clear())해서 영속성 컨텍스트의 잘못된 사용을 예방한다.
더 자세한 내용은 org.springframework.orm.jpa.JpaTransactionManager.doRollback() 메서드를 참고하면 된다.
영속성 컨텍스트 내 엔터티 인스턴스를 저장하는 1차 캐시는 영속성 컨텍스트와 생명주기를 공유한다. 1차 캐시의 기능들 중 애플리케이션 수준의 반복 가능한 읽기는 데이터베이스 조회를 생략하고 1차 캐시에 앞서 저장된 엔터티를 반환함으로써 엔터티 객체의 동등성(equality)뿐만 아니라 동일성(identity)까지 보장한다.

테스트 환경에서 영속성 컨텍스트의 반복 가능한 읽기에 대해 검증해 보자. @Transactional 애너테이션으로 지정된 테스트 메서드는 트랜잭션 안에서 시작하므로 둘의 생명주기는 같으며 테스트 메서드 전체에서 영속성 컨텍스트는 공유된다.
예제 15.3. 테스트와 트랜잭션 범위 예제 코드
@SpringBootTest
@Transactional
class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
public void join() throws Exception {
// Given
Member member = new Member("kim");
// When
Long memberId = memberService.join(member);
// Then
Member foundMember = memberRepository.findOne(memberId);
assertSame(member, foundMember);
}
}
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public Long join(Member member) {
memberRepository.save(member);
return member.getId();
}
}
@Repository
@RequiredArgsConstructor
public class MemberRepository {
private final EntityManager em;
public void save(Member member) {
em.persist(member);
}
public Member findOne(Long id) {
return em.find(Member.class, id);
}
}
테스트 클래스를 @Transactional 애너테이션으로 지정하였기 때문에 영속성 컨텍스트가 공유되고, member, foundMember는 동일한 객체가 된다. 만약 @Transactional 애너테이션을 제거하면 영속성 컨텍스트가 공유되지 않기 때문에 서로 다른 객체가 반환되고 테스트에 실패한다.
즉, 영속성 컨텍스트가 같으면 엔터티 비교 시 다음의 조건을 모두 만족한다.
== 비교가 같다. 같은 참조 값을 가진다.equals() 비교가 같다. 같은 내용을 가진다.참고로 테스트 클래스와 서비스 클래스의 메서드가 @Transactional 애너테이션으로 지정되어 있으면 기본 전략은 이전에 시작된 트랜잭션을 이어 받아 사용(Propagation.REQUIRED)하는 것이다.

만약 테스트 클래스가 @Transactional 애너테이션으로 지정되지 않는다면 테스트 메서드에서는 준영속 상태가 된다.
예제 15.4. 영속성 컨텍스트가 다를 때 엔터티 비교 예제 코드
@SpringBootTest
//@Transactional
class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
public void join() throws Exception {
// Given
Member member = new Member("kim");
// When
Long memberId = memberService.join(member);
// Then
Member foundMember = memberRepository.findOne(memberId);
assertSame(member, foundMember);
}
}
@Repository
@Transactional
@RequiredArgsConstructor
public class MemberRepository {
...
}
memberService.join(member)를 호출하면 서비스 계층에서 트랜잭션이 시작되면서 영속성 컨텍스트가 생성된다.memberRepository.save(member) 호출 시 member 엔터티가 영속화된다.member 엔터티는 준영속 상태가 된다.memberRepository.findOne(memberId) 호출 시 리포지토리 계층에서 새로운 트랜잭션이 시작되면서 새로운 영속성 컨텍스트가 생성된다. 이는 1번에서 생성된 영속성 컨텍스트와는 다르다.앞서 영속성 컨텍스트가 같을 때는 동일성, 동등성, 데이터베이스 동등성을 모두 만족하였지만 이 경우 동일성 비교에는 실패하게 된다. 그러므로 같은 영속성 컨텍스트가 보장되면 두 엔터티의 비교는 동일성 비교만으로 충분하다. OSIV와 같이 요청 스코프 동안 영속성 컨텍스트가 공유되는 경우 동일성 비교에 성공한다.
엔터티 비교 시에는 비즈니스 키를 활용한 동등성 비교가 권장된다. 왜냐하면 동일성 비교는 두 엔터티가 같은 영속성 컨텍스트에 있어야 한다는 조건이 성립해야 하기 때문이다.
프록시는 원본 엔터티를 상속받아 생성되므로 클라이언트 입장에선 다형성 덕분에 엔터티가 프록시 객체인지 원본 객체인지를 구분하지 않고 사용할 수 있다. 원본 엔터티를 사용하다가 지연 로딩이 필요하여 프록시로 변경하는 상황에도 비즈니스 로직을 직접적으로 수정할 필요는 없다. 하지만 프록시 기술 자체의 기술적인 한계로 인해 예상치 못한 문제가 발생할 수는 있다.
영속성 컨텍스트는 자신이 관리하는 영속 상태 엔터티의 동일성(identity)을 보장한다.
예제 15.5. 영속성 컨텍스트와 프록시 예제 코드
@Test
public void persistenceContextAndProxy() throws Exception {
Member newMember = new Member();
em.persist(newMember);
em.flush();
em.clear();
Member refMember = em.getReference(Member.class, newMember.getId());
Member foundMember = em.find(Member.class, newMember.getId());
log.info("refMember: " + refMember.getClass());
log.info("foundMember: " + foundMember.getClass());
assertSame(refMember, foundMember);
}
테스트 출력
2026-01-17T22:42:21.006+09:00 INFO 218767 --- [jpabook-advanced] [ main] j.s.j.service.MemberServiceTest : refMember: class jpabook.start.jpabookadvanced.domain.Member$HibernateProxy
2026-01-17T22:42:21.006+09:00 INFO 218767 --- [jpabook-advanced] [ main] j.s.j.service.MemberServiceTest : foundMember: class jpabook.start.jpabookadvanced.domain.Member$HibernateProxy
EntityManager.getReference() 메서드를 사용하여 회원 엔터티를 프록시 객체로 조회하고, EntityManager.find() 메서드를 사용하여 원본 엔터티를 조회하려는 의도를 표현하였으나 출력 결과를 보면 둘은 같은 프록시 객체이다. 영속성 컨텍스트는 동일한 엔터티가 먼저 프록시로 조회되었으므로 후에 조회되는 엔터티도 프록시로 조회되게 함으로써 영속 상태 엔터티의 동일성을 보장한다.
예제 15.6. 원본 먼저 조회하고 나서 프록시로 조회하기 예제 코드
@Test
public void persistenceContextAndProxy() throws Exception {
Member newMember = new Member();
em.persist(newMember);
em.flush();
em.clear();
Member foundMember = em.find(Member.class, newMember.getId());
Member refMember = em.getReference(Member.class, newMember.getId());
log.info("refMember: " + refMember.getClass());
log.info("foundMember: " + foundMember.getClass());
assertSame(refMember, foundMember);
}
테스트 출력
2026-01-17T22:46:30.429+09:00 INFO 237882 --- [jpabook-advanced] [ main] j.s.j.service.MemberServiceTest : refMember: class jpabook.start.jpabookadvanced.domain.Member
2026-01-17T22:46:30.429+09:00 INFO 237882 --- [jpabook-advanced] [ main] j.s.j.service.MemberServiceTest : foundMember: class jpabook.start.jpabookadvanced.domain.Member
순서를 바꾸어 원본 엔터티를 먼저 조회하면 이때는 영속성 컨텍스트가 동일한 객체를 프록시로 제공할 이유가 없다. 이미 원본 엔터티가 1차 캐시에 캐싱되어 있기 때문이다. 그러므로 이 경우에는 두 메서드 모두 원본 엔터티를 반환함으로써 동일성을 보장한다.
프록시는 원본 엔터티를 상속받으므로 프록시로 조회한 엔터티의 타입 비교 시 == 대신 instanceof를 사용해야 한다.
예제 15.7. 프록시 타입 비교 예제 코드
@Test
void proxyTypeComparison() {
Member member = new Member("kim");
em.persist(member);
em.flush();
em.clear();
Member refMember = em.getReference(Member.class, 1L);
assertNotSame(Member.class, refMember.getClass());
assertInstanceOf(Member.class, refMember);
}
엔터티 간 동등성 비교가 필요할 시 비즈니스 키를 활용하여 equals() 메서드를 오버라이딩하면 된다. 하지만 IDE나 외부 라이브러리를 사용해 구현한 equals() 메서드로 엔터티를 비교할 때 비교 대상이 프록시면 문제가 발생할 수 있다.
예제 15.8. 프록시 동등성 비교, 회원 엔터티
@Entity
@Getter
@Setter
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
public Member() {
}
public Member(String name) {
this.name = name;
}
@Override
public boolean equals(Object object) {
if (this == object) return true;
if (object == null) return false;
if (this.getClass() != object.getClass()) return false;
Member member = (Member) object;
if (name != null ? !name.equals(member.name) : member.name != null)
return false;
return true;
}
@Override
public int hashCode() {
return name != null ? name.hashCode() : 0;
}
}
회원 엔터티는 name 필드를 비즈니스 키로 사용하여 equals() 메서드를 오버라이딩했다. 그러므로 name이 중복되는 회원은 없다고 가정한다.
예제 15.9. 프록시 동등성 비교, 실행
@Test
void proxyEquivalenceComparison() {
Member member = new Member("kim");
em.persist(member);
em.flush();
em.clear();
Member newMember = new Member("kim");
Member refMember = em.getReference(Member.class, 1L);
assertTrue(newMember.equals(refMember));
}
위 테스트는 실패한다. 앞서 equals() 메서드를 오버라이딩할 때 getClass() 메서드를 사용하여 타입 간 일치 여부를 검사했는데, 이 방식은 프록시 객체와 원본 엔터티 객체 간 equals() 메서드를 사용한 동등성 비교를 오류 있게 만든다. 따라서 instanceof를 활용하여 정의할 필요가 있다.
그리고 프록시 객체는 실제 데이터를 갖고 있지 않기 때문에 멤버 변수에 직접 접근하면 아무 값도 조회할 수 없다. member.name과 같이 접근하면 항상 null이 반환되어 equals() 메서드는 여전히 false를 반환할 것이다. 그러므로 접근자(Getter)를 사용해야 한다.
예제 15.10. 프록시 동등성 비교 예제, 수정
@Override
public boolean equals(Object object) {
if (this == object) return true;
if (object == null) return false;
if (!(object instanceof Member)) return false;
Member member = (Member) object;
if (name != null ? !name.equals(member.getName()) : member.getName() != null)
return false;
return true;
}
수정 시 테스트는 성공한다. 그러므로 프록시의 동등성 비교 시 다음 사항을 주의해야 한다.
== 대신 instanceof를 사용해야 한다.
프록시를 부모 타입으로 조회하면 문제가 발생한다. 이를 확인하기 위해 위 다이어그램에 기반한 엔터티 관계를 가정한다.
예제 15.11. 프록시 부모 타입으로 조회
@Test
void parentTypeProxy() {
// Given
Book book = new Book();
book.setName("jpabook");
book.setAuthor("kim");
em.persist(book);
em.flush();
em.clear();
// When
Item proxyItem = em.getReference(Item.class, book.getId());
log.info("proxyItem.class: {}", proxyItem.getClass());
if (proxyItem instanceof Book) {
log.info("proxyItem instanceof Book");
Book proxyBook = (Book) proxyItem;
log.info("proxyBook.author: {}", proxyBook.getAuthor());
}
// Then
assertFalse(proxyItem.getClass() == Book.class);
assertFalse(proxyItem instanceof Book);
assertTrue(proxyItem instanceof Item);
}
테스트 출력
2026-01-19T21:20:15.551+09:00 INFO 98418 --- [jpabook-advanced] [ main] j.s.j.JpabookAdvancedApplicationTests : proxyItem.class: class jpabook.start.jpabookadvanced.domain.Item$HibernateProxy
EntityManager.getReference() 메서드로 조회한 proxyItem 프록시 객체는 Item$HibernateProxy 타입이다. 이는 Item 클래스로부터 파생된 것이며 원본 엔터티로는 Book 클래스를 참조한다. 하지만 이 타입 자체는 Book 타입과는 아무 관련이 없기 때문에 캐스팅은 불가능하다. 캐스팅 시엔 ClassCastException 예외가 발생한다.
프록시를 부모 타입으로 조회할 시 부모의 타입을 기반으로 프록시가 생성되는 문제가 있다.
instanceof 연산을 사용할 수 없다.이러한 문제는 주로 다음과 같이 다형성을 다루는 도메인 모델에서 발생한다.
예제 15.12. 다형성과 프록시 조회 정의
@Entity
public class OrderItem {
@Id
@GeneratedValue
private Long id;
@ManyToOne
@JoinColumn
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn
private Item item;
...
}
OrderItem 엔터티는 Item 엔터티와의 연관 관계에서 글로벌 페치 전략을 지연 로딩으로 설정했기 때문에 엔터티가 프록시로 조회된다.
예제 15.13. 다형성과 프록시 조회 실행
@Test
void inheritanceAndProxyDomainModel() {
// Given
Book book = new Book();
book.setName("jpabook");
book.setAuthor("kim");
em.persist(book);
OrderItem orderItem = new OrderItem();
orderItem.setItem(book);
em.persist(orderItem);
em.flush();
em.clear();
// When
OrderItem foundOrderItem = em.find(OrderItem.class, orderItem.getId());
Item item = foundOrderItem.getItem();
log.info("item.class: {}", item.getClass());
// Then
assertFalse(item.getClass() == Book.class);
assertFalse(item instanceof Book);
assertTrue(item instanceof Item);
}
상속 관계에서 발생하는 프록시 문제는 다음과 같은 방법들로 해결할 수 있다.
가장 간단하게는 처음부터 자식 타입을 직접 조회하여 필요한 연산을 하는 것이지만 이 경우 다형성을 활용할 수 없다.
자식 타입을 직접 조회하는 JPQL
Item item = em.createQuery("SELECT b FROM Book b WHERE b.id=:bookId", Book.class)
.setParameter("bookId", book.getId())
.getSingleResult();
하이버네이트가 제공하는 기능을 활용하면 프록시 객체로부터 원본 엔터티 객체를 가져올 수도 있다.
예제 15.14. 프록시 벗기기 예제
@Test
void inheritanceAndProxyDomainModel() {
// Given
Book book = new Book();
book.setName("jpabook");
book.setAuthor("kim");
em.persist(book);
OrderItem orderItem = new OrderItem();
orderItem.setItem(book);
em.persist(orderItem);
em.flush();
em.clear();
// When
OrderItem foundOrderItem = em.find(OrderItem.class, orderItem.getId());
Item item = foundOrderItem.getItem();
Item unproxyedItem = unProxy(item);
if (unproxyedItem instanceof Book) {
log.info("unproxyedItem instanceof Book");
Book castedBook = (Book) unproxyedItem;
log.info("unproxyedItem.author: {}", castedBook.getAuthor());
}
log.info("item.class: {}", item.getClass());
// Then
assertTrue(item != unproxyedItem);
assertFalse(item instanceof Book);
assertTrue(item instanceof Item);
}
public static <T> T unProxy(Object entity) {
if (entity instanceof HibernateProxy) {
entity = ((HibernateProxy) entity)
.getHibernateLazyInitializer()
.getImplementation();
}
return (T) entity;
}
영속성 컨텍스트는 영속 상태 엔터티의 동일성 보장을 위해 한 번 프록시로 노출된 엔터티는 계속 프록시로 노출한다. 하지만 이 방법은 프록시로부터 원본 엔터티를 직접 꺼내기 때문에 두 객체는 동일하지 않게 되어 == 비교에 실패한다. 그러나 원본 엔터티에 대한 변경 감지는 작동한다.
이 방법을 사용할 때는 원본 엔터티가 필요한 곳에서만 잠깐 사용하고 달리 사용되지 않게 하는 것이 중요하다.
특정 기능을 위한 별도의 인터페이스를 제공할 수도 있다.
예제 15.15. 프록시 인터페이스 제공 정의
public interface TitleView {
String getTitle();
}
@Getter @Setter
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item implements TitleView {
@Id
@GeneratedValue
private Long id;
private String name;
private Integer price;
private Integer stockQuantity;
}
@Getter @Setter
@Entity
@DiscriminatorValue("B")
public class Book extends Item {
private String author;
private String isbn;
@Override
public String getTitle() {
return "[title: " + getName() + ", author: " + author + "]";
}
}
@Getter @Setter
@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
private String director;
private String actor;
@Override
public String getTitle() {
return "[title: " + getName() + ", director: " + director + ", actor: " + actor + "]";
}
}
@Getter @Setter
@Entity
@DiscriminatorValue("A")
public class Album extends Item {
private String artist;
@Override
public String getTitle() {
return "[title: " + getName() + ", artirst: " + artist + "]";
}
}
공통 인터페이스인 TitleView를 정의하고 자식 클래스들이 이 인터페이스의 getTitle() 메서드를 구현했다.
예제 15.16. 프록시 인터페이스 제공 사용
OrderItem orderItem = em.find(OrderItem.class, orderItem.getId());
log.info("title: {}", orderItem.getItem().getTitle());
이 경우 캐스팅이 필요하지 않고 상품 종류마다 서로 다른 제목을 출력할 수 있다. 또한 상품이 변경되어도 OrderItem 클래스의 코드는 수정하지 않아도 되고, 클라이언트 입장에서 대상 객체가 프록시 객체인지 아닌지 고민할 필요가 없다는 장점이 있다. 하지만 프록시의 특징 때문에 프록시 대상이 되는 Item 타입이 인터페이스를 구현해야 한다.
방문자 패턴은 알고리즘과 객체 구조를 분리하는 GoF 디자인 패턴 중 하나로 SOLID 원칙 중 개방-폐쇄 원칙을 준수한다. 방문자 패턴은 Visitor 클래스와 이를 받아들이는(accept()) 대상 클래스로 분리한다.
이 경우 Item.accept(visitor) 메서드로 방문자를 받아들인다. Item 엔터티가 방문자를 단순히 받아들이면 실제 필요한 로직은 방문자가 처리하는 식이다.
예제 15.18. Visitor 인터페이스
public interface Visitor {
void visit(Book book);
void visit(Album album);
void visit(Movie movie);
}
Visitor 인터페이스에는 visit() 메서드를 정의하여 모든 대상 클래스를 받아들일 수 있도록 한다. 이 경우 Book, Album, Movie가 대상 클래스가 된다.
예제 15.19. 비지터 구현
public class PrintVisitor implements Visitor {
@Override
public void visit(Book book) {
System.out.println("book.class = " + book.getClass());
System.out.println("[PrintVisitor] [title: " + book.getTitle() + ", author: " + book.getAuthor() + "]");
}
@Override
public void visit(Album album) {
System.out.println("album.class = " + album.getClass());
System.out.println("[PrintVisitor] [title: " + album.getTitle() + ", artist: " + album.getArtist() + "]");
}
@Override
public void visit(Movie movie) {
System.out.println("movie.class = " + movie.getClass());
System.out.println("[PrintVisitor] [title: " + movie.getTitle() + ", actor: " + movie.getActor() + "]");
}
}
public class TitleVisitor implements Visitor {
private String title;
public String getTitle() {
return title;
}
@Override
public void visit(Book book) {
title = "[title: " + book.getTitle() + ", author: " + book.getAuthor() + "]";
}
@Override
public void visit(Album album) {
title = "[title: " + album.getTitle() + ", artist: " + album.getArtist() + "]";
}
@Override
public void visit(Movie movie) {
title = "[title: " + movie.getTitle() + ", actor: " + movie.getActor() + "]";
}
}
Visitor 인터페이스의 구현 클래스로 대상 클래스의 내용을 출력하는 알고리즘을 PrintVisitor 구현 클래스에, 내용을 보관하는 알고리즘을 TitleVisitor 구현 클래스에 정의했다.
Item 클래스와 이를 상속받는 클래스에는 단순히 Visitor 타입을 받아들일 수 있도록 accept() 메서드를 정의한다.
예제 15.20. 비지터 대상 클래스
@Getter @Setter
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item implements TitleView {
...
public abstract void accept(Visitor visitor);
}
@Getter @Setter
@Entity
@DiscriminatorValue("B")
public class Book extends Item {
...
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
@Getter @Setter
@Entity
@DiscriminatorValue("A")
public class Album extends Item {
...
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
@Getter @Setter
@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
...
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
예제 15.21. 비지터 사용 코드
@Test
void visitorPattern() {
Book book = new Book();
book.setName("jpabook");
book.setAuthor("kim");
em.persist(book);
OrderItem orderItem = new OrderItem();
orderItem.setItem(book);
em.persist(orderItem);
em.flush();
em.clear();
OrderItem foundOrderItem = em.find(OrderItem.class, orderItem.getId());
Item item = foundOrderItem.getItem();
item.accept(new PrintVisitor());
}
item은 Item 클래스를 하이버네이트가 감싼 프록시 객체로 조회된다. 그러므로 item.accept() 메서드 호출 시 일단 프록시 객체의 accept() 메서드가 호출되는데 이것이 원본 엔터티인 Book의 accept() 메서드를 실행하게 된다.
예제 15.22. 비지터 구현 예제
public class PrintVisitor implements Visitor {
@Override
public void visit(Book book) {
// 매개변수 book은 프록시가 아닌 원본 엔터티이다.
System.out.println("book.class = " + book.getClass());
System.out.println("[PrintVisitor] [title: " + book.getTitle() + ", author: " + book.getAuthor() + "]");
}
...
}
앞서 작성한 테스트 코드에서 new PrintVisitor()를 new TitleVisitor()로 변경하면 다른 알고리즘을 실행할 수 있다. 그러므로 방문자 패턴은 기존 코드의 구조를 변경하지 않으면서 필요한 알고리즘을 구현하고 기능을 추가해 나갈 수 있다는 장점이 있다.
방문자 패턴 정리
instanceof와 타입 캐스팅 없이 코드를 구현할 수 있다.Visitor를 수정해야 한다.예제 15.23. 회원
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<>();
...
}
예제 15.24. 회원의 주문 정보
@Entity
@Table(name = "ORDERS")
public class Order {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private Member member;
...
}
예제에서 회원과 주문 정보 간에는 1:N 양방향 연관 관계가 설정되어 있고, Member.orders의 글로벌 페치 전략은 즉시 로딩이다.
특정 회원을 EntityManager.find() 메서드로 조회할 시 즉시 로딩으로 설정한 주문 정보도 함께 조회된다. 이때 대략적으로 다음과 같은 SQL이 실행된다.
EntityManager 즉시 로딩 시 실행되는 SQL
SELECT M.*, O.*
FROM MEMBER M
OUTER JOIN ORDERS O ON M.ID=O.MEMBER_ID
이때는 조인을 활용하여 한 번의 SQL 쿼리로 두 엔터티를 동시에 조회하므로 N+1 문제가 발생할 여지는 없다. 하지만 JPQL을 사용하여 다음과 같이 조회한다고 생각해 보자.
JPQL 예제 코드
List<Member> members =
em.createQuery("SELECT m FROM Member m", Member.class)
.getResultList();
이때 JPA는 JPQL 쿼리를 분석하여 대략적으로 다음과 같은 두 개의 SQL을 실행한다.
JPQL 즉시 로딩 시 실행되는 SQL
SELECT * FROM MEMBER
SELECT * FROM ORDERS WHERE MEMBER_ID=1
SELECT * FROM ORDERS WHERE MEMBER_ID=2
SELECT * FROM ORDERS WHERE MEMBER_ID=3
SELECT * FROM ORDERS WHERE MEMBER_ID=4
SELECT * FROM ORDERS WHERE MEMBER_ID=5
...
이처럼 처음 실행한 결과의 수만큼 추가적으로 SQL이 실행되는 N+1 문제는 글로벌 페치 전략이 즉시 로딩일 때도 발생한다.
예제 15.25. 지연 로딩 설정
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
...
}
지연 로딩은 N+1 문제가 조회 즉시 발생하진 않는다. 그러므로 EntityManager.find() 메서드를 활용하든 JPQL을 활용하든 그것이 N+1 문제의 직접적인 원인이 되지는 않는 것이다. 하지만 비즈니스 로직에 따라서 N+1 문제가 발생할 가능성이 있다. 예를 들어 회원마다 연관 엔터티에 대해 추가적인 작업을 해주는 비즈니스 로직이 있다면 페치 조인 등의 해결책 없이는 무조건 N+1 문제를 맞닥뜨리게 될 것이다.
N+1 문제를 해결하는 가장 일반적인 방법은 페치 조인을 사용하는 것이다. 페치 조인은 SQL의 조인 쿼리를 실행하면서 연관된 엔터티를 모두 영속화한다.
하이버네이트가 제공하는 org.hibernate.annotations.BatchSize 애너테이션을 사용하면 연관된 엔터티를 조회할 때 지정한 size 만큼 SQL IN 절을 사용하여 조회한다. 이는 조인을 사용하지 않고 대상 테이블에 대한 조회 쿼리에 IN 절을 추가하기 때문에 소수의 데이터를 다룰 때 매우 효율적이다.
예제 15.26. BatchSize 적용
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@BatchSize(size = 5)
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<>();
...
}
예를 들어 위와 같이 설정되어 있을 때 한 번에 조회된 회원이 10명이라면 다음과 같은 쿼리가 두 번 실행될 것이다.
@BatchSize 적용 시 실행되는 SQL
SELECT * FROM ORDERS
WHERE MEMBER_ID IN ( ?, ?, ?, ?, ? )
hibernate.default_batch_fetch_size 속성 사용 시 이러한 설정을 애플리케이션 전역에 등록할 수 있다.
org.hibernate.annotations.Fetch 애너테이션의 인자로 FetchMode.SUBSELECT를 전달하면 연관 엔터티 조회 시 서브 쿼리를 사용하여 N+1 문제를 해결한다.
예제 15.27. @Fetch 적용
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<>();
...
}
만약 SELECT m FROM Member m WHERE m.id > 10와 같은 JPQL을 실행한다면 글로벌 페치 전략이 즉시 로딩일 시 조회 시점에, 지연 로딩일 시 지연 로딩된 엔터티를 사용하는 시점에 다음 SQL이 실행될 것이다.
@Fetch 적용 시 실행되는 SQL
SELECT O FROM ORDERS O
WHERE O.MEMBER_ID IN (
SELECT M.ID
FROM MEMBER M
WHERE M.ID > 10
)
즉, 처음 엔터티를 조회하는 시점에 사용한 쿼리를 기억해 두었다가 연관 엔터티 조회 시 서브 쿼리에 활용하는 것이다.
즉시 로딩은 일반적으로 N+1 문제가 조회 즉시 발생하게 되어 대처가 어렵고 성능 최적화가 어렵기 때문에 지연 로딩 사용을 사용하면서 필요할 때 페치 조인을 사용하는 방식이 권장된다.
JPA의 글로벌 페치 전략 기본 값은 다음과 같다.
그러므로 X:1 매핑 애너테이션은 글로벌 페치 전략을 명시적으로 지연 로딩으로 변경해야 한다.
엔터티가 영속성 컨텍스트에서 관리되면 1차 캐시, 변경 감지 등 다양한 혜택을 얻을 수 있다. 하지만 변경 감지를 위해 엔터티 객체들의 스냅샷을 유지해야 하므로 더 많은 메모리를 사용하게 되고 약간의 오버헤드가 발생한다. 만약 엔터티의 변경이 안 이루어지고 단순 조회만 발생하는 비즈니스 로직이라면 읽기 전용으로 엔터티를 조회하여 메모리 사용량을 최적화할 수 있다.
다음의 JPQL 쿼리를 최적화해 보자.
최적화 대상 JPQL 쿼리
SELECT o FROM Order o
가장 확실한 방법은 스칼라 타입으로 모든 필드를 조회하는 것이다. 스칼라 타입의 결과는 영속성 컨텍스트의 관리 대상이 아니다.
스칼라 타입 조회 JPQL
SELECT o.id, o.name, o.price FROM Order o
하이버네이트 전용 JPA 힌트인 org.hibernate.readOnly를 사용하여 엔터티를 읽기 전용으로 조회할 수 있다. 이때 조회된 엔터티에 대해 당연히 변경 감지는 작동하지 않는다.
org.hibernate.readOnly 적용
TypedQuery<Order> query = em.createQuery("SELECT o FROM Order o", Order.class);
query.setHint("org.hibernate.readOnly", true);
스프링 프레임워크 사용 시 @Transactional 애너테이션의 readOnly 속성 설정으로 트랜잭션을 읽기 전용 모드로 설정할 수 있다.
@Transactional 애너테이션 읽기 전용 지정
@Transactional(readOnly = true)
이 경우 스프링 프레임워크는 하이버네이트 세션의 플러시 모드를 MANUAL로 설정하여 자동으로 플러시가 호출되지 않는다. 하지만 강제로 플러시를 호출할 수는 있다.
이는 트랜잭션 없이 엔터티를 조회한다는 의미이다. JPA에서 엔터티를 변경하려면 트랜잭션이 필수이기 때문에 조회가 목적일 때만 사용할 수 있는 방법이다.
스프링 프레임워크 사용 시
@Transactional(propagation = Propagation.NOT_SUPPORTED)
J2EE 표준 컨테이너 사용 시
@TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED)
위와 같은 트랜잭션들은 자동으로 플러시가 호출되지 않는다. 플러시 모드는 기본적으로 AUTO로 설정되어 있는데, 커밋 또는 쿼리 실행 시 플러시가 작동하게 되어 있다. 참고로 JPQL 쿼리도 트랜잭션 없이 실행 시 플러시를 호출하지 않는다.
정리하자면 읽기 전용 데이터 조회 시 메모리를 최적화하려면 스칼라 타입으로 조회하거나 하이버네이트가 제공하는 읽기 전용 쿼리 힌트를 사용하면 되고, 플러시 호출을 막아서 실행 성능을 최적화하려면 읽기 전용 트랜잭션을 사용하거나 트랜잭션 밖에서 읽기를 사용하면 된다. 스프링 프레임워크 사용 시엔 읽기 전용 트랜잭션을 사용하는 것이 편리하다.
따라서 다음과 같이 읽기 전용 트랜잭션(또는 트랜잭션 밖에서 읽기)과 읽기 전용 쿼리 힌트(또는 스칼라 타입 조회)를 동시에 사용하는 것이 가장 효과적이다.
예제 15.28. 읽기 전용 트랜잭션과 읽기 전용 쿼리 힌트 적용
@Transactional(readOnly = true)
public List<DataEntity> find() {
return em.createQuery("select d from DataEntity d", DataEntity.class)
.setHint("org.hibernate.readOnly", true)
.getResultList();
}
다량의 데이터를 배치 처리할 때 일반적인 방식으로 엔터티를 반복 조회하면 영속성 컨텍스트에 다량의 엔터티가 누적되면서 메모리 자원이 고갈된다. 배치 처리는 적정 주기를 설정하고 영속성 컨텍스트를 초기화하면서 진행해야 하며, 2차 캐시 사용 시에는 2차 캐시에 엔터티를 캐싱해서는 안 된다.
예제 15.29. JPA 등록 배치 예제
@Test
void batch() {
for (int i = 0; i < 100000; ++i) {
Product product = new Product("item" + i, 10000);
em.persist(product);
if (i % 100 == 0) {
em.flush();
em.clear();
}
}
}
다량의 엔터티를 한 번에 등록할 때는 단순히 반복문에 대해 주기적으로 영속성 컨텍스트를 플러시 및 초기화하면 된다.
수정 배치 작업 시에는 다량의 데이터를 우선 조회하게 된다. 이때 두 가지 방법이 주로 사용된다.
예제 15.30. JPA 페이징 배치 처리 예제
@Test
void batchPaging() {
int pageSize = 100;
for (int i = 0; i < 10; ++i) {
List<Product> resultList = em.createQuery("SELECT p FROM Product p", Product.class)
.setFirstResult(i * pageSize)
.setMaxResults(pageSize)
.getResultList();
for (Product product : resultList) {
product.setPrice(product.getPrice() + 100);
}
em.flush();
em.clear();
}
}
예제 15.31. 하이버네이트 scroll 사용 예제
@Test
void batchScroll() {
Session session = em.unwrap(Session.class);
ScrollableResults scroll = session.createQuery("SELECT p FROM Product p")
.setCacheMode(CacheMode.IGNORE) // 2차 캐시 기능 비활성화
.scroll(ScrollMode.FORWARD_ONLY);
int count = 0;
while (scroll.next()) {
Product p = (Product) scroll.get();
p.setPrice(p.getPrice() + 100);
++count;
if (count % 100 == 0) {
session.flush();
session.clear();
}
}
}
scroll은 하이버네이트 전용 기능이기 때문에 EntityManager.unwrap() 메서드를 사용해 하이버네이트 세션 객체를 생성하고 쿼리와 Session.scroll() 메서드로 ScrollableResults 객체를 반환받는다. 이 객체의 next() 메서드를 호출하면 엔터티를 하나씩 조회할 수 있다.
예제 15.32. 하이버네이트 무상태 세션 사용 예제
@Test
void batchStatelessSession() {
SessionFactory sessionFactory = em.unwrap(SessionFactory.class);
StatelessSession session = sessionFactory.openStatelessSession();
ScrollableResults scroll = session.createQuery("SELECT p FROM Product p").scroll();
while (scroll.next()) {
Product p = (Product) scroll.get();
p.setPrice(p.getPrice() + 100);
session.update(p);
}
}
하이버네이트의 무상태 세션은 영속성 컨텍스트를 생성하지 않고 2차 캐시도 사용하지 않는다. 그리고 엔터티 수정이 필요하면 무상태 세션이 제공하는 update() 메서드를 직접 호출해야 한다.
JPA는 데이터베이스 SQL 힌트 기능을 제공하지 않기 때문에 하이버네이트의 addQueryHint() 메서드를 사용해야 한다.
예제 15.33. SQL 쿼리 힌트 사용
@Test
void queryHint() {
Session session = em.unwrap(Session.class);
List<Member> list = session.createQuery("SELECT m FROM Member m")
.addQueryHint("FULL (MEMBER)")
.list();
list.size();
}
JDBC가 제공하는 SQL 배치 기능을 사용하면 다수의 SQL을 모아서 한 번에 데이터베이스로 전송함으로써 네트워크 호출 비용을 절약할 수 있다. 하지만 코드의 많은 부분을 수정해야 하기 때문에 보통 수백, 수천 건 이상의 데이터를 변경하는 특수한 상황에 이 기능을 활용한다.
hibernate.jdbc.batch_size 속성의 값으로 같은 배치의 최대 작업 개수를 설정할 수 있고, 지정한 개수 만큼 같은 쿼리가 모이면 데이터베이스로 전송된다. SQL 배치는 같은 SQL일 때만 유효하기 때문에 중간에 다른 처리가 추가되면 SQL 배치를 다시 시작한다.
참고로 IDENTITY 식별자 생성 전략을 사용하는 경우엔 엔터티를 우선 데이터베이스에 저장해야 식별자를 구할 수 있기 때문에 EntityManager.persist() 메서드 호출 즉시 SQL이 전송되어 쓰기 지연을 활용한 성능 최적화가 불가능하다.
트랜잭션을 지원하는 쓰기 지연과 변경 감지 기능은 성능과 개발의 편의성을 동시에 충족시켜주며 데이터베이스 테이블 로우에 락이 걸리는 시간을 최소화한다.