스터디/JPA 스터디

[JPA] 고급 주제와 성능 최적화

제이온 (Jayon) 2022. 3. 22.

jpa-study에서 스터디를 진행하고 있습니다.

예외 처리

JPA 표준 예외 정리

  • JPA 표준 예외는 javax.persistence.PersistenceException 의 자식 클래스이며, 해당 클래스는 RuntimeException의 자식 클래스이므로 Unchecked Exception이다.
  • JPA 표준 예외는 트랜잭션 롤백을 표시하는 예외와 롤백 표시를 개발자가 선택할 수 있는 예외로 나뉜다.

 

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

  • 서비스 계층에서 JPA의 예외를 직접 사용하면 JPA에 의존적이므로 스프링 프레임워크는 데이터 접근 계층에 대한 예외를 추상화하여 개발자에게 제공한다.

 

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

  • JPA 예외를 스프링 프레임어크가 제공하는 추상화된 예외로 변경하려면 PersistenceExceptionTranslationPostProcessor 를 스프링 빈으로 등록하면 된다.
  • 이것은 @Repository 어노테이션을 사용한 곳에 예외 변환 AOP를 적용해서 JPA 예외를 스프링 프레임워크가 추상화한 예외로 변환해 준다.

 

@Bean
public PersistenceExceptionTranslationpostProcessor exceptiobnTranslation() {
    return new PersistenceExceptionTranslationPostProcessor();
}

 

  • 만약 특정 예외는 JPA 예외 그대로 반환하게 하고 싶다면 메소드 옆에 throws javax.persistence.NoResultException 을 붙이면 된다.

 

트랜잭션 롤백 시 주의 사항

  • 트랜잭션을 롤백하는 것은 데이터베이스의 반영 사항만 롤백하는 것이지, 수정한 자바 객체까지 원 상태로 복구해 주지 않는다.
  • 따라서 영속성 컨텍스트를 새롭게 생성하거나 EntityManager.clear() 를 통해 영속성 컨텍스트를 초기화해야 한다.
  • 스프링 프레임워크는 위 문제를 예방하기 위하여 영속성 컨텍스트의 범위에 따라 다른 방법을 적용한다.
    • 기본 전략 - 트랜잭션 당 영속성 컨텍스트
      • 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트 종료
    • OSIV - 영속성 컨텍스트의 범위를 트랜잭션 범위보다 넓게 사용
      • 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용하는 상황임.
      • 이때는 트랜잭션 AOP 롤백 시, 영속성 컨텍스트를 초기화한다.

 

엔티티 비교

  • 영속성 컨텍스트 내부에는 엔티티 인스턴스를 보관하는 1차 캐시가 있으며, 애플리케이션 수준의 반복 가능한 읽기 기능을 제공한다.
  • 같은 영속성 컨텍스트에서 엔티티를 조회하면 주소 값이 같은, 항상 동일한 인스턴스를 반환한다. (== 비교)

 

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

@SpringBootTest
@Transactional
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 {
    ...
}

 

위와 같이 테스트 클래스 위에 @Transactional을 붙이게 되면 트랜잭션 범위는 아래 사진이 된다.

 

Untitled

 

따라서 테스트 메소드인 register() 는 이미 트랜잭션 범위에 들어있고, 이 메소드가 끝나면 트랜잭션이 종료된다. 그러므로 register() 에서 사용된 코드는 항상 같은 트랜잭션과, 같은 영속성 컨텍스트에 접근한다. 이러한 이유로 memberfindMember 는 애플리케이션 수준의 반복 가능한 읽기 기능에 따라 동일한 객체임을 보장한다.

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

 

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

이번에는 테스트 클래스의 @Transactional를 제거해 보자.

 

