BOOK 3 - 자바 ORM 표준 JPA 프로그래밍(14)

고급 주제와 성능 최적화

예외 처리

JPA 표준 예외 정리

  • JPA 표준 예외들은 PersistenceException 의 자식 클래스다.
    • 이 예외 클래스는 RuntimeException 의 자식 클래스다.
  • JPA 표준 예외는 크게 2가지로 나눌 수 있다.
    • 트랜잭션 롤백을 표시하는 예외
    • 트랜잭션 롤백을 표시하지 않는 예외
  • 서비스 계층에서 JPA의 예외를 직접 사용하면 JPA에 의존하게 되기 때문에 스프링 프레임워크는 Data Access Layer에 대한 예외를 추상화해서 제공한다.

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

  • 심각한 예외이므로 복구하면 안된다.
  • 예외가 발생하면 트랜잭션을 강제로 커밋해도 커밋되지 않고 RollbackException 예외가 발생한다.
예외 설명 스프링 변환 예외
EntityExistsException EntityManager.persist() 호출 시 이미 같은 엔티티가 있으면 발생한다. DataIntegrityViolationException
EntityNotFoundException EntityManager.getReference() 호출 후 실제 사용시 엔티티가 존재하지 않으면 발생한다. JpaObjectRetrievalFailureException
OptimisticLockException Optimistic Lock 충돌 시 발생한다. JpaOptimisticLockingFailureException
PessimisticLockException Pessimistic Lock 충돌 시 발생한다. PessimisticLockingFailureException
RollbackException EntityTransaction.commit() 실패 시 발생한다. TransactionSystemException
TransactionRequiredException 트랜잭션 없이 엔티티를 변경할 때 주로 발생한다. InvalidDataAccessApiUsageException

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

  • 심각한 예외는 아니므로 개발자가 트랜잭션을 롤백할지 커밋할지 판단하면 된다.
예외 설명 스프링 변환 예외
NoResultException Query.getSingleResult() 호출 시 결과가 하나도 없을 때 발생한다. EmptyResultDataAccessException
NonUniqueResultException Query.getSingleResult() 호출 시 결과가 둘 이상일 때 발생한다. IncorrectResultSizeDataAccessException
LockTimeoutException Pessimistic Lock에서 시간 초과 시 발생한다. CannotAcquireLockException
QueryTimeoutException 쿼리 실행 시간 초과 시 발생한다. QueryTimeoutException

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

  • JPA 예외를 스프링 프레임워크가 제공하는 추상화된 예외로 변경하려면 PersistenceExceptionTranslationPostProcessor 를 스프링 빈으로 등록하면 된다.
  • @Repository 를 사용하는 곳에 예외 변환 AOP를 적용해서 JPA 예외를 스프링 프레임워크가 추상화한 예외로 변환해준다.
@Bean
public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
  return new PersistenceExceptionTranslationPostProcessor();
}
  • 스프링부트의 경우 application.properties 에 다음 코드를 추가하면 된다.
spring.dao.exceptiontranslation.enabled=true
  • Repository에서 쿼리 메소드 수행 중에 JPA 예외가 발생하면 AOP 인터셉터가 동작해서 해당 예외를 스프링 프레임워크가 추상화한 예외로 변환해서 반환한다.

트랜잭션 롤백 시 주의사항

  • 트랜잭션을 롤백하는 것은 데이터베이스의 반영사항만 롤백하는 것이지 수정한 자바 객체까지 원상태로 복구해주지는 않는다.
    • 즉, 데이터베이스의 데이터는 원래대로 복구되지만 객체는 수정된 상태로 영속성 컨텍스트에 남아있다.
  • 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용하는 것은 위험하다.
    • 새로운 영속성 컨텍스트를 생성해서 사용하거나 EntityManager.clear() 를 호출해서 초기화한 다음에 사용해야 한다.
  • 트랜잭션당 영속성 컨텍스트 전략은 문제가 발생하면 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트도 함께 종료하므로 문제가 발생하지 않는다.
  • OSIV 처럼 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용할 때는 문제가 있을 수 있다.
    • 스프링 프레임워크는 트랜잭션 롤백시 영속성 컨텍스트를 초기화해서 잘못된 영속성 컨텍스트를 사용하는 문제를 예방한다.

