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

주제

  • 예외 처리
  • 엔티티 비교 주의점과 해결 방법
  • 프록시 심화 주제
  • 성능 최적화
    • N+1 문제
    • 읽기 전용 쿼리의 성능
    • 배치 처리
    • SQL 쿼리 힌트 사용
    • 트랜잭션을 지원하는 쓰기 지연과 성능 최적화

예외 처리

JPA 표준 예외 정리

  • JPA 모든 예외 < PersistenceException < RuntimeException 즉, JPA 예외는 모두 uncheked 예외이다.
  • JPA 표준 예외는 크게 2가지로 나눌 수 있다.
    • 트랜잭션 롤백을 표시하는 예외
    • 트랜잭션 롤백을 표시하지 않는 예외

https://gblobscdn.gitbook.com/assets%2F-M7KQQ2ZLP5HVNbeQNZi%2F-M9rvhkZUPl1UJlCyCkj%2F-M9rydbHid2jKHKMr_mu%2Fimage.png?alt=media&token=1e2ffdbd-9e4e-4067-9375-00d792556b89

트랜잭션 롤백을 표시하는 예외(빨간색)

  • 심각한 예외이므로 복구해선 안 된다. 강제로 커밋해도 트랜잭션이 커밋되지 않고 대신에 RollbackException예외가 발생한다.

https://gblobscdn.gitbook.com/assets%2F-M7KQQ2ZLP5HVNbeQNZi%2F-M9uK9Bk9ry0NAhApFTv%2F-M9uNNxy6YcjGC9gSx6h%2Fimage.png?alt=media&token=a033eda8-41b6-412b-a4ad-7565cae13b1b

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

  • 심각한 예외가 아니다. 개발자가 판단해서 트랜잭션 커밋, 롤백한다.

    https://gblobscdn.gitbook.com/assets%2F-M7KQQ2ZLP5HVNbeQNZi%2F-M9uK9Bk9ry0NAhApFTv%2F-M9uNR1ggzVJf76Cs-lu%2Fimage.png?alt=media&token=d80f4df3-cc7c-4385-927a-96d5873af42f

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

서비스 계층에서 JPA의 예외를 직접 사용하면 JPA에 의존하게 된다. 아래는 추상화된 예외들이다.

https://gblobscdn.gitbook.com/assets%2F-M7KQQ2ZLP5HVNbeQNZi%2F-M9pCpNj-z45mmYbD0ej%2F-M9pD2-SFe5jnL1PK2Wy%2Fimage.png?alt=media&token=1a90cc89-3794-4681-9707-f52ed02a563d

JPA 표준 명세상 발생할 수 있는 다음 두 예외도 추상화 해서 제공한다.

https://gblobscdn.gitbook.com/assets%2F-M7KQQ2ZLP5HVNbeQNZi%2F-M9pD7WPLmqvl04r2pzr%2F-M9pETbnIoGdwl0qienc%2Fimage.png?alt=media&token=10b763ef-80c2-44b4-a49b-56039ab3f4ab

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

JPA 예외를 스프링 프레임워크가 제공하는 추상화된 예외로 변경하려면 PersistenceExceptionTranslationPostProcessor를 스프링 빈으로 등록한다.

//JavaConfig
@Bean
public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
    return new PersistenceExceptionTranslationPostProcessor();
}
@Repository
public class NoResultExceptionTestRepository {
    @Persistencecontext EntityManager em;

    //리턴값이 없다고 가정
    //javax.persistence.NoResultException 발생
    //메소드 빠져나갈때 AOP 인터셉터 동작
    //org.springframework.dao.EmptyResultDataAccessException로 예외 변환 후 반환
    public Member findMember() {
         return em.createQuery("select m from Member m", Member.class)
				        .getSingleResult();
    }
}

//추상화된 예외를 변환하지 않을 경우 
@Repository
public class NoResultExceptionTestService {
    @Persistencecontext EntityManager em;

