예외 처리
JPA표준 예외들은 javax.persistence.PersistenceException의 자식 클래스다.
그리고 이 예외 클래스는 RuntimeException의 자식이다.
따라서 JPA예외는 모두 언체크 예외다.
JPA 표준 예외는 크게 2가지로 나눌 수 있다.
- 트랜잭션 롤백을 표시하는 예외
- 트랜젝션 롤백을 표시하지 않는 예외
트랜젝션 롤백을 표시하는 예외는 심각한 예외이므로 복구해선 안된다.
스프링 프레임워크의 JPA 예외 변환
서비스 계층에서 데이터 접근 계층의 구현 기술에 직접 의존하는 것은 좋은 설계가 아니듯, 예외 또한 마찬가지이다.
스프링 프레임워크는 이런 문제를 해결하려고 데이터 접근 계층에 대한 예외를 추상화해서 개발자에게 제공한다.
스프링 프레임워크에 JPA 예외 변환기 적용
JPA 예외를 스프링 프레임워크가 제공하는 추상화 된 예외로 변경하려면 PersistenceExceptionTranslationPostProcesser를 스프링 빈으로 등록하면 된다.
이것은 @Repository 어노테이션을 사용한 곳에 예외 변환 AOP를 적용해서 JPA예외를 스프링 프레임워크가 추상화한 예외로 변환해준다.
@Repository
public class NoResultExceptionTestRepository
{
@PersistenceContext
EntityManager em;
public Member findMember()
{
// 조회된 데이터가 없음
return em.createQuery("select m from Member m", Member.class)
.getSingleResult();
}
}
이 메소드는 조회된 결과가 없으면 javax.persistence.NoResultException이 발생한다.
이 예외가 메소드를 빠져나갈 때 PersistenceExceptionTranslationPostProcessor에서 등록한 AOP 인터셉터가 동작해서 해당 예외를 org.springframework.dao.EmptyResultDataAccessException예외로 변환해서 반환한다.
@Repository
public class NoResultExceptionTestRepository
{
@PersistenceContext
EntityManager em;
public Member findMember() throws javax.persistence.NoResultException
{
// 조회된 데이터가 없음
return em.createQuery("select m from Member m", Member.class)
.getSingleResult();
}
}
만약 예외를 변환하지 않고 그대로 반환하고 싶으면 위 코드처럼 throws 절에 명시하면 된다.
트랜잭션 롤백 시 주의사항
트랜잭션을 롤백하는 것은 DB의 반영사항만 롤백하는 것이지 수정한 자바 객체까지 원상태로 복구해주지는 않는다.
따라서 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용하는 것은 위험하다.
새로운 영속성 컨텍스트를 생성해서 사용하거나 EntityManager.clear()를 호출해서 영속성 컨텍스트를 초기화한 다음에 사용해야 한다.
기본 전략인 트랜잭션당 영속성 컨텍스트 전략은 문제가 발생하면 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트도 함꼐 종료하므로 문제가 발생하지 않는다.
문제는 OSIV처럼 영속성 컨텍스트의 범위를 트랜잭션 범위보다 넓게 사용해서 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용할 때 발생한다.
이때는 트랜잭션을 롤백해서 영속성 컨텍스트에 이상이 발생해도 다른 트랜잭션에서 해당 영속성 컨텍스트를 그대로 사용하는 문제가 있다.
스프링 프레임워크는 영속성 컨텍스트의 범위를 트랜잭션의 범위보다 넓게 설정하면 잘못된 영속성 컨텍스트를 사용하는 문제를 예방한다.
엔티티 비교
영속성 컨텍스트 내부에는 엔티티 인스턴스를 보관하기 위한 1차 캐시가 있다.
영속성 컨텍스트를 더 정확히 이해하기 위해서는 1차 캐시의 가장 큰 장점인 애플리케이션 수준의 반복 가능한 읽기를 이해해야한다.
Member member1 = em.find(Member.class, "1L");
Member member2 = em.find(Member.class, "2L");
assertTrue(member1 == member2); // 둘은 같은 인스턴스다.
같은 영속성 컨텍스트에서 엔티티를 조회하면 위 코드와 같이 항상 같은 엔티티 인스턴스를 반환한다.
영속성 컨텍스트가 같을 때 엔티티 비교
@Transactional
public class MemberServiceTest
{
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
pulbic void 회원가입() throws Exception
{
//Given
Member member = new Member("kim");
//When
Long saveId = memberService.join(member);
//Then
Member findMember = memberRepository.fineOne(saveId);
assertTrue(member == findMember); // 참조값 비교
}
}
위 코드의 테스트는 트랜잭션 안에서 시작하므로 테스트의 범위와 트랜잭션의 범위가 위 그림과 같다.
따라서 테스트 전체에서 같은 영속성 컨텍스트에 접근한다.
위 코드와 같이 테스트 클래스의 @Transactional이 선언되어 있으면 트랜잭션을 먼저 시작하고 테스트 메소드를 실행한다.
그러므로 회원가입 메서드에서 사용된 코드는 항상 같은 트랜잭션과 같은 영속성 컨텍스트에 접근한다.
저장한 회원과 회원 리포지토리에서 찾아온 엔티티가 완전히 같은 인스턴스이다.
이것은 같은 트랜잭션 범위에 있으므로 같은 영속성 컨텍스트를 사용하기 때문이다.
따라서 영속성 컨텍스트가 같으면 엔티티를 비교할 때 다음 3가지 조건을 모두 만족한다.
- 동일성(identical): == 비교가 같다.
- 동등성(equinalent): equals() 비교가 같다.
- DB 동등성 : @Id인 DB 식별자가 같다.
영속성 컨텍스트가 다를 때 엔티티 비교
// @Transactional
public class MemberServiceTest
{
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
pulbic void 회원가입() throws Exception
{
//Given
Member member = new Member("kim");
//When
Long saveId = memberService.join(member);
//Then
Member findMember = memberRepository.fineOne(saveId);
assertTrue(member == findMember); // 참조값 비교
}
}
위 코드는 실패한다.
- 테스트 코드에서 memberService.join(member)를 호출해서 회원가입을 시도하면 서비스 계층에서 트랜잭션이 시작되고, 영속성 컨텍스트1이 만들어진다.
- memberRepository 에서 em.persist()를 호출해서 member 엔티티를 영속화한다.
- 서비스 계층이 끝날 때 트랜잭션이 커밋되면서 영속성 컨텍스트가 플러시된다. 이때 트랜잭션과 영속성 컨텍스트가 종료된다.
- 테스트 코드에서 memberRepository.findOne(saveId)를 호출해서 저장한 엔티티를 조회하면 리포지토리 계층에서 새로운 트랜잭션이 시작되면서 새로운 영속성 컨텍스트2가 생성된다.
- 저장된 회원을 조회하지만 새로 생성된 영속성 컨텍스트 2에는 찾는 회원이 존재하지 않는다.
- 따라서 DB에서 찾아온다.
member와 findMember는 각각 다른 영속성 컨텍스트에서 관리되었기 때문에 둘은 다른 인스턴스다.
하지만 member와 findMember는 인스턴스는 다르지만 같은 DB 로우를 가르키고 있어서 사실상 같은 엔티티이다.
- 동일성(identical): == 비교가 실패한다.
- 동등성(equinalent): equals() 비교가 만족한다. 단 equals를 구현해야한다.
- DB 동등성: @Id인 DB 식별자가 같다.
지금처럼 영속성 컨텍스트가 달라지면 동일성 비교는 실패하기 때문에 다른 방법을 사용해야 한다.
프록시 심화 주제
프록시는 원본 엔티티를 상속받아서 만들어지므로 엔티티를 사용하는 클라이언트는 엔티티가 프록시인지 원본엔티티인지 구분하지 않고 사용할 수 있다.
영속성 컨텍스트와 프록시
영속성 컨텍스트는 자신이 관리하는 영속 엔티티의 동일성(identical)을 보장한다.
@Test
public void 영속성컨텍스트와_프록시()
{
Member newMember = new Member("member1", "회원1");
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); //성공
}
refMember는 프록시고 findMember는 원본 엔티티이므로 둘은 서로 다른 인스턴스로 생각할 수 있지만 이렇게 되면 영속성 컨텍스트가 영속 엔티티의 동일성을 보장하지 못하는 문제가 발생한다.
때문에 영속성 콘텍스트는 프록시로 조회된 엔티티에 대해서 같은 엔티티를 찾는 요청이 오면 원본 엔티티가 아닌 처음 조회된 프록시를 반환한다.
따라서 프록시로 조회해도 영속성 컨텍스트는 영속 엔티티의 동일성을 보장한다.
@Test
public void 영속성컨텍스트와_프록시()
{
Member newMember = new Member("member1", "회원1");
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()를 호출해도 프록시가 아닌 원본을 반환한다.
프록시 타입 비교
@Test
public void 프록시_타입비교() {
Member newMember = new Member("member1", "회원1");
em.persist(newMember);
em.flush();
em.clear();
Member refMember = em.getReference(Member.class, "member1");
System.out.println("refMember Type = " + refMember.getClass());
Assert.assertFalse(Member.class == refMember.getClass()); // false
Assert.assertTrue(refMember instanceof Member); // true
}
프록시는 원본 엔티티를 상속 받아서 만들어지므로 프록시로 조회한 엔티티의 타입을 == 비교하면 안되고 대신에 instanceof를 사용해야 한다.
==비교는 부모 클래스와 자식 클래스를 == 비교한 것이 되서 false이다.
프록시 동등성 비교
엔티티의 동등성을 비교하려면 비즈니스 키를 사용해서 equals()메소드를 오버라이딩하고 비교하면 된다.
그러나 IDE나 외부 라이브러리를 사용해서 구현한 equals() 메소드로 엔티티를 비교할 때, 비교 대상이 원본 엔티티면 문제가 없지만 프록시면 문제가 발생할 수 있다.
@Entity
public class Member {
@Id
private String id;
private String name;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
if (this.getClass() != obj.getClass()) return false; //1
Member member = (Member) obj;
if (name != null ? !name.equals(member.name) : member.name != null) //2
return false;
return true;
}
@Override
public int hashCode() {
return name != null ? name.hashCode() : 0;
}
}
회원 엔티티는 name필드를 비즈니스 키로 사용해서 equals() 메서드를 오버라이딩했다.
@Test
public void 프록시와_동등성비교()
{
Member saveMember = new Member("member1", "회원1");
em.persists(saveMember);
em.flush();
em.clear();
Member newMember = new Member("member1", "회원1");
Member refMember = em.getReference(Member.class, "member1");
Assert.assertTrue( newMember.equals(refMember));
}
새로 생성한 회원 newMember와 프록시로 조회한 회원 refMember의 name 속성은 둘 다 회원1로 같으므로 동등성 비교를 하면 성공할 것 같다.
하지만 실행해보면 false가 나오면서 테스트가 실패한다.
this.getClass() != obj.getClass()) return false;
여기서 타입을 동일성(==) 비교한다.
앞서 이야기한 대로 프록시는 원본을 상속받은 자식 타입이므로 프록시의 타입을 비교할 때는 == 비교가 아닌 instanceof를 사용해야 한다.
Member member = (Member) obj; // member는 프록시다.
if (name != null ? !name.equals(member.name):member.name != null)
return false;
equals() 메서드를 구현할 때는 일반적으로 멤버변수를 직접 비교하는데, 프록시의 경우는 문제가 된다.
프록시는 실제 데이터를 가지고 있지 않기 때문이다.
따라서 프록시의 멤버변수에 직접 접근하면 아무 값도 조회할 수 없다.
따라서 equals()는 false를 반환한다.
Member member = (Member) obj;
if(name != null ? !name.equals(member.getName()) : member.getName() != null)
return false;
다음은 수정한 코드다.
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Member)) return false;
Member member = (Member) obj;
if (name != null ? !name.equals(member.getName()) : member.getName() != null)
return false;
return true;
}
정리
- 프록시의 타입 비교는 ==비교 대신에 instanceof를 사용해야 한다.
- 프록시의 멤버변수에 직접 접근하면 안 되고 대신에 접근자 메소드를 사용해야 한다.
상속관계와 프록시
@Test
public void 부모타입으로_프록시조회() {
// 테스트 데이터 준비
Book saveBook = new Book();
saveBook.setName("jpaBook");
saveBook.setAuthor("kim");
em.persist(saveBook);
em.flush();
em.clear();
// 테스트 시작
Item proxyItem = em.getReference(Item.class, saveBook.getId());
System.out.println("proxyItem = " + proxyItem.getClass());
if (proxyItem instanceof Book) {
System.out.println("proxyItem instanceof Book");
Book book = (Book) proxyItem;
System.out.println("책 저자 = " + book.getAuthor());
}
// 결과 검증
Assert.assertFalse(proxyItem.getClass() == Book.class);
Assert.assertFalse(proxyItem instanceof Book);
Assert.assertTrue(proxyItem instanceof Item);
}
위 코드는 Item을 조회해서 Book 타입이면 저자 이름을 출력한다.
코드 분석
먼저 em.getReference() 메소드를 사용해서 Item엔티티를 프록시로 조회했다.
그리고 instanceof 연산을 사용해서 proxyItem이 Book 클래스 타입인지 확인한다.
Book 타입이면 다운캐스팅해서 Book 타입으로 변경하고 저자 이름을 출력한다.
그런데 출력 결과를 보면 기대와는 다르게 저자가 출력되지 않은 것을 알 수 있다.
코드에서는 em.getReference() 메서드를 사용해서 Item 엔티티를 프록시로 조회했다.
이때 실제 조회된 엔티티는 Book 이므로 Book 타입을 기반으로 원본 엔티티 인스턴스가 생성된다.
그런데 em.getReference() 메서드에서 Item엔티티를 대상으로 조회했으므로 프록시인 proxyItem은 Item타입을 기반으로 만들어진다.
이 프록시 클래스는 원본 엔티티로 Book 엔티티를 참조한다.
프록시인 proxyItem은 Item의 프록시 타입이고 이 타입은 Book타입과 관계가 없기 때문이다.
캐스팅 또한 되지 않는다.
해결 방법
1. JPQL로 대상 직접 조회
가장 간단한 방법은 다음과 같이 처음부터 자식타입을 직접 조회해서 필요한 연산을 하면 된다.
물론 이 방법을 사용하면 다형성을 활용할 수 없다.
Book jpqlBook = em.createQuery(
"select b from Book b where b.id=:bookId", Book.class)
.setParameter("bookId", item.getId())
.getSingleResult();
2. 프록시 벗기기
하이버네이트가 제공하는 기능을 사용하면 프록시에서 원본 엔티티를 가져올 수 있다.
...
Item item = orderItem.getItem();
Item unProxyItem = unProxy(item);
if (unProxyItem instanceof Book) {
System.out.println("proxyItem instanceof Book");
Book book = (Book) unProxyItem;
System.out.println("책 저자 = " + book.getAuthor());
}
Assert.assertTrue(item != unProxyItem);
// 하이버네이트가 제공하는 프록시에서 원본 엔티티를 찾는 기능을 사용하는 메소드
public static <T> T unProxy(Object entity) {
if (entity instanceof HibernateProxy) {
entity = ((HibernateProxy) entity)
.getHibernateLazyInitializer()
.getImplementation();
}
return (T) entity;
}
영속성 컨텍스트는 한 번 프록시로 노출한 엔티티는 계쏙 프록시로 노출한다.
그래야 영속성 컨텍스트가 영속 엔티티의 동일성을 보장할 수 있고, 클라이언트는 조회한 엔티티가 프록시인지 아닌지 구분하지 않고 사용할 수 있다.
하지만 이 방법은 프록시에서 원본 엔티티를 직접 꺼내기 때문에 프록시와 원본 엔티티의 동일성 비교가 실패한다는 문제점이 있다.
3. 기능을 위한 별도의 인터페이스 제공
public interface TitleView
{
String getTitle();
}
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item implements TitleView {
@Id @GeneratedValue
@Column(name = "ITEM_ID")
private Long id;
private String name;
private int price;
private int stockQuantity;
// Getter, Setter
}
@Entity
@DiscriminatorValue("B")
public class Book extends Item {
private String author;
private String isbn;
@Override
public String getTitle() {
return "[제목:" + getName() + " 저자:" + author + "]";
}
}
@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
private String director;
private String actor;
// Getter, Setter
@Override
public String getTitle() {
return "[제목:" + getName() + " 감독:" + director + " 배우:" + actor + "]";
}
}
위 코드에서 TitleView라는 공통 인터페이스를 만들고 자식 클래스들은 인터페이스의 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());
}
}
이제 위 코드처럼 Item의 getTitle() 을 호출하면 된다.
이처럼 인터페이스를 제공하고 각각의 클래스가 자신에 맞는 기능을 구현하는 것은 다형성을 활용하는 좋은 방법이다.
이후에 다양한 상품 타입이 추가되어도 Item을 사용하는 OrderItem의 코드는 수정하지 않아도 된다.
그리고 이 방법은 클라이언트 입장에서 대상 객체가 프록시인지 아닌지를 고민하지 않아도 되는 장점이 있다.
4. 비지터 패턴 사용
위 그림을 보면 비지터 패턴은 Visitor와 Visitor를 받아들이는 대상 클래스로 구성된다.
여기서는 Item이 accept(visitor) 메서드를 사용해서 Visitor를 받아들인다.
그리고 Item은 단순히 Visitor를 받아들이기만 하고 실제 로직은 Visitor가 처리한다.
Visitor 인터페이스
public interface Visitor
{
void visit(Book book);
void visit(Album album);
void visit(Movie movie);
}
Visitor에는 visit()라는 메소드를 정의하고 모든 대상 클래스를 받아들이도록 작성하면 된다.
여기서는 Book, Album, Movie를 대상 클래스로 사용한다.
비지터 구현
public class PrintVisitor implements Visitor {
@Override
public void visit(Book book) {
// 넘어오는 book은 Proxy가 아닌 원본 엔티티다.
System.out.println("book.class = " + book.getClass());
System.out.println("[PrintVisitor] [제목:" + book.getName() + " 저자:" + 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 = "[제목:" + book.getName() + " 저자:" + book.getAuthor() + "]";
}
@Override
public void visit(Album album) { ... }
@Override
public void visit(Movie movie) { ... }
}
위 코드는 Visitor의 구현 클래스로 대상 클래스의 내용을 출력해주는 PrintVisitor와 대상 클래스의 제목을 보관하는 TitleVisitor를 작성했다.
비지터 대상 클래스
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {
@Id @GeneratedValue
@Column(name = "ITEM_ID")
private Long id;
private String name;
...
public abstract void accept(Visitor visitor);
}
@Entity
@DiscriminatorValue("B")
public class Book extends Item {
private String author;
private String isbn;
// Getter, Setter
public String getAuthor() {
return author;
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
private String director;
private String actor;
// Getter, Setter
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
@Entity
@DiscriminatorValue("A")
public class Album extends Item {
...
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
위 코드에 있는 각각의 자식 클래스들은 부모에 정의한 accept(visitor)메서드를 구현했는데, 구현 내용은 단순히 파라미터로 넘어온 Visitor의 visit(this) 메서드를 호출하면서 자신(this)을 파라미터로 넘기는 것이 전부다.
@Test
public void 상속관계와_프록시_VisitorPattern() {
...
OrderItem orderItem = em.find(OrderItem.class, orderItemId);
Item item = orderItem.getItem();
// PrintVisitor
item.accept(new PrintVisitor());
}
실제로는 위 코드처럼 사용한다.
먼저 item.accept() 메서드를 호출하면서 파라미터로 PrintVisitor를 넘겨주었다.
item은 프록시이므로 먼저 프록시(ProxyItem)가 accept() 메서드를 받고 원본 엔티티(book)의 accept()를 실행한다.
원본 엔티티
다음 코드를 실행해서 자신(this)을 visitor에 파라미터로 넘겨준다.
visitor가 PrintVisitor 타입이므로 PrintVisitor.visit(this) 메서드가 실행되는데 이때 this가 Book타입이므로 visit(Book book) 메서드가 실행된다.
비지터 패턴 정리
장점
- 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있다.
- instanceof와 타입캐스팅 없이 코드를 구현할 수 있다.
- 알고리즘과 객체 구조를 분리해서 구조를 수정하지 않고 새로운 동작을 추가할 수 있다.
단점
- 너무 복잡하고 더블 디스패치를 사용해야하기 때문에 이해하기 어렵다.
- 객체 구조가 변경되면 모든 Visitor를 수정해야 한다.
성능 최적화
N + 1 문제
JPA로 애플리케이션을 개발할 때 성능상 가장 주의해야 하는 것이 N + 1 문제다
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<Order>();
...
}
@Entity
@Table(name = "ORDERS")
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne
private Member member;
...
}
회원과 주문정보는 1:N, N:1 양방향 연관관계다.
그리고 회원이 참조하는 주문정보인 Member.orders를 즉시로딩으로 설정했다.
즉시 로딩과 N+1
특정 회원 하나를 em.find() 메서드로 조회하면 즉시 로딩으로 설정한 주문 정보도 함께 조회한다.
여기서 함께 조회하는 방법이 중요한데, SQL을 두 번 실행하는 것이 아니라 조인을 사용해서 한번의 SQl로 회원과 주문정보를 함께 조회한다.
여기까지만 보면 즉시 로딩이 상당히 좋아보인다.
문제는 JPQL을 사용할 때 발생한다.
JPQL을 실행하면 JPA는 이것을 분석해서 SQL을 생성한다.
이때는 즉시 로딩과 지연 로딩에 대해서 전혀 신경 쓰지 않고 JPQL만 사용해서 SQL을 생성한다.
따라서 다음과 같은 SQL이 실행된다.
SELECT * FROM MEMBER
SQL의 실행 결과로 먼저 회원 엔티티를 애플리케이션에 로딩한다.
그런데 회원 엔티티와 연관된 주문 컬렉션이 즉시 로딩으로 설정되어 있으므로 JPA는 주문 컬렉션을 즉시 로딩하려고 다음 SQL 을 추가로 실행한다.
SELECT * FROM ORDERS WHERE MEMBER_ID = ?
조회된 회원이 하나면 이렇게 총 2번의 SQL을 실행하지만 조회된 회원이 5명이면
SELECT * FROM MEMBER // 1번 실행으로 회원 5명 조회
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의 결과 수만큼 추가로 SQL을 실행하는 것을 N + 1 문제라고 한다.
지연 로딩과 N + 1
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<Order>();
...
}
지연 로딩으로 설정하면 JPQL에서는 N + 1 문제가 발생하지 않는다.
List<Member> members =
em.createQuery("select m from Member m", Member.class)
.getResultList();
지연 로딩이므로 DB에서 회원만 조회된다.
따라서 다음 SQL만 실행되고 연관된 주문 컬렉션은 지연 로딩된다.
SELECT * FROM MEMBER
이후에 비즈니스 로직에서 주문 컬렉션을 실제 사용할 때 지연 로딩이 발생한다.
문제는 다음처럼 모든 회원에 대해 연관된 주문 컬렉션을 사용할 때 발생한다.
for(Member member : members)
{
System.out.println("member = " + member.getOrders().size());
}
주문 컬렉션을 초기화하는 수만큼 다음 SQL이 실행될 수 있다.
회원이 5명이면 회원에 따른 주문도 5번 조회된다.
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 // 회원과 관련된 주문
결국 이것도 N + 1 문제다.
N + 1 문제는 즉시 로딩과 지연 로딩일 때 모두 발생할 수 있다.
해결 방법
1. 페치 조인 사용
N + 1 문제를 해결하는 가장 일반적인 방법은 페치 조인을 사용하는 것이다.
페치 조인은 SQL 조인을 사용해서 연관된 엔티티를 함꼐 조회하므로 N + 1 문제가 발생하지 않는다.
페치 조인 JPQL
select m from Member m join fetch m.orders
실행된 SQL
SELECT M.*, O.* FROM MEMBER M
INNER JOIN ORDERS O ON M.ID=O.MEMBER_ID
2. 하이버네이트 @BatchSize
하이버네이트가 제공하는 org.hibernate.annotations.BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN절을 사용해서 조회한다.
만약 조회한 회원이 10명인데 size=5로 지정하면 2번의 SQL만 추가로 실행한다.
@Entity
public class Member
{
...
@org.hibernate.annotations.BatchSize(size = 5)
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<Order>();
...
}
즉시 로딩으로 설정하면 조회 시점에 10건의 데이터를 모두 조회해야 하므로 다음 SQL이 두번 실행된다.
SELECT * FROM ORDERS
WHERE MEMBER_ID IN (
?, ?, ?, ?, ?
)
3. 하이버네이트 @Fetch(FetchMode.SUBSELECT)
하이버네이트가 제공하는 org.hibernate.annotations.Fetch 어노테이션에 FetchMode를 SUBSELECT로 사용하면 연관된 데이터를 조회할 때 서브 쿼리를 사용해서 N + 1 문제를 해결한다.
@Entity
public class Member
{
...
@org.hibernate.annotations.Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<Order>();
...
}
N+1 정리
즉시 로딩과 지연 로딩 중 추천하는 방법은 즉시 로딩은 사용하지 말고 지연 로딩만 사용하는 것이다.
즉시 로딩 전략은 그럴듯해 보이지만 N+1 문제는 물론이고 비즈니스 로직에 따라 필요하지 않은 엔티티를 로딩해야 하는 상황이 자주 발생한다.
그리고 즉시 로딩의 가장 큰 문제는 성능 최적화가 어렵다는 점이다.
엔티티를 조회하다보면 즉시 로딩이 연속으로 발생해서 전혀 예상하지 못한 SQL이 실행될 수 있다.
따라서 모두 지연 로딩으로 설정하고 성능 최적화가 꼭 필요한 곳에는 JPQL 페치 조인을 사용하자.
JPA의 글로벌 페치 전략 기본값은 다음과 같다:
- @OneToOne, @ManyToOne: 기본 페치 전략은 즉시 로딩
- @OneToMany, @ManyToMany: 기본 페치 전략은 지연 로딩
읽기 전용 쿼리의 성능 최적화
엔티티가 영속성 컨텍스트에 관리되면 1차 캐시부터 변경 감지까지 얻을 수 있는 혜택이 많다.
하지만 영속성 컨텍스트는 변경 감지를 위해 스냅샷 인스턴스를 보관하므로 더 많은 메모리를 사용하는 단점이 있다.
이때는 있다.
읽기 전용으로 엔티티를 조회하면 메모리 사용량을 최적화할 수 있다.
- 스칼라 타입으로 조회
- 가장 확실한 방법은 다음처럼 엔티티가 아닌 스칼라 타입을 모든 필드를 조회하는 것이다.
- 읽기 전용 쿼리 힌트 사용
- 하이버네이트 전용 힌트인 org.hibernate.readOnly 를 사용하면 엔티티를 읽기 전용으로 조회할 수 있다.
- 읽기 전용이므로 영속성 컨텍스트는 스냅샷을 보관하지 않는다.
- 따라서 메모리 사용량을 최적화할 수 있다.
- 단 스냅샷이 없으므로 엔티티를 수정해도 DB에 반영되지 않는다.
- 읽기 전용 트랜젝션 사용
- 스프링 프레임워크를 사용하면 트랜잭션을 읽기 전용 모드로 설정할 수 있다.
- @Transactional(readonly = true)를 하면 강제로 플러시를 호출하지 않는 한 플러시가 일어나지 않는다.
- 트랜잭션 밖에서 읽기
- 트랜잭션 밖에서 읽는 다는 것은 트랜잭션 없이 엔티티를 조회하는 것이다.
- JPA에선 수정 시 트랜잭션은 필수이기 때문에 조회가 목적일 때만 사용해야 한다.
배치 처리
수백만건의 데이터를 배치 처리해야 하는 상황이라 가정.
일반적인 방식으로 엔티티를 계속 조회하면 영속성 컨텍스트에 아주 많은 엔티티가 쌓이면서 메모리 부족 오류가 발생한다.
따라서 이런 배치 처리는 적절한 단위로 영속성 컨텍스트 초기화를 해야한다.
JPA 등록 배치
수천에서 수만 건 이상의 엔티티를 한 번에 등록할 때 주의할 점은 영속성 컨텍스트에 엔티티가 계속 쌓이지 않도록 일정 단위마다 영속성 컨텍스트의 엔티티를 DB에 플러시하고 영속성 컨텍스트를 초기화해야 한다.
만약 이런 작업을 하지 않으면 영속성 컨텍스트에 너무 많은 엔티티가 저장되면서 메모리 부족 오류가 발생할 수 있다.
EntityManager em = entityManagerFactory.createEntityManager();
EntityTransaction tx = em.getTransaction();
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 페이징 배치 처리
EntityManager em = entityManagerFactory.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();
한번에 100건씩 페이징 쿼리로 조회하면서 상품의 가격을 100원 씩 증가한다.
그리고 페이지 단위마다 영속성 컨텍스트를 플러시하고 초기화한다.
하이버네이트 scroll사용
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();
scroll은 하이버네이트 전용 기능이므로 먼저 em.unwrap() 메서도를 사용해서 하이버네이트 세션을 구한다.
해서 하이버네이트 세션을 구한다.
다음으로 쿼리를 조회하면서 scroll() 메서드를 호출하면 엔티티 하나씩 조회할 수 있다.
하이버네이트 무상태 세션 사용
하이버네이트는 무상태 세션이라는 특별한 기능을 제공한다.
이름 그대로 무상태 세션은 영속성 컨텍스트를 만들지 않고 심지어 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); // 직접 update를 호출해야 한다.
}
tx.commit();
session.close();
SQL 쿼리 힌트 사용
JPA는 DB SQL 힌트 기능을 제공하지 ㅇ낳는다.
SQL 힌트를 사용하려면 하이버네이트를 직접 사용해야 한다
SQL 힌트는 하이버네이트 쿼리가 제공하는 addQueryHint() 메서드를 사용한다.
Session session = em.unwrap(Session.class);
List<Member> list = session.createQuery("select m from Member m")
.addQueryHint("FULL (MEMBER)") // SQL 힌트 추가
.list();
실행된 SQL
select
/*+ FULL (MEMBER) */ m.id, m.name
from
Member m
현재 하이버네이트 버전에는 오라클 방언에만 힌트가 적용되어 있다.
다른 DB에서 SQL 힌트를 사용하려면 각 방언에서 org.hibernate.dialect.Dialect에 있는 다음 메서드를 오버라이딩해서 기능을 구현해야 한다.
트랜잭션을 지원하는 쓰기 지연과 성능 최적화
트랜잭션을 지원하는 쓰기 지연과 JDBC 배치
네트워크 호출 한 번은 단순한 메서드를 수만 번 호출하는 것보다 더 큰 비용이 든다.
JDBC가 제공하는 SQL 배치 기능을 사용하면 SQL을 모아서 DB에 한 번에 보낼 수 있다.
그래서 보통은 수백 수천 건 이상의 데이터를 변경하는 특수한 상황에 SQL 배치 기능을 사용한다.
JPA는 플러시 기능이 있으므로 SQL 배치 기능을 효과적으로 사용할 수 있다.
트랜잭션을 지원하는 쓰기 지연과 애플리케이션 확장성
트랜잭션을 지원하는 쓰기 지연과 변경 감지 기능 덕분에 성능과 개발의 편의성이라는 두 마리 토끼를 모두 잡을 수 있었다.
하지만 진짜 장점은 DB 테이블 로우에 락이 걸리는 시간을 최소화한다는 점이다.
이 기능은 트랜잭션을 커밋해서 영속성 컨텍스트를 플러시하기 전까지는 DB에 데이터를 등록, 수정, 삭제하지 않는다.
따라서 커밋 직전까지 DB 로우에 락을 걸지 않는다.
'JPA' 카테고리의 다른 글
JPA TIL - 09 값 타입 (0) | 2024.05.15 |
---|---|
JPA TIL - 07 고급 매핑 (0) | 2024.05.08 |
JPA TIL - 06 다양한 연관관계 매핑 (0) | 2024.04.16 |
JPA TIL - 05 연관관계 매핑 기초 (0) | 2024.04.09 |
JPA TIL - 04 엔티티 매핑 (0) | 2024.04.02 |