고급 기능, 성능최적화

JPA 예외처리

JPA 표준 예외는 PersistenceException의 자식 클래스인데, 이는 Java의 RuntimeException의 자식 클래스이다.

JPA 예외는 크게 2가지로 나뉘는데, 트랜잭션 롤백을 표시하는 예외, 표시하지 않는 예외로 나뉜다. 트랜잭션을 표시하는 예외는 절대로 복구해서는 안되는 에러이다. 강제로 커밋을 수행한다 하더라고 RollbackException 에러가 발생한다. 그래서 이러한 예외에 대해서는 무조건 롤백으로 처리된다.

반면, 트랜잭션 롤백을 표시하지 않는 에러는 커밋/롤백 여부를 개발자가 선택할 수 있다.

트랜잭션 롤백을 표시하는 에러

Exceptions Descriptions
javax.persistence.EntityExstsException persist함수 호출 시 이미 같은 엔티티가 있으면 발생
javax.persistence.EntityNotFoundException getReference함수 호출하여 프록시를 받아 실제 사용 시 엔티티가 존재하지 않으면 발생
javax.persistence.OptimisticLockException 낙관적 락 충돌 시 발생
javax.persistence.PessimisticLockException 비관적 락 충돌 시 발생
javax.persistence.RollbackException 트랜잭션 커밋 실패 시 발생,
롤백을 해야 하는 트랜잭션 커밋 시에도 발생  
javax.persistence.TransactionRequiredException 트랜잭션이 필요할 때 트랜잭션이 없으면 발생,
트랜잭션을 선언하지 않고 엔티티를 수정할 때 주로 발생  

트랜잭션 롤백을 표시하지 않는 에러

Exceptions Descriptions
javax.persistence.NoResultException getSingleResult함수 호출 시 결과가 없을 때 발생
javax.persistence.NonUniqueResultException getSingleResult함수 호출 시 결과가 둘 이상인 경우 발생
javax.persistence.LockTimeoutException 비관적 락에서 시간 초과 시 발생
javax.persistence.QueryTimeoutException 쿼리 실행 시간 초과 시 발생

JPA 예외 변환

위와 같이 DAL 계층의 예외처리에 직접 의존하는 것은 좋지 못하다. Spring에서는 JPA 예외를 변환해서 스프링에서 예외를 처리할 수 있도록 예외를 변환해서 제공한다.

JPA -> Spring

JPA Exceptions Spring Exceptions  
javax.persistence.PersistenceException org.springframework.orm.jpa. JpaSystemException  
javax.persistence.NoResultException org.springframework.dao. EmptyResultDataAccessException  
javax.persistence.NonUniqueResultException org.springframework.dao.IncorrectResultSize. DataAccessException  
javax.persistence.LockTimeoutException org.springframework.dao. CannotAcquireLockException  
javax.persistence.QueryTimeoutException org.springframework.dao. QueryTimeoutException  
javax.persistence.EntityExistsException org.springframework.dao. DataIntegrityCiolationException  
javax.persistence.EntityNotFoundException org.springframework.orm.jpa. JpaObjectRetirevalFailureException  
javax.persistence.OptimisticLockException org.springframework.orm.jpa. JpaOptimistickLockingFailureException  
javax.persistence.PessimisticLockException org.springframework.dao. PessimisticLockingFailureException  
javax.persistence.TransactionRequiredException org.springframework.dao. InvalidDataAccessApiUsageException  
javax.persistence.RollbackException org.springframework.transaction. TransactionSystemException  
java.lang.IllegalStatieException org.springframework.dao.InvalidDataAccessApiUsageException  
java.lang.IllegalArgumentException org.springframework.dao. InvalidDataAccessApiUsageException

JPA 예외 변환기를 적용

JPA 예외 변환기를 적용해서 스프링 타입 예외로 변환될 수 있도록 설정해야한다.

AppConfig.xml 방식

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

java Config 방식

//Java Config
@Bean
public PersistenceExceptionTransactionPostProcessor exceptionTranslation() {
		return new PersistenceExceptionTranslationPostProcessor();
}

이렇게 설정하게 되면 예외 변환 AOP가 @Repository로 설정한 repository에 대해서 해당 JPA Exception을 Spring Exception으로 변환해준다.

try-catch을 이용해서 예외처리를 수행해도 되고, throws을 이용해서 예외를 넘겨도 된다.

트랜잭션 롤백 주의 사항