    public member findMember() throws javax.persistence.NoResultException    {
        return em.createQuery("select m from Member m", Member.class)
									.getSingleResult();
    }
}

트랜잭션 롤백 시 주의사항

데이터베이스의 반영사항만 롤백하였을 경우 영속성 컨택스트 내부 객체는 그대로 남아다. 따라서 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용할 때 조심하자.

  • 트랜잭션당 영속성 컨택스트 전략
    • 문제생겼을 때 트랜잭션 AOP 종료시점 트랜잭션 롤백하면서 영속성 컨택스트 함께 종료하므로 문제가 없다.
  • OSIV 사용 전략(문제됨)
    • 영속성 컨택스트 범위 > 트랜잭션 범위일 경우 문제가 발생한다.
    • 롤백하고 남아있는 영속성 컨택스트에 이상이 발생해도 다른 컨택스트에서 그대로 사용할 수 있다.
    • 해결방법 → em.clear() 영속성 컨텍스트 초기화해 예방한다.

엔티티 비교

  • 비교 방법 3가지
    • 동일성(identical): == 비교가 같다.
    • 동등성(equivalent): equals() 비교가 같다.
    • 데이터베이스 동등성: @Id인 데이터베이스 식별자가 같다. member.getld().equals(findMember.getld())

https://gblobscdn.gitbook.com/assets%2F-M7KQQ2ZLP5HVNbeQNZi%2F-M9paJmHcxPigO5sS4rp%2F-M9paSXYzvbfXhVh7yz6%2Fimage.png?alt=media&token=dd46fb18-e056-4ec2-bc5e-4d5057178eaa

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

    @Test
    public void 회원가입() throws Exception {
            //Given 
        Member member = new Member("kim");
                //When
        Long saveId = memberService.join(member);
                //Then
        Member findMember = memberRepository.fineOne(saveId);
        assertTrue(member == findMember); //참조값 비교
    }
}

https://gblobscdn.gitbook.com/assets%2F-M7KQQ2ZLP5HVNbeQNZi%2F-M9paJmHcxPigO5sS4rp%2F-M9paMDZ71Yr9IsZLQ6A%2Fimage.png?alt=media&token=826a2d57-f68c-4e42-b428-ef392c9cb0ea

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath: appConfig.xml")
//@Transactional
public class MemberServiceTest {
    @Autowired MemberService memberservice;
    @Autowired MemberRepository memberRepository;

    @Test
    public void 회원가입 () throws Exception {
            //Given
        Member member = new Member("kim");
        
        //When
        Long saveld = memberService.join(member);
         
        //Then
        Member findMember = memberRepository.fineOne(saveld);
        assertTrue(member == findMember); // 실패
    }
}

@Transactional
public class MemberService {
    @Autowired MemberRepository memberRepository;
        public Long join(Member member) {
        //...
        memberRepository.save(member);
        return member.getld();
    }
}

@Repository
@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);
    }
}
member.getId().equals(findMember.getId()); // DB 식별자 비교 

하지만 영속화 해야 식별자를 얻어 비교할 수 있다는 문제가 있다. 결론, 비즈니스키(예.주민번호)를 이용해 동등성 비교(equals())를 사용할 수 있다.

member.equals(findMember);
  • 동일성 비교는 같은 영속성 컨텍스의 관리를 받는 영속 상태의 엔티티에만 적용
  • 그렇지 않을 경우 동등성 비교

프록시 심화문제

영속성 컨텍스트와 프록시

영속성 컨텍스트는 자신이 관리하는 엔티티의 동일성(identity)을 보장한다.

그럼 프록시로 조회한 엔티티의 동일성도 보장할까?

@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");
    
    System.out.println("refMember Type = " + refMember.getClass());
    System.out.println("findMember Type = " + findMember.getClass());
    
    Assert.assertTrue(refMember == findMember); //성공
}
//결과
//refMember Type = class jpabook.advanced.Member_$$_jvst843_0
//findMember Type = class jpabook.advanced.Member_$$_jvst843_0// 둘다 프록시

