[자바 ORM 표준 JPA 프로그래밍] 15주차 스터디 (1)

박서영·7일 전

15장. 고급 주제와 성능 최적화

  • 예외 처리: JPA 사용 시 발생할 때 다양한 예외와 예외에 따른 주의점
  • 엔티티 비교: 엔티티를 비교할 때 주의점과 해결 방법을 설명
  • 성능 최적화:
    • N+1 문제
    • 읽기 전용 쿼리의 성능 최적화
    • 배치 처리
    • SQL 쿼리 힌트 사용
    • 트랜잭션을 지원하는 쓰기 지연과 성능 최적화

15.1 예외 처리

(1) JPA 표준 예외 정리

  • JPA의 표준 예외들은 모두 PersistenceException의 자식 클래스이며, RuntimeException의 자식 클래스
    • JPA 예외는 모두 언체크 예외

JPA 표준 예외 종류

  • 트랜잭션 롤백을 표시하는 예외:
    • 트랜잭션 롤백을 표시하는 예외의 경우 심각한 예외이기에 복구해서는 안됨
    • 해당 예외 발생 시, 트랜잭션을 강제 커밋해도 트랜잭션이 커밋되지 않고 RollbackException 예외가 발생
  • 트랜잭션 롤백을 표시하지 않는 예외
    • 심각하지 않은 예외로 개발자가 커밋 롤백의 여부를 판단

트랜잭션 롤백을 표시하는 예외

예외설명
EntityExistsExceptionEntityManager.persist() 호출 시 이미 같은 엔티티가 있으면 발생.
EntityNotFoundExceptionEntityManager.getReference()를 호출했는데, 실제 사용 시 엔티티가 존재하지 않으면 발생, refresh(), lock()에서도 발생.
OptimisticLockException낙관적 락 충돌 시 발생
PessimisticLockException비관적 락 충돌 시 발생
RollbackExceptionEntityTransaction.commit() 실패 시 발생. 롤백

트랜잭션 롤백을 표시하지 않는 예외

예외설명
NoResultExceptionQuery.getSingleResult() 호출 시 결과가 하나도 없을 때 발생
NonUniqueResultExceptionQuery.getSingleResult() 호출 시 결과가 둘 이상일 때 발생.
LockTimeoutException비관적 락에서 시간초과 시 발생
QueryTimeoutException쿼리 실행 시간 초과 시 발생

(2) 스프링 프레임워크와 JPA 예외 반환

  • 서비스 계층에서 데이터 접근 계층의 구현 기술에 직접 의존 = 좋지 않은 설계 → 이런 특징은 예외에서도 적용됨 예) 서비스 계층에서 JPA의 예외 직접 사용 시, JPA에 의존.
  • 위의 문제를 해결하기 위해 스프링 프레임워크는 데이터 접근 계층에 대한 예외를 추상화해 개발자에게 제공함. 예) PersistenceException → JpaSystemException, NoResultException → EmptyResultException

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

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

설정방법:

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

JavaConfig를 사용하여 등록:

@Bean
public PersistenceExceptionTranslationPostProcessorexceptionTranslation(){
	return new PersistenceExceptionTranslationPostProcessor();
}

예) 예외 변환 예제

@Repository
public class NoResultExceptionTestRepository {

	@PersistenceContext EntityManager em;
	
	public Member findMember() {
		//조회된 데이터 X
		return em.createQuery("select m from Member m", Member.class).getSingleResult();
	}
}
  • findMember() 메소드에서 엔티티 조회를 위해 getSingleResult() 메소드를 사용
    • 해당 메소드는 조회 결과가 없을 때, NoResultException이 발생.
    • 이 예외가 findMember() 메소드를 빠져나갈 때, PersistenceExceptionTranslationPostProcessor에 등록한 AOP 인터셉터가 동작해 해당 예외를 EmptyResultDataAccessException 예외로 변환해서 반환 ⇒ 따라서 해당 메소드를 호출한 클라이언트는 스프링 프레임워크가 추상화한 예외를 받음.
  • 예외를 반환하지 않고 그대로 반환할 때: throws 절에 그대로 반환활 JPA 예외 또는 JPA 예외의 부모 클래스를 직접 명시

예) 예외를 반환하지 않는 코드

@Repository
public class NoResultExceptionTestService {
	
	@PersistenceContext EntityManager em;
	
	public member findMember() throws NoResultException {
		return em.createQuery("select m from Member m", Member.class).getSingleResult();
	}
}

