고급 주제와 성능 최적화

고급 주제와 성능 최적화

예외 처리

JPA 표준 예외 정리

JPA 표준 예외들은 javax.persistence.PersistenceException의 자식 클래스이며, 이 예외 클래스는 RuntimeException의 자식이다.

따라서 JPA 모든 예외는 언체크 예외이다.

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

이 예외는 심각한 예외이므로 복수하면 안된다.

발생하면 트랜잭션을 강제로 커밋해도 트랜잭션이 커밋되지 않고 대신 javax.persistence.RollbackException 예외가 발생한다.

해당 예외를 표시하는 것들은 아래와 같다.

  • EntityExistsException: em.persist() 호출시 이미 같은 엔티티가 있으면 발생

  • EntityNotFoundException: em.getReference()를 호출 했는데 실제 사용시 엔티티가 존재하지 않으면 발생, refresh(), lock()에서도 발생

  • OptimisticLockException: 낙관적 락 충돌 시 발생

    → 낙관적 락: 수정할 때 수정했다고 명시하여 다른 트랜잭션이 동일한 조건으로 값을 수정할 수 없게 하는 것

  • PessimisticLockException: 비관적 락 충돌 시 발생

    → 데이터를 읽을 때 write 작업 말고, 변경할 때 트랜잭션이 완료할 때까지 read/write 다 막는 것

  • RollbackException: EntityTransaction.commit() 실패 시 발생, 롤백이 표시되어 있는 트랜잭션 커밋 시에도 발생한다.

  • TransactionRequiredException: 트랜잭션이 필요할 때 트랜잭션이 없으면 발생, 트랜잭션이 없이 엔티티를 변경할 때 주로 발생한다.

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

이 예외는 심각한 예외는 아니고, 개발자가 트랜잭션을 커밋할지 롤백할지 판단한다.

  • NoResultException: Query.getSingleResult() 호출 시 결과가 하나도 없을 때 발생

  • NonUniqueResultException: Query.getSingleResult() 호출 시 결과가 둘 이상일 때 발생

  • LockTimeoutException: 비관적 락에서 시간 초과 시 발생

  • QueryTimeoutException: 쿼리 실행 시간 초과 시 발생

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

서비스 계층에서 데이터 접근 계층의 구현 기술에 직접 의존하는건 좋은 설계라고 할 수 없다.

예외도 마찬가지로, 예를 들어서 서비스 계층에서 JPA 예외를 직접 사용 시 JPA에 의존하게 된다.

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

JPA 예외를 → 스프링 변환 예외

몇가지만 쓰면,

PersistenceExceptionorm.jpa.JpaSystemException

NoResultExceptiondao.EmptyResultDataAccessException

이런 식으로 변경한다.

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

JPA 예외를 스프링 프레임워크가 제공하는 추상화 된 예외로 변경하려면

<bean class="org.springframework.dao.annotaion.PersistenceExceptionTranslationPostProcessor" />
//javaConfig 사용하면
@Bean
public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
	return new PersistenceExceptionTranslationPostProcessor();
}

persistenceExceptionTranslationPostProcessor를 스프링 빈으로 등록하면 된다.

예외 변환 코드 예제

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

이렇게 getSingleResult() 메서드를 사용했는데, 결과가 없으면 javax.persistence.NoResultException이 발생한다.

해당 예외가 findMember()를 빠져 나갈 때 아까 등록한 PersistenceExceptionTranslationPostProcessor에서 등록한 AOP 인터셉터가 동작해 EmptyResultDataAccessException 예외로 변환홰서 반환한다.

따라서 이 메서드를 호출한 클라이언트는 스프링 프레임워크가 추상화한 예외를 받는다.

만약 예외를 변환하지 않고, 그대로 반환하고 싶으면 throws 절에 그대로 반환할 JPA 예외나 JPA 예외의 부모 클래스를 직접 명시하면 된다.

참고로 java.lang.Exception을 선언하면 모든 예외의 부모이므로 예외를 변환하지 않는다.

트랜잭션 롤백 시 주의사항

트랜잭션 롤백 시 데이터베이스의 반영 사항만 롤백하지, 자바 객체까지 원상태로 복구해주지는 않는다.

따라서 영속성 컨텍스트를 새롭게 생성하거나 EntityManager.clear()를 통해 영속성 컨텍스트를 초기화해야 한다.

그래서 스프링 프레임워크는 위 문제를 예방하기 위하여 영속성 컨텍스트의 범위에 따라 다른 방법을 적용한다.

  • 트랜잭션 당 영속성 컨텍스트(기본전략): 문제 발생 시 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백, 영속성 컨텍스트도 같이 종료.

    하지만 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용할 때 발생.

예를 들어서 OSIV는 영속성 컨텍스트의 범위를 트랜잭션 범위보다 넓게 사용하는데, 트랜잭션 롤백 시 영속성 컨텍스트에 이상이 발생해도 다른 트랜잭션에서 해당 영속성 컨텍스트를 그대로 사용하는 문제 발생