트랜잭션을 롤백한다고 하면 DB에 수정사항이 반영이 안될 뿐, 자바 객체의 수정사항까지 원복되지 않고, 영속성 컨텍스트에는 수정사항이 그대로 반영되게 된다.

그래서, 롤백을 수행하게 되면 영속성 컨텍스트는 새로 생성하거나, 초기화를 해주는 것이 좋다.

트랜잭션당 OSIV를 이용하는 경우 트랜잭션 롤백시 영속성 컨텍스트가 자동 종료 되므로 문제가 되지 않지만, Spring OSIV의 경우 트랜잭션 범위 보다 영속성 컨텍스트의 범위가 크기 때문에 트랜잭션이 롤백 되어도 영속성 컨테스트가 종료되지 않고 유지 된다. 스프링에서는 이러한 문제를 방지하기 위해 트랜잭션이 롤백됬을 때, 자동으로 영속성 컨텍스트를 초기화하도록 지원한다.

엔티티 비교

영속성 컨텍스트에는 1차 캐시를 둬서 변경감지를 수행하고, 엔티티에 대한 조회를 원할하게 한다. 또 매번 같은 영속성 컨텍스트에 인스턴스를 요청하는 경우 항상 같은 인스턴스를 반환한다.

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

assertTrue(member==member2);

위와 같이 동등값 뿐만아니라 주소값 까지 같은 인스턴스를 반환하게 된다.

같은 영속성 컨텍스트에 대한 조회

same_persistence_context

아래의 예제를 통해 같은 트랜잭션 내에서 조회한 엔티티는 항상 같은 인스턴스를 참조하는 것을 확인해보자

@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); //참조 값 비교
		}
        
}

위와 같이, 저장한 회원과 리포지토리에서 가져온 회원은 같은 인스턴스임이 확인된다.

그래서 같은 영속성 컨텍스트에 대해서는 아래의 3가지 조건이 모두 만족한다.

  1. 동일성 (==)
  2. 동등성 (equals)
  3. DB 동등성 (id 값 동일)

다른 영속성 컨텍스트에 대한 조회

different_persistence_context

위의 예제와 달리, Test에서 @Transactional을 분리해서 Test에서는 트랜잭션이 유지 되지 않도록 한다. 이렇게 하면 Test에서는 준영속 상태가 되어, 엔티티를 읽어드릴때 새로운 트랜잭션을 생성해서 조회를 수행하게 된다.

//@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); //참조 값 비교
		}
}

서로 다른 영속성 컨텍스트에서 조회한 엔티티는 위와 같이 동일성은 보장되지 않지만 동등성은 보장된다

  1. 동등성 (equals)
  2. DB 동등성 (id 값 동일)

위와 같이 영속성 컨텍스트가 유지 되지 않는 Spring OSIV 와 같은 환경에서는 동일성 보장이 되지 않으므로 동등성 비교를 해야한다. 그렇다고 식별자 값을 비교하는 것은 엔티티가 영속상태가 되지 않으면 null 값이므로 비교하기 어렵기 때문에, 비즈니스 키의 경우 객체를 생성할 때 부여하는 값이므로 항상 존재한다. 따라서 주민등록번호와 같은 비즈니스 키를 이용한 동등값 비교를 수행한다.

프록시

위에서 보면 같은 영속성 컨텍스트에 대해서는 같은 인스턴스(동일성)임이 보장되어있다. 그렇다면 프록시를 통한 조회한 엔티티에 대해서도 동일성이 보장될까?

프록시 예제 코드

@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");

		System.out.println("refMember Type = " + refMember.getClass());
		System.out.println("findMember Type = " + findMember.getClass());

		Assert.assertTrue(refMember == findMember); //성공
}

Result

refMember Type = class jpabook.advanced.Member_$$_jvst843_0
findMember Type = class jpabook.advanced.Member_$$_jvst843_0

위를 보면 처음에 getReference를 통해 프록시 객체를 반환받은 것을 확인할 수 있다. 그 다음에 em.find()를 이용해서 엔티티를 조회하게 되면 실제 엔티티 대신에 위에서 반환받은 프록시 객체를 반환받게 된다. 이를 통해 영속성 컨텍스를 통한 조회와 프록시 조회를 통한 인스턴스가 같은 인스터스로 보장받을 수 있다.

find()를 먼저하고 getReference()를 수행한다고 하더도 동일성을 보장받을 수 있다. 이 경우 두 조회 방식 모두 실제 엔티티를 반환받게된다.

프록시 타입

