김영한 님의 "자바 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()가 실행되거나 트랜잭션 커밋 시 실제 쿼리 실행
'Spring > Spring Data JPA' 카테고리의 다른 글
[자바 ORM 표준 JPA 프로그래밍] 16. 트랜잭션과 락, 2차 캐시 (1) | 2025.04.26 |
---|---|
[자바 ORM 표준 JPA 프로그래밍] 13. 웹 애플리케이션과 영속성 관리 (1) | 2025.04.26 |
[자바 ORM 표준 JPA 프로그래밍] 12. 스프링 데이터 JPA (0) | 2025.04.25 |
[자바 ORM 표준 JPA 프로그래밍] 10-5. 객체지향 쿼리 언어: 객체지향 쿼리 심화 (0) | 2025.04.24 |
[자바 ORM 표준 JPA 프로그래밍] 10-4. 객체지향 쿼리 언어: Native SQL (0) | 2025.04.24 |