(4) 트랜잭션 롤백 시 주의사항

  • 트랜잭션의 롤백 = 데이터베이스의 반영사항만 롤백. 수정한 자바 객체까지 원상태로 복구하지는 않음.
    • 객체는 수정된 상태로 영속성 컨텍스트에 남아있음 → 따라서 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용하는 것은 위험.
    • 따라서 새로운 영속성 컨텍스트를 생성해 사용하거나 EntityManager.clear()를 호출해 영속성 컨텍스트 초기화 후 사용.

스프링 프레임워크의 예방

위의 문제를 예방하기 위해 영속성 컨텍스트의 범위에 따라 다른 방법을 사용함

  • 기본 전략: 트랜잭션 당 영속성 컨텍스트 전략 문제가 발생하면 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백하며 영속성 컨텍스트도 함께 종료하여 문제 발생 예방
  • 추가 문제점: OSIV같이 영속성 컨텍스트 범위를 트랜잭션 범위보다 넓게 사용해 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용할 때는 트랜잭션 롤백으로 인해 영속성 컨텍스트에 이상이 발생해도 다른 트랜잭션에서 해당 영속성 컨텍스트를 그대로 사용함
    • 해결책: 영속성 컨텍스트 범위를 트랜잭션 범위보다 넓게 설정했을 때는, “트랜잭션 롤백시 영속성 컨텍스트를 초기화(EntityManager.clear())”를 통해 잘못된 영속성 컨텍스트 사용 문제를 예방.

15.2 엔티티 비교

1차 캐시

  • 영속성 컨텍스트 내부에 엔티티 인스턴스를 보관하기 위해 존재.
  • 영속성 컨텍스트와 생명주기를 같이함
  • 영속성 컨텍스트를 통해 데이터를 저장하거나 조회하면 1차 캐시에 엔티티가 저장됨
  • 1차 캐시를 통해 변경감지 기능 동작
  • 1차 캐시로 사용되어 DB를 통하지 않고 데이터 조회 가능
  • 장점: “애플리케이션 수준의 반복 가능한 읽기” → 이걸 이해해야 영속성 컨텍스트를 더 잘 이해할 수 있음. 예) 같은 영속성 컨텍스트의 엔티티를 조회하면 항상 같은 엔티티 인스턴스를 반환함.
    Member member1 = em.find(Member.class, "1L");
    Member member2 = em.find(Member.class, "1L");
    
    assertTrue(member1 == member2); //둘은 같은 인스턴스

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

예) 회원가입 테스트 케이스

테스트는 트랜잭션 안에서 시작하기에 테스트의 범위와 트랜잭션의 범위가 아래와 같음.
즉, 테스트 전체에서 같은 영속성 컨텍스트에 접근함

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:appConfig.xml")
@Transactional //트랜잭션 안에서 테스트를 실행
public class MemberServiceTest {

	@Autowired MemberService memberService;
	@Autowired MemberRepository memberRepository;
	
	
	@Test
	public void 회원가입() throws Exception {...}
}

@Transactional
public class MemberRepository {
	
	@PersistenceContext EntityManager em;
	
	public void save (Member member) {
		em.persist(member);
	}
	
	public Member findOne (Long id) {
		return em.find(Member.class, id);
	}
}
  • 테스트 클래스에 @Transactional이 선언되어 있는 경우 트랜잭션을 먼저 시작하고 테스트 메소드를 실행함
    • 즉, 테스트 메소드인 회원가입()은 이미 트랜잭션 범위에 들어있고, 해당 메소드가 끝나면 트랜잭션이 종료됨
    • 따라서 회원가입() 에서 사용된 코드는 항상 같은 트랜잭션과 같은 영속성 컨텍스트에 접근

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

아래 3가지 조건을 모두 만족함

  • 동일성: == 비교가 같음
  • 동등성: equals() 비교가 같음
  • 데이터베이스 동등성: @Id인 데이터베이스 식별자가 같음

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

예) 테스트 클래스에 @Transactional이 없고 서비스에만 @Transactional이 있을 때

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:appConfig.xml")
//@Transactional: 테스트에서 트랜잭션을 사용하지 않음
public class MemberServiceTest {

	@Autowired MemberService memberService;
	@Autowired MemberRepository memberRepository;
	
	
	@Test
	public void 회원가입() throws Exception {...}
}

@Transactional
public class MemberRepository {
	
	@PersistenceContext EntityManager em;
	
	public void save (Member member) {
		em.persist(member);
	}
	
