Skip to content

Spring Data JPA를 사용할 때 주의해야 할 점

요약

이 글은 아래와 같은 내용을 다룹니다.

  • open-in-view 옵션
  • transaction과 EntityManager의 생명주기를 로그로 확인하는 법
  • open-in-view를 활성화 했을 때 EntityManager의 생명주기와 비활성화 했을때의 생명주기
  • Spring Interceptor의 동작 과정

통합 테스트는 성공하고 서비스 테스트는 실패하는 이유

Spring Boot 프로젝트에 JPA를 활용하여 닉네임 변경 로직을 작성했습니다.

JPA는 Update에 관한 메서드를 직접 제공하지 않으나 @Transaction을 빠져나갈 때 관리하고 있는 엔티티의 속성값이 변경되면 이를 감지하여(Dirty-Check) 데이터베이스에 업데이트 하는 것으로 알고 있습니다.

ArugmentResolver에서 가져온 MemberEntity를 재활용하기 위해 서비스 메서드의 매개변수로 받고 필드값을 변경했습니다.

@Transactional
public MemberChangeResponse changeNickname(MemberEntity member, MemberChangeRequest request) {
    member.changeNickname(request.nickname());
    return new MemberChangeResponse(request.nickname());
}
    // 변경감지가 일어나서 닉네임이 업데이트 되겠지?

@Transactional 어노테이션을 빠져나가니 변경감지가 일어나고 업데이트가 되었습니다. 기능을 구현하고 마무리 하려고 했으나 서비스 테스트가 실패함을 알았습니다. (에?)

사실 이 상황이 매우 이상했습니다. EntityManager는 트렌젝션이 끝나면 닫히는 걸로 알고있는데 ArgumentResolver가 반환한 MemberEntity준영속(detach) 상태이고 서비스 로직에서 변경해도 변경감지(Dirty-Check)가 되지 않기 때문입니다. 지금부터 그 이유를 알아보려고 합니다.

Image title

왜 API테스트는 정상 작동하고 서비스 테스트는 그렇지 않을까?

open-in-view

Spring Data JPA를 사용하면 open-in-view 옵션이 기본으로 켜져있습니다. 이 기능은 한 유저가 API를 요청할 때 HTTP Response를 전달하기 전까지 EntityManager를 유지합니다. SpringBoot를 실행하면 아래와 같이 경고 문구로 켜져 있음을 안내하고 있습니다.

spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

뷰를 처리하기 전까지 EntityManager를 유지하므로 @Transactional이 붙은 서비스 로직을 빠저나가면 변경 감지로 인해 업데이트 쿼리가 작동하게 됩니다. 그러나 서비스 테스트에선 open-in-view 옵션과 상관 없이 트렌젝션이 종료되면 사라지게 됩니다 왜 그런걸까요?

1. 트렌젝션 로그 확인

trasnaction의 생명주기와, EntityManager객체의 생명주기를 확인하기 위해 application.yml 파일에 아래의 옵션을 추가했습니다.

logging:
  level:
    org.springframework.orm.jpa: DEBUG
    org.springframework.transaction.interceptor: TRACE

org.springframework.orm.jpa : Transaction의 생명주기와 EntityManager의 생명주기를 한눈에 볼 수 있게 합니다. org.springframework.transaction.interceptor : @Transactional이 붙은 메서드의 시작과 끝을 명확하게 알 수 있도록 로깅을 보여줍니다.

API 테스트 결과

API 테스트 로깅 결과를 보면 argumentResolver에서 OpenEntityManagerInViewInterceptor에서 생성한 EntityManager를 사용하고 있었습니다.