엔티티 비교

  • 영속성 컨텍스트 내부에는 엔티티 인스턴스를 보관하기 위한 1차 캐시가 있다.
    • 1차 캐시는 영속성 컨텍스트와 생명 주기를 같이 한다.
  • 1차 캐시의 장점은 어플리케이션 수준의 반복 가능한 읽기이다.
    • 같은 영속성 컨텍스트에서 엔티티를 조회하면 항상 주소값이 같은 엔티티 인스턴스를 반환한다.

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

@RunWith(SpringJUnit4ClassRunner.class)
@Transactional
public class MemberServiceTest {
  @Autowired
  MemberService memberService;
  @Autowired
  MemberRepository memberRepository;
  
  @Test
  public void 회원가입() throws Exception {
    // given
    Member member = new Member("kim");
    
    // when
    Long saveId = memberService.join(member);
    
    // then
    Member findMember = memberRepository.findOne(saveId);
    assertTrue(member == findMember);
  }
}
  • 테스트 클래스에 @Transactional 이 선언되어 있으면 트랜잭션을 먼저 시작하고 테스트 메소드를 실행한다.
    • 테스트의 범위와 트랜잭션의 범위가 같다.
    • 회원가입() 에서 사용된 코드는 항상 같은 트랜잭션과 같은 영속성 컨텍스트에 접근한다.
  • 위 코드에서 memberService.join() 으로 저장한 회원과 memberRepository.findOne() 으로 조회한 엔티티가 완전히 같은 인스턴스이다.
    • 즉, member == findMember 가 참이다.
    • 같은 트랜잭션 범위에서 같은 영속성 컨텍스트를 사용하기 때문이다.
  • 영속성 컨텍스트가 같으면 엔티티를 비교할 때 다음 3가지 조건을 모두 만족한다.
    • 동일성 : == 비교 성공
    • 동등성 : equals() 비교 성공
    • 데이터베이스 동등성 : @Id 데이터베이스 식별자 비교 성공

테스트 클래스에 @Transactional을 적용하면 테스트가 끝날 때 트랜잭션을 커밋하지 않고 강제로 롤백한다.

그래야 데이터베이스에 영향을 주지 않고 테스트를 반복해서 할 수 있기 때문이다.

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

  • 테스트 클래스에 @Transactional 이 없고 서비스에만 @Transactional 이 있으면 하나의 테스트 메소드 안에서 여러 개의 트랜잭션이 실행될 수 있다.
@RunWith(SpringJUnit4ClassRunner.class)
public class MemberServiceTest {
  @Autowired
  MemberService memberService;
  @Autowired
  MemberRepository memberRepository;
  
  @Test
  public void 회원가입() 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 member.getId();
  }
}

@Repository
@Transactional		// 테스트 예제를 위해 추가했다.
pulic class MemberRepository {
  @PersistenceContext
  EntityManager em;
  
  public void save(Member member) {
    em.persite(member);
  }
  
  public Member findOne(Long id) {
    return em.find(Member.class, id);
  }
}
  1. memberService.join() 을 호출하면 서비스 계층에서 트랜잭션이 시작되고 영속성 컨텍스트1 이 만들어진다.
  2. MemberRepository 에서 em.persist() 호출해서 엔티티를 영속화한다.
  3. 서비스 계층이 끝날 때 트랜잭션이 커밋되면서 영속성 컨텍스트1 이 플러시된다.
    • 트랜잭션과 영속성 컨텍스트1 이 종료된다.
    • member 엔티티 인스턴스는 준영속 상태가 된다.
  4. 테스트 코드에서 membeRepository.findOne() 을 호출해서 저장한 엔티티를 조회하면 리포지토리 계층에서 새로운 트랜잭션이 시작되면서 새로운 영속성 컨텍스트2 가 생성된다.
  5. 새로 생성된 영속성 컨텍스트2 에는 찾는 회원이 존재하지 않으므로 데이터베이스에서 회원을 찾아온다.
  6. 데이터베이스에서 조회된 회원 엔티티를 영속성 컨텍스트2 에 보관하고 반환한다.
  7. membeRepository.findOne() 메소드가 끝나면서 트랜잭션이 종료되고 영속성 컨텍스트2 도 종료된다.
  • memberfindMember 는 각각 다른 영속성 컨텍스트에서 관리되었기 때문에 둘은 다른 인스턴스다.
    • member == findMember 는 false다.
  • 하지만 memberfindMember 는 인스턴스는 다르지만 같은 데이터베이스 row를 가리키고 있기 때문에 사실상 같은 엔티티이다.

  • 영속성 컨텍스트가 다를 때 엔티티 비교는 다음과 같다.
    • 동일성 : == 비교 실패
    • 동등성 : equals 비교 성공 (단, equals 메소드가 구현되어 있을 때)
    • 데이터베이스 동등성 : @Id 데이터베이스 식별자 비교 성공
  • 동등성 비교를 위해 equals() 를 오버라이드할 때는 비즈니스 키가 되는 필드들을 선택하여 비교한다.
    • 보통 중복되지 않고 거의 변하지 않는 데이터베이스 기본 키 후보들이 좋은 대상이다.