	public Member findOne (Long id) {
		return em.find(Member.class, id);
	}
}
  • 위의 경우처럼 테스트에 @Transactional이 붙어있는지 않는 경우 테스트는 실패함
  • 진행과정 1- 테스트코드에서 memberService.join()을 호출하여 회원가입 시도 → 트랜잭션이 서비스 계층에서 시작→ 영속성 컨텍스트1 생성 2- memberRepository에서 em.persist()를 호출하여 member 엔티티를 영속화 3- 서비스 계층이 끝날 때 트랜잭션이 커밋되어 영속성 컨텍스트가 플러시 → 이때 트랜잭션과 영속성 컨텍스트 종료. 즉, member 엔티티 인스턴스는 준영속 상태. 4- 테스트코드에서 memberRepository.findOne()을 호출해 엔티티를 조회 → 레포지토리 계층에서 새로운 트랜잭션이 시작 → 새로운 영속성 컨텍스트2 생성 5- 저장된 회원을 조회 → but 새로 생긴 영속성 컨텍스트2에는 회원이 존재하지 않음 6- 데이터베이스에서 회원을 찾아옴 7- 데이터베이스에서 조회된 회원 엔티티를 영속성 컨텍스트에 보관 및 반환 8- memberRepository.findOne() 메소드가 끝나며 트랜잭션이 종료. 영속성 컨텍스트2도 종료됨
  • memberfindMember는 각각 다른 영속성 컨텍스트에서 관리 → 즉, 둘은 다른 인스턴스 assertTrue(member == findMember): 실패

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

  • 동일성: == 비교가 실패함
  • 동등성: equals() 비교가 만족하지만, 반드시 equals()를 구현해야함. 보통 비즈니스키로 구현
  • 데이터베이스 동등성: @Id인 데이터베이스 식별자가 같음

정리

  • 같은 영속성 컨텍스트를 보장해야만 동일성 비교로 엔티티 비교 가능
    예) OSIV처럼 요청의 시작부터 끝까지 같은 영속성 컨텍스트를 사용하는 경우
  • 영속성 컨텍스트 변경 시 동일성 비교는 실패

대안1 - 데이터베이스 동등성 비교

member.getId().equals(findMember.getId()) → 데이터베이스 식별자를 비교

  • 단점: 엔티티를 영속화해야 식별자를 얻을 수 있음. 즉, 엔티티 영속화 전에는 식별자 값이 null이기에 정확한 비교 불가. (단, 식별자값 직접 부여하는 방식에서는 가능. 하지만 이런 경우를 보장하기가 쉽지 않음)

대안2 - equals()를 이용한 동등성 비교

엔티티 비교에서는 비즈니스 키를 활용한 동등성 비교를 권장

  • 동등성 비교를 위해 equals() 오버라이딩할 때 비즈니스 키가되는 필드들을 선택
  • 비즈니스 키: 보통 중복되지 않고 거의 변하지 않는 데이터베이스 기본키 후보
    예) 주민등록번호

15.3 프록시 심화 주제

프록시

  • 원본 엔티티를 상속받아 만들어지기에 엔티티를 사용하는 클라이언트의 경우 엔티티가 프록시인지 원본 엔티티인지를 구분하지 않고 사용 가능.
  • 즉, 원본 엔티티 사용 중 지연로딩을 위해 프록시로 변경하여도 클라이언트의 비즈니스 로직 수정 불필요

(1) 영속성 컨텍스트와 프록시

의문점: 영속성 컨텍스트 = 관리하는 영속 엔티티들의 동일성을 보장 → 다만, 프록시로 조회한 엔티티의 동일성도 보장할까?

예) 프록시 조회 → 원본 엔티티 조회

@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");
	
	Assert.assertTrue(refMember == findMember); //성공
}
  • 먼저 member1을 getReference() 메소드를 통해 프록시로 조회, 이후 같은 member1을 em.find()로 조회. 전자는 프록시, 후자는 원본 엔티티

문제점

만일 이 둘을 다른 인스턴스로 보게되면 영속성 컨텍스트가 영속 엔티티의 동일성을 보장하지 못하는 문제 발생

해결

영속성 컨텍스트에서 프록시로 조회된 엔티티에 대해 같은 엔티티를 찾는 요청이 들어올 때, 원본 엔티티가 아닌 처음 조회된 프록시를 반환함

⇒ 즉, 프록시로 조회할 때도 영속성 컨텍스트는 영속 엔티티의 동일성을 보장함

예) 원본 엔티티 조회 → 프록시 조회