@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() 테스트는 실패한다. 그 이유는 아래를 살펴 보자. (사진은 findOne() 으로 되어 있는데, findById() 로 생각해도 된다.

 

Untitled

 

  1. 테스트 코드에서 memberService.join(member) 를 호출해서 회원 가입을 시도하면 서비스 계층에서 트랜잭션이 시작되고 영속성 컨텍스트 1이 만들어진다.
  2. memberRepository에서 em.persist() 를 호출해서 member 엔티티를 영속화한다.
  3. 서비스 계층이 끝날 때 트랜잭션이 커밋되면서 영속성 컨텍스트가 플러시된다. 이때 트랜잭션과 영속성 컨텍스트가 종료된다. 따라서 member 엔티티 인스턴스는 준영속 상태가 된다.
  4. 테스트 코드에서 memberRepository.findById(saveId) 를 호출해서 저장한 엔티티를 조회하면 Repository 계층에서 새로운 트랜잭션이 시작되면서 새로운 영속성 컨텍스트 2가 생성된다.
  5. 저장된 회원을 조회하지만, 새로 생성된 영속성 컨텍스트 2에는 찾는 회원이 없다.
  6. 따라서 데이터베이스에서 회원을 찾아 온다.
  7. 데이터베이스에서 조회된 회원 엔티티를 영속성 컨텍스트에 보관하고 반환한다.
  8. memberRepository.findById() 메소드가 끝나면서 트랜잭션이 종료되고 영속성 컨텍스트2도 종료된다.

 

따라서 위 상황에서 member와 findMember를 비교하려면 동일성이 아닌 동등성 비교를 해야 한다. 이때 서로의 PK 값이 같으면 같은 객체로 판단하게끔 equals() 메소드를 재정의하는 것이 좋다.

 

프록시 심화 주제

영속성 컨텍스트와 프록시

  • 프록시 객체를 먼저 조회하고, em.find() 로 엔티티 조회하면 앞서 조회한 프록시 객체를 반환해 주며, 서로 동일한 객체가 된다.
  • em.find() 로 엔티티 조회하고, 프록시 객체를 조회하면, 앞서 조회한 원본 엔티티 객체를 반환해 주며, 서로 동일한 객체가 된다.

 

프록시 타입 비교

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

 

프록시 동등성 비교

엔티티의 동등성을 비교하려면 PK와 같은 키를 사용해서 equals() 메소드를 오버라이딩하고 비교하면 된다. 그런데 IDE를 사용해서 equals() 메소드를 자동 완성하고, 프록시 엔티티를 비교 대상으로 삼으면 문제가 발생할 수 있다.

 

@Override
public boolean equals(Object obj) {
    if (this == obj) {
        return true;
    }
    if (obj == null) {
        return false;
    }
    if (this.getClass != obj.getClass()) {
        return false;
    }

    Member member = (Member) obj;
    if (id != null ? !id.equals(member.id) :
        member.id!= null) {
        return false;
    }
    return true;
}

 

우선 프록시는 원본 엔티티를 상속 받은 자식 타입이므로 프록시의 타입을 비교할 때는 getClass() 가 아니라 instanceof를 사용해야 한다. 또한, id 멤버 변수는 private이므로 일반적인 상황에서는 프록시의 멤버 변수에 접근할 일이 없지만, equals() 는 자신을 비교하기 때문에 private 멤버 변수에도 접근할 수 있다. 즉, 프록시의 데이터를 조회할 때는 getter를 사용해야 한다. 정리하면, 아래 코드가 된다.

 

@Override
public boolean equals(Object obj) {
    if (this == obj) {
        return true;
    }
    if (obj == null) {
        return false;
    }
    if (!(obj instanceof Member)) {
        return false;
    }

    Member member = (Member) obj;
    if (id != null ? !id.equals(member.getId()) :
        member.getId()!= null) {
        return false;
    }
    return true;
}

 

상속 관계와 프록시

Untitled

 

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

 

@Test
void view_parent_object_by_proxy() {
    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()); // proxyItem = class jpabook.proxy.advanced.item.Item_$$_jvstXXX

    if (proxyItem instanceof Book) { // if문 조건이 false.
        Book book = (Book) proxyItem;
        System.out.println("책 저자 = " + book.getAuthor()); // 출력 안됨.
    }

    assertThat(proxyItem.getClass() == Book.class).isTrue(); // 실패
    assertThat(proxyItem instanceof Book).isTrue(); // 실패
    assertThat(proxyItem instanceof Item).isTrue(); // 성공

 

em.getReference() 메소드에서 Item 엔티티를 대상으로 조회했으므로 프록시인 proxyItem은 Item 타입을 기반으로 만들어진다. 그래서 proxyItem은 Item$Proxy 타입이므로 Book 타입과는 관계가 없다. 정리하자면, 프록시를 부모 타입으로 조회하면 instanceof 연산을 사용하여 하위 타입으로 비교할 수 없고, 하위 타입으로 다운 캐스팅을 할 수 없다.

위 문제는 주로 다형성을 다루는 도메인 모델에서 발생한다.

 

@Entity
public class OrderItem {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "ITEM_ID")
    private Item item; // 프록시 객체로 조회됨.

    ...
}

 

상속 관계에서 발생하는 프록시 문제를 해결하는 방법을 살펴 보자.

 

JPQL로 대상 직접 조회

Book book = em.createQuery
    ("select b from Book b where b.id=:bookId", Book.class)
        .setParameter("bookId", item.getId())
        .getSingleResult();

 

처음부터 자식 타입을 조회하는 방법이 있으나, 이것은 다형성을 활용할 수 없다.

 

프록시 벗기기

하이버네이트가 제공하는 unProxy() 메소드를 통해 프록시에서 원본 엔티티를 가져올 수 있다.

 

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

Untitled

 

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

 

public interface TitleView {

    String getTitle();
}

// 이후 하위 클래스에서 getTitle() 재정의

 