프록시 심화 주제

  • 프록시는 원본 엔티티를 상속받아 만들어지므로 엔티티를 사용하는 클라이언트는 엔티티가 프록시인지 원본인지 구분하지 않고 사용할 수 있다.

영속성 컨텍스트와 프록시

@Test
public void 영속성컨텍스트와_프록시() {
  Member newMember = new Member("member1");
  em.persist(newMember);
  em.flush();
  em.clear();
  
  Member refMember = em.getReference(Member.class, "member1");
  Member findMember = em.find(Member.class, "member1");
  
  Assert.assertTrue(refMember == findMember);
}
  • 먼저 em.getReference() 를 사용해서 프록시를 조회하고, 다음으로 em.find() 를 사용해서 엔티티를 조회했다.
  • 영속성 컨텍스트는 이미 프록시로 조회된 엔티티에 대해서 같은 엔티티를 찾는 요청이 오면 원본 엔티티가 아닌 처음 조회된 프록시를 반환한다.
    • 위 코드에서도 member1 엔티티를 프록시로 처음 조회했기 때문에 이후에 em.find() 를 사용해서 같은 엔티티를 조회해도 영속성 컨텍스트는 원본이 아닌 프록시를 반환한다.
    • 따라서 refMember == findMember 는 true다.
  • 반대로 원본 엔티티를 먼저 조회하고 다음으로 프록시를 조회하는 경우를 보자.
@Test
public void 영속성컨텍스트와_프록시2() {
  Member newMember = new Member("member1");
  em.persist(newMember);
  em.flush();
  em.clear();
  
  Member findMember = em.find(Member.class, "member1");
  Member refMember = em.getReference(Member.class, "member1");
  
  Assert.assertTrue(refMember == findMember);
}
  • 원본 엔티티를 먼저 조회하면 영속성 컨텍스트는 원본 엔티티를 이미 가지고 있으므로 프록시를 반환할 이유가 없다.
  • 따라서 em.getReference() 를 호출해도 프록시가 아닌 원본 엔티티를 반환한다.
    • refMember == findMember 는 역시 true다.

프록시 타입 비교

  • 프록시는 원본 엔티티를 상속 받아서 만들어지므로 프록시로 조회한 엔티티의 타입을 비교할 때는 == 비교를 하면 안되고 대신에 instanceof 를 사용해야 한다.
@Test
public void 프록시_타입비교() {
  Member newMember = new Member("member1");
  em.persist(newMember);
  em.flush();
  em.clear();
  
  Member refMember = em.getReference(Member.class, "member1");
  
  Assert.assertFalse(Member.class == refMember.getClass());
  Assert.assertTrue(refMember instanceof Member);
}