→ 이때는 트랜잭션 AOP 롤백 시, 영속성 컨텍스트를 초기화하여(em.clear()) 잘못된 영속성 컨텍스트 사용을 예방한다.

엔티티 비교

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

영속성 컨텍스트를 통해 데이터 저장/조회 시 1차 캐시에 엔티티가 저장되고, 이를 통해 변경 감지 기능도 동작하고, DB를 통하지 않고 데이터를 바로 조히할 수 있다.

1차 캐시의 가장 큰 장점은 애플리케이션 수준의 반복 가능한 읽기다.

같은 영속성 컨텍스트에서 엔티티를 조회하면 아래 코드와 같이 항상 같은 엔티티 인스턴스를 반환한다.

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

Member member1 = em.find(Member.class, "1L");
Member member2 = em.find(Member.class, "1L");

assertTrue(member1 == member2); // 둘은 같은 인스턴스다.

영속성 컨텍스트가 같을 때 엔티티 비교

테스트, 서비스 클래스에 @Transactional이 있으면 이런 트랜잭션 범위와 영속성 컨텍스트 범위를 갖는다.

스크린샷 2023-11-10 오후 2.39.02.png
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:appConfig.xml")
@Transactional // 트랜잭션 안에서 테스트를 실행한다.
public class MemberServiceTest {
    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;

    @Test
    public void create() throws Exception {
        // Given
        Member member = new Member("kim");

        // When
        Long saveId = memberService.join(member);

        // Then
        Member findMember = memberRepository.findOne(saveId);
        assertTrue(member == findMember); // 참조값 비교
    }
}

@Transactional
public class MemberService {
    @Autowired
    MemberRepository memberRepository;

    public Long join(Member member) {
        memberRepository.save(member);
        return Long.valueOf(member.getId());
    }
}

@Repository
public class MemberRepository {
    @PersistenceContext
    EntityManager em;

    public void save(Member member) {
        em.persist(member);
    }

    public Member findOne(Long id) {
        return em.find(Member.class, id);
    }
}

위와 같이 테스트 클래스 위에 @Transactional을 붙이면 트랜잭션 범위는 아래 그림처럼 된다.

스크린샷 2023-11-10 오후 2.44.35.png

이렇게 트랜잭션을 먼저 시작하고 테스트 메서드를 실행한다. 따라서 테스트 메서드인 create()는 이미 트랜잭션 범위에 들어있고, 이 메서드가 끝나면 트랜잭션이 종료된다.

그러므로 create()에서 사용된 코드는 항상 같은 트랜잭션과 같은 영속성 컨텍스트에 접근한다.

그리고 위에 Then에서 사용한 코드를 보면,

Member findMember = memberRepository.findOne(saveId);
assertTrue(member == findMember); // 참조값 비교

저장한 회원과 회원 레퍼지토리에서 찾아온 엔티티가 완전히 같은 인스턴스이다. 이유는 같은 영속성 컨텍스트를 사용하기 때문이며, 엔티티를 비교할 때 3가지 조건을 모두 만족한다.

  • 동일성: == 비교 같음

  • 동등성: == equals() 비교 같음

  • 데이터베이스 동등성: @Id인 데이터베이스 식별자가 같음

참고로 테스트와 서비스에 모두 @Transactional이 있는데, 기본 전략은 먼저 시작된 트랜잭션이 있으면 그 트랜잭션을 그대로 이어 받아 사용하고 없으면 새로 시작한다.

그리고 테스트 클래스에 @Transactional 적용 시 트랜잭션을 커밋하지 않고, 트랜잭션을 강제로 롤백한다.

하지만 롤백 시 영속성 컨텍스트를 플러시하지 않아 어떤 SQL이 실행되는지 콘솔 로그에 남지 않는다.

영속성 컨텍스트가 다를 때 엔티티 비교

테스트 클래스에 @Transactional이 없고, 서비스에만 @Transactional이 있으면 아래 그림과 같이 트랜잭션 범위와 영속성 컨텍스트 범위를 갖는다.

스크린샷 2023-11-10 오후 2.53.50.png
@SpringBootTest
class MemberServiceTest {

    @Autowired
    private MemberService memberService;

    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void register() {
        Member member = new Member("jayon");

        Long saveId = memberService.join(member);

        Member findMember = memberRepository.findById(saveId).get();
        assertThat(findMember == member).isTrue();
    }
}

@Teansactional
public class MemberService {
    ...
}

@Repository
public class MemberRepository {
    ...
}

이때 register() 테스트는 실패한다.

스크린샷4 2023-11-10 오후 2 55 07