이처럼 인터페이스를 제공하고 각각의 클래스가 자신에 맞는 기능을 구현하여 다형성을 활용하는 것이 좋다. 이 방법은 클라이언트 입장에서 대상 객체가 프록시인지 아닌지 고민 할 필요가 없다. 즉, 프록시를 다운캐스팅하거나 벗기는 일 없이 그대로 getTitle() 메소드를 호출하면 된다.

 

비지터 패턴 사용

Untitled

 

비지터 패턴은 Visitor와 Visitor를 받아 들이는 대상 클래스로 구성된다.

 

public interface Visitor {

    void visit(Book book);
    void visit(Album album);
    void visit(Movie movie);
}

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) {...}
}

@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) 를 호출하면, 자신(this)을 visit(this) 의 파라미터로 넘긴다. 이렇게 하여 실제 로직 처리를 visitor에 위임한다.

 

@Test
void inheritance_proxy_visitor_pattern() {
    ...
    item.accept(new PrintVisitor());
}

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

 

동작 과정은 그림을 보자.

 

Untitled

 

  1. item.accept() 메소드를 호출하여 파라미터로 PrintVisitor를 넘겨준다.
  2. item은 프록시이므로 먼저 프록시가 accept() 메소드를 받고, 원본 엔티티(book)의 accept() 를 실행한다.
  3. 원본 엔티티(book)는 자신(this)을 visitor에 파라미터로 넘겨 준다.
  4. visitor가 PrintVisitor 타입이므로 PrintVisitor.visit(this) 메소드가 실행된다.
  5. this가 Book 타입이므로 visit(Book book) 메소드가 실행된다.

 

[비지터 패턴의 장단점]

  • 장점
    • 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있다.
    • instanceof와 타입 캐스팅 없이 코드를 구현할 수 있다.
    • 알고리즘과 객체 구조를 분리하여 구조를 수정하지 않고 새로운 동작을 추가할 수 있다.
  • 단점
    • 너무 복잡하고 더블 디스패치를 사용하기 때문에 이해하기 어렵다.
    • 객체 구조가 변경되면 모든 Visitor를 수정해야 한다.

 

성능 최적화

N + 1 문제

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

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

    ...
}

@Entity
@Table("name = ORDERS")
@public class Order {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    private Member member;
}

 

회원과 주문 정보는 1:N 양방향 관계이며 Member의 orders는 즉시 로딩으로 설정하였다.

 

즉시 로딩과 N + 1

특정 회원 하나를 em.find() 메소드로 조회하면 즉시 로딩으로 설정한 주문 정보도 같이 조회해 온다. 한 번의 조인문으로 연관된 정보를 가져올 수 있으니 효율적일 것 같지만, JPQL을 사용할 때 N + 1 문제가 발생한다.

JPQL을 실행하면 JPA는 이것을 분석하여 SQL을 생성하는데, 즉시 로딩이나 지연 로딩과는 관계 없이 오직 JPQL만 사용해서 SQL을 생성하므로 SELECT * FROM MEMBER 쿼리가 날아가게 된다. 문제는 회원 엔티티와 연관된 주문 컬렉션이 즉시 로딩으로 설정되어 있으므로 JPQ는 주문 컬렉션을 즉시 로딩하려고 SELECT * FROM ORDERS WHERE MEMBER_ID = ? 쿼리를 추가로 날린다. 이때 회원 N명에 대해 주문 컬렉션 가져오는 쿼리는 날리므로 총 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<>();

    ...
}

 

위와 같이 지연 로딩으로 설정하면 조회할 때 N + 1 문제가 발생하지 않는다. 다만, 비즈니스 로직에서 프록시 주문 컬렉션으로 실제 사용할 때 SELECT * FROM ORDERS WHERE MEMBER_ID = ? 쿼리가 날아게 되므로 똑같이 N + 1 문제가 발생한다. 단지, 시점만 뒤로 미뤄졌을 뿐이다.

 

페치 조인 사용

select m from Member m join fetch m.orders 와 같이 페치 조인을 사용하면 조인을 통해 연관된 엔티티를 함께 조회하여 N + 1 문제를 피할 수 있다. 다만 일대다 관계에 대해 페치 조인을 하면, 외래 키가 N쪽에 있으므로 중복된 Member 엔티티가 발생하므로 distinct를 사용해야 한다.

 

하이버네이트 @BatchSize

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

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

    ...
}

 

위와 같이 @BatchSize를 사용하고 size를 5로 주면, 지연 로딩으로 설정했을 때 지연 로딩된 엔티티를 최초 사용하는 시점에 SELECT * FROM ORDERS WHERE MEMBER_ID IN (?, ?, ?, ?, ?) 을 실행해서 5건의 데이터를 미리 로딩해 두었다가 6번째 데이터를 사용하면 다음 SQL를 실행한다. 즉시 로딩으로 설정된 엔티티는 조회 시점에 10건의 데이터를 모두 수행해야 하므로 위 쿼리가 2번 실행된다.

 

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

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

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

    ...
}

 

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