프록시 동등성 비교

  • 엔티티의 동등성을 비교하려면 비즈니스 키를 사용해서 equals() 메소드를 오버라이딩하면 된다.

  • 프록시와 엔티티의 동등성을 비교할 때는 몇가지 유의할 점이 있다.

    • if(this.getClass() != obj.getClass()) 와 같이 클래스 타입을 비교하면 프록시는 원본을 상속받은 자식타입이므로 결과가 false 가 나오게 된다.

      • 프록시의 타입을 비교할 때는 if(!(obj instance of Member)) 와 같이 변경해야 한다.
    • 프록시는 실제 데이터를 가지고 있지 않기 때문에 프록시의 멤버변수에 직접 접근하여 값을 비교하면 false 가 반환된다.

      if(name != null ? !name.equals(member.name) : member.name != null) return false;
      
      • 프록시의 데이터를 조회할 때는 Getter를 사용해서 값을 비교하도록 해야한다.
      if(name != null ? !name.equals(member.getName()) : member.getName() != null) return false;
      
  • 프록시의 동등성을 비교할 때는 다음 사항을 주의해야 한다.

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

상속관계와 프록시

  • Item 클래스를 Book 클래스가 상속한 경우 부모 타입으로 프록시를 조회했을 때 다음과 같은 문제가 발생한다.
@Test
public void 부모타입으로_프록시조회() {
  Book saveBook = new Book();
  saveBook.setName("book1");
  em.persiste(saveBook);
  
  em.flush();
  em.clear();
  
  Item proxyItem = em.getReference(Item.class, saveBook.getId());
  
  if(proxyItem instanceof Book) {
    Book book = (Book) proxyItem;
    System.out.println("Book name = " + book.getName());
  }
  
  Assert.assertFalse(proxyItem.getClass() == Book.class);
  Assert.assertFalse(proxyItem instanceof Book);
  Assert.assertTrue(proxyItem instanceof Item);
}
  • 위 코드에서 em.getReference() 를 사용해서 Item 엔티티를 프록시로 조회했다.
    • Item 엔티티를 대상으로 조회했으므로 proxyItemItem 타입을 기반으로 만들어진다.
    • 이 프록시 클래스는 원본 엔티티로 Book 엔티티를 참조한다.
  • proxyItemItem 타입을 기반으로 한 Item$Proxy 타입이므로 Book 타입과는 관계가 없다.
    • 따라서 proxyItem instanceof Book 은 false 이다.
  • 프록시를 부모 타입으로 조회하면 부모의 타입을 기반으로 프록시가 생성된다.
    • instanceof 연산을 사용할 수 없다.
    • 자식 타입으로 다운 캐스팅을 할 수 없다.
  • 프록시를 부모 타입으로 조회하는 문제는 다형성을 다루는 도메인 모델에서 나타난다.

    @Entity
    public class OrderItem {
      ...
      @ManyToOne(fetch=FetchType.LAZY)
      @JoinColumn(name="ITEM_ID")
      private Item item;
      ...
    }
    
    • OrderItem.item 을 지연로딩으로 설정했기 때문에 orderItem.getItem() 을 호출했을 때 Item 엔티티 기반의 프록시 객체가 반환된다.

      • 이 프록시는 부모 타입인 Item 타입을 기반으로 생성되었기 때문에 자식 타입 엔티티로 다운캐스팅 할 수 없다.

JPQL로 대상 직접 조회

  • 처음부터 자식 타입을 직접 조회해서 필요한 연산을 하면 된다.
    • 대신 다형성을 활용할 수 없다.
Book book = em.createQuery("select b from Book b where b.id = :bookId", Book.class)
  .setParameter("bookId", item.getId())
  .getSingleResult();

프록시 벗기기

  • 하이버네이트가 제공하는 기능을 사용하면 프록시에서 원본 엔티티를 가져올 수 있다.
...
Item item = orderItem.getItem();
Item unProxyItem = unProxy(item);

if(unProxyItem instanceof Book) {
  Book book = (Book) unProxyItem;
  System.out.println("Book name : " + book.getName());
}

Assert.assertTrue(item != unProxyItem);
  • unProxy() : 하이버네이트가 제공하는 프록시에서 원본 엔티티를 찾는 기능을 사용하는 메소드
  • 영속성 컨텍스트는 영속 엔티티의 동일성을 보장하기 위해 한번 프록시로 노출한 엔티티는 계속 프록시로 노출한다.
    • 그러나 이 방법은 프록시에서 원본 엔티티를 직접 꺼내기 때문에 프록시와 원본 엔티티의 동일성 비교가 실패한다는 문제점이 있다.