@Test
public void 영속성컨텍스트와_프록시2() {

	Member newMember = new Member("member1", "회원1");
	em.persist(newMember);
	em.flush();
	em.clear();
	
	Member findMember = em.find(Member.class, "member1");
	Member refMember = em.getReference(Member.class, "member1");
	
	Assert.assertTrue(refMember == findMember); //성공
}
  • 결과: 원본 엔티티를 먼저 조회하는 경우, 영속성 컨텍스트는 원본 엔티티를 이미 데이터베이스에서 조회했기에 프록시를 반환할 이유가 없음. 즉, getReference()를 호출해도 프록시가 아닌 원본을 반환함. ⇒ 즉, 이런 경우에도 영속성 컨텍스트는 자신이 관리하는 영속 엔티티의 동일성을 보장함

(2) 프록시 타입 비교

프록시는 원본 엔티티를 상속 받아 만들어지기에 프록시로 조회한 엔티티 타입의 비교에는 == 비교가 아닌 instanceof를 사용해야함

@Test
public void 영속성컨텍스트와_프록시2() {

	Member newMember = new Member("member1", "회원1");
	em.persist(newMember);
	em.flush();
	em.clear();
	
	Member refMember = em.getReference(Member.class, "member1");
	
	Assert.assertFalse(refMember == findMember); //false
	Assert.assertTrue(refMember instanceof Member); //true
}
  • == 비교: 부모 클래스와 자식 클래스를 비교하는 것. 즉, 결과가 false.

(3) 프록시 동등성 비교

동등성 비교를 위해서는 비즈니스 키를 사용해 equals() 메소드를 오버라이딩하여 비교.

IDE/외부 라이브러리를 사용해 구현한 equals() 메소드를 사용해 엔티티 비교 시, 비교 대상이 원본 엔티티이면 괜찮지만, 프록시인 경우에는 문제 발생 가능

@Entity
public class Member {
	
	@Id
	private String id;
	private String name;
	
	...
	public String getName() {return name;}
	public void setName(String name) {this.name = name;}
	
	@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 (name != null ? !name.equals(member.name) : member.name != null) return false;
	}
	
	@Override
	public int hashCode() {
		return name != null ? name.hashCode() : 0;
	}
}
  • 위의 경우 회원 엔티티의 name 필드를 비즈니스 키로 사용해 equals() 메소드를 오버라이딩.

문제점

  • 새로 생성한 회원 newMember와 프록시로 조회한 회원 refMember의 name 속성은 같지만, 동등성 비교 시 실패(false 반환).

원인 및 프록시 equals() 비교 주의점

문제1:

this.getClass() != obj.getClass()의 부분은 타입을 동등성 비교함 → 즉, 프록시는 원본을 상속받은 자식 타입이기에 프록시의 타입을 비교할 때는 instanceof를 사용해야함.

문제2:

Member member = (Member) obj;
if(name != null ? !name.equals(member.name) : member.name != null) return false;
  • member.name을 보면 프록시의 멤버변수에 직접 접근함
  • equals() 메소드를 구현할 때는 일반적으로 멤버변수를 직접 비교하는데 프록시의 경우에는 문제가 생김 ⇒ 왜냐하면 프록시는 실제 데이터를 가지고 있지 않음.
  • 따라서 위처럼 프록시의 멤버변수에 직접 접근하면 아무값도 조회할 수 없어 null이 반환, equals()는 false 반환함

해결: 프록시의 데이터 조회를 위해서는 접근자(getter)를 사용해야함.

정리

  • 프록시의 타입 비교는 instanceof를 사용해야함
  • 프록시의 멤버변수에 직접 접근하면 안되고 접근자 메소드를 사용해야함

(4) 상속관계와 프록시

문제점

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

예) Item(부모 클래스) - Book(하위 클래스). Item을 조회하여 Book 타입인 경우 저자 이름을 출력하려할 때.

em.getReference()를 통해 Item 엔티티를 프록시로 조회 → instanceof 연산을 통해 Book 클래스 타입인지를 확인 → Book 타입인 경우 다운캐스팅하여 Book 타입으로 변경 후 저자이름 출력.

⇒ 다만 결과로 저자는 출력되지 않음.

  • Item 엔티티를 프록시로 조회, 실제 조회 엔티티는 Book 타입 기반으로 원본 엔티티 인스턴스가 생성. 프록시인 proxyItem은 Item 타입을 기반으로 생성
  • 따라서 instanceof의 비교가 false를 반환하게됨.
  • 여기에 더해 직접 다운캐스팅을 하여도 문제 발생함. proxyItem이 Book 타입이 아니라 Item 타입 기반의 ItemProxy 타입이기에 ClassCastException 예외가 발생함.

📌 문제점 정리

프록시를 부모 타입으로 조회하면 부모의 타입을 기반으로 프록시가 생성되게 됨

  • instanceof 연산 사용 불가
  • 하위타입으로 다운캐스팅 불가

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

