Spring/Spring Data JPA

[자바 ORM 표준 JPA 프로그래밍] 15. 고급 주제와 성능 최적화

noahkim_ 2025. 4. 26. 07:40

김영한 님의 "자바 ORM 표준 JPA 프로그래밍" 책을 정리한 포스팅 입니다.


1. 예외 처리

JPA 예외 변환

  • 서비스 계층이 JPA 구현 기술에 의존하지 않게 하기 위해 예외를 추상화
항목 설명
기능 제공 클래스
PersistenceExceptionTranslationPostProcessor
적용 방식
@Repository가 붙은 클래스를 대상으로 AOP 적용
JPA 예외를 스프링 예외로 변환 (DataAccessException 등)

 

JPA 표준 예외

  • PersistenceException (RuntimeException 하위 클래스)
구분 설명
롤백 필수
(심각한 예외)
예외 복구 시, 커밋 
- EntityExistsException: 엔티티 영속화 시도 중, 이미 같은 엔티티가 있음

- EntityNotFoundException: 엔티티 참조 후 실제 사용 시, 엔티티가 존재하지 않음
- OptimisticLockException: 낙관적 락 충돌 시 발생
- PessimisticLockException: 비관적 락 충돌 시 발생
- RollbackException: 커밋 실패
- TransactionRequiredException: 트랜잭션이 필요할 때 트랜잭션이 없음 ex) 트랜잭션 없이 엔티티 변경
롤백 불필요 예외
(덜 심각)
예외 복구 시, 커밋 여부는 개발자 판단에 따라 결정
- NoResultException: Query.getSingleResult() 호출 시 결과가 하나도 없을 때
- NonUniqueResultException Query.getSingleResult() 호출 시 결과가 둘 이상일 때
- LockTimeoutException 비관적 락에서 시간 초과 시 발생
- QueryTimeoutException 쿼리 실행 시간 초과 시 발생

 

롤백 발생 시 유의사항
항목 설명
문제 DB와 영속성 컨텍스트의 상태가 불일치하게 됨
- DB: 커밋 자체가 안 되기 때문에 DB는 원래 상태 그대로 유지됨
- 영속성 컨텍스트: 이미 변경된 엔티티 상태를 계속 유지하고 있음
해결 방법
새로운 영속성 컨텍스트 생성 또는 em.clear()로 초기화 후 사용

 

롤백 규칙

예외 종류 자동 롤백 여부
RuntimeException
(예: IllegalArgumentException, javax.persistence.PersistenceException)
✅ 
Error ✅ 
CheckedException
(예: IOException, SQLException)

(명시적으로 rollbackFor 지정해야 함)

 

2. 엔티티 비교

  • 영속성 컨텍스트가 다르면 같은 행이더라도 동일성 불일치
  • 엔티티 비교 시, equals() 재정의하기 (동등성 일치 기반)

 

예제) equals()

더보기
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof User)) return false;

    User other = (User) o;

    return this.name.equals(other.getName());
}
  • @Id로만 동등성 판단하면 안됨 (영속화 안된 상태에선 null값이기 때문)

 

3. 프록시 심화 주제

주제 설명 주의사항
프록시 영속성 컨텍스트 프록시 객체와 실제 엔티티 객체 모두 동일성 보장
- getReference(): 프록시 객체 (HibernateProxy)
- find(): 실제 엔티티 객체
같은 식별자면 동일 객체 반환됨
프록시 타입 비교 프록시는 원본의 자식 클래스이므로 == 대신 instanceof 사용  
프록시 동등성 비교 equals() 오버라이딩 시 타입 비교와 getter 사용 필수
- 타입은 instanceof로 비교
- 필드는 getter로 접근
직접 멤버 변수 접근 시 null 발생
프록시 상속관계 부모 타입으로 프록시 조회 후, 다운캐스팅 시 ClassCastException 발생
- 자식 클래스를 포함할 뿐, 자식 클래스 타입과는 무관함
 
➡️ 해결책 1 JPQL로 자식 타입 직접 조회
em.createQuery(..., Book.class)
➡️ 해결책 2 프록시 벗기기 (unProxy)
원본 엔티티 꺼내기 (HibernateProxy)
➡️ 해결책 3 인터페이스 제공
다형성 유지 + 타입에 맞는 구현 가능
➡️ 해결책 4 비지터 패턴 (각 타입마다 visit() 구현)
역할 분리
다형성 유지

 

예제) 프록시 & 엔티티 동일성 비교

더보기
@Test
@Transactional
void equality() {
    User entity = entityManager.find(User.class, 1);
    User proxy = entityManager.getReference(User.class, 1);

    assertEquals(entity, proxy);
}

 

예제) 프록시 타입 비교

더보기
@Test
@Transactional
void type_compare() {
    User proxy = entityManager.getReference(User.class, 1);

    assertFalse(proxy.getClass() == User.class);
    assertTrue(proxy instanceof User);
}

 

해결책) 프록시 상속관계

더보기

JPQL로 자식 타입 직접 조회