기능을 위한 별도의 인터페이스 제공

public interface TitleView {
  String getTitle();
}

@Entity
@Inheritance(strategy=InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name="DTYPE")
public abstract Item implements TitleView {
  @Id
  @GeneratedValue
  @Column(name="ITEM_ID")
  private Long id;
  
  private String name;
  private int pricte;
  private int stockQuantity;
 
  // Getter, Setter
  ...
}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
  private Strint author;
  private String isbn;
  
  @Override
  public String getTitle() {
    return "name = " + getName() + " author = " + author;
  }
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
  private String director;
  private String actor;
  
  @Override
  public String getTitle() {
    return "name = " + getName() + " director = " + director;
  }
}
  • TitleView 라는 공통 인터페이스를 만들고, 자식 클래스들에서 인터페이스의 getTitle() 을 오버라이드하여 구현했다.
OrderItem orderItem = em.find(OrderItem.class, orderItemId);
orderItem.getItem().getTitle();
  • 위 코드의 실행 결과는 조회된 Item 의 구현체에 따라 각기 다른 getTitle() 메소드가 호출되어 출력된다.
  • 이 방법을 사용할 때는 프록시의 대상이 되는 타입에 인터페이스를 적용해야 한다.
  • 클라이언트 입장에서 대상 객체가 프로시인지 아닌지를 고민하지 않아도 되는 장점이 있다.

비지터 패턴 사용

  • Visitor pattern을 사용해서 상속관계와 프록시 문제를 해결할 수 있다.
  • Visitor pattern은 VisitorVisitor 를 받아들이는 대상 클래스로 구성된다.
    • Itemaccept() 메소드를 사용해서 Visitor 를 받아들이고, 실제 로직은 Visitor 가 처리한다.
public interface Visitor {
  void visit(Book book);
  void visit(Album album);
  void visit(Movie movie);
}
  • Visitor 에는 visit() 라는 메소드를 정의하고, 모든 대상 클래스를 받아들이도록 작성한다.
Visitor 구현
public class PrintVisitor implements Visitor {
  @Override
  public void visit(Book book) {
    System.out.println("[Name : " + book.getName() + ", Author : " + book.getAuthor() + "]");
  }
  
  @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) {
    title = "name = " + getName() + " author = " + author;
  }
  
  @Override
  public void visit(Album album) { ... }
  @Override
  public void visit(Movie movie) { ... }
}
대상 클래스 작성
@Entity
@Inheritance(strategy=InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name="DTYPE")
public abstract Item {
  @Id
  @GeneratedValue
  @Column(name="ITEM_ID")
  private Long id;
  
  private String name;
  private int pricte;
  private int stockQuantity;
 
 	public abstract void accept(Visitor visitor);
}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
  private Strint author;
  private String isbn;
  
  @Override
  public void accept(Visitor visitor) {
    visitor.visit(this);
  }
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
  private String director;
  private String actor;
  
  @Override
  public void accept(Visitor visitor) {
    visitor.visit(this);
  }
}
  • 각각의 자식 클래스들은 부모에 정의한 accept() 메소드를 오버라이드해서 구현한다.
    • 단순히 파라미터로 넘어온 Visitorvisit() 메소드에 자기 자신의 인스턴스(this)를 넘겨주기면 하면 된다.
    • 이렇게 실제 로직 처리를 Visitor 에 위임한다.
비지터 패턴 실행
@Test
public void 상속관계와_프록시_비지터패턴() {
  ...
  OrderItem orderItem = em.find(OrderItem.class, orderItemId);
  Item item = orderItem.getItem();
  
  item.accept(new PrintVisitor());
}
  • item.accept() 메소드를 호출하면서 파라미터로 Visitor 구현 클래스를 생성해서 넘겨주었다.
  • item 은 프록시이므로 먼저 ProxyItemaccept() 메소드를 받고, 원본 엔티티의 accept() 를 실행한다.
    • 즉, Visitor 구현 클래스의 visit() 메소드의 파라미터로 전달되는 엔티티는 프록시가 아니라 원본 엔티티이다.
