기록

[자바 ORM 표준 JPA 프로그래밍] 예외 처리, 엔티티 비교, 프록시 문제 및 성능 최적화(15장) 본문

교육/책

[자바 ORM 표준 JPA 프로그래밍] 예외 처리, 엔티티 비교, 프록시 문제 및 성능 최적화(15장)

youngyin 2024. 12. 14. 19:00

JPA 활용과 심화 이해

이 글은 "자바 ORM 표준 JPA 프로그래밍" 책의 주요 내용을 바탕으로 작성되었습니다. 특히 15장에서 다룬 예외 처리, 엔티티 비교, 프록시 문제 및 성능 최적화에 관한 내용을 정리한 기록입니다.


1. 예외 처리

1.1 JPA 예외 개요

JPA를 사용할 때 주로 발생하는 예외는 두 가지로 구분됩니다.

  1. 트랜잭션 롤백이 필요한 예외
    • 데이터베이스 상태를 되돌려야 할 정도로 심각한 문제.
    • 예시: DataIntegrityViolationException, OptimisticLockingFailureException
    • 롤백이 필요한 이유는 데이터 무결성 위반이나 잠금 충돌 같은 경우 데이터의 일관성을 보장해야 하기 때문입니다.
  2. 트랜잭션 롤백이 필요하지 않은 예외
    • 시스템에 큰 영향을 주지 않고 처리 가능한 오류.
    • 예시: QueryTimeoutException, NonTransientDataAccessException
    • 가벼운 문제로 간주되며 작업을 중단하지 않아도 됩니다.

스프링 프레임워크는 JPA 예외를 추상화된 형태로 변환하여 DataAccessException 계층의 예외로 제공함으로써 특정 기술에 종속되지 않는 설계를 지원합니다. 주요 예외 유형은 다음과 같습니다:

  • DataIntegrityViolationException: 데이터 무결성 위반 시 발생.
  • OptimisticLockingFailureException: 낙관적 락 충돌 시 발생.
  • QueryTimeoutException: 쿼리 시간 초과 시 발생.

이처럼 스프링을 통해 변환된 예외는 비즈니스 로직에서 일관된 방식으로 처리할 수 있어 유지보수가 용이합니다.

1.2 스프링에서 JPA 예외 처리 적용 방법

스프링은 PersistenceExceptionTranslationPostProcessor라는 컴포넌트를 제공하며, 이를 빈으로 등록하면 @Repository가 붙은 클래스에서 발생하는 JPA 예외가 자동으로 스프링 예외로 변환됩니다.

1.3 트랜잭션 롤백 처리 시 주의점

트랜잭션 롤백은 데이터베이스 작업을 취소하지만, 영속성 컨텍스트에서 관리 중인 엔티티의 상태는 초기화되지 않습니다. 이를 해결하기 위해 다음 방법 중 하나를 사용해야 합니다:

  1. 새로운 영속성 컨텍스트 생성: 현재 컨텍스트를 대체합니다.
  2. EntityManager.clear()** 호출**: 기존 컨텍스트를 수동으로 초기화하여 불필요한 데이터가 남지 않도록 합니다.

2. 엔티티 비교

2.1 같은 영속성 컨텍스트에서의 비교: 동일성 보장

같은 영속성 컨텍스트에서 조회된 엔티티는 동일성을 보장합니다. 즉, 두 엔티티가 메모리 주소가 같은 객체로 간주됩니다:

Member findMember = memberRepository.findOne(saveId);
assertTrue(member == findMember); // true

2.2 다른 영속성 컨텍스트에서의 비교: equals 메서드를 구현

서로 다른 트랜잭션(영속성 컨텍스트)에서 조회된 엔티티는 동일하지 않으며, 비교 결과가 달라질 수 있습니다:

assertTrue(member == findMember); // false

이 문제를 해결하려면 비즈니스 키(예: ID)를 기반으로 equals() 메서드를 적절히 구현해 엔티티를 비교해야 합니다.


3. 프록시 문제와 해결

3.1 프록시와 동일성: 프록시로 조회된 엔티티도 동일성을 보장

JPA는 성능 최적화를 위해 실제 엔티티 대신 프록시 객체를 반환합니다. 프록시로 조회된 엔티티는 동일성을 보장하므로 원본 엔티티와 같은 방식으로 비교할 수 있습니다:

JPA는 성능 최적화를 위해 실제 엔티티 대신 프록시 객체를 반환합니다. 그러나 프록시로 조회된 엔티티도 동일성을 보장하므로 원본 엔티티와 같은 방식으로 비교할 수 있습니다:

Member refMember = em.getReference(Member.class, "member1");
Member findMember = em.find(Member.class, "member1");
assertTrue(refMember == findMember); // true

3.2 프록시와 타입 비교: 프록시 타입은 instanceof로 확인

프록시는 실제 엔티티의 자식 클래스이므로, 타입 비교 시 instanceof를 사용하는 것이 안전합니다:

프록시는 실제 엔티티의 자식 클래스이므로, 타입 비교 시 instanceof를 사용하는 것이 안전합니다:

assertTrue(refMember instanceof Member);

3.3 상속 관계와 프록시 문제: 프록시를 부모 타입으로 조회할 때 문제 해결

상속 관계에서 프록시를 부모 타입으로 조회하면 예상치 못한 문제가 발생할 수 있습니다. 예를 들어, 부모 클래스 Item과 이를 상속받는 Book 클래스가 있을 때, Item 타입으로 프록시를 조회하면 구체 클래스인 Book의 메서드나 필드에 접근할 수 없습니다:

Item proxyItem = em.getReference(Item.class, bookId);
proxyItem.getAuthor(); // 컴파일 오류 또는 런타임 오류 발생

이 문제를 해결하려면 다음과 같은 방법을 고려해야 합니다:

  1. JPQL을 사용해 자식 엔티티를 명시적으로 조회이렇게 하면 정확히 자식 엔티티 타입을 조회할 수 있습니다.
Book book = em.createQuery("SELECT b FROM Book b WHERE b.id = :id", Book.class) 
    .setParameter("id", bookId) 
    .getSingleResult();
  1. 프록시 초기화
    Hibernate에서는 프록시를 초기화하여 실제 엔티티로 변환할 수 있습니다:
Item proxyItem = em.getReference(Item.class, bookId); 
if (proxyItem instanceof HibernateProxy) { 
    proxyItem = ((HibernateProxy) proxyItem)
        .getHibernateLazyInitializer()
        .getImplementation(); 
}
  1. 인터페이스 설계를 통해 다형성을 활용
    공통 기능을 정의한 인터페이스를 설계하고 이를 구현함으로써 구체 클래스에 의존하지 않고도 원하는 동작을 수행할 수 있습니다.
public interface Item { 
    String getDetails(); 
} 

public class Book implements Item { 
    @Override 
    public String getDetails() { 
        return "Author: " + this.author; 
    } 
}
  1. 비지터 패턴 적용으로 코드의 유연성을 높이기
    비지터 패턴을 사용하면 객체의 구체적인 타입에 따라 다르게 동작하도록 설계할 수 있습니다.
public interface ItemVisitor { 
    void visit(Book book); 
} 

public class ItemPrinter implements ItemVisitor { 
    @Override 
    public void visit(Book book) { 
        System.out.println("Book Author: " + book.getAuthor()); 
    } 
}

4. 성능 최적화

4.1 N+1 문제

4.1.1 N+1 문제란?

즉시 로딩(EAGER)이나 지연 로딩(LAZY) 설정으로 인해 연관된 데이터를 여러 번 추가 조회하는 문제가 발생합니다. 예를 들어, 회원과 주문 데이터 관계에서 모든 회원의 주문 데이터를 조회하려고 하면:

  1. 회원 데이터 조회
  2. 각 회원의 주문 데이터를 개별적으로 조회 (회원 수만큼 반복)

4.1.2 해결 방법

페치 조인 사용:

List<Member> members = em.createQuery( "SELECT m FROM Member m JOIN FETCH m.orders", Member.class ).getResultList();
// SELECT m.id, m.name, o.id, o.order_date 
// FROM members m 
// INNER JOIN orders o ON m.id = o.member_id;

@BatchSize설정

// SELECT o.id, o.order_date FROM orders o WHERE o.member_id IN (?, ?, ?, ...); -- 최대 100개의 ID
@BatchSize(size = 100) 
private List<Order> orders;

@Fetch(FetchMode.SUBSELECT)활용

// SELECT o.id, o.order_date FROM orders o 
// WHERE o.member_id IN ( SELECT m.id FROM members m );

@Fetch(FetchMode.SUBSELECT) 
private List<Order> orders;

4.2 읽기 전용 쿼리 최적화

읽기 전용 데이터를 처리할 때는 다음 방법으로 메모리 사용량을 줄일 수 있습니다:

스칼라 타입 조회:

List<Object[]> results = em.createQuery( "SELECT o.id, o.name FROM Order o" ).getResultList();

읽기 전용 힌트 사용:

query.setHint("org.hibernate.readOnly", true);

읽기 전용 트랜잭션 사용:

@Transactional(readOnly = true)

트랜잭션 외부 조회:

@Transactional(propagation = Propagation.NOT_SUPPORTED)

4.3 배치 처리

많은 데이터를 한 번에 처리할 때는 메모리 부족을 방지하기 위해 주기적으로 플러시(flush)와 초기화(clear)를 수행해야 합니다:

for (int i = 0; i < totalEntities; i += batchSize) {
    List<Product> products = em.createQuery("SELECT p FROM Product p")
                               .setFirstResult(i)
                               .setMaxResults(batchSize)
                               .getResultList();

    for (Product product : products) {
        product.setPrice(product.getPrice() + 100);
    }

    em.flush();
    em.clear();
}

4.4 SQL 힌트 사용

SQL 힌트를 사용하려면 하이버네이트 세션을 직접 이용해야 합니다. SQL 힌트는 데이터베이스에서 쿼리의 실행 계획을 제어하기 위해 제공되는 기능으로, 특정 상황에서 성능을 최적화하거나 원하는 방식으로 데이터를 조회하도록 설정할 수 있습니다. 예를 들어, 힌트를 사용해 특정 인덱스를 강제로 사용하거나 병렬 쿼리 실행을 활성화할 수 있습니다.

SQL 힌트의 실제 적용 예시

  1. 쿼리 힌트 적용:
    아래는 특정 인덱스를 강제로 사용하도록 SQL 힌트를 설정하는 예입니다.여기서 /*+ INDEX(tbl_name idx_name) */는 데이터베이스가 idx_name 인덱스를 사용하도록 지시합니다.
SELECT /*+ INDEX(tbl_name idx_name) */ col1, col2 FROM tbl_name WHERE col3 = 'value';
  1. 병렬 쿼리 실행:
    대량 데이터 조회 시 성능을 높이기 위해 병렬 처리를 요청할 수 있습니다.이 힌트는 쿼리가 병렬로 실행되도록 설정하며, 병렬 실행 스레드 수를 4로 지정합니다.
SELECT /*+ PARALLEL(4) */ col1, col2 FROM tbl_name;
  1. 하이버네이트와의 통합:
    하이버네이트를 통해 SQL 힌트를 적용하려면 다음과 같이 addQueryHint 메서드를 사용할 수 있습니다:
Session session = em.unwrap(Session.class); 
List<Member> list = session.createQuery("SELECT m FROM Member m") .addQueryHint("FULL(Member)") .list();

이러한 힌트를 적절히 사용하면 쿼리 성능을 높이고 데이터베이스 리소스를 효율적으로 활용할 수 있습니다.

4.5 쓰기 지연과 성능 최적화

JPA의 쓰기 지연 기능은 데이터베이스 락을 최소화하면서 성능을 높입니다. hibernate.jdbc.batch_size를 설정하면 배치 쿼리를 실행할 수 있습니다:

hibernate.jdbc.batch_size=50

이 설정은 여러 SQL 작업을 한 번의 플러시로 묶어서 처리합니다.

@BatchSize와의 차이점

  • hibernate.jdbc.batch_size:
    • 전역 설정으로, 특정 엔티티에 국한되지 않고 모든 배치 작업에 적용됩니다.
    • JDBC 레벨에서 쿼리를 모아서 실행합니다.
    • 주로 대량 INSERT, UPDATE, DELETE 작업에서 성능을 최적화합니다.
  • @BatchSize:
    • 특정 엔티티나 컬렉션 필드에 적용되는 설정입니다.
    • SELECT 쿼리에서 IN 절을 활용하여 여러 데이터를 한 번에 가져옵니다.
    • 연관된 엔티티의 조회 성능을 높이는 데 유용합니다.

5. 정리

JPA는 객체 지향 프로그래밍과 관계형 데이터베이스 간의 격차를 해소해주는 강력한 도구입니다. 그러나 이를 효율적으로 사용하려면 몇 가지 중요한 개념을 숙지해야 합니다.

  • 예외 처리: JPA 예외는 스프링의 예외 추상화를 통해 관리할 수 있으며, 롤백 여부에 따라 적절히 처리해야 합니다.
  • 엔티티 비교: 동일성과 동등성 비교의 차이를 이해하고, 비즈니스 키를 활용하여 equals()를 올바르게 구현해야 합니다.
  • 프록시: 프록시는 성능 최적화를 위해 제공되며, 타입 비교와 상속 구조에서 발생하는 문제를 해결하는 방법을 알아야 합니다.
  • 성능 최적화: N+1 문제를 방지하기 위해 페치 조인과 배치 설정을 활용하며, 쓰기 지연 및 SQL 힌트를 통해 쿼리 성능을 극대화할 수 있습니다.
Comments