본문 바로가기
Spring/JPA

그렇게 JPA 사용하면 좋니?

by 조니 Johnny 2022. 4. 18.

JPA의 장점은 워낙 막강해서 개발자들에게 좋은 선택지가 된다. (계속 사용하다보면. JPA 자체의 공부도 필요하기 때문에 구현이 복잡해질수록 어려워진다는 함정이 있다.) 하지만, 별 생각 없이 JPA를 도입하는 사람이 있을 수 있어 정리해본다.

 

 

JPA를 사용하면 아래와 같은 이점이 있다.

1. 직접 쿼리를 쓸 필요 없음. 생산성이 높아짐 (DataJPA)

2. 유지보수

3. 성능 (배치쿼리)

4. 데이터 추상화와 벤더 독립성

 

영속성 컨텍스트 (persistence context)는 아래와 같은 이점을 제공한다.

1. 1차 캐시

2. 동일성 보장

3. 트랜잭션을 지원하는 쓰기 지연

4. 변경 감지

5. 지연 로딩

 

이 이점들은 상호연관 되어 있지만. 오늘은 1차 캐시, 동일성 보장, 쓰기 지연을 메인으로 이야기 해볼게요.

 

이 포스팅은 단순히 1차캐시가 있어 성능이 좋고.. 하나의 영속성 컨텍스트에서는 동일성이 보장되며.. 내부적으로 쓰기지연 SQL 저장소를 두기 때문에 배치쿼리가 가능해진다는걸 검증하는데 초점을 맞춘건 아니고. Spring 트랜잭션과 JPA 그리고 DB 사이에서 어떤 관련이 있는지 다뤄보려고 합니다.

 


 

Index

1. 영속성 컨텍스트

-1) 같은 트랜잭션에서 영속성 컨텍스트 1차 캐시는 응답 성능을 향상 시켜준다.

-2) 영속성 컨텍스트는 쓰기 지연 기능을 제공해준다.

 

2. JPA 트랜잭션 격리 수준, DB 트랜잭션 격리 수준

-1) JPA Transaction Isolation Level

-2) DB Transaction Isolation Level

-3) 주문 시나리오

 


 

하나. 영속성 컨텍스트

JPA가 내부적으로 영속성 컨텍스트를 두어 얻을 수 있는 장점을 확인해볼게요.

JPA는 영속성 컨텍스트 "1차 캐시"를 두어 성능, 동일성 보장, 변경감지를 할 수 있게 됩니다.

 

 

-1) 같은 트랜잭션에서 영속성 컨텍스트 1차 캐시는 응답 성능을 향상 시켜준다.

아래 테스트 같은 경우에는 워낙 다른 책, 블로그들에서 많이 다뤄서 지루할 수도 있습니다. (하지만 오늘의 메인은 아니라는 점.)
@Test
@DisplayName("[기본] 영속성 컨텍스트 1차캐시는 동일성을 보장한다.")
void persistenceContextTest(@Autowired EntityManager em){
    // Given
    final StoreEntity jyeonjyan_store = StoreEntity.builder()
            .storeId(1L)
            .storeName("jyeonjyan store")
            .build();

    em.persist(jyeonjyan_store);

    // When
    final StoreEntity store = em.find(StoreEntity.class, 1L);

    // Then
    assertEquals(jyeonjyan_store, store);
}

 

결론부터 말씀드리자면.

이 테스트는 성공하고. select 쿼리는 날라가지 않아요.

트랜잭션 begin, 트랜잭션 close

테스트의 트랜잭션이 시작하고 끝나는 사이에 아무런 쿼리가 날라가지 않습니다.

 

1차 캐시에 StoreEntity가 등록되었다.

 

이유를 과정으로 나누어 설명하자면..

 

1. em.persist()로 StoreEntity를 JPA persistence context가 관리하는 객체로 영속화 시켰고.

2. 영속 상태가 된 StoreEntity 타입의 jyeonjyan_store 객체가 1차캐시에 등록되었습니다.

3. 그 뒤에 같은 트랜잭션에서 storeId = 1L 인 객체를 조회했기 때문에 따로 DB 에 쿼리를 날리지 않고 1차 캐시의 객체를 반환해준겁니다.

 