비지터 패턴 정리
  • 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있다.
  • instanceof 와 타입 캐스팅 없이 코드를 구현할 수 있다.
  • 알고리즘과 객체 구조를 분리해서 구조를 수정하지 않고 새로운 동작을 추가할 수 있다.
  • 객체 구조가 변경되면 모든 Visitor를 수정해야 하는 단점이 있다.

성능 최적화

N+1 문제

  • Member - Order 의 일대다 양방향 관계를 가정한다.

즉시로딩과 N+1

em.find() 로 조회
  • 특정 회원 하나를 em.find() 로 조회하면 즉시로딩으로 설정한 주문 정보도 함께 조회한다.

  • 실행되는 SQL은 다음과 같다.

    SELECT m.*, o.*
    FROM MEMBER m
    OUTER JOIN ORDERS o ON m.ID = o.MEMBER_ID
    WHERE m.MEMBER_ID = ?
    
    • 조인을 사용해서 한번의 SQL로 회원과 주문정보를 함께 조회한다.
JPQL로 조회
  • JPQL을 작성해서 실행하면 JPA는 이것을 분석해서 SQL을 생성한다.

    • 즉시 로딩과 지연 로딩은 신경쓰지 않고 JPQL만 사용해서 SQL을 생성한다.
  • 즉, select m from Member m 을 실행하면 다음과 같은 SQL이 실행된다.

    SELECT * FROM MEMBER
    
    • SQL의 실행 결과로 먼저 회원 엔티티를 어플리케이션에 로딩한다.
    • 회원 엔티티와 연관된 주문 컬렉션이 즉시 로딩으로 설정되어 있으므로 JPA는 주문 컬렉션을 즉시 로딩하기 위해 다음 SQL을 추가로 실행한다.
    SELECT * FROM ORDERS WHERE MEMBER_ID = ?
    
    • 조회된 회원이 하나면 한번만 더 실행되지만, 조회된 회원이 N명이면 위 SQL이 N번 더 실행된다.
  • 이처럼 처음 실행한 SQL의 결과 수만큼 즉시 로딩을 위해 추가로 SQL을 실행하는 것을 N+1 문제라 한다.

지연로딩과 N+1

  • Member.orders 에 지연 로딩을 설정하면 비즈니스 로직에서 주문 컬렉션을 실제 사용할 때 지연 로딩이 발생한다.
List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();
firstMember = members.get(0);
firstMember.getOrders().size();		// 지연 로딩 초기화
  • members.get(0) 로 회원 하나만 조회해서 사용했기 때문에 지연 로딩시 실행되는 SQL은 다음과 같다.

    SELECT * FROM ORDERS WHERE MEMBER_ID = ?
    
  • 그러나 조회한 모든 회원에 대해 연관된 주문 컬렉션을 사용하면 N+1 문제가 그대로 발생한다.

for(Member member : members) {
  System.out.println("member = " + member.getOrders().size());
}
  • 위 코드를 실행했을 때 주문 컬렉션을 초기화하는 수만큼 위 SQL이 실행된다.
    • 즉, 조회된 회원의 수만큼 SQL이 실행된다.

페치 조인 사용

  • N+1 문제를 해결하는 가장 일반적인 방법은 페치 조인을 사용하는 것이다.
  • 페치 조인은 SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로 N+1 문제가 발생하지 않는다.
select m from Member m join fetch m.orders
SELECT m.*, o.* 
FROM MEMBER m
INNER JOIN ORDERS o ON m.ID = o.MEMBER_ID

하이버네이트 @BatchSize

  • @BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size 만큼 SQL의 IN 절을 사용해서 조회한다.
    • 예를 들어, 조회한 회원이 10명인데 size = 5 로 지정했다면 2번의 SQL만 추가로 실행한다.