해결책 (1) - JPQL로 대상 직접 조회

간단한 방법으로, 처음부터 자식 타입을 직접 조회하여 필요한 연산을 수행. 단점으로는 다형성을 활용할 수 없다는 한계 존재.

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

해결책 (2) - 프록시 벗기기

하이버네이트에서 제공하는 기능을 사용해 프록시에서 원본 엔티티를 가져올 수 있음

...
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.assertTrue(item != unProxyItem);
}

//하이버네이트가 제공하는 프록시에서 원본 엔티티를 찾는 기능을 사용하는 메소드
**public static <T> T unProxy(Object entity) {
    if (entity instanceof HibernateProxy) {
        entity = ((HibernateProxy) entity)
                    .getHibernateLazyInitializer()
                    .getImplementation();
    }
    return (T) entity;
}**
  • 영속성 컨텍스트는 한 번 프록시로 노출한 엔티티는 계속 프록시로 노출함 (이래야 동일성 보장 가능 + 클라이언트가 조회한 엔티티가 프록시인지 아닌지의 구분하지 않고 사용 가능)
  • 다만, 위의 방법은 원본 엔티티를 프록시에서 직접 꺼내기에 추후 “프록시와 원본 엔티티의 동일성 비교가 실패한다는 문제점이 존재”함.
    • 따라서 해당 방법을 사용할 때, 원본 엔티티가 꼭 필요한 곳에서 잠깐 사용하고 다른 곳에서 사용되지 않도록 하는 것이 중요함. 원본 엔티티의 값을 직접 변경해도 변경 감지 기능은 동작.

해결책 (3) - 기능을 위한 별도의 인터페이스 제공

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

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
	private String author;
	private String isbn;
	
	@Override
	public String getTitle() {
		return "[제목: " + getName() + " 저자:" + author + "]";
	}
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
	
	private String director;
	private String actor;
	
	@Override
	public String getTitle() {
		return "[제목: " + getName() + " 감독:" + director + " 배우:" + actor + "]";
	}
}
  • TitleView라는 공통 인터페이스를 만들고 자식 클래스들은 인터페이스의 getTitle() 메소드를 각각 구현.
@Entity
public class OrderItem {
	
	@Id @GeneratedValue
	private Long id;
	
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "ITEM_ID")
	private Item item;
	
	...
}

OrderItem orderItem = em.find(OrderItem.class, saveOrderItem.getId());
orderItem.printItem();
  • 위의 방법을 사용할 때는 프록시의 대상이 되는 타입에 인터페이스를 적용해야함. 위에서는 Item이 프록시 대상이기에 Item이 인터페이스를 받아야함

해결책 (4) - 비지터 패턴 사용

  • 비지터(Visitor) 패턴: Visitor와 Visitor를 받아들이는 대상 클래스로 구성
  • 위의 경우에서는 Item이 accept(visitor) 메소드를 사용해 Visitor를 받아들임. Item은 단순히 Visitor를 받아들이기만하고 실제 로직은 Visitor가 처리.

1-Visitor 정의와 구현

public interface Visitor {
	void visit(Book book);
  void visit(Album album);
  void visit(Movie movie);
}
  • Visitor에는 visit() 메소드를 정의하고 모든 대상 클래스를 받아들이도록 작성
  • 위의 경우에서는 Book, Album, Movie를 대상 클래스로 사용

2- 대상 클래스 작성

Item에 Visitor를 받아들일 수 있도록 accept(visitor) 메소드 추가

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {
	
	**public abstract void accept(Visitor visitor);**
}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
	...
	
	**@Override
	public void accept(Visitor visitor) {
		visitor.visit(this)
	}**
}
  • 각각의 자식 클래스들은 부모에 정의한 accept(visitor) 메소드를 구현했는데, 구현 내용은 단순히 파라미터로 넘어온 Visitor의 visit(this) 메소드를 호출해 자신을 파라미터로 넘김
  • 실제 로직 처리는 visitor에게 위임

3-비지터 패턴 실행

@Test
public void 상속관계와_프록시_VisitorPattern() {
	OrderItem orderItem = em.find(OrderItem.class, orderItemId);
	Item item = orderItem.getItem();
	
	//PrintVisitor
	**item.accept(new PrintVisitor());**
}
  • item.accept() 메소드를 호출하여 파라미터로 PrintVisitor를 넘겨줌.
    • item은 프록시 이기에 먼저 프록시가 accept() 메소드를 받고 원본 엔티티의 accept()를 실행함.
    • 원본 엔티티는 자신을 visitor 파라미터로 넘겨줌