그림을 보면,

  1. 테스트 코드에서 memberService.join(member)를 호출해서 회원 가입을 시도하면 서비스 계층에서 트랜잭션이 시작되고 영속성 컨텍스트 1이 만들어진다.

  2. memberReposiotry에서 em.persist()를 호출해서 member 엔티티를 영속화한다.

  3. 서비스 계층이 끝날 때 트랜잭션이 커밋되면서 영속성 컨텍스트가 플러시된다. 이때 트랜잭션과 영속성 컨텍스트가 종료된다. 따라서 member 엔티티 인스턴스는 준영속 상태가 된다.

  4. 테스트 코드에서 memberRepository.findById(saveId)를 호출해서 저장한 엔티티를 조회하면 Repository 계층에서 새로운 트랜잭션이 시작되면서 새로운 영속성 컨텍스트 2가 생성된다.

  5. 저장된 회원을 조회하지만, 새로 생성된 영속성 컨텍스트 2에는 찾는 회원이 없다.

  6. 따라서 데이터베이스에서 회원을 찾아온다.

  7. 데이터베이스에서 조회된 회원 엔티티를 영속성 컨텍스트에 보관하고 반환한다.

  8. memberRepository.findById() 메서드가 끝나면서 트랜잭션이 종료되고 영속성 컨텍스트 2도 종료된다.

위 테스트 코드를 보면,

assertTrue(member == findMember);

있는데, 해당 코드는 실패한다.

memberfindMember는 각각 다른 영속성 컨텍스트에서 관리되었기 때문에 둘은 다른 인스턴스이다.

하지만, 같은 데이터베이스 로우를 가르키고 있기 때문에 사실상 같은 엔티티로 봐야한다.

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

그래서 비교하려면 동일성이 아닌 동등성 비교를 해야하며 이때 서로 pk 값이 같으면 같은 객체로 판단하게끔 equals()로 재정의하는 것이 좋다.

프록시 심화 주제

먼저 프록시는 원본 엔티티를 상속 받아 만들어져서 클라이언트는 엔티티가 프록시인지 원본인지 구분하지 않고 사용한다.

그래서 원본 엔티티를 사용하다가 지연 로딩을 하려고 프록시로 변경해도 클라이언트의 비즈니스 로직을 수정하지 않아도 된다.

그런데 프록시를 사용하는 방식이 기술적인 한계로 인하여 예상하지 못한 문제들이 발생하기도 하는데, 어떤 문제가 발생하고 이에 대한 해결 방식을 살펴보자.

영속성 컨텍스트와 프록시

일단 영속성 컨텍스트는 자신이 관리하는 영속 엔티티의 동일성을 보장한다.

그런데 프록시도 마찬가지로 보장하는지 알아보자.

Member refMember = em.getReference(Member.class, "member1");
Member findMember = em.find(Member.class, "member1");
//--> em.find() 의 결과는 member1 의 프록시 객체
Member findMember = em.find(Member.class, "member1");
Member refMember = em.getReference(Member.class, "member1");
//--> em.getReference() 의 결과는 member의 엔티티

영속성 컨텍스트에 원본 엔티티 또는 프록시 든간에 먼저 조회해 놓은 것을 이후에도 반환한다.

프록시 타입, 동등성 비교

  1. 프록시 타입 비교는 == 말고 instanceof를 써야한다.

  2. 프록시는 원본 엔티티를 상속 받아서 만들어지므로 프록시로 조회한 엔티티의 타입을 비교할 때는 getClass()를 통한 == 비교가 아니라 instanceof를 사용해야 한다.

Member refMember = em.getReference(Member.class, "member1");
Assert.assertFalse(Member.class == refMember.getClass()); // false
Assert.assertTrue(refMember instanceof Member); // true

여기서 refMember.getClass()를 하면 class jpabook.advanced.Member_$$_jvsteXXX 이다.

_$$_jvsteXXX가 붙어 있으면 프록시라는 의미이다.

따라서 Member.class == refMember.getClass()는 부모 클래스와 자식 클래스를 == 한 것이다.

동등성 비교는

  • 엔티티의 동등성 비교 시 비즈니스 키를 사용해 equals() 메서드를 오버라이딩하고 비교한다.

    → 이때 비교 대상이 원본 엔티티면 상관없지만, 프록시는 문제가 된다.

  • 프록시의 멤버 변수에 직접 접근하면 null이 변환된다. 따라서 getter를 사용해야 한다.

    → 프록시는 실제 데이터를 갖고 있찌 않기 때문에 null이 변환된다.

Member newMember = new Member("member1", "회원1");
Member refMember = em.getReference(Member.class, "member1");
Assert.assertTrue(newMember.equals(refMember));

상속관계와 프록시

프록시를 부모타입으로 조회하면 문제가 발생한다.

Book saveBook = new Book("jpabook", "kim");
em.persist(saveBook);
em.flush();
em.clear();

Item proxyItem = em.getReference(Item.class, saveBook.getId());

Assert.assertFalse(proxyItem.getClass == Book.class);
Assert.assertFalse(proxyItem instanceof Book);
Assert.assertTrue(proxyItem instanceof Item);

실제 조회된 엔티티는 Book이기에 Book 타입 인스턴스가 생성되고,

em.getReference()Item 타입을 대상으로 조회해서 Item 타입으로 만들어진다.