@Entity
public class Member {
  ...
  @BatchSize(size = 5)
  @OneToMany(mappedBy="member", fetch=FetchType.EAGER)
  private List<Order> orders = new ArrayList<Order>();
}
  • 즉시 로딩으로 설정하면 조회 시점에 size 만큼 묶어 IN 절을 사용해 연관된 엔티티를 조회한다.

  • 지연 로딩으로 설정하면 지연 로딩된 엔티티를 최초 사용하는 시점에 다음 SQL을 실행해서 5건의 데이터를 미리 로딩해둔다.

    • 6번째 데이터를 사용하면 다음 SQL을 추가로 실행한다.
    SELECT * FROM ORDERS
    WHERE MEMBER_ID IN (?, ?, ?, ?, ?)
    

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

  • @Fetch 어노테이션에 FetchModeSUBSELECT 로 사용하면 연관된 엔티티를 조회할 때 서브 쿼리를 사용해서 N+1 문제를 해결한다.
@Entity
public class Member {
  ...
  @Fetch(FetchMode.SUBSELECT)
  @OneToMany(mappedBy="member", fetch=FetchType.EAGER)
  private List<Order> orders = new ArrayList<Order>();
}
  • 식별자 값이 10 초과인 회원을 모두 조회하는 경우를 보자.
select m from Member m from m.id > 10
  • 즉시 로딩으로 설정하면 조회 시점에, 지연 로딩으로 설정하면 지연 로딩된 엔티티를 사용하는 시점에 다음 SQL이 실행된다.
SELECT o.* FROM ORDERS o
WHERE o.MEMBER_ID IN ( SELECT m.ID
                      FROM MEMER m
                      WHERE m.ID > 10)

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

  • 영속성 컨텍스트는 dirty checking을 위해 스냅샷 인스턴스를 보관하므로 더 많은 메모리를 사용한다는 단점이 있다.

  • 엔티티를 딱 한번만 읽기만 하면 되는 경우에는 읽기 전용으로 엔티티를 조회하면 메모리 사용량을 최적화할 수 있다.

  • 다음 JPQL 쿼리를 최적화해보자.

    select o from Order o
    

스칼라 타입으로 조회

  • 가장 확실한 방법은 엔티티가 아닌 스칼라 타입으로 모든 필드를 조회하는 것이다.
    • 스칼라 타입은 영속성 컨텍스트가 관리하지 않는다.
select o.id, o.name, o.price from Order o

읽기 전용 쿼리 힌트 사용

  • 하이버네이트 전용 힌트인 org.hibernate.readOnly 를 사용하면 엔티티를 읽기 전용으로 조회할 수 있다.
    • 읽기 전용이므로 영속성 컨텍스트는 스냅샷을 보관하지 않는다.
  • 단, 스냅샷이 없으므로 엔티티를 수정해도 데이터베이스에 반영되지 않는다.
TypedQuery<Order> query = em.createQuery("select o from Order o", Order.class);
query.setHint("org.hibernate.readOnly", true);

읽기 전용 트랜잭션 사용

  • 스프링 프레임워크에서 트랜잭션을 읽기 전용 모드로 설정할 수 있다.
@Transactional(readOnly=true)
  • 트랜잭션에 readOnly=true 옵션을 설정하면 스프링 프레임워크가 하이버네이트 세션의 플러시 모드를 MANUAL 로 설정한다.

    • 강제로 플러시를 호출하지 않는 한 플러시가 일어나지 않는다.

    • 따라서 트랜잭션을 커밋해도 영속성 컨텍스트를 플러시 하지 않는다.
    • 영속성 컨텍스트를 플러시하지 않기 때문에 엔티티의 등록, 수정, 삭제가 데이터베이스에 반영되지 않는다.

엔티티 매니저의 플러시 설정에는 AUTO와 COMMIT 모드만 있지만, 하이버네이트 세션의 플러시 설정에는 MANUAL 모드가 있다.

MANUAL 모드는 강제로 플러시를 호출하지 않으면 절대 플러시가 발생하지 않는다.

트랜잭션 밖에서 읽기

  • 트랜잭션 없이 엔티티를 조회한다는 의미이다.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
  • 기본적으로 플러시 모드는 AUTO 로 설정되어 있기 때문에 트랜잭션을 커밋하면 플러시가 작동한다.
  • 그러나 트랜잭션 자체가 존재하지 않으면 트랜잭션을 커밋할 일이 없어 플러시를 호출하지 않는다.

배치 처리

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

