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)가 되지 않기 때문입니다. 지금부터 그 이유를 알아보려고 합니다.
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
를 사용하고 있었습니다.
서비스 테스트 결과
createMember
로 회원을 생성한 상태에서 changeNickname
메서드를 실행시켜 해당 회원의 닉네임을 변경합니다. 메서드 호출에 따른 로깅정보는 주석에 담았습니다.
흥미로운 점은 API 테스트는 OpenEntityManagerInViewInterceptor
에서 얻은 EntityManager를 활용했으나 서비스 테스트는 서비스 메서드를 직접 호출하기 때문에 Interceptor를 거치지 않고 트렌젝션 시작 시 새로운 EntityManager를 생성(Opened new EntityManager)한다는 점이었습니다.
2. OpenEntityManagerInViewInterceptor 분석하기
OpenEntityManagerInViewInterceptor
는 등록된 Interceptor들과 똑같이 처리됩니다.
DispatcherServlet
이 Controller
메서드를 실행하기 전 preHandle
을 호출하고 Controller
메서드 실행 후 postHandle
를 호출합니다. 마지막엔 예외와 상관 없이 afterCompletion
을 호출하게 됩니다.
OSIV를 켜면 EntityManager의 생명주기는 아래의 그림처럼 클라이언트 요청을 받은 순간 부터 Viewer에서 랜더링 하기 까지 범위가 확장이 됩니다. 반면 OSIV를 끄게 되면 트렌젝션 범위와 동일합니다.
OpenEntitymanagerInviewInterceptor는 언제 등록될까?
등록 주체는 위에서 소개한 spring.jpa.open-in-view is enabled by default.
로그를 출력한 JpaBaseConfiguration
입니다. open-in-view=true
일 때 아래와 같이 Bean으로 등록하고 WebMvcConfigurer
를 생성해서 등록하고 있습니다.
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