즉, 타입은 Item이고 Book을 참조한 상태이지만, 타입이 다르기 때문에 false 반환한다.

그래서 직접 다운 캐스팅해도 문제가 발생한다. → java.lang.ClassCastException 예외 발생

보통 프록시를 부모 타입으로 조회하는 문제는 주로 다형성을 다루는 도메인 모델에서 나타난다.

예를 들어서 OrderItem에서 Item을 지연 로딩으로 설정한 상황으로 가정하자.

Book book = new Book("jpabook", "kim");
em.persist(book);
em.flush(); em.clear();

OrderItem saveOrderItem = new OrderItem();
saveOrderItem.setItem(book);
em.persist(saveOrderItem);
em.flush(); em.clear();

OrderItem orderItem = em.find(OrderItem.class, saveOrderItem.getId());
Item item = orderItem.getItem();

Assert.assertFalse(item.getClass() == Book.class); // item은 지연 로딩으로 조회해서 프록시이다.
Assert.assertFalse(item instanceof Book);
Assert.assertTrue(item instanceof Item);

해결 방법

  1. JPQL로 대상 직접 조회

    가장 간단한 방법이다.

    처음 부터 자식 타입을 직접 조회하여 필요한 연산을 수행한다.

    대신에 다형성을 활용하지 못한다.

    Book jpqlBook = em.createQuery("select b from Book b where b.id = :bookId", Book.class)
                        .setParameter("bookId", item.getId())
                        .getSingleResult();
  2. 프록시 벗기기

    하이버네이트가 제공하는 기능을 이용하면 프록시에서 원본 엔티티를 가져올 수 있다.(unProxy())

    Item item = orderItem.getItem();
    Item unProxyItem = unProxy(item);
    Assert.assertTrue(unProxyItem instanceof Book);
    Assert.assertTrue(((Book)unProxyItem).getClass() == Book.class);
    Assert.assertTrue(item != unProxyItem);
    // 하이버네이트가 제공하는 프록시에서 원본 엔티티를 찾는 기능을 사용하는 메소드
    public static <T> T unProxy(Object entty) {
        if(entity instanceof HibernateProxy){
            entity = ((HibernateProxy) entity)
                        .getHibernateLazyInitializer()
                        .getImplementation();
        }
        return (T) entity;
    }

    하지만 이 방법은 프록시에서 원본 엔티티를 직접 꺼내기 때문에 프록시와 원본 엔티티의 동일성 비교가 실패한다는 문제점이 있다.

    그래서 item == unProxyItemfalse이다.

    이 방법을 사용할 때는 원본 엔티티가 꼭 필요한 곳에서 잠깐 사용하고, 다른 곳에서 사용되지 않도록 주의해야 한다. → 참고로 원본 엔티티의 값을 직접 변경해도 변경 감지 기능이 동작한다.

  3. 프록시 인터페이스 사용

    스크린샷5 2023-11-10 오후 3 22 53

    getTitle() 메서드를 지닌 인터페이스를 만들고, 이들을 하위 클래스에서 재정의하는 것이다.

    @Entity
    public class OrderItem {
        @Id @GeneratedValue
        private Long id;
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "ITEM_ID")
        private Item item;
        ...
        public void printItem() {
            System.out.println("TITLE=" + item.getTitle());
        }
    }
    OrderItem orderItem = em.find(OrderItem.class, saveOrderItem.getId());
    orderItem.printItem();

    Item의 구현체에 따라 각각 다른 getTitle() 메서드가 호출된다.

    인터페이스를 제공하고, 각각의 클래스가 자신에 맞는 기능을 구현하는 것이다. → 다형성을 활용하는 좋은 방법

    이후에 상품 타입이 추가되도 코드 변경을 안해도 된다.

  4. 비지터 패턴 사용

    스크린샷6 2023-11-10 오후 3 25 47

    Visitor를 받아 들이는 대상 클래스로 구성된다.

    Visitor를 정의하고 구현한다.

    public interface Visitor {
        void visit(Book book);
        void visit(Album album);
        void visit(Movie movie);
    }

    Visitor 구현

    public class PrintVisitor implements Visitor {
    
        @Override
        public void visit(Book book) {
            // 넘어오는 book은 원본 엔티티임.
            // 출력 로직
        }
    
        @Override
        public void visit(Album album) {...}
    
        @Override
        public void visit(Movie movie) {...}
    }
    
    public class TitleVisitor implements Visitor {
    
        private String title;
    
        public String getTitle() {
            return title;
        }
    
        @Override
        public void visit(Book book) {
            // 넘어오는 book은 원본 엔티티임.
            title = [book과 관련된 정보 출력];
        }
    
        @Override
        public void visit(Album album) {...}
    
        @Override
        public void visit(Movie movie) {...}
    }

    Visitor 대상 클래스

    ItemVisitor를 받아 들일 수 있도록 accept(visitor) 메서드를 추가한다.

    @Entity
    @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
    @DiscriminatorColumn(name = "DTYPE")
    public abstract class Item {
    
        // 필드
    
        public abstract void accept(Visitor visitor);
    }
    
    @Entity
    @DiscriminatorColumn(name = "B")
    public class Book extends Item {
    
        // 필드
    
        @Override
        public void accept(Visitor visitor) {
            visitor.visit(this); // this는 프록시가 아닌 원본이다.
        }
    }
    
    @Entity
    @DiscriminatorColumn(name = "A")
    public class Album extends Item {
    
        // 필드
    
        @Override
        public void accept(Visitor visitor) {
            visitor.visit(this);
        }
    }
    
    @Entity
    @DiscriminatorColumn(name = "M")
    public class Movie extends Item {
    
        // 필드
    
        @Override
        public void accept(Visitor visitor) {
            visitor.visit(this);
        }
    }

    각각의 자식 클래스는 부모에 정의한 accept(visitor) 메서드를 구현하고,

    구현 내용은 넘어온 visitorvitit 메서드를 호출하면서 자신(this)을 visit(this)의 파라미터로 넘긴다.

    이렇게 하여 실제 로직 처리를 visitor에 위임한다.

    비지터 패턴 실행

    동작 과정

    스크린샷7 2023-11-10 오후 3 30 17
    @Test
    public void 상속관계와_프록시_VisitorPattern() {
        ...
        OrderItem orderItem = em.find(OrderItem.class, orderItemId);
        Item item = orderItem.getItem();
        // PrintVisitor
        item.accept(new PrintVisitor());
    }
    1. item.accept() 호출하면서 파라미터로 PrintVisitor를 넘겨준다.

    2. item은 프록시이므로 먼저 프록시가 accept() 메서드를 받고, 원본 엔티티의 accept()를 실행한다.

    3. 원본 엔티티는 다음 코드를 실행해서 자신을 visitor에 파라미터에 넘겨준다.

    public void accept(Visitor visitor){
        visitor.visit(this); // this 는 프록시가 아닌 원본이다.
    }
    1. visitorPrintVisitor 타입이므로 PrintVisitor.visit(this) 메서드가 실행된다.

    2. thisBook 타입이므로 visit(Book book) 메서드가 실행된다.

    비지터 패턴의 장단점

    장점

    • 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있다.

    • instanceof나 타입 캐스팅 없이 코드를 구현할 수 있는 장점이 있다.

    • 알고리즘과 객체 구조를 분리해서 구조를 수정하지 않고, 새로운 동작을 추가할 수 있다.

    단점

    • 너무 복잡하고 더블 디스패치를 사용하기 때문에 이해하기 어렵다.

    • 객체 구조가 변경되면 모든 Visitor를 수정해야 한다.