⇒ 비지터 패턴을 사용하면 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있고 instanceof나 타입 캐스팅 없이 코드를 구현할 수 있음

📌 비지터 패턴 정리

비지터 패턴과 확장성

새로운 기능이 필요할 때 Visitor만 추가하면 되기에 기존 코드의 구조를 변경하지 않고 기능을 추가할 수 있는 장점.

비지터 패턴 정리

장점:

  • 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근 가능
  • instanceof와 타입캐스팅 없이 코드 구현 가능
  • 알고리즘과 객체 구조를 분리해 구조를 수정하지 않고 새로운 동작 추가 가능

단점:

  • 너무 복잡하고 더블 디스패치를 사용하기에 이해가 어려움
  • 객체 구조가 변경되면 모든 Visitor를 수정해야함

15.4 성능 최적화

(1) N+1 문제

N+1 문제: JPA 애플리케이션 개발 시 성능 상 가장 주의해야하는 문제

예)

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

회원과 주문정보 → 1:N, N:1 양방향 관계 + 회원이 참조하는 주문정보인 Member.orders를 즉시로딩으로 설정.

즉시로딩과 N+1

예시에서 특정 회원 하나를 em.find()로 조회하면 즉시로딩으로 설정한 주문정보들도 함께 조회하게됨.

이때 SQL을 두 번 실행하지 않고, 조인을 통해 하나의 SQL로 회원+주문정보를 모두 조회.

SELECT M.*, O.*
FROM MEMBER M OUTER JOIN 
		 ORDERS O ON M.ID=O.MEMBER_ID

문제점: JPQL을 사용할 때 발생.

  • JPA가 JPQL을 분석해 SQL을 생성 → 이때 즉시로딩, 지연로딩 고려하지 않고 JPQL만 사용.
  • 회원 엔티티를 로딩한 후에, 연관된 주문 컬렉션이 무엇인지 찾음.
    • 만약 조회하는 회원이 N명이면?

N+1 문제: 처음 실행한 SQL의 결과 수만큼 추가로 SQL을 실행하는 문제

지연로딩과 N+1

  • 앞선 예시인 회원과 주문을 지연로딩으로 설정해도 N+1 문제에서 자유로울 수는 없음.
  • JPQL에서는 다만, N+1 문제가 발생하지 않음
    List<Member> members = 
    	em.createQuery("select m from Member m", Member.class)
    		.getResultList();
    • 지연로딩이기에 데이터베이스에서 회원만 조회됨 → 이후 비즈니스 로직에서 주문 컬렉션을 실제 사용할 때 지연로딩 발생
  • 문제: 모든 회원에 대한 연관 주문 컬렉션 사용
    for (Member member : members) {
    	//지연로딩 초기화
    	System.out.println("member = "+ member.getOrders().size());
    }
    ⇒ 주문 컬렉션을 초기화하는 수만큼 SQL이 실행되며 N+1 문제 발생

해결책(1) - 페치 조인 사용

N+1 문제를 해결하는 가장 일반적인 방법. 페치 조인은 SQL 조인을 사용해 연관 엔티티를 함께 조회하기에 N+1 문제가 발생하지 않음.

예)

select m from Member m join fetch m.orders

SELECT M.*, O.*
FROM MEMBER M
INNER JOIN ORDERS O ON M.ID = O.MEMBER_ID

해결책(2) - 하이버네이트 @BatchSize

하이버네이트에서 제공하는 @BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN절을 사용해 조회함

@Entity
public class Member {
	...
	
	**@BatchSize(size = 5)**
	@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
	private List<Order> orders = new ArrayList<Order>();
}

즉시로딩 설정: 10건의 데이터 모두 조회가 필요하면 SQL이 2번 실행됨

지연로딩 설정: 엔티티 최초 사용 시점에 SQL 1번 실행해 5건은 미리 로딩 → 이후 6번째 데이터 필요 시 추가 SQL 시행

SELECT O
FROM ORDERS O
WEHRE MEMBER_ID IN (?,?,?,?,?)

해결책(3) - 하이버네이트 @Fetch(FetchMode.SUBSELECT)

하이버네이트에서 제공하는 @Fetch 어노테이션에 FetchMode를 SUBSELECT로 사용 → 연관된 데이터 조회 시 서브 쿼리를 사용해 N+1 문제를 해결

예)

@Entity
public class Member {
	
	@Fetch(FetchMode.SUBSELECT)
	@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
	private List<Order> orders = new ArrayList<Order>();
}

즉시로딩으로 설정 시 조회 시점에, 지연로딩으로 설정 시 엔티티를 사용하는 시점에 아래 SQL이 실행됨

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

