[JPA] 자바 ORM 표준 JPA 프로그래밍 15장

xyzw·2023년 9월 4일
0

Spring

목록 보기
20/22

예외 처리

JPA 표준 예외 정리

트랜잭션 롤백을 표시하는 예외

  • 심각한 예외이므로 복구해서는 안 됨
  • 이 예외가 발생하면 트랜잭션을 강제로 커밋해도 트랜잭션이 커밋되지 않고 대신에 javax.persistence.RollbackException 예외가 발생함

종류

  • javax.persistence.EntityExistsException: EntityManager.persist() 호출 시 이미 같은 엔티티가 있으면 발생
  • javax.persistence.EntityNotFoundException: EntityManager.getReference()를 호출했는데 실제 사용 시 엔티티가 존재하지 않으면 발생. refresh(), lock()에서도 발생
  • javax.persistence.OptimisticLockException: 낙관적 락 충돌 시 발생
  • javax.persistence.PessimisticLockException: 비관적 락 충돌 시 발생
  • javax.persistence.RollbackException: EntityTransaction.commit() 실패 시 발생. 롤백이 표시되어 있는 트랜잭션 커밋 시에도 발생
  • javax.persistence.TransactionRequiredException: 트랜잭션이 필요할 때 트랜잭션이 없으면 발생. 트랜잭션 없이 엔티티를 변경할 때 주로 발생

트랜잭션 롤백을 표시하지 않는 예외

  • 심각한 예외가 아님
  • 개발자가 트랜잭션을 커밋할지 롤백할지 판단하면 됨

종류

  • javax.persistence.NoResultException: Query.getSingleResult() 호출 시 결과가 하나도 없을 때 발생
  • javax.persistence.NonUniqueResultException: Query.getSingleResult() 호출 시 결과가 둘 이상일 때 발생
  • javax.persistence.LockTimeoutException: 비관적 락에서 시간 초과 시 발생
  • javax.persistence.QueryTimeoutException: 쿼리 실행 시간 초과 시 발생

스프링 프레임워크의 JPA 예외 변환

서비스 계층에서 데이터 접근 계층의 구현 기술에 직접 의존하는 것은 좋은 설계라 할 수 없다. 이것은 예외도 마찬가지인데, 예를 들어 서비스 계층에서 JPA의 예외를 직접 사용하면 JPA에 의존하게 된다.

스프링 프레임워크는 이런 문제를 해결하려고 데이터 접근 계층에 대한 예외를 추상화해서 개발자에게 제공한다.

스프링 프레임워크에 JPA 예외 변환기 적용

JPA 예외를 스프링 프레임워크가 제공하는 추상화된 예외로 변경하려면 PersistenceExceptionTranslationPostProcessor를 스프링 빈으로 등록하면 된다.
이것은 @Repository 어노테이션을 사용한 곳에 예외 변환 AOP를 적용해서 JPA 예외를 스프링 프레임워크가 추상화한 예외로 변환해준다. 설정 방법은 다음과 같다.

<bean class="org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor" />

JavaConfig를 사용하면 다음처럼 등록한다.

@Bean
public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
	return new PersistenceExceptionTranslationPostProcessor();
}

예외 변환 예제 코드

@Repository
public class NoResultExceptionTestRepository {

	@PersistenceContext EntityManager em;
    
    public Member findMember() {
    	//조회된 데이터가 없음
        return em.createQuery("select m from Member m", Member.class).getSingleResult();
    }
}

findMember() 메소드는 엔티티를 조회하려고 getSingleResult() 메소드를 사용했다. 이 메소드는 조회된 결과가 없으면 javax.persistence.NoResultException이 발생한다.
이 예외가 findMember() 메소드를 빠져 나갈 때 PersistenceExceptionTranslationPostProcessor에서 등록한 AOP 인터셉터가 동작해 해당 예외를 org.springframework.dao.EmptyResultDataAccessException 예외로 변환해서 반환한다.
따라서 이 메소드를 호출한 클라이언트는 스프링 프레임워크가 추상화한 예외를 받는다.
만약 예외를 변환하지 않고 그대로 반환하고 싶으면 throws 절에 그대로 반환할 JPA 예외나 JPA 예외의 부모 클래스를 직접 명시하면 된다.

트랜잭션 롤백 시 주의사항