성능 최적화

N+1 문제

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

즉시 로딩 했을때

em.find(): N+1 문제 발생 X

JPQL: N+1 문제 발생 O

  • em.find() 사용시에 조인을 사용한 한번의 SQL로 연관된 엔티티도 같이 조회한다.

  • JPQL은 JPA가 분석해 SQL을 만들고 사용한다. 이때는 즉시 로딩과 지연 로딩에 대해 전혀 신경쓰지 않고 JPQL만 사용해서 SQL을 생성한다.

    • 먼저 SELECT * FROM MEMBER 실행

    • 이후 SELECT * FROM ORDERS WHERE MEMBER_ID=1 쿼리를 추가로 날린다. → 회원과 연관된 주문 엔티티 조회를 위한 N개 SQL 발생

지연 로딩 했을때

JPQL로 조회한 지연 로딩 초기화: N+1 문제 발생 O

JPQL: N+1 문제 발생 X

지연 로딩으로 조회한 컬렉션을 사용할 시점에 N+1 문제가 발생한다.

문제 해결 방법: 페치 조인 사용

가장 일반적인 해결 방법으로, SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로 N+1 문제가 발생하지 않는다. 다만 일대다 관계에 대해 페치 조인을 하면, 외래 키가 N쪽에 있으므로 중볻된 Member 엔티티가 발생하므로 distinct를 사용해야 한다.

select distinct m from Member m join fetch m.orders // 중복 결과를 제거하기 위해 distinct 키워드 사용
// 실행된 SQL
SELECT M.*, O.* FROM MEMBER M
INNER JOIN ORDERS O ON M.ID=O.MEMBER_ID

문제 해결 방법: 하이버네이트(@BatchSize)

public class Member {

    @Id
    @GeneratedValue
    private Long id;

    @BatchSize(size = 5)
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();

    ...
}

위와 같이 @BatchSize를 사용하고 5로 주면 지연 로딩으로 설정했을때 지연 로딩 된 엔티티를 최초 사용하는 시점에 SELECT * FROM ORDERS WHERE MEMBER_ID IN (?, ?, ?, ?, ?) 이런 sql을 실행해 5건의 데이터를 미리 로딩해 뒀다가 6번째 데이터를 사용하면 다음 SQL을 실행한다.