📌 정리

추천하는 방법: 즉시로딩은 사용하지 않고 지연 로딩 + 페치 조인만 사용하는 것.

  • 즉시로딩의 문제점
    • N+1 문제는 물론 비즈니스 로직에 필요하지 않은 엔티티를 로딩해야하는 상황이 자주 발생
    • 성능 최적화가 어려움 → 엔티티 조회 시, 즉시로딩이 연속적으로 발생해 예상치 못한 SQL이 실행될 수 있음
  • JPA의 글로벌 페치 전략 기본값
    - @OneToOne, @ManyToOne: 기본 페치 전략은 즉시 로딩
    - @OneToMany, @ManyToMany: 기본 페치 전략은 지연 로딩

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

엔티티가 영속성 컨텍스트에서 관리 → 1차 캐시, 변경 감지 등 이점이 많지만 영속성 컨텍스트는 스냅샷 인스턴스를 보관하기에 더 많은 메모리를 사용한다는 단점이 존재함

예) 100건의 구매 내용을 출력하는 조회 화면

  • 조회한 엔티티 재조회 필요없고, 수정없이 한 번만 읽어서 화면에 출력하기만하면됨 ⇒ 이런 경우에는 읽기 전용으로 엔티티 조회 시 메모리 사용량 최적화 가능

스칼라 타입으로 조회

가장 확실한 방법으로 엔티티가 아닌 스칼라 타입으로 모든 필드를 조회하는 것. 스칼라 타입의 경우 영속성 컨텍스트가 결과를 관리하지 않음

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

읽기 전용 쿼리 힌트 사용

하이버네이트 전용 힌트인 readOnly를 사용해 읽기 전용으로 조회 가능. 읽기 전용이기에 영속성 컨텍스트는 스냅샷을 보관하지 않음 → 즉, 메모리 사용량 최적화 가능.

(단, 스냅샷이 없기에 엔티티를 수정해도 데이터베이스에 반영 X)

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

읽기 전용 트랜잭션 사용

스프링 프레임워크 사용 시 트랜잭션을 읽기 전용으로 설정할 수 있음

@Transactional(readOnly = true)

위처럼 설정하면 스프링 프레임워크가 하이버네이트 세션의 플러시 모드를 MANUAL로 설정함.

  • 이러면 강제 플러시 호출 전까지는 플러시가 발생하지 않음.
  • 즉, 트랜잭션을 커밋해도 영속성 컨텍스트를 플러시하지 않아 엔티티의 등록/수정/삭제는 동작하지 않음.
  • 트랜잭션을 시작하긴했으니 트랜잭션 시작, 로직수행, 커밋의 과정은 발생.

트랜잭션 밖에서 읽기

트랜잭션의 밖 = 트랜잭션 없이 엔티티를 조회. JPA에서 데이터 변경 시 트랜잭션은 필수이기에 조회가 목적일 때만 사용해야함.

@Transactional(propagation = Propagation.NOT_SUPPORTED)

트랜잭션을 사용하지 않으면 플러시가 일어나지 않기에 조회 성능이 향상됨.


(3) 배치 처리

수백만건의 데이터를 처리해야할 때 → 일반적인 방식은 영속성 컨텍스트에 많은 엔티티가 쌓여 메모리 부족 오류가 발생하기에 배치 처리는 적절한 단위로 영속성 컨텍스트를 초기화해야함. 또한, 2차 캐시를 사용하지 않고 있다면, 2차 캐시에 엔티티를 보관하지 않도록 주의 필요.

JPA 등록 배치

여러 엔티티를 한 번에 등록할 때의 주의점 = 영속성 컨텍스트에 엔티티가 계속 쌓이지 않도록 일정 단위마다 영속성 컨텍스트의 엔티티를 데이터베이스에 플러시 → 영속성 컨텍스트 초기화할 필요 존재

위의 작업을 하지 않으면 영속성 컨텍스트에 너무 많은 엔티티가 저장되며 메모리 부족 오류가 발생할 수 있음

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

JPA 페이징 배치 처리

EntityManager 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)
		.setMaxResults(pageSize)
		.getResultList();**
		
	for (Product p : resultList) {
		p.setPrice(p.getPrice() + 100);
	}
	
	em.flush();
	em.clear();
}

tx.commit();
em.close();
  • 페이지 단위마다 영속성 컨텍스트 플러시 후 초기화 진행
  • 한 번에 100건씩 페이징 쿼리를 통해 조회

하이버네이트 scroll

JPA는 JDBC 커서를 지원하지 않기에, 커서 사용을 위해서는 하이버네이트 세션을 사용해야함. 하이버네이트는 scroll이라는 이름으로 JDBC 커서를 지원함