트랜잭션을 롤백하는 것은 데이터베이스의 반영사항만 롤백하는 것이지 수정한 자바 객체까지 원상태로 복구해주지는 않는다.
예를 들어 엔티티를 조회해서 수정하는 중에 문제가 있어서 트랜잭션을 롤백하면 데이터베이스의 데이터는 원래대로 복구되지만 객체는 수정된 상태로 영속성 컨텍스트에 남아 있다.
따라서 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용하는 것은 위험하다. 새로운 영속성 컨텍스트를 생성해서 사용하거나 EntityManager.claer()를 호출해서 영속성 컨텍스트를 초기화한 다음에 사용해야 한다.

스프링 프레임워크는 이런 문제를 예방하기 위해 영속성 컨텍스트의 범위에 따라 다른 방법을 사용한다.
기본 전략인 트랜잭션당 영속성 컨텍스트 전략은 문제가 발생하면 트랜잭션 APO 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트도 함께 종료하므로 문제가 발생하지 않는다.
문제는 OSIV처럼 영속성 컨텍스트의 범위를 트랜잭션 범위보다 넓게 사용해서 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용할 때 발생한다.
이때는 트랜잭션을 롤백해서 영속성 컨텍스트의 이상이 발생해도 다른 트랜잭션에서 해당 영속성 컨텍스트를 그대로 사용하는 문제가 있다.
스프링 프레임워크는 영속성 컨텍스트의 범위를 트랜잭션의 범위보다 넓게 설정하면 트랜잭션 롤백 시 영속성 컨텍스트를 초기화해서 잘못된 영속성 컨텍스트를 사용하는 문제를 예방한다.

엔티티 비교

영속성 컨텍스트 내부에는 엔티티 인스턴스를 보관하기 위한 1차 캐시가 있다. 이 1차 캐시는 영속성 컨텍스트와 생명주기를 같이 한다.

영속성 컨텍스트를 통해 데이터를 저장하거나 조회하면 1차 캐시에 엔티티가 저장된다. 이 1차 캐시 덕분에 변경 감지 기능도 동작하고, 이름 그대로 1차 캐시로 사용되어서 데이터베이스를 통하지 않고 데이터를 바로 조회할 수도 있다.

영소성 컨텍스트를 더 정확히 이해하기 위해서는 1차 캐시의 가장 큰 장점인 애플리케이션 수준의 반복 가능한 읽기를 이해해야 한다. 같은 영속성 컨텍스트에서 엔티티를 조회하면 다음 코드와 같이 항상 같은 엔티티 인스턴스를 반환한다.

이것은 단순히 동등성(equals) 비교 수준이 아니라 정말 주소값이 같은 인스턴스를 반환한다.

영속성 컨텍스트가 같을 때

영속성 컨텍스트가 같으면 엔티티를 비교할 때 다음 3가지 조건을 모두 만족한다.

  • 동일성(identical): == 비교가 같다.
  • 동등성(equinalent): equals() 비교가 같다.
  • 데이터베이스 동등성: @Id인 데이터베이스 식별자가 같다.

영속성 컨텍스트가 다를 때

영속성 컨텍스트가 다르면 동일성 비교에 실패한다.

  • 동일성(identical): == 비교가 실패한다.
  • 동등성(equinalent): equals() 비교가 만족한다. 단 equals()를 구현해야 한다. 보통 비즈니스 키로 구현한다.
  • 데이터베이스 동등성: @Id인 데이터베이스 식별자가 같다.

프록시 심화 주제

프록시는 원본 엔티티를 상속받아서 만들어지므로 엔티티를 사용하는 클라이언트는 엔티티가 프록시인지 아니면 원본 엔티티인지 구분하지 않고 사용할 수 있다.
따라서 원본 엔티티를 사용하다가 지연 로딩을 하려고 프록시로 변경해도 클라이언트의 비즈니스 로직을 수정하지 않아도 된다. 하지만 프록시를 사용하는 방식의 기술적인 한계로 인해 예상하지 못한 문제들이 발생하기도 한다.

영속성 컨텍스트와 프록시

영속성 컨텍스트는 자신이 관리하는 영속 엔티티의 동일성을 보장한다.
그리고 프록시로 조회해도 영속성 컨텍스트는 영속 엔티티의 동일성을 보장한다.

프록시 타입 비교