즉시 로딩으로 설정된 엔티티는 조회 시점에 10건의 데이터를 모두 수행해야하기 때문에 위 쿼리가 2번 실행된다.

문제 해결 방법: 하이버네이트 @Fetch(FetchMode.SUBSELECT)

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

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    @Fetch(FetchMode.SUBSELECT)
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();

    ...
}

위와 같이 @Fetch를 사용하고 FetchModeSUBSELECT로 사용하면 연관된 엔티티를 조회할 대 서브 쿼리를 사용해서 N+1 문제를 해결한다.

select m from Member m where m.id > 10
// 실행된 SQL
SELECT O FROM ORDERS O
WHERE O.MEMBER_ID IN (
                SELECTR M.ID FROM MEMBER M
                WHERE M.ID > 10
                )

정리

즉시 로딩, 지연 로딩 중 추천하는 것은 즉시 로딩은 사용하지 않고, 지연 로딩만 사용하는 것이다.

즉시 로딩은 N+1 문제 + 비즈니스 로직에 따라 불필요한 엔티티도 로딩해야 하는 상황이 발생해 성능 최적화가 어렵다.

성능 최적화가 필요한 시점에 페치 조인을 고민해 보자. 만약 페치 조인을 사용하지 않고 N+1 문제를 해결해야 한다면 BatchSize, FetchMode.SUBSELECT, QueryDSL 등을 고려해보자.

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

  • @OneToOne, @ManyToOne: 기본 페치 전략은 즉시 로딩

  • @OneToMany, @ManyToMany: 기본 페치 전략은 지연 로딩

읽기 전용 쿼리의 성능 최적화

엔티티가 영속성 컨텍스트에서 관리되면 1차 캐시부터 변경 감지까지 얻을 수 있는 해택이 많지만,

변경 감지를 위해 스냅샷 인스턴스를 보관하므로 더 많은 메모리를 사용하는 단점이 있다.

그래서 엔티티를 읽기만 하고, 다른 용도로 사용하지 않을 때는 읽기 전용으로 엔티티를 조회하여 메모리 사용량을 최적화할 수 있다.

참고로 영속성 컨텍스트는 엔티티를 저장할 때, 컬렉션으로 또 다른 복사본을 저장하게 되는데, 이를 스냅샷이라고 한다.

select o from Order o

스칼라 타입으로 조회

엔티티가 아닌 스칼라 타입으로 모든 필드를 조회하면 영속성 컨텍스트가 결과를 관리하지 않는다.