이렇게 영속성 컨텍스트를 두어 1차 캐시를 활용할 수 있게 되면 굳이 DB 자원을 쓸 필요가 없기 때문에 성능상 이점이 있는 것입니다.

 

여기서 꼭 알아야 할 점은 트랜잭션이 같으면 같은 영속성 컨텍스트를 사용하기 때문에 1차캐시에서 조회가 가능한 것입니다.

아래와 같이 다른 트랜잭션을 사용하면 다른 영속성 컨텍스트를 사용하기에 당연히 1차캐시에 조회하고자 하는 정보가 없을거고 그 때 DB에 select 쿼리를 날리게 되는 것입니다.

 

 

-2) 쓰기 지연을 제공한다. (쓰기 지연 SQL 저장소)

아래 보여줄 테스트는 영속성 컨텍스트를 통해 쓰기 지연과 변경 감지의 기능이 가능하다는걸 보여준다.

@Test
@DisplayName("[기본] 쓰기 지연 SQL 저장소 덕분에 한 네트워크에 batch 쿼리로 날린다.")
@Rollback(value = false)
void writeDelayedSqlStorage( @Autowired EntityManager em ){
    final StoreEntity jyeonjyan_store = StoreEntity.builder()
            .storeId(1L)
            .storeName("jyeonjyan store")
            .build();

    storeRepository.save(jyeonjyan_store);
    log.info("============= is exist insert query? ==============");

    final String MODIFIED_STORE_NAME = "Anonymous store";
    jyeonjyan_store.setStoreName(MODIFIED_STORE_NAME);
    log.info("============= is exist insert query? ==============");

    final StoreEntity storeById = storeRepository.findById(1L).orElseThrow(IllegalArgumentException::new);
    assertEquals(MODIFIED_STORE_NAME, storeById.getStoreName());
}
+ Rollback(value = false) 를 한 이유는 DataJpaTest는 기본전략으로 @Transactional을 갖고 있기 때문에 트랜잭션 커밋 이후에 Rollback을 하게 됩니다. 그렇게 되면 콘솔에서 쿼리를 확인하기 어렵습니다.

 

 

콘솔에 로깅이 어떻게 될거라고 예상하시나요?

.save() 메소드 호출 시점에 쿼리가 날라갈거라고 생각하나요?

 

.save() 메소드는 기본적으로 트랜잭션 커밋 되고 나서 .flush()를 호출하기 때문에 해당 테스트 메소드의 트랜잭션이 커밋되고 나서 DB에 쿼리가 동기화 됩니다.

 

 

 

추가적으로 콘솔로그에서 볼 수 있는 재미난 점을 간단히 설명해 드릴게요.

1. .save() 호출시점에 query가 날라가지 않고 트랜잭션 커밋 이후에 query가 날라간다.

2. .update() 쿼리를 따로 날리지 않았는데 update() 쿼리가 날라간다.

save() 되는 시점에 영속성 컨텍스트로 jyeonjyan_store 객체가 등록되게 되고, setStoreName()을 통해 storeName을 변경했기 때문에 영속성 컨텍스트에서 엔티티의 변경이 감지되어 update 쿼리를 추가적으로 날립니다. *더티체킹

3. .findById() 를 했지만 추가적인 select 쿼리가 날라가지 않는 이유는. DB로 select 쿼리를 날리기 전에 1차캐시에서 조회 대상 객체를 찾았고 찾은 결과를 반환했기 때문입니다.

 

 

 

 

둘. JPA 트랜잭션 격리 수준, DB 트랜잭션 격리 수준

우선 이 글은 트랜잭션 격리 수준에 대한 기본적인 이해가 있는 사람들을 대상으로 작성했다는 점 알아주세요.

 

-1) JPA 트랜잭션

사실상 JPA 트랜잭션은 따로 없습니다. 스프링 프레임워크의 Transaction을 사용하게 되는데요. 이런 미친 추상화를 통해 개발자는 비즈니스에만 집중하게 해주는게 spring framework의 큰 장점이라고도 할 수 있습니다.

 

자세한 spring framework의 transaction isolation level은 공식문서를 참고해주세요.

 

@Transactional
void transactionalTest(){
// do something
}

이렇게 @Transactional을 선언만 해주더라도 해당 메소드 단위로 transaction이 활성화가 됩니다.

 

@Transactional 같은 경우에 기본 전략으로 DEFAULT 를 가져가고 datastore에 격리 수준을 맞춘다고 합니다.