프록시는 원본 엔티티를 상속받아서 만들어지므로 프록시로 조회한 엔티티의 타입을 비교할 때는 == 비교를 하면 안 되고 대신에 instanceof를 사용해야 한다.

프록시 동등성 비교

주의사항

  • 프록시의 타입 비교는 == 비교 대신에 instanceof를 사용해야 한다.
  • 프록시의 멤버 변수에 직접 접근하면 안 되고 대신에 접근자(Getter)를 사용해야 한다.

상속관계와 프록시

문제

프록시를 부모 타입으로 조회하면 부모의 타입을 기반으로 프록시가 생성되는 문제가 있다.

  • instanceof 연산을 사용할 수 없다.
  • 하위 타입으로 다운캐스팅을 할 수 없다.

해결 방법

  • JPQL로 대상 직접 조회
    처음부터 자식 타입을 직접 조회해서 필요한 연산을 하면 된다. 하지만 다형성을 활용할 수 없다.

  • 프록시 벗기기
    하이버네이트가 제공하는 기능을 사용하면 프록시에서 원본 엔티티를 가져올 수 있다. 그러나 프록시와 원본 엔티티의 동일성 비교가 실패한다는 문제점이 있다. 따라서 이 방법을 사용할 때는 원본 엔티티가 꼭 필요한 곳에서 잠깐 사용되고 다른 곳에서 사용되지 않도록 하는 것이 중요하다.

  • 기능을 위한 별도의 인터페이스 제공
    인터페이스를 제공하고 각각의 클래스가 자신에 맞는 기능을 구현하는 것은 다형성을 활용하는 좋은 방법이다. 그리고 이 방법은 클라이언트 입장에서 대상 객체가 프록시인지 아닌지 고민하지 않아도 된다. 이 방법을 사용할 때는 프록시의 특징 때문에 프록시의 대상이 되는 타입에 인터페이스를 적용해야 한다.

  • 비지터 패턴 사용
    비지터 패턴을 사용하면 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있고 instanceof나 타입캐스팅 없이 코드를 구현할 수 있는 장점이 있다. 그리고 알고리즘과 객체 구조를 분리해서 구조를 수정하지 않고 새로운 동작을 추가할 수 있다.
    그러나 너무 복잡하고 더블 디스패치를 사용하기 때문에 이해가 어렵다. 객체 구조가 변경되면 모든 Visitor를 수정해야 한다.

성능 최적화

N+1 문제

JPA로 애플리케이션을 개발할 때 성능상 가장 주의해야 하는 문제이다.

즉시 로딩

처음 실행한 SQL의 결과 수만큼 추가로 SQL을 실행하는 것을 N+1 문제라 한다.
즉시 로딩은 JPQL을 실행할 때 N+1 문제가 발생할 수 있다.

지연 로딩

N+1 문제에서 자유로울 수 없다.

N+1 문제를 피할 수 있는 방법

페치 조인 사용

가장 일반적인 방법이다.
페치 조인은 SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로 N+1 문제가 발생하지 않는다.

하이버네이트 @BatchSize

@BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN 절을 사용해서 조회한다.

하이버네이트 @Fetch(FetchMode.SUBSELECT)

연관된 데이터를 조회할 때 서브 쿼리를 사용해서 N+1 문제를 해결한다.

정리

즉시 로딩을 사용하지 말고 지연 로딩만 사용하자.

즉시 로딩 전략은 그럴듯해 보이지만 N+1 문제는 물론이고 비즈니스 로직에 따라 필요하지 않은 엔티티를 로딩해야 하는 상황이 자주 발생한다.
그리고 가장 큰 문제는 성능 최적화가 어렵다는 점이다.
엔티티를 조회하다보면 즉시 로딩이 연속으로 발생해서 전혀 예상하지 못한 SQL이 실행될 수 있다.

따라서 모두 지연 로딩으로 설정하고 성능 최적화가 꼭 필요한 곳에는 JPQL 페치 조인을 사용하자.

JPA의 글로벌 페치 전략 기본값

  • @OneToOne, @ManyToOne: 즉시 로딩
  • @OneToMany, @ManyToMany: 지연 로딩

기본값이 즉시 로딩인 @OneToOne, @ManyToOne은 fetch = FetchType.LAZY로 설정해서 지연 로딩 전략을 사용하도록 변경하자.

0개의 댓글