@Test
void 닉네임을_변경할_수_있다() {
    RestAssured.given().log().all()
            .body(request)
            .contentType(ContentType.JSON)
        /*
        argumentResolver 처리
            o.j.s.OpenEntityManagerInViewInterceptor : Opening JPA EntityManager in OpenEntityManagerInViewInterceptor
            o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(1601516819<open>)] for JPA transaction
            o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
            o.s.orm.jpa.JpaTransactionManager        : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@45a2aba9]
            o.s.t.i.TransactionInterceptor           : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]
        argumentResolver 처리 후
            o.s.t.i.TransactionInterceptor           : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]
            o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
            o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(1601516819<open>)]
            o.s.orm.jpa.JpaTransactionManager        : Not closing pre-bound JPA EntityManager after transaction
        changeNickname 서비스 호출
            o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(1601516819<open>)] for JPA transaction
            o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [com.example.springboot_jpa_osiv.service.MemberService.changeNickname]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
            o.s.orm.jpa.JpaTransactionManager        : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@2638b14e]
            o.s.t.i.TransactionInterceptor           : Getting transaction for [com.example.springboot_jpa_osiv.service.MemberService.changeNickname]
        changeNickname 서비스 호출 후
            o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
            o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(1601516819<open>)]
        */
            .when().patch("/member")
            .then().log().all()
        /*
            o.s.orm.jpa.JpaTransactionManager        : Not closing pre-bound JPA EntityManager after transaction
            o.j.s.OpenEntityManagerInViewInterceptor : Closing JPA EntityManager in OpenEntityManagerInViewInterceptor
        */
            .statusCode(200);

}

서비스 테스트 결과

createMember로 회원을 생성한 상태에서 changeNickname메서드를 실행시켜 해당 회원의 닉네임을 변경합니다. 메서드 호출에 따른 로깅정보는 주석에 담았습니다.

흥미로운 점은 API 테스트는 OpenEntityManagerInViewInterceptor에서 얻은 EntityManager를 활용했으나 서비스 테스트는 서비스 메서드를 직접 호출하기 때문에 Interceptor를 거치지 않고 트렌젝션 시작 시 새로운 EntityManager를 생성(Opened new EntityManager)한다는 점이었습니다.

@Test
void 회원_이름을_변경할_수_있다() {
    // given

    Member member = memberFixture.createMember("test");
    MemberChangeRequest request = new MemberChangeRequest("changeNick");

    // when
    /*
    o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [com.example.springboot_jpa_osiv.service.MemberService.changeNickname]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
    o.s.orm.jpa.JpaTransactionManager : Opened new EntityManager [SessionImpl(1953436933<open>)] for JPA transaction
    o.s.orm.jpa.JpaTransactionManager : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@1507bc3]
    o.s.t.i.TransactionInterceptor    : Getting transaction for [com.example.springboot_jpa_osiv.service.MemberService.changeNickname]
        */
    memberService.changeNickname(member, request);
    /*
    o.s.t.i.TransactionInterceptor    : Completing transaction for [com.example.springboot_jpa_osiv.service.MemberService.changeNickname]
    o.s.orm.jpa.JpaTransactionManager : Initiating transaction commit
    o.s.orm.jpa.JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(1953436933<open>)]
    o.s.orm.jpa.JpaTransactionManager : Closing JPA EntityManager [SessionImpl(1953436933<open>)] after transaction
        */

    String actual = memberRepository.findById(member.getId()).get().getNickname();

    // then
    assertThat(actual).isEqualTo("changeNick");
}

2. OpenEntityManagerInViewInterceptor 분석하기

OpenEntityManagerInViewInterceptor는 등록된 Interceptor들과 똑같이 처리됩니다.

DispatcherServletController 메서드를 실행하기 전 preHandle을 호출하고 Controller 메서드 실행 후 postHandle를 호출합니다. 마지막엔 예외와 상관 없이 afterCompletion을 호출하게 됩니다.

OSIV를 켜면 EntityManager의 생명주기는 아래의 그림처럼 클라이언트 요청을 받은 순간 부터 Viewer에서 랜더링 하기 까지 범위가 확장이 됩니다. 반면 OSIV를 끄게 되면 트렌젝션 범위와 동일합니다.

Image title

OSIV on/off에 따른 EntityManager의 생명주기

OpenEntitymanagerInviewInterceptor는 언제 등록될까?

등록 주체는 위에서 소개한 spring.jpa.open-in-view is enabled by default.로그를 출력한 JpaBaseConfiguration 입니다. open-in-view=true일 때 아래와 같이 Bean으로 등록하고 WebMvcConfigurer를 생성해서 등록하고 있습니다.

package org.springframework.boot.autoconfigure.orm.jpa;

public abstract class JpaBaseConfiguration {
    protected static class JpaWebConfiguration {
        //...
        @Bean
        public OpenEntityManagerInViewInterceptor openEntityManagerInViewInterceptor() {
            if (this.jpaProperties.getOpenInView() == null) {
                logger.warn("spring.jpa.open-in-view is enabled by default. "
                        + "Therefore, database queries may be performed during view "
                        + "rendering. Explicitly configure spring.jpa.open-in-view to disable this warning");
            }
            return new OpenEntityManagerInViewInterceptor();
        }