프록시는 실제 엔티티를 상속받아서 만들어지는 객체이므로 ==가 아닌 해당 클래스에 대한 instanceOf를 통한 비교를 수행해야된다.

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

위를 보면, 클래스 타입에 대한 직접 비교에 대해서는 false 가 되지만, instanceOf를 수행하면 true을 반환하는 것을 확인할 수 있다.

프록시 동등성 비교

실제 엔티티 간의 동등성 비교를 위해 equals 메소드를 아래와 같이 overriding 할 수 있다.

@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;
    }

하지만 위의 equals 메소드를 이용해서 프록시 객체간의 동등성 비교를 수행하면 에러가 발생하게 된다.

  1. getClass() 문제
if (this.getClass() != obj.getClass()) return false; // -- 1

프록시 객체에 대해 getClass를 하게 되면 엔티티 타입이 아닌 엔티티의 프록시 타입으로 반환되게 된다. 따라서 서로 비교를 하면 다르다. 따라서, 이때는 instanceOf 활용해야한다.

if(!(obj.instanceOf Member))
return false;
  1. 엔티티의 비즈니스 키 값 비교 과정
if (name != null ? !name.equals(member.name) : member.name != null)  {
    return false;
}               

프록시 객체는 실제 데이터를 가지고 있지 않기 때문에 member.name과 같이 멤버변수에 접근하게 되면 아무런 값이 없다. 따라서, 프록시를 통한 멤버 변수 값을 조회할때는 getName()과 같이 getter을 활용해야한다. 프록시 getName을 통해 실제 엔티티의 name에 접근해서 해당 값을 반환하게 된다.

if (name != null ? !name.equals(member.getName()) 
			: member.getName() != null) // -- 2
		return false;

상속관계와 프록시

proxy_inheritance_1

만약 위와 같이 상속관계를 가지는 ITEM 클래스에 대해서 프록시 객체로 조회하게 되면 자식 클래스를 활용할 때 문제가 발생한다.

예제

//테스트 데이터 준비
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 클래스로 다운 캐스팅을 수행해서 Book에 접근하는 것을 확인할 수 있다. 하지만 해당 테스트를 실행해보면 에러가 발생하는 것을 확인할 수 있다. 이는 프록시 객체가 Item Class 기반으로 만들어진 프록시 객체여서 그렇다.

즉, 프록시를 통한 상속관계를 가지는 클래스에 대한 조회를 수행하면 아래와 같은 문제들이 있다.

  1. instanceOf 를 통한 자식 클래스 타입 비교를 수행할 수 없다.
  2. 하위 타입으로 다운캐스팅 할 수 없다.

이렇게 상속관계 문제를 해결하기 위한 방법에 대해 알아보자

JPQL 직접 조회

Book jpqlBook = 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) {
    System.out.println("proxyItem instanceof Book");
    Book book = (Book) unProxyItem;
    System.out.println("책 저자 = " + book.getAuthor());
}

Assert.assetTrue(item != unProxyItem);

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

이와 같이 하이버네이트에서 지원하는 기능을 이용해서 프록시 객체에서 원본엔티티를 받아올 수 있다. 하지만 이렇게 프록시로부터 원본 엔티티를 받아오게 되면 프록시와 원본 엔티티간에 동일성이 깨지게 된다.

특정 기능에 대한 인터페이스 생성

proxy_inheritance2

위와 같이 특별한 기능에 대한 인터페이스를 생성해서 해당 문제를 처리할 수 있다.

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;

    //Getter, Setter

    @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 + "]";
    }		
}

이와 같이 특정 기능에 대한 interface를 만들어서, 각각의 자식 클래스에서 해당 메소드를 구현하도록 구성한다.

Item item = new Book(...);
System.out.println("TITLE: " + item.getTitle());

getTitle을 호출하게 되면 item 프록시 객체의 getTitle이 호출되며, 해당 메소드에서 특정 구현체에 대한 실제 엔티티의 getTitle 메소드가 실행된다. 이렇게 하면 프록시, 실제 엔티티에 관계없이 해당 기능을 호출해서 이용가능하다.

Visitor 패턴

visitor_pattern

위 처럼, visitor와 visitor가 방문하는 클래스를 두는 visitor pattern을 이용해서 상속에서 발생하는 문제를 해결할 수 있다.

Visitor

//Visitor Interface
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은 Proxy가 아닌 원본 엔티티
        System.out.println("book.class = " + book.getClass());
        System.out.println("[PrintVisitor] [제목: " + book.getName() + 
                "저자 :" + book.getAutor() + "]");
    }

    @Override
    public void visit(Album album) {...}

    @Override
    public void visit(Movie album) {...}
}