영속성 컨텍스트는 한 번 프록시로 노출한 엔티티는 계속 프록시로 노출한다.

그래야 영속성 컨텍스트가 영속 엔티티의 동일성을 보장 할 수 있기 때문이다.

반대로 엔티티 조회후 동일 엔티티를 프록시로 찾으면? 프록시가 아닌 원본 엔티티가 반환된다. 이 경우에도, 영속성 컨텍스트는 엔티티의 동일성을 보장한다

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

프록시 타입비교

프록시로 조회한 엔티티의 타입을 비교할 때는, == 비교를 하면 안 되고, instanceof를 사용해야 한다.

if (!(obj instanceof Member)) return false;

프록시 동등성 비교

앞서 이야기한데로 프록시는 원본을 상속받은 자식 타입이므로 프록시의 타입을 비교할 때는 == 비교가 아닌 instanceof를 사용해야 한다.

equals() 메소드를 구현할 때는 일반적으로 멤버 변수를 직접 비교하는데, 프록시의 경우는 실제값이 존재하지 않기 때문에 아무것도 조회할 수 없다.

따라서 멤버변수에 직접 접근하지 말고 접근자(Getter)를 사용해야 한다.

상속 관계와 프록시

  • 다형성을 다루는 도메인 모델에서 주로 발생
  • 상속관계(OrderItem<->Item-Album, Movie, Book)를 프록시로 조회할 때 발생할 수 있는 문제점
  • 프록시를 부모 타입으로 조회하면 부모의 타입을 기반으로 프록시가 생성되는 문제가 있다.
    • instanceof 연산을 사용 x
    • 하위 타입으로 다운캐스팅 x
@Test
public void 부모타입으로_프록시조회() {
	//테스트 데이터 준비
	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());

	if (proxyItem instanceof Book) {
		System.out.println("proxyItem instanceof Book");
		Book book = (Book) proxyItem;
		System.out.println("책 저자 = " + book.getAuthor());
	}

		//결과 검증
		Assert.assertFalse(proxyItem.getClass() == Book.class);
		Assert.assertFalse(proxyItem instanceof Book);
		Assert.assertTrue(proxyItem instanceof Item);
}

출력 결과에 저자가 출력되지 않는다. proxyltem은 Book이 아닌 Item 클래스를 기반으로 만들어졌다. 즉, proxyltem은 Item$Proxy 타입이고 Book 타입과 관계가 없다. 그래서 proxyitem instanceof Book가 false가 나온다. 조건문이 없더라도, 다운 캐스팅에서 ClassCastException 에러가 난다.

상속관계에서 발생하는 프록시 문제를 어떻게 해결해야 할까?

  • JPQL로 대상 직접 조회

    Book jpqlBook = em.createQuery
        ("select b from Book b where b.id=:bookId", Book.class)
        .setParameter("bookId", item.getId())
        .getSingleResult();
    
  • 프록시 벗기기

    //프록시에서 원본 엔티티를 찾는 메소드
    public static <T> T unProxy(Obj ect entity) {
        if (entity instanceof HibernateProxy) {
            entity = ((HibernateProxy) entity)
                    .getHibernateLazylnitializer()
                    .getlmplementation();
        }
        return (T) entity;
    }
    
    //실행 코드
            ...
        Item item = orderItem.getItem();
        Item unProxyItem = unProxy(item);
          
        if (unProxyItem instanceof Book) {
                System.out.println("proxyItem instanceod Book");
                Book book = (Book) unproxyItem;
                System.out.println("책 저자 = " + book.getAuthor());
          }
          Assert.assertTrue(item != unProxyItem);
    }
    
    
    • unProxy()에서 원본 엔티티를 직접 꺼내기 때문에 프록시와 원본 엔티티의 동일성 비교가 실패한다는 문제점이 발생한다.
  • 기능을 위한 별도 인터페이스 제공

    • 다형성을 활용하는 좋은 방법
    • 프록시의 대상이 되는 타입에 인터페이스를 적용
    public interface TitleView {
        String getTitle();
    }
    
    @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
    @DiscriminatorColumn(name = "DTYPE")
    public abstract class Item implements TitleView {
        @Id @GeneratedValue
        @Column(name = "ITEM_ID")
        private Long id;
          
        ...
    }
    
  • 비지터 패턴 사용