select [o.id](http://o.id), [o.name](http://o.name), o.price from Order o

읽기 전용 쿼리 힌트 사용

하이버네이트 전용 힌트인 org.hibernate.readOnly를 사용하면 엔티티를 읽기 전용으로 조회할 수 있다.

읽기 전용이기에 영속성 컨텍스트는 스냅샷을 보관하지 않는다.

읽기 전용 트랜잭션 사용

@Transactional(readOnlt=true) 옵션을 주면, 스프링 프레임워크가 하이버네이트 세션의 플러시 모드를 MANNAL로 설정한다.

이렇게 하면 강제로 플러시를 호출하지 않는 한 플러시가 일어나지 않고, 트랜잭션을 커밋해도 영속성 컨텍스트를 플러시 하지 않는다.

따라서 엔티티의 등록, 수정, 삭제가 동작하지 않으며 플러시를 하더라도 스냅샷 비교와 같은 무거운 로직을 수행하지 않는다. → 성능 향상은 물론 트랜잭션을 시작했으므로 트랜잭션 시작, 로직 수행, 트랜잭션 커밋 과정은 일어나지만, 영속성 컨텍스트를 플러시하지 않는다.

하이버네이트 세션

JPA 엔티티 매니저를 하이버네이트로 구현한 구현체

Session session = em.unwrap(Session.class)

엔티티 매니저의 플러시 설정은 AUTO(기본값, 트랜잭션 커밋 or 쿼리 실행 시 플러시), COMMIT(커밋 발생 시 플러시) 모드만 존재 하이버네이트 세션의 설정에는 MANUAL 모드가 있다. → 강제로 플러시 호출하지 않으면 절대 플러시 발생 X

트랜잭션 밖에서 읽기

트랜잭션 없이 엔티티를 조회한다.(조회가 목적이라서 가능. JPA에서 데이터를 변경하려면 트랜잭션 필수)

@Transactional(propagation = Propagation.NOT_SUPPORTED) 옵션을 주면, 트랜잭션 없이 엔티티를 조회하게 된다.

트랜잭션을 사용하지 않으면 플러시가 일어나지 않고, 트랜잭션 자체가 없으므로 커밋할 일이 없다.

정리하자면,

  • 플러시를 작동하지 않도록하여 성능 향상

    • 읽기 전용 트랜잭션 사용

    • 트랜잭션 밖에서 읽기

  • 스냅샷을 저장하지 않도록 하여 메모리 공간 향상

    • 읽기 전용 쿼리 힌트 사용

배치 처리

수백만 건의 데이터를 배치 처리해야 하는 상황으로 가정한다면 일반적인 방식으로 엔티티를 계속 조회시 영속성 컨텍스트에 아주 많은 엔티티가 쌓이면서 메모리 부족 오류가 발생한다.

따라서 이런 배치 처리는 적절한 영속성 컨텍스트의 엔티티를 데이터베이스에 플러시하고, 초기화 해준다.

만약 2차 캐시를 사용하고 있다면 2차 캐시에 엔티티를 보관하지 않도록 주의해야 한다.

JPA 등록 배치

tx.begin();
for(int i=0; i<100000; ++i){
    Product product = new Product("item" + i, 10000);
    em.persist(product);
    // 100 건마다 플러시와 영속성 컨텍스트 초기화
    if(i % 100 == 0) {
        em.flush();
        em.clear();
    }
}
tx.commit();
em.close();

JPA 수정 배치

수정 배치 처리는 아주 많은 데이터를 조회해서 수정한다.

이때 수많은 데이터를 한 번에 메모리에 올려둘 수 없어서 3가지 방법을 주로 사용한다.

  • 페이징 처리

  • 커서 방식

  • 하이버네이트 무상태 세션

  1. 페이징 처리

    데이터베이스 페이징 기능을 사용

    한 번에 조회할 페이지 수를 지정하고, 페이지 단위마다 영속성 컨텍스트를 플러시하고 초기화한다.

    tx.begin();
    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)
                                        .setMaxResult(pageSize)
                                        .getResultList();
        // 비즈니스 로직 실행
        for(Product product : resultList){
            product.setPrice(product.getPrice() + 100);
        }
        em.flush();
        em.clear();
    }
    tx.commit();
    em.close();
  2. 커서 방식

    하이버네이트는 scroll 이라는 이름으로 JDBC 커서를 지원한다.

    → 하이버네이트 전용 기능이므로 먼저 em.unwrap() 메서드를 사용해 하이버네이트 세션을 구한다.

    Session session = em.unwrap(Session.class);
    tx.begin();
    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(0);
        p.setPrice(p.getPrice() + 100);
        count++;
        if(count % 100 == 0) {
            session.flush(); // 플러시
            session.clear(); // 영속성 컨텍스트 초기화
        }
    }
    tx.commit();
    session.close();
  3. 하이버네이트 무상태 세션

    무상태 세션은 영속성 컨텍스트를 만들지 않고, 2차 캐시도 사용하지 않는다.

    영속성 컨텍스트가 없어서 엔티티 수정 시 무상태 세션이 제공하는 update() 메서드를 직접 호출해야 한다.

    Session session = em.unwrap(Session.class);
    tx.begin();
    ScrollableResults scroll = session.createQuery("select p from Product p")
                                        .scroll();
    while(scroll.next()){
        Product p = (Product) scroll.get(0);
        p.setPrice(p.getPrice() + 100);
        session.update(p); // 직접 호출
    }
    tx.commit();
    session.close();

SQL 쿼리 힌트 사용

  • 데이터베이스 벤더에게 제공하는 SQL 힌트를 JPA는 제공하지 않는다.

    • 그래서 SQL 힌트를 사용하고 싶다면 하이버네이트를 직접 사용해야 한다.

addQueryHint() 메서드 사용

Session session = em.unwrap(Session.class); // 하이버네이트 직접 사용
List<Member> list = session.createQuery("select m from Member m")
                            .addQueryHint("FULL (MEMBER)") // SQL HINT 추가
                            .list();
// 실행된 SQL
select
    /* + FULL(MEMBER) */ m.id, n.name
from
    Member m

SQL 힌트를 사용하려면 각 방언에서 다음 메서드를 오버라이딩해서 기능을 구현해야 한다.

public String getQueryHintString(String query, List<String> hints){
    return query;
}

트랜잭션을 지원하는 쓰기 지연과 성능 최적화

트랜잭션을 지원하는 쓰기 지연과 JDBC 배치

네트워크 호출 한 번은 단순한 메서드를 수만 번 호출하는 것보다 더 큰 비용이 든다.

그래서 N개의 INSERT SQL 과 1번의 커밋으로 데이터와 통신할 때, 여러번 INSERT SQL을 모아서 한번에 데이터베이스로 보내면서 최적화할 수 있다.

이때 JDBC가 제공하는 SQL 패치 기능을 사용할 수 있다.

  • 이 기능을 사용하려면 코드의 많은 부분을 수정해야 한다.

  • 특히 비즈니스 로직이 복잡하게 얽혀 있는 곳에서 사용하기 쉽지 않고, 적용해도 코드가 상당히 지저분해진다.

따라서 보통은 수백 수천 건 이상의 데이터를 변경하는 특수한 상황에 SQL 배치 기능을 사용한다.

참고로 SQL 배치 최적화 전략은 구현체마다 조금씩 다르다.