// 부모 클래스의 프록시가 아닌, 자식 클래스의 객체를 직접 조회
String jpql = "SELECT c FROM Child c WHERE c.id = :id";
Child child = entityManager.createQuery(jpql, Child.class)
                           .setParameter("id", 1)
                           .getSingleResult();

// child는 자식 타입으로 안전하게 사용 가능
System.out.println(child.getSomeChildSpecificMethod());

 

프록시 벗기기 (unProxy)

Object proxy = entityManager.getReference(Parent.class, 1);

// 프록시 객체를 실제 엔티티로 변환
if (Hibernate.isInitialized(proxy)) {
    Parent parent = (Parent) proxy;
    System.out.println(parent.getName());
} else {
    Parent unproxiedParent = (Parent) Hibernate.unproxy(proxy);
    System.out.println(unproxiedParent.getName());
}

 

인터페이스 제공

// 공통 인터페이스 정의
public interface CommonInterface {
    void commonMethod();
}

// 부모 클래스 구현
@Entity
public class Parent implements CommonInterface {
    @Id
    private Long id;
    
    @Override
    public void commonMethod() {
        // 부모 클래스 메소드 구현
    }
}

// 자식 클래스 구현
@Entity
public class Child extends Parent {
    @Override
    public void commonMethod() {
        // 자식 클래스 메소드 구현
    }
}

// 프록시 객체 조회 후 인터페이스로 작업
CommonInterface proxy = entityManager.getReference(CommonInterface.class, 1);
proxy.commonMethod();  // 부모나 자식 모두 같은 인터페이스를 통해 메소드 호출 가능

 

비지터 패턴

// 비지터 인터페이스 정의
public interface EntityVisitor {
    void visit(Parent parent);
    void visit(Child child);
}

// 부모 클래스
@Entity
public class Parent {
    @Id
    private Long id;
    
    public void accept(EntityVisitor visitor) {
        visitor.visit(this);
    }
}

// 자식 클래스
@Entity
public class Child extends Parent {
    public void accept(EntityVisitor visitor) {
        visitor.visit(this);
    }
}

// 비지터 구현
public class EntityVisitorImpl implements EntityVisitor {
    @Override
    public void visit(Parent parent) {
        System.out.println("Visiting parent");
    }

    @Override
    public void visit(Child child) {
        System.out.println("Visiting child");
    }
}

// 비지터 사용 예시
Parent proxy = entityManager.getReference(Parent.class, 1);
EntityVisitor visitor = new EntityVisitorImpl();
proxy.accept(visitor);  // 부모인지 자식인지에 따라 다른 처리
  • 각 타입마다 visit() 구현

 

4. 성능 최적화

N+1 문제

  • 연관된 컬렉션 엔티티를 조회하려 할 때 한번의 조인 쿼리가 아닌 각각의 추가 SQL 쿼리가 실행될 수 있음

 

문제

더보기
List<Team> list = entityManager.createQuery("SELECT t FROM Team t", Team.class).getResultList();
Hibernate: select t1_0.id,t1_0.name from team t1_0

Hibernate: select m1_0.team_id,m1_0.id,m1_0.age,m1_0.name,m1_0.nickname from user m1_0 where m1_0.team_id=?
Hibernate: select m1_0.team_id,m1_0.id,m1_0.age,m1_0.name,m1_0.nickname from user m1_0 where m1_0.team_id=?
  • 팀당 멤버들을 따로 조회하는 쿼리가 나감

 

해결

더보기

1. Fetch Join 사용

List<Team> list = entityManager.createQuery("SELECT t FROM Team t JOIN FETCH t.members", Team.class).getResultList();

// select t1_0.* from team t1_0 join user m1_0 on t1_0.id=m1_0.team_id
  • 연관된 엔티티를 하나의 SQL 쿼리로 함께 조회할 수 있음

 

 

2. @BatchSize 사용

@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
@BatchSize(size = 10)
private List<User> members = new ArrayList<>();
List<Team> list = entityManager.createQuery("SELECT t FROM Team t", Team.class).getResultList();

// select t1_0.* from team t1_0
// select m1_0.* from user m1_0 where m1_0.team_id in (?,?)
  • 여러 개의 Member를 한 번에 조회하는 SQL 쿼리로 묶기
  • 지정한 크기만큼 IN 절을 사용하여 조회

 

3. @Fetch(FetchMode.SUBSELECT) 사용

@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
@Fetch(FetchMode.SUBSELECT)
private List<User> members = new ArrayList<>();
List<Team> list = entityManager.createQuery("SELECT t FROM Team t", Team.class).getResultList();

// select t1_0.* from team t1_0
// select m1_0.* from user m1_0 where m1_0.team_id in(select t1_0.id from team t1_0)
  • 서브쿼리를 통해 연관된 엔티티를 조회

 

읽기 전용 쿼리

구분 Dirty Checking 트랜잭션 지연 로딩 기타 특성
스프링에서 트랜잭션을 읽기 전용으로 설정
Connection.setReadOnly(true)
flush() 무시
하이버네이트 세션에서 특정 엔티티 읽기 전용 설정
특정 객체만 dirty checking 생략
트랜잭션 자체를 사용하지 않고 실행
트랜잭션 커밋/롤백 비용 없음
락 부담 없음

 