JPA 등록 배치

  • 수천에서 수만 건 이상의 엔티티를 한번에 등록할 때 영속성 컨텍스트에 엔티티가 계속 쌓이지 않도록 일정 단위마다 영속성 컨텍스트를 플러시하고 초기화 해야 한다.
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();

for (int i = 0; i < 100000; i++) {
  Product product = new Product("item" + i);
  em.persist(product);
  
  if(i % 100 == 0) {
    em.flush();
    em.clear();
  }
}

tx.commit();
em.close();
  • 엔티티를 100건 저장할 때마다 영속성 컨텍스트를 플러시하고 초기화한다.

JPA 페이징 배치 처리

  • 엔티티 수정 배치 처리의 경우, 수많은 데이터를 한번에 메모리에 올려둘 수 없다.
    • 페이징 기능을 사용해 일정 페이지 단위만큼 조회해서 비즈니스 로직을 수행한 뒤, 영속성 컨텍스트를 플러시하고 초기화한다.
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
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)
    .setMaxResults(pageSize)
    .getResultList();
  
  // 비즈니스 로직 실행
  for(Product product : resultList) {
    product.setPrice(product.getPrice() + 100);
  }
  
  em.flush();
  em.clear();
}

tx.commit();
em.close();

하이버네이트 scroll 사용

  • 하이버네이트는 scroll 이라는 이름으로 JDBC 커서를 지원한다.
    • 이를 사용해 엔티티 수정 배치 처리를 할 수 있다.
EntityTransaction tx = em.getTransaction();
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();
  • em.unwrap() : scroll 기능을 사용하기 위해 하이버네이트 세션을 구한다.
  • next() : 엔티티를 하나씩 조회한다.

하이버네이트 무상태 세션 사용

  • 무상태 세션은 영속성 컨텍스트를 만들지 않고 2차 캐시도 사용하지 않는다.
  • 엔티티를 수정하려면 무상태 세션이 제공하는 update() 메소드를 직접 호출해야 한다.
SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
StatelessSession session = sessionFactory.openStatelessSession();
Transaction tx = session.beginTransaction();
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 쿼리 힌트 사용

  • JPA는 데이터베이스 SQL 힌트 기능을 제공하지 않기 때문에 SQL 힌트를 사용하려면 하이버네이트를 직접 사용해야 한다.
    • addQueryHint() 메소드를 사용한다.
Session session = em.unwrap(Session.class);

List<Member> list = session.createQuery("select m from Member m")
  .addQueryHint("FULL (MEMBER)")
  .list();

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

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

  • 네트워크 호출 한번은 단순한 메소드를 수만 번 호출하는 것보다 더 큰 비용이 든다.
  • 데이터베이스에 쓰기를 최적화하려면 여러개의 INSERT SQL을 모아서 한번에 데이터베이스로 보내면 된다.
  • JDBC가 제공하는 SQL 배치 기능을 사용하면 SQL을 모아서 데이터베이스에 한번에 보낼 수 있다.
    • 코드가 상당히 복잡하기 때문에 보통 수백 수천건 이상의 데이터를 변경하는 특수한 상황에 사용한다.
  • SQL 배치는 같은 SQL일 때만 유효하다.

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

  • 트랜잭션을 지원하는 쓰기 지연과 변경 감지 기능 덕분에 데이터베이스 테이블 row에 Lock이 걸리는 시간을 최소화하는 장점이 있다.
update(memberA);	// UPDATE SQL A
logicA();					// UPDATE SQL ...
logicB();					// INSERT SQL ...
commit();
  • JPA를 사용하지 않으면 update(memberA) 를 호출할 때 UPDATE SQL을 실행하면서 데이터베이스 테이블 row에 락을 건다.
    • 이 락은 logicA() , logicB() 를 모두 수행하고 커밋을 할 때까지 유지된다.
    • 락이 걸려있는 동안 다른 트랜잭션은 대기해야 한다.
  • JPA는 트랜잭션을 커밋하기 직전까지 데이터베이스 테이블 row에 락을 걸지 않고, 커밋을 해야 플러시를 호출하고 데이터베이스에 SQL을 보낸다.
    • SQL을 실행하고 바로 데이터베이스 트랜잭션을 커밋하기 때문에 데이터베이스에 락이 걸리는 시간을 최소화한다.