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);
}
}
memberService.join()을 호출하면 서비스 계층에서 트랜잭션이 시작되고영속성 컨텍스트1이 만들어진다.MemberRepository에서em.persist()호출해서 엔티티를 영속화한다.- 서비스 계층이 끝날 때 트랜잭션이 커밋되면서
영속성 컨텍스트1이 플러시된다.- 트랜잭션과
영속성 컨텍스트1이 종료된다. member엔티티 인스턴스는 준영속 상태가 된다.
- 트랜잭션과
- 테스트 코드에서
membeRepository.findOne()을 호출해서 저장한 엔티티를 조회하면 리포지토리 계층에서 새로운 트랜잭션이 시작되면서 새로운영속성 컨텍스트2가 생성된다. - 새로 생성된
영속성 컨텍스트2에는 찾는 회원이 존재하지 않으므로 데이터베이스에서 회원을 찾아온다. - 데이터베이스에서 조회된 회원 엔티티를
영속성 컨텍스트2에 보관하고 반환한다. membeRepository.findOne()메소드가 끝나면서 트랜잭션이 종료되고영속성 컨텍스트2도 종료된다.
member와findMember는 각각 다른 영속성 컨텍스트에서 관리되었기 때문에 둘은 다른 인스턴스다.member == findMember는 false다.
-
하지만
member와findMember는 인스턴스는 다르지만 같은 데이터베이스 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엔티티를 대상으로 조회했으므로proxyItem은Item타입을 기반으로 만들어진다.- 이 프록시 클래스는 원본 엔티티로
Book엔티티를 참조한다.
proxyItem은Item타입을 기반으로 한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은
Visitor와Visitor를 받아들이는 대상 클래스로 구성된다.Item이accept()메소드를 사용해서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()메소드를 오버라이드해서 구현한다.- 단순히 파라미터로 넘어온
Visitor의visit()메소드에 자기 자신의 인스턴스(this)를 넘겨주기면 하면 된다. - 이렇게 실제 로직 처리를
Visitor에 위임한다.
- 단순히 파라미터로 넘어온
비지터 패턴 실행
@Test
public void 상속관계와_프록시_비지터패턴() {
...
OrderItem orderItem = em.find(OrderItem.class, orderItemId);
Item item = orderItem.getItem();
item.accept(new PrintVisitor());
}
item.accept()메소드를 호출하면서 파라미터로 Visitor 구현 클래스를 생성해서 넘겨주었다.item은 프록시이므로 먼저ProxyItem이accept()메소드를 받고, 원본 엔티티의accept()를 실행한다.- 즉, Visitor 구현 클래스의
visit()메소드의 파라미터로 전달되는 엔티티는 프록시가 아니라 원본 엔티티이다.
- 즉, Visitor 구현 클래스의
비지터 패턴 정리
- 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있다.
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만 추가로 실행한다.
- 예를 들어, 조회한 회원이 10명인데
@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어노테이션에FetchMode를SUBSELECT로 사용하면 연관된 엔티티를 조회할 때 서브 쿼리를 사용해서 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을 실행하고 바로 데이터베이스 트랜잭션을 커밋하기 때문에 데이터베이스에 락이 걸리는 시간을 최소화한다.