//대상 클래스의 제목을 보관하는 Visitor
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) {...}
}

대상 클래스

@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

    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
    ...

    @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);
    }
}

예제

OrderItem oi=em.find(OrderItem.class,orderItemId);
Item item=oi.getItem();

item.accept(new PrintVisitor());

Results

//출력 결과
book.class = class jpabook.advanced.item.Book
[PrintVisitor] [제목:jpabook 저자:kim]

위와 같이 visitor 클래스를 넘겨줘서 해당 visitor에서 제공하는 기능을 활용할 수 있다.

visitor_pattern_mechanism

위의 과정을 살펴보면, item 클래스에 대한 프록시 객체가 먼저 생성된다. 이 프록시 객체에서 accept()를 호출되는데, 그러면 실제 엔티티인 Book에 대한 accept을 실행켜서 visitor 을 넘겨준다. 위의 출력결과를 보면 Book 엔티티는 프록시 객체가 아닌 실제 엔티티임을 확인할 수 있다.

visitor 패턴을 이용하면 프록시로 인한 문제가 해결된다.

장점

만약, 새로운 기능이 필요하다면, Visitor 클래스만 추가 하면 된다. accept 메소드에서 어떠한 Visitor이든 다 받을 수 있기때문에, Visitor 클래스 생성으로 다른 코드의 수정 일절 없이 새로운 기능을 추가할 수 있다.

단점

복합하고 더블 디스패치 문제가 있으며, 객체의 상속구조가 바뀌면 모든 Visitor 클래스를 변경해야되는 문제가 존재한다.

성능 최적화

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;
    ...
}

즉시로딩으로 설정한 연관관계 에 대해서, em.find()릍 통해 조회를 수행하면 join을 이용해서 연관된 엔티티 까지 한번에 조회를 진행한다

em.find(Member.class, id);

//실행 SQL
/*
SELECT M.*, O.*
FROM MEMBER M OUTER JOIN ORDERS O ON M.ID = O.MEMBER_ID
*/

하지만, jpql을 이용하게 되면 여러번의 sql이 실행되는 문제가 발생한다. sql는 우선 엔티티가 즉시로딩인지 여부를 상관하지 않고, query를 있는 그대로 실행한다. 하지만, 해당 엔티티와 연관된 엔티티가 즉시로딩으로 설정되어 있어 다시 sql 문을 통해 연관된 엔티티를 조회하게 된다. 이때, 처음 조회한 데이터 개수만큼 추가적인 sql을 실행하게 되는 데 이를 N+1 문제라고 한다.

SELECT * FROM MEMBER //1 실행으로 회원 여러명 조회
SELECT * FROM ORDERS WHERE MEMBER_ID = 1; //회원과 연관된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID = 2; //회원과 연관된 주문

해당 로딩 부분을 지연로딩으로 처리하더라도 N+1 문제는 해결되지 않는다. 어차피, 이후에 연관 엔티티에 접근하게 될때 프록시 객체에 대한 초기화를 해야되므로 N+1 문제는 여전히 발생한다.

페치 조인

페치 조인을 이용하면 N+1 문제를 해결할 수 있다.

//페치 조인 JPQL
select m from distinct Member m join fetch m.orders

//실행된 SQL
SELECT DISTINCT M.*, O.* FROM MEMBER M
INNER JOIN ORDERS O ON M.ID=O.MEMBER_ID

위와 같이 페치 조인을 이용하면 연관된 엔티티를 join을 이용해서 한번에 조회할 수 있다. 일대다 페치 조인을 수행시, 쿼리 결과가 중복될 수 있기 때문에 distinct 처리를 해준다.

하이버네이트 @BatchSize

연관된 엔티티 조회 시, 지정한 size 만큼 SQL in 절을 통해 조회할 수 있다. 즉, 조회된 회원이 10명일때, size을 5로 설정하면, 2번의 sql문으로 조회를 수행할 수 있다.

@Entity
public class Member {
    ...
    @org.hibernate.annotaions.BatchSize(size = 5)
    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
    ...
}
SELECT * FROM ORDERS WHERE MEMBER_ID IN (?????)

만약 10개의 회원 데이터가 있닥로 가정하면,

즉시 로딩으로 설정하게 되면, 조회 시 2번의 sql이 실행되고,