        @Bean
        public WebMvcConfigurer openEntityManagerInViewInterceptorConfigurer(
                OpenEntityManagerInViewInterceptor interceptor) {
            return new WebMvcConfigurer() {
                @Override
                public void addInterceptors(InterceptorRegistry registry){
                    registry.addWebRequestInterceptor(interceptor);
                }
            };
        }
    }
}

Info

Interceptor인걸 알았으니 WebMvcConfigurer를 상속 받아 커스텀 Interceptor를 등록할때 사용하는 InterceptorRegistry에서 받아올 수 있지 않을까요? 그러나 예상과 다르게 받아올 수 없었습니다.

그 이유는 OpenEntityManagerInViewInterceptor는 유저가 작성한 Interceptor보다 나중에 등록 되었기 때문입니다. 하지만 이 순서는 스프링에서 보장이 되지 않기 때문에 무조건 나중에 등록된다고 볼 수 없었습니다.

4. 결론

서비스 테스트와 API 테스트가 왜 다른 결과가 나오는지 고민하다가 Spring Data JPA의 기본옵션인 open-in-view으로 인해 예상과 다른 실행결과가 나올 수 있음을 분석하면서 어떤 기술을 도입할 때는 문제 해결을 위해 바로 도입하기 이전에 해당 기술의 올바른 사용법 부터 익혀야 함을 알게 되었습니다.

그러나 문제를 해결하기 위해 기술부터 적용하면 프로젝트가 커질 때 사이드 이팩트가 늦게 발견되어 버릴 수 있습니다. 그렇다고 JPA 같은 기술을 완벽하게 학습하는건 시간이 오래 걸리는 일입니다. 위와 같은 트레이드 오프는 어떻게 기준을 삼아야 할까요?

지금의 저주의사항 보다는 기술을 도입할 때의 장점 을 먼저 생각합니다. 기술을 도입하기 전 얼마나 편해질 지(코드가 간결해고 유지보수가 쉬어지는 방향인지, 이전에 사용한 기술의 문제점을 효과적으로 해결하는지)부터 생각하고 난 후 기술이 가지고 있는 성질을 파악했습니다.

도입하고 난 뒤 문제가 발생하면 위와 같이 분석하기 시작합니다. 해당 문제는 닉네임을 변경하는 기능을 개발 하고나서야 발견되었습니다. 기술의 문제점을 발견하기 전까진 저희팀은 JPA를 편하게 사용하고 있었습니다(약 6개월 동안이요).

문제를 발견했으니 ArgumentResolver에서 repository를 사용하지 않도록 구조를 변경하는 것과 같이 효과적으로 해결할 수 있는 방법을 찾아봐야겠습니다. 프로젝트 기간이 길어짐에 따라 수정 사항이 많아지고 사이드 이팩트도 파악해야 합니다. 이런 상황에선 코드를 과감하게 변경하기 쉽지 않습니다.

그러나 지금까지 꼼꼼히 작성한 테스트 코드로 실패 내역을 바로 확인할 수 있어 걱정없이 바꿀 수 있습니다. 위와 같은 사건을 겪고 나서 테스트 코드가 기술 부채를 줄여준다는 효과를 알게되었습니다.

참고자료
  • handler interceptor 동작 과정 - https://www.baeldung.com/spring-mvc-handlerinterceptor
  • transaction 로깅 방법 - https://www.baeldung.com/spring-transaction-active
  • persistance-context 생명주기- https://www.baeldung.com/jpa-hibernate-persistence-context
  • transaction persistance-context - https://docs.oracle.com/html/E13946_01/ejb3_overview_emfactory_perscontext.html
  • jakarta JPA persistance-context type - https://jakarta.ee/specifications/persistence/2.2/apidocs/javax/persistence/persistencecontext
  • entity 라이프 사이클 - https://tecoble.techcourse.co.kr/post/2020-09-20-entity-lifecycle-2/
  • open-session-in-view - https://www.baeldung.com/spring-open-session-in-view
  • OpenEntityManagerInViewInterceptor - https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/orm/jpa/support/OpenEntityManagerInViewInterceptor.html


Comments