해결책) 트랜잭션 읽기 전용

더보기
@Test
@Transactional(readOnly = true)
void Transactional_read_only() {
    Team team = entityManager.find(Team.class, 1);
}

 

해결책) 하이버네이트 읽기 전용

더보기
@Test
@Transactional
void hibernate_read_only() {
    Session session = entityManager.unwrap(Session.class);

    Team team = session.get(Team.class, 1);
    session.setReadOnly(team, true);
}

 

해결책) 트랜잭션 밖에서 읽기

더보기
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void Transactional_not_supported() {
    Team team = entityManager.find(Team.class, 1);
}

 

배치 처리

  • 수백만 건의 데이터를 처리 시, 영속성 컨텍스트의 메모리 부족 발생 가능

 

예제

더보기

1. JPA 등록 배치 처리

@Transactional
public void batchInsertProducts(List<Product> products) {
    for (int i = 0; i < products.size(); i++) {
        em.persist(products.get(i));

        if (i % 50 == 0) { // 50건마다 flush & clear
            em.flush();    // 쿼리 실행
            em.clear();    // 영속성 컨텍스트 초기화
        }
    }
}
  • 일정 건마다 flush & clear 수행하여 영속성 컨텍스트 비움

 

2. JPA 페이징 배치 처리

@Transactional
public void processProductsByPaging() {
    int batchSize = 100;
    for (int page = 0;; page++) {
        List<Product> products = em.createQuery("SELECT p FROM Product p", Product.class)
                                   .setFirstResult(page * batchSize)
                                   .setMaxResults(batchSize)
                                   .getResultList();

        if (products.isEmpty()) break;

        for (Product product : products) {
            product.process(); // 가공 처리
        }

        em.flush();
        em.clear();
    }
}
  • 데이터를 페이지 단위로 나누어 반복 처리

 

3. 하이버네이트 Scroll 처리

@Transactional
public void processProductsWithScroll() {
    Session session = em.unwrap(Session.class);
    ScrollableResults scroll = session.createQuery("SELECT p FROM Product p")
                                      .scroll(ScrollMode.FORWARD_ONLY);

    int count = 0;
    while (scroll.next()) {
        Product product = (Product) scroll.get(0);
        product.process(); // 가공 처리

        if (++count % 50 == 0) {
            session.flush();
            session.clear();
        }
    }
    scroll.close(); // 꼭 닫아야 함
}
  • JDBC 커서를 사용해 한 건씩 순차 처리
  • 반드시 scroll.close()로 자원 반납

 

4. 하이버네이트 무상태 세션 처리

public void processWithStatelessSession() {
    SessionFactory sessionFactory = em.getEntityManagerFactory()
                                      .unwrap(SessionFactory.class);

    StatelessSession statelessSession = sessionFactory.openStatelessSession();
    Transaction tx = statelessSession.beginTransaction();

    ScrollableResults scroll = statelessSession.createQuery("SELECT p FROM Product p")
                                               .scroll(ScrollMode.FORWARD_ONLY);

    while (scroll.next()) {
        Product product = (Product) scroll.get(0);
        product.process(); // 상태 없는 단순 가공
        statelessSession.update(product); // 무상태에서도 업데이트 가능
    }

    scroll.close();
    tx.commit();
    statelessSession.close();
}
  • 영속성 컨텍스트, 1·2차 캐시 없이 작동
  • 엔티티 상태 관리 불가 (조회 후 수정·병합 등 불가능)

 

SQL 쿼리 힌트 사용

  • 데이터베이스 옵티마이저에게 실행 계획 힌트를 주어 성능을 직접 제어하려는 경우 사용
  • 예: 인덱스 무시, 풀 스캔 강제, 파티션 힌트, 등

 

예제

더보기

Hibernate Native HQL

@Test
@Transactional
void sql_hint_oracle() {
    Session session = entityManager.unwrap(Session.class);
    session.createQuery("SELECT u FROM User u", User.class)
            .addQueryHint("FULL(USER)")
            .list();
}
  • 특정 벤더만 지원 (Oracle, DB2 등) 

 

Native Query

@Test
@Transactional
void sql_hint_mysql() {
    entityManager.createNativeQuery("SELECT * FROM springboot_document.user IGNORE INDEX (`PRIMARY`)", User.class)
            .getResultList();
}

 

쓰기 지연

  • 다수의 INSERT를 모아 한 번에 DB로 전송

 

예제

더보기
spring:
  jpa:
    properties:
      hibernate.jdbc.batch_size: 50
      hibernate.order_inserts: true
      hibernate.order_updates: true
@Transactional
public void insertMembersBatch() {
    for (int i = 0; i < 1000; i++) {
        Member member = new Member("user" + i);
        em.persist(member);

        // 배치 사이즈 단위로 flush + clear → 메모리 최적화
        if (i % 50 == 0) {
            em.flush();
            em.clear();
        }
    }
}
  •  em.persist()만으로는 DB에 바로 반영되지 않음 → 쓰기 지연
  • em.flush()가 실행되거나 트랜잭션 커밋 시 실제 쿼리 실행