지연로딩으로 설정시, 해당 객체에 접근하게 될때 처음 5개의 연관된 엔티티를 로딩하게 되고, 6번째 엔티티에 접근할때 나머지 5개의 엔티티를 조회한다.

<property name="hibernate.default_batch_fetch_size" value="5" />

위와 같이 설정하면, 어플리케이션 전반에 걸쳐서 글로벌하게 batchsize을 지정할 수 있다.

하이버네이트 @Fetch , FetchMode.SUBSELECT

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>();
		...
}

즉시 로딩, 지연로딩으로 설정에 따라 sql 실행 시점이 달라지지만 실행 내용은 달라지지 않는다.

만약, JPQL로 식별자가 값이 10을 초과하는 회원을 모두 조회하는 경우 아래와 같이 서브쿼리를 통해 실행된다.

select m from Member m where m.id > 10

SELECT O FROM ORDERS O 
	WHERE O.MEMBER_ID IN (
			SELECT 
					M.ID
			FROM
					MEMBER M
			WHERE M.ID > 10
)

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

엔티티를 영속성 컨텍스트에서 관리하게 되면 변경 감지 및 1차 캐시, 등 다양한 기능을 활용할 수 있지만, 스냅샷 인스턴스를 보관해서 메모리를 많이 차지하는 문제가 발생한다.

그러니, 단순 화면 출력을 위한 쿼리 수행을 할때에는, 읽기 전용쿼리를 통해 성능을 최적화 할 수 있다.

스칼라 타입으로 조회

스칼라 타입으로 조회를 하게 되면 영속성 컨텍스트에 저장되지 않는다.

select o.id, o.name, o.price from Order p

읽기 전용 쿼리 힌트 사용

TypedQuery<Order> query = em.createQuery("select o from Order o", Order.class);
query.setHint("org.hibernate.readOnly", true);

읽기 전용을 위한 jpa 힌트를 적용하면 스냅샷을 보관하지 않게 된다.

읽기 전용 트랜잭션

@Transactional(readOnly=True)

트랜잭션을 읽기 모드 전용으로 수행해서 해당 트랜잭션 내에서 플러시가 발생하지 않도록 한다.

트랜잭션 밖에서 읽기

트랜잭션 없이 엔티티를 읽게 되는 경우, 트랜잭션이 없는 상태에서는 DB에 수정사항 반영이 불가 하므로, 영속성 컨텍스트가 플러시 되는 것을 방지한다.

아래와 같이 설정하여 트랜잭션이 전파되지 않도록 설정한다.

@Transactional(propagation = Propagation.NOT_SUPROTED) //Spring

트랜잭션이 없는 상황에서 강제로 플러시를 수행하게 되면 TransactionRequiredException 에러가 발생한다.

스프링 컨테이너를 활용하는 경우에는 아래와 같이 읽기전용 트랜잭션을 만들고, 안에서 읽기 전용 쿼리를 동시에 수행하는 것이 가장 효율적이다.

@Transactional(readOnly = true) //읽기 전용 트랜잭션 -- 1
public List<DataEntity> findDatas() {
    return em.createQuery("select d from DataEntity d", DataEntity.class)
        .setHint("org.hibernate.readOnly", true) //읽기 전용 쿼리 힌트 --2
        .getResultList();
}

배치 처리

수백만 건 이상의 데이터를 배치 처리해야되는 상황에서 엔티티를 조회하게 되면 영속성 컨텍스트에 엔티티가 많이 쌓이게 되면서 메모리 부족 현상이 발생할 수 있다. 따라서 배치 처리에 대한 적절한 작업을 처리해야된다.

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();

위와 같은 경우 100000개의 엔티티를 등록하는 과정에서, 100개 정도 쌓이게 되면 영속성 컨텍스트에 등록된 엔티티를 플러시하고 초기화 해주는 작업을 수행해, 영속성 컨텍스트가 꽉차는 문제를 방지한다.

JPA 페이징 배치 처리

JPA에서 제공해주는 페이징 기능을 활용해서 페이지 마다 영속성 컨텍스트를 플러시하고 초기화 해 줄 수 있도록 한다.

EntityManger 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)
        .setMaxResult(pageSize)
        .getResultList();

    for(Product product : resultList) {
        product.setPrice(product.getPrice() + 100);
    }
    
    em.flush();
    em.clear();
}

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

하이버네이트 scroll

JPA에서는 JDBC 커서를 지원하지 않는데, 대신 hibernate scroll을 통해 커서를 이용할 수 있다.