만약 select m from Member m where m.id > 10 JPQL을 실행할 때, 즉시 로딩이면 아래 SQL을 곧바로, 지연 로딩이면 사용 시점에 아래 SQL이 실행된다.

 

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

 

N + 1 정리

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

 

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

엔티티가 영속성 컨텍스트에 관리되면 1차 캐시부터 변경 감지까지 얻을 수 있는 혜택이 많으나, 영속성 컨텍스트는 변경 감지를 위해 스냅샷 인스턴스를 보관하므로 더 많은 메모리를 사용한다. 그래서 엔티티를 읽기만 하고 다른 용도로 사용하지 않을 때는 읽기 전용으로 엔티티를 조회하여 메모리 사용량을 최적화할 수 있다. 다음 JPQL을 최적화해 보자.

 

select o from Order o

 

스칼라 타입으로 조회

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

 

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

 

읽기 전용 쿼리 힌트 사용

하이버네이트 전용 힌트인 org.hibernate.readOnly 를 사용하면 엔티티를 읽기 전용으로 조회할 수 있다. 읽기 전용이므로 영속성 컨텍스트는 스냅샷을 보관하지 않는다.

 

읽기 전용 트랜잭션 사용

@Transactional(readOnly = true) 옵션을 주면, 스프링 프레임워크가 하이버네이트 세션의 플러시 모드를 MANUAL로 설정한다. 이렇게 하면 강제로 플러시를 호출하지 않는 한 플러시가 일어나지 않고, 트랜잭션을 커밋해도 영속성 컨텍스트를 플러시하지 않는다. 따라서 엔티티의 등록, 수정, 삭제가 동작하지 않으며 플러시를 하더라도 스냅샷 비교와 같은 무거운 로직이 수행되지 않는다.
물론 트랜잭션을 시작했으므로 트랜잭션 시작, 로직 수행, 트랜잭션 커밋 과정은 일어나지만, 영속성 컨텍스트를 플러시하지 않는다.

 

트랜잭션 밖에서 읽기

@Transactional(propagation = Propagation.NOT_SUPPORTED) 옵션을 주면, 트랜잭션 없이 엔티티를 조회하게 된다. 트랜잭션을 사용하지 않으면 플러시가 일어나지 않고, 트랜잭션 자체가 없으므로 커밋할 일이 없다.

 

  • 플러시를 작동하지 않도록 하여 성능 향상
    • 읽기 전용 트랜잭션 사용
    • 트랜잭션 밖에서 읽기
  • 스냅샷을 저장하지 않도록 하여 메모리 공간 향상
    • 읽기 전용 쿼리 힌트 사용

 

배치 처리

JPA 등록 배치

수 천에서 수 만 건의 엔티티를 한 번에 등록할 때 영속성 컨텍스트에 엔티티가 계속 쌓이면 안 되고, 중간 중간 플러시하고 영속성 컨텍스트를 초기화해야 한다.

 

JPA 페이징 배치 처리

수정 배치 처리할 때 사용된다. 크게 2가지 방법이 쓰인다.

 

  • 하이버네이트의 커서
  • 하이버네이트 무상태 세션
    • 영속성 컨텍스트 및 2차 캐시를 사용하지 않는다.

 

SQL 쿼리 힌트 사용

  • 데이터베이스 벤더에게 제공하는 SQL 힌트를 JPA는 제공하지 않는다. 그래서 SQL 힌트를 사용하고 싶다면 하이버네이트를 직접 사용해야 한다.
  • addQueryHint("FULL (MEMBER)")

 

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

  • hibernate.jdbc.batch_size 속성의 값을 50으로 주면 최대 50건씩 모아서 SQL 배치를 실행한다. 이때 같은 SQL일 때만 유효하고, 만약 중간에 다른 처리가 들어가면 SQL 배치를 다시 시작한다.
  • 트랜잭션을 지원하는 쓰기 지연과 변경 감지 기능은 성능과 개발의 편의성 및 데이터베이스 row에 락이 걸리는 시간을 최소화한다.
    • 트랜잭션을 커밋해서 영속성 컨텍스트를 플러시하기 전까지 데이터베이스에 데이터를 등록, 수정, 삭제하지 않는다.
    • 따라서 커밋 직전까지 데이터베이스 row에 락을 걸지 않는다.
    • 만약 JPA를 사용하지 않고 SQL을 직접 다루면 update() 메소드를 호출할 때, 즉시 UPDATE SQL을 실행하여 데이터베이스 테이블 row에 락을 걸게 된다.

 

출처

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

댓글

추천 글