(이 글에서는 OSIV/OEMV 모두 OSIV로 지칭합니다)
(이 글을 작성할 수 있게 소스를 준 노랑에게 감사합니다)
우연히 노랑이라는 크루를 통해 `@Transactional`을 붙이지 않았는데 더디체킹이 된다는 소리를 들었고
실제로 Query를 보니 update를 하지 않았는데 update가 되었다.
처음 봤는데 신기했다. 동시에 무엇때문에 그러는지 궁금해지기 시작했다.
문제의 코드는 아래와 같다.
public void cancelReservationById(final long id) {
waitingRepository.findFirstByReservationIdOrderByCreatedAtAsc(id)
.ifPresentOrElse(
(waiting) -> processWaitingToReservation(id, waiting),
() -> reservationRepository.deleteById(id)
);
}
private void processWaitingToReservation(final long id, final Waiting waiting) {
final Reservation reservation = reservationRepository.findById(id)
.orElseThrow(() -> new NotFoundException("예약을 찾을 수 없습니다."));
reservation.updateMember(waiting.getMember());
waitingRepository.delete(waiting);
}
해당 메서드는 `@Transactional` 이 존재하지 않는다.
하지만 reservation이 업데이트가 되었다.
`@Transactional`이 존재하지 않기 때문에 더티 체킹이 일어나지 않으니 당연히 reservation은 따로 save를 하지 않으면 업데이트가 일어나면 안 되지만 일어났다.
왜 그럴까?
일단 사전지식으로 JPA의 모든 CRUD메서드에는 `@Transactional`이 붙어있다는 것은 알고 있었다.
(물론 CUD에는 @Transactional이 적용되어있다)
이를 알고 있었지만 해당 동작은 이해가 되지 않았다.
그렇지만 노랑이 말하길 delete의 위치에 따라 update가 될 수도 되지 않을 수도 있다고 했다.
✅ update 쿼리 발생 O
private void processWaitingToReservation(final long id, final Waiting waiting) {
final Reservation reservation = reservationRepository.findById(id)
.orElseThrow(() -> new NotFoundException("예약을 찾을 수 없습니다."));
reservation.updateMember(waiting.getMember());
waitingRepository.delete(waiting);
}
✅ update 쿼리 발생 X
private void processWaitingToReservation(final long id, final Waiting waiting) {
final Reservation reservation = reservationRepository.findById(id)
.orElseThrow(() -> new NotFoundException("예약을 찾을 수 없습니다."));
waitingRepository.delete(waiting);
reservation.updateMember(waiting.getMember());
}
차이는 단지 delete의 위치였다.
이렇게 위치에 따라 update가 발생한 이유는 바로 OSIV 때문이다.
📚 더티 체킹 (Dirty Checking)
OSIV에 대해 알아보기 전에 일단 더티 체킹에 대해 다시 알아보자
더티 체킹은 상태 변형 검사이다.
JPA에서는 트랜잭션이 끝나는 시점에 변화가 있는 모든 엔티티 객체를 데이터베이스에 자동으로 반영해 준다.
이때 '변화가 있다'의 기준은 최초 조회 상태이다.
JPA에서는 엔티티를 조회하면 해당 엔티티의 조회 상태 그대로 스냅샷으로 만들어 놓는다.
그리고 트랜잭션이 끝나는 시점에 이 스냅샷과 비교해서 다른 점이 있다면 Update Query를 데이터베이스로 전달한다.
당연히 이런 상태 변경 검사의 대상은 영속성 컨텍스트가 관리하는 엔티티에만 적용된다.
준영속, 비영속 상태의 엔티티는 더티 체킹 대상에 포함되지 않는다.
즉, 값을 변경해도 데이터베이스에 반영되지 않는다는 것이다.
자 그럼 다시 코드를 보자
private void processWaitingToReservation(final long id, final Waiting waiting) {
final Reservation reservation = reservationRepository.findById(id)
.orElseThrow(() -> new NotFoundException("예약을 찾을 수 없습니다."));
}
이렇게 조회를 했을 때는 JPA의 구현체인 Hibernate를 이용할 경우 트랜잭션 범위에서 Entity를 조회할 경우 아래와 같이 조회시점의 Entity 복사본을 만들어 준다.
그리고 트랜잭션이 끝나는 시점에 여러 가지 서비스 로직으로 원본 Entity의 변경이 있다면 조회시점에 복사해 둔 복사본과 비교를 하여 다른 점이 있다면 Update 쿼리를 발생시킨다.
그런데 여기서는 로직을 실행하는 메서드에 트랜잭션이 붙어있지 않는다.
뭐야 그럼 더티 체킹이 일어날 수 없는 조건에서 도대체 누가 업데이트 쿼리를 날리는 거야?
이를 알기 위해서는 EntityManager도 알아야 한다.
영속성 컨텍스트는 EntityManager 단위로 존재하고 일반적으로 트랜잭션이 시작될 때 스프링이 EntityManger를 만들어서 트랜잭션 바인딩을 시킨다. 하지만 트랜잭션이 먼저 열리지 않아도 EntityManger는 존재할 수 있다.
언제? OSIV가 켜져 있을 때
바로 이때 `deleteById()` 메서드를 사용하면 `deleteById()`가 트랜잭션을 열 때 이미 존재하던 영속성 컨텍스트에 reservation이라는 엔티티가 들어 있었다면, reservation.updateMember()로 수정된 상태였을 테고, reservation도 같이 DB에 반영된다.
하지만 만약 OSIV 설정이 꺼져 있다면 반영되지 않는다.
🚀 OSIV
스프링 부트에서 기본으로 켜져 있는 설정으로, HTTP 요청의 시작부터 끝(View 렌더링까지) 영속성 컨텍스트(EntityManager)를 열어두는 전략이다.
OSIV가 켜져 있으면 컨트롤러나 서비스에 `@Transactional`이 없어도 이미 요청 시점에 영속성 컨텍스트가 열려 있고, 엔티티가 조회되면 영속 상태로 관리 된다. 그래서 지금까지 View Template이나 API 컨트롤러에서 지연 로딩이 가능했던 것이다.
지연 로딩은 영속성 컨텍스트가 살아있어야 가능하고, 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지한다.
이것 자체가 큰 장점이다.
그런데 이 전략은 너무 오랜 시간 동안 데이터베이스 커넥션 리소스를 사용하기 때문에, 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자랄 수 있다. 이것은 결국 장애로 이어진다.
예를 들어서 컨트롤러에서 외부 API를 호출하면 외부 API 대기 시간만큼 커넥션 리소스를 반환하지 못하고, 유지해야한다.
아래 이미지는 OSIV가 켜고 꺼짐에 따른 영속성 컨텍스트 생존 범위를 나타낸 것이다.
OSIV ON
OSIV OFF
OSIV가 꺼져 있으면 HTTP 요청을 시작할 때 영속성 컨텍스트가 열리지 않는다.
`@Transactional`이 걸린 시점에서야 EntityManager가 열린다.
상위 메서드에서 조회한 엔티티는 영속 상태가 아님 (Detached) 즉, 더티 체킹 대상이 아니다.
따라서 하위에서 트랜잭션이 열려도 flush 시킬 변경사항이 없다.
정리해 보면 아래와 같다.
📌 왜 트랜잭션 어노테이션이 붙어있지 않은 상위 메서드의 변경사항이 하위 메서드에 붙어있는 트랜잭션에 영향을 받을까?
스프링에서는 보통 HTTP 요청 전체 기간 동안 하나의 영속성 컨텍스트를 유지하는 OSIV 전략을 사용한다.
OSIV 전략이 켜져 있으면
요청을 처리하는 동안 항상 영속성 컨텍스트가 유지된다. 그래서 별도의 트랜잭션 선언이 없어도 엔티티가 계속 영속 상태로 유지된다.
이때 하위 메서드에서 트랜잭션이 열릴 때, 기존 영속성 컨텍스트의 모든 변경사항이 DB에 반영된다(flush)
OSIV 전략이 꺼져 있으면
명시적으로 트랜잭션이 붙은 메서드에서만 영속성 컨텍스트가 열린다.
상위 메서드에서 조회한 엔티티는 영속성 컨텍스트가 닫힌 상태라 더티 체킹이 일어나지 않는다.
🤔 왜 스프링 부트는 OSIV 기본값을 true로 ?
여기서 이런 궁금증이 생길 수 있다.
스프링 부트는 도대체 왜 OSIV를 기본값으로 설정해 놓았을까??
그 이유는 아래 spring-projects 이슈에서 알 수 있다.
(읽는데 1시간 이상 걸리니 주의해야 한다. 아주 피 터지게 토론을 한다.)
Log a warning on startup when spring.jpa.open-in-view is enabled but user has not explicitly opted in · Issue #7107 · spring-p
Considering OSIV/OEMIV is widely considered an anti-pattern, OpenEntityManagerInViewInterceptor should IMO not be enabled by default. Rather than that it should be opt-in. If this proposal isn't ac...
github.com
👍 OSIV 기본 활성화 찬성 측 주장
신규 사용자가 LazyInitializationException을 자주 겪고, 이를 회피하기 위한 개발자 경험(DX)을 고려해야 한다!
🙅🏻 OSIV 기본 활성화 반대 측 주장
OSIV는 지연 로딩을 무분별하게 하게 만들어 예기치 않은 DB 쿼리 폭주, 커넥션 풀 고가, 심각한 성능 저하를 유발할 수 있음
Reddit에서 OSIV off 버전이 처리량에서 2배 이상 월등하다는 비판적인 의견도 게시
주요 의견
결국은 스프링의 철학은 아래와 같다.
“Spring은 복잡한 엔터프라이즈 개발을 쉽게 만들기 위해 시작되었다.”
— Rod Johnson (Spring 창시자), “Expert One-on-One J2EE Design and Development” 서문
OSIV 기본값을 true로 해놓는 것은 누구나 쉽게 러닝커브가 거의 없이 무언가를 스프링을 통해 쉽게 만들기 위해서인데 이를 false로 해놓으면 초보자 입장에서는 알아야 할게 너무나 많아진다는 것이다.(어차피 숙련자라면 상황에 따라 true/false를 자유롭게 선택할 수 있을 것이다)
이 이슈 글을 읽을 때 주의할 점은 OSIV를 써야 한다가 아니라 OSIV 기본값을 true로 해놓은 이유라는 걸 잊으면 안 된다.
📌 현업에서 osiv를 관련 문제 발생 사례
Spring Boot의 open-in-view, 그 위험성에 대하여 - 프렙
📚 Reference
- 스프링 부트와 JPA 활용2
- Two reasons why you might want to disable Open Session in View in a Spring application
'Spring' 카테고리의 다른 글
Controller에서 왜 반환값을 ResponseEntity로 안 해? (1) | 2025.06.20 |
---|---|
@RequestBody 이게 뭐야? (2) | 2025.04.17 |