@Transactional underlying datastore

 

-2) DB 트랜잭션 

DBMS 마다 각각 다른 기본 격리 수준을 제공한다.

MySQL, Maria DB는 기본적으로 REPEATABLE READ 이고 Oracle, MsSQL은 READ COMMITTED 이다.

 

DB Isolation level types

 

DBMS의 트랜잭션 격리 수준을 생각하지 않고 개발을 한다면 큰 오류를 야기 시킬 수 있어요.

아래 시나리오를 통해 좀 더 자세히 설명해보겠습니다.

 

 

-3) 주문 시나리오

 

사장님 A는 알바생 B에게 매장을 맡기고 집에서 운영에 대한 여러 부가적인 업무를 한다.

매출은 늘어나지만 계속되는 재료비 인상으로 인해 순수익에는 변함이 없어 메뉴의 가격을 올리려고 한다.

매장에서 사용하는 POS 주문 시스템의 DBMS 는 Oracle 로 기본적으로 READ COMMITTED 수준의 격리레벨을 갖는다고 가정해보자.

참고로  READ COMMITTED 수준의 격리레벨은 commit 된 데이터만 보이는 수준의 isolation level 입니다.

 

정확히 17시 56분 34초에 Transaction 2가 시작된다.

Transaction 2는 최초로 메뉴의 가격을 조회하고 결재로 넘기는 쿼리를 수행한다.

select a.price from menu a where a.menu_name="something";

 

정확히 17시 56분 36초에 Transaction 2는 메뉴의 가격을 다시 한번 조회하고 최종적으로 결재를 하는 쿼리를 수행한다.

select a.price from menu a where a.menu_name="something";
insert into order values('something', ?);

 

Transaction 2가 수행 되는 동안 사장님이 Transaction 1(메뉴의 가격 수정) 커밋을 17시 56분 35초에 수행했을 때.

(Transaction 1 에 대한 쿼리는 아래와 같다.)

update menu set menu.price = 40000 where menu.menu_name='something';
commit;

 

트랜잭션 격리수준이 READ COMMITTED 라고 할 때.

Transaction 2에서 order 테이블로 insert 되는 가격의 값이 몇이라고 예상하나요?

 

 

READ COMMITTED

 

 

결과는 "트랜잭션 1이 커밋한 이후 아직 끝나지 않은 트랜잭션 2가 테이블 값을 select 하게 되면 40000 값이 반환 됩니다."

트랜잭션2의 두번째 select 에서 40000을 얻는것을 의도하지 않았다면, 이는 의도치 않게 정합성이 깨지고 버그가 되는거예요.

 

하나의 트랜잭션에서 select는 언제나 같은 값을 반환할것이라고 예상하셨나요?

그렇다면, 평소에 트랜잭션에 이해가 부족했거나 JPA의 영속성 컨텍스트 기능에 대해 별 생각 없이 사용하셨던거예요.

 

이 시나리오에서 Oracle 기본 세션을 이용한다면 다른 트랜잭션의 변경 커밋에 따라 select 되는 값이 변경되지만 (23000 -> 40000) JPA + spring의 기본 세팅을 이용하여 구현한다면 (23000 -> 23000)이 보장될거예요.

 

@Test
void tx1Interrupt(){
    findMenuByMenu_menuName("something"); // actual: 23000
    // tx 1 - update and commit;
    findMenuByMenu_menuName("something"); // actual: 23000
}

 

이 글을 잘 읽었다면, 이 상황을 어떻게 정리할 수 있을까요?

 

"JPA는 영속성 컨텍스트라는 매커니즘을 이용하기 때문에 Oracle을 사용하든 MsSQL을 사용하든 영속성 컨텍스트의 1차캐시 이점을 잘 활용한다면 REPEATABLE READ 수준의 격리 수준을 지킬 수 있게 되는 것이다."

 

라고 정리할 수 있을 것 같습니다.

 

JPA 같은 고수준 기술을 사용하실때 기술적 배경을 이해하고 사용하면 좋을 것 같습니다.

이 글에서는 "JPA의 구현, spring 트랜잭션, DB 트랜잭션을 이해해야 한다."는 내용을 전달 드렸습니다.

 

오늘도 읽어주셔서 감사합니다.

댓글