데이터를 등록, 수정, 삭제 시 하이버네이트는 SQL 배치 기능을 사용한다.

만약에 hibernate.jdbc.batch_size 속성의 값을 50으로 주면 최대 50건씩 모아서 SQL 배치를 실행한다.

이때 같은 SQL일 때만 유효하고, 만약 중간에 다른 처리가 들어가면 SQL 배치를 다시 시작한다.

em.persist(new Member()); // 1
em.persist(new Member()); // 2
em.persist(new Member()); // 3
em.persist(new Member()); // 4
em.persist(new Child()); //5, 다른 연산
em.persist(new Member()); // 6
em.persist(new Member()); // 7

1, 2, 3, 4를 모아서 하나의 SQL 배치 실행.

5를 한 번 실행.

6, 7을 모아서 실행 → 총 3번 SQL 배치 실행

참고로 엔티티가 영속 상태가 되려면 식별자가 꼭 필요한데, 만약 IDENTITY 전략으로 식별자를 생성하면 데이터베이스에 엔티티를 저장해야 식별자를 구할 수 있다.

따라서 em.persist() 호출 즉시 INSERT SQL이 데이터베이스에 전달되어 쓰기 지연을 활용한 성능 최적화를 할 수 없다.

트랜잭션을 지원하는 쓰기 지연과 애플리케이션 확장성

트랜잭션을 지원하는 쓰기 지연과 변경 감지 기능 덕분에 성능과 개발 편의성을 얻을 수 있는데,

진짜 장점은 데이터베이스 테이블 rowlock이 걸리는 시간을 최소화 한다는 점이다.

즉, 이 기능은 트랜잭션을 커밋해 영속성 컨텍스트를 플러시하기 전까지 데이터베이스 rowlock을 걸지 않는다.

update(memberA); // UPDATE SQL A
비즈니스 로직A(); // UPDATE SQL ...
비즈니스 로직B(); // INSERT SQL ...
commit();

트랜잭션 격리 수준에 따라 다르지만 보통 많이 사용하는 커밋된 읽기 격리 수준이나 그 이상에서는 데이터베이스에 현재 수정 중인 데이터를 수정하려는 다른 트랜잭션은 락이 풀릴 때 까지 대기한다.

commit() 호출할 때 UPDATE SQL을 실행하고, 바로 데이터베이스 트랜잭션을 커밋한다.

→ 데이터베이스에 락이 걸리는 시간 최소화

사용자가 증가하면 애플리케이션 서버를 증설하면 되지만, 데이터베이스 락은 애플리케이션 서버 증설만으로 해결이 불가능하다.

오히려 서버 증설로 트랜잭션이 증가할수록 더 많은 데이터베이스 락이 걸린다.

JPA의 쓰기 지연 기능은 데이터베이스 락이 걸리는 시간을 최소화 해주기 때문에 동시에 더 많은 트랜잭션을 처리할 수 있는게 장점이다.

만약에 JPA를 안쓰고, SQL을 직접 다룰 경우

update(memberA)를 호출할 때 UPDATE SQL을 실행하면서 데이터베이스 테이블 로우에 lock을 건다.

lock비즈니스 로직A(), 비즈니스 로직B() 를 모두 수행하고, commit()을 호출할 때까지 유지시킨다.

정리

  1. JPA 예외는 트랜잭션 롤백을 예외로 표시하냐 안하냐로 나뉘는데, 트랜잭션을 롤백하는 예외는 심각한 예외이기때문에 트랜잭션을 강제로 커밋해도 커밋되지 않고 롤백된다.

  2. 스프링 프레임워크는 JPA의 예외를 스프링 프레임워크가 추상화한 예외로 변환해준다.

  3. 영속성 컨텍스트의 엔티티 비교시 같은 걸 썼으면 동일성 비교 가능, 다르면 불가능

    → 그래서 자주 변하지 않는 비즈니스 키를 사용한 동등성 비교를 해야한다.

  4. 프록시를 사용하는 클라이언트는 조회한 엔티티가 프록시인지 원본 엔티티인지 구분하지 않고, 사용할 수 있어야 한다.

    → 왜? 프록시는 기술적 한계가 있으니 한계점을 인식하고 사용해야 한다.

  5. JPA 사용시 N+1 문제를 가장 생각해야한다. 주로 페치 조인으로 해결한다.

  6. 엔티티 읽기 전용 조회를 하면 스냅샷을 유지할 필요가 없고, 영속성 컨텍스트를 플러시하지 않아도 된다.

  7. 대량의 엔티티를 배치 처리하려면 적절한 시점에 꼭 플러시 호출 및 영속성 컨텍스트를 초기화해야 한다.

  8. JPA는 SQL 쿼리 힌트를 지원 X, 하이버네이트 구현체는 SQL 쿼리 힌트 제공

  9. 트랜잭션을 지원하는 쓰기 지연 덕분에 SQL 배치 기능을 사용할 수 있다.

참고 자료

Last updated