EntityTransaction tx = em.getTransaction();
//하이버네이트 세션은 JPA 엔티티 매니저를 구현한 구현체이므로, 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();

scroll 메소드를 이용해서 ScrollableResults을 받아오고, scroll.next()을 이용해서 다음 엔티티에 순차적으로 접근한다.

하이버네이트 무상태 세션

영속성 컨텍스트를 사용하지 않고, 2차 캐시도 이용하지 않는다. 영속성 컨텍스트가 없기 때문에, 플러시 및 초기화 작업이 이루어지지 않는다. 대신, 업데이트를 수행하고자 하면 session의 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 힌트 사용

SQL 실행시 hint을 부여해 특정 방식대로 실행할 수 있다. JPA에서는 쿼리 힌트를 제공하지 않기 때문에, hibernate를 통해서 sql 힌트를 적용할 수 있다.

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, m.name from Member m

현재 하이버네이트는 oracle에서 제공한 sql 힌트에 대해서만 적용가능한데, 다른 DB의 sql 힌트를 사용하고자 하면 org.hibernate.dialect.Dialect 에 있는 getQueryHintString 메소드를 오버라이딩 해야한다.

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

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

insert (member1); //INSERT INTO ...
insert (member2); //INSERT INTO ...
insert (member3); //INSERT INTO ...

commit();

위와 같이 매 insert 마다 DB에 sql문을 수행하는 경우, 매 SQL 마다 네트워크 호출을 해, 매우 큰 통신 비용(latency)가 발생하게 된다. 따라서 이러한 INSERT문을 묶어서 배치 방식으로 SQL을 실행하게 되면 네트워크 호출을 최소화 시킬 수 있다.

JDBC에서 제공하는 SQL 배치 기능을 적용하기 위해서는 많은 부분의 코드 수정이 요구되고, 비즈니스 로직이 복잡한 경우 적용하기 어려워 수천건 이상의 데이터를 수정해야하는 경우에만 SQL 배치기능을 이용한다.

JPA를 이용하면 SQL 기능을 효과적으로 사용할 수 있게 된다.

JPA 방식에서는 단순히 아래와 같이 BatchSize을 지정해주게 되면 등록,수정,삭제 과정에서 SQL 배치 기능을 자동으로 활용할 수 있다.

<property name="hibernate.jdbc.batch_size" value="50"/>

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~4 번까지 배치 기능을 수행하고, 5번을 따로 수행하고, 6~7 번에 대한 배치 기능을 수행한다. 이처럼, 다른 엔티티가 중간에 있는 경우 배치 기능을 끊고 새로운 배치를 수행하게 된다.

트랜잭션 방식의 쓰기 지연이 가지는 확장성

쓰기 지연, 변경 감지, 지연 로딩 방식을 통해 성능, 개발의 편의성을 보장한다.

하지만 최대의 장점은 로우 단위 락 시간을 최소화한다는 점이다. 트랜잭션 커밋 이후 영속성 컨텍스트를 플러시 하기 이전까지는 데이터에 대한 등록, 수정, 삭제를 진행하지 않아 커밋직전까지 로우에 락을 걸지 않는다.

update(memberA)
logicA();
logicB();
commit();

원래 기존대로 jdbc를 활용하게 되면 update를 수행할 때, 락을 걸게 된다. 그리고 commit 하기 전까지 락이 유지된다. 그렇게 되면 락이 유지되는 기간 동안에는 다른 애플리케이션에서는 해당 데이터에 대한 접근을 할 수 없다.

JPA는 반면, 커밋을 수행할 때 영속성 컨텍스트를 플러시하게 된다. 즉 플러시 하게 될때 update 쿼리를 수행하게 되어 데이터에 대한 락 시간을 최소화하게 된다.

사용자가 증가하여, 증가하는 요청을 처리하기 위해 어플리케이션 서버를 증축했다고 해보자. 그렇게 되면 증가되는 요청을 처리하기 위해 더 많은 트랜잭션이 실행되고, 또 그 만큼 데이터베이스에 대한 락 시간도 증가하게 된다. 그러면 다른 트랜잭션에서는 해당 데이터에 접근할 수 없게 되어 latency가 증가하게 된다. JPA는 쓰기 지연을 통해 락 시간을 최소화 해서 성능을 최적화하게 된다.

References

book: 자바 ORM 표준 JPA 프로그래밍 -김영한 저

book_link

태그: ,

카테고리:

업데이트:

댓글남기기