https://gblobscdn.gitbook.com/assets%2F-M7KQQ2ZLP5HVNbeQNZi%2F-M9uhegVqYnBnVL1AfVd%2F-M9uhkPPU7Lw3DKn-r5w%2Fimage.png?alt=media&token=6fc1c5b4-3229-427c-ae44-9ab524d40d51

1 - 인터페이스 정의
public interface Visitor {
	void visit(Book book);
	void visit(Album album);
	void visit(Movie movie);
}

2 - Visitor의 구현 클래스
public class PrintVisitor implements Visitor {
	@Override
	public void visit(Book book) {
		//넘어오는 book은 Proxy가 아닌 원본 엔티티다. 
		System.out.println("book.class = " + book.getClass());
	}
	
	@Override
	void visit(Album album) {...}
	@Override
	void visit(Movie movie) {...}
}

3 - Book, Movie, Album 부모 클래스에 추상 메소드 추가
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {

	@Id @GeneratedValue
	@Column(name = "ITEM_ID")
	private Long id;

	...

	public abstract void accept(Visitor visitor);
}

4 - 서브클래스 오버라이드 추가
@Entity
@DiscriminatorValue("B")
public class Book extends Item {

	...
	@Override
	public void accept(Visitor visitor){
		visitor.visit(this);
	}
}

5 - 실행 코드
@Test
public void 상속관계와_프록시_VisitorPattern() {
	...
	OrderItem orderItem = em.find(OrderItem.class, orderItemId);
	Item item = orderItem.getItem();

	//PrintVisitor
	item.accept(new PrintVisitor());
}

public class PrintVisitor implements Visitor {
	public void visit(Book book) {
		//넘어오는 book은 Proxy가 아닌 원본 엔티티다. 
		System.out.println("book.class = " + book.getClass());

	}
	public void visit(Album album) {...}
	public void visit(Movie movie) {...}
}

TitleVisitor 클래스는 사용하지 않았는데, 비지터 패턴을 구현만 하면 확장이 가능하다는 것을 보여준다.

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

성능 최적화

N+1 문제

10장에 요약 되어 있으므로 넘김

결론은 지연로딩으로 사용하고, 성능 최적화가 필요한 곳에 fetch join을 사용하자

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

엔티티가 영속성 컨텍스트에 관리되면, 1차 캐시부터 변경 감지까지 얻을 수 있는 해택이 많다.

하지만 영속성 컨텍스트는 변경감지를 위해 스냅샷 인스턴스를 보관하므로 더 많은 메모리를 사용하는 단점이 있다.

@Transactional(readOnly = true)

위와 같이 읽기 전용으로 설정하면 트랜잭션을 커밋해도 영속성 컨텍스트를 플러시하지 않는다. 그러므로 플러시할 때 일어나는 스냅샷 비교와 같은 무거운 로직들을 수행하지 않으므로 성능이 향상된다.

JPA 플러시 설정에는 AUTO, COMMIT 두가지만 존재한다.

배치 처리

메모리 부족을 피하기 위해, 일정 단위마다 영속성 컨텍스트의 엔티티를 데이터베이스에 플러시하고 영속성 컨텍스트를 초기화해야 한다. 또한, 2차 캐시에 엔티티를 보관하지 않도록 주의해야 한다. (16.2절 참고)

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