EntityTranscation tx = em.getTransaction();
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(); //영속성 컨텍스트 초기화
	}
}
  • scroll: 하이버네이트 전용 기능으로 먼저 em.unwrap() 메소드를 통해 하이버네이트 세션을 구함
  • 쿼리를 조회하면 scroll() 메소드로 ScrollableResults 객체를 반환받음. 해당 객체의 next() 메소드를 호출하여 엔티티를 하나씩 조회할 수 있음.

하이버네이트 무상태 세션 사용

무상태 세션: 영속성 컨텍스트를 만들지 않고, 2차 캐시를 사용하지 않음. 즉, 엔티티 수정을 위해서는 무상태 세션이 제공하는 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);** //직접 호출 필요
}

tx.commit();
session.close();

하이버네이트 무상태 세션은 일반 하이버네이트 세션과 거의 비슷하지만 영속성 컨텍스트가 없음. 즉, 영속성 컨텍스트를 플러시나 초기화할 필요가 없고 대신 엔티티 수정 시 update() 메소드를 직접 호출해야함.


(4) SQL 쿼리 힌트 사용

JPA는 데이터베이스 SQL 힌트 기능을 제공하지 않기에, SQL 힌트 사용을 위해서는 하이버네이트를 직접 사용해야함.

SQL 힌트는 하이버네이트 쿼리가 제공하는 addQueryHint() 메소드를 사용함.

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

+현재 하이버네이트 4.3.10 버전에는 오라클 방언에 대한 힌트만 적용되어 있음. 다른 데이터베이스 SQL 힌트 사용을 위해서는 각 방언에서 Dialect에 있는 아래의 메소드를 오버라이딩하여 구현해야함

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

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

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

예)

insert(member1);
insert(member2);
insert(member3);
insert(member4);
insert(member5);

commit();

네트워크 호출 한 번은 단순 메소드 수만 번 호출보다 더 큰 비용이 듬. 위의 코드는 5번의 INSERT SQL과 1번의 커밋을 통해 6번 데이터베이스와 통신.

→ 최적화를 위해서 5번의 INSERT SQL을 모아서 한 번에 데이터베이스로 보내면됨.

  • JDBC가 제공하는 SQL 배치 기능을 사용하면 SQL을 모아서 데이터베이스에 한 번에 보낼 수 있음. 다만, 이를 위해서는 코드의 많은 부분을 수정해야함
    • 비즈니스 로직이 복잡하게 얽힌 곳에서는 사용이 쉽지 않고 코드가 지저분해짐
    • 따라서 보통 수백, 수천건 이상의 데이터를 변경하는 특수 상황에 SQL 배치 기능을 사용
    • JPA는 플러시 기능이 있으므로 SQL 배치 기능 효과적으로 사용 가능
  • SQL 배치 최적화 전략은 구현체마다 다름.

트랜잭션을 지원하는 쓰기 지연과 애플리케이션 확장성

트랜잭션을 지원하는 쓰기 지연과 변경 감지 기능 → 성능과 개발의 편의성을 줌과 동시에 데이터베이스 테이블 로우(row)에 락(lock)이 걸리는 시간 최소화한다는 장점 존재

  • 트랜잭션 커밋해서 영속성 컨텍스트를 플러시하기 전까지는 데이터베이스에 등록, 수정, 삭제하지 않음
  • 커밋 직전까지 데이터베이스 로우에 락을 걸지 않음

예)

update(memberA); //UPDATE SQL A
비즈니스로직A(); //UPDATE SQL
비즈니스로직B(); //INSERT SQL
commit();
  • JPA를 사용하지 않고, SQL을 직접 다루면 update(memberA)를 호출할 때 UPDATE SQL을 실행하며 데이터베이스 테이블 로우에 락을 검.
    • 해당 락은 비즈니스 로직 A,B와 커밋이 모두 끝날때까지 유지됨.
    • 커밋된 읽기(Read Committed) 격리 수준 또는 그 이상에서는 데이터베이스에 현재 수정 중인 데이터(row)를 수정하려는 다른 트랜잭션은 락이 풀릴 때까지 대기.
  • JPA는 커밋을 해야 플러시를 호출하고 데이터베이스에 수정 쿼리를 보냄.
    • commit()을 호출하는 시점에 UPDATE SQL을 실행해 바로 데이터베이스에 트랜잭션을 커밋
    • 쿼리를 보내고 바로 커밋하므로 데이터베이스에 락이 걸리는 시간을 최소화함.
profile
이불 밖은 위험해.

0개의 댓글