배달 주문 서비스를 만들다가 "트랜잭션 밖에서 읽은 엔티티를 새 트랜잭션에 넘겨도 되나?"라는 의문이 생겼다.
직접 실험해 보고 정리한 글이다. (Spring Boot 4, JPA/Hibernate, H2 기준)
고객이 한 번에 여러 가게의 메뉴를 주문할 수 있다.
사장님 A - 짜장면 2개, 탕수육 1개
사장님 B - 팥빙수 1개
요구사항은 사장님별 주문은 서로 독립이라는 것이다. B의 주문이 실패해도 A의 주문은 저장돼야 한다.
그래서 사장님마다 트랜잭션을 따로 열고, 한 쪽이 롤백돼도 다른 쪽에 영향이 없게 만들었다.
구조는 이렇다.
@Service
@RequiredArgsConstructor
public class OrderService { // 클래스에 @Transactional 없음
private final OrderProcessor orderProcessor;
public OrderResponse.Create createOrder(String loginId, OrderRequest.Create request) {
User customer = findUser(loginId); // 트랜잭션 밖에서 조회
// ... 메뉴 조회, 검증, 사장님별 그룹핑 ...
byOwner.forEach((ownerId, ownerMenus) -> {
try {
successes.add(orderProcessor.process( // 사장님마다 트랜잭션 1개
customer, ownerMenus.get(0).getUser(), ownerMenus, itemByMenuId, address));
} catch (GlobalException e) {
failures.add(new Failure(ownerId, e.getMessage()));
} catch (RuntimeException e) {
log.error("주문 처리 실패 ownerId={}", ownerId, e);
failures.add(new Failure(ownerId, "주문 처리 중 오류가 발생했습니다."));
}
});
// ...
}
}
OrderService에는 @Transactional을 걸지 않았다. 바깥에 트랜잭션이 있으면 안쪽에서 난 예외가
바깥 트랜잭션을 "롤백 전용"으로 표시해서, try-catch로 잡아도 마지막에 UnexpectedRollbackException이 난다.
그리고 트랜잭션 처리는 다른 빈(OrderProcessor)으로 분리했다.
같은 클래스 안에서 this.process()를 부르면 프록시를 거치지 않아 @Transactional이 적용되지 않기 때문이다.
위 코드는 customer, owner, menus 엔티티를 OrderProcessor로 넘긴다.
그런데 이 엔티티들은 OrderService에서 트랜잭션 없이 읽어 온 것이다.
반대로 ID만 넘기고 안에서 다시 조회하는 방법도 있다.
// 방법 A. 엔티티로 넘기기 (현재 코드)
@Transactional
public Success process(User customer, User owner, List<Menu> menus,
Map<Long, OrderItem> itemByMenuId, String address) {
Order order = Order.create(customer, owner, address);
for (Menu menu : menus) {
order.addOrderMenu(menu, itemByMenuId.get(menu.getId()).quantity());
}
orderRepository.save(order);
return new Success(order.getId(), owner.getId(), order.getTotalPrice());
}
// 방법 B. ID로 넘기기 (안에서 다시 조회)
@Transactional
public Success process(Long customerId, Long ownerId, List<Long> menuIds,
Map<Long, OrderItem> itemByMenuId, String address) {
User customer = userRepository.findById(customerId).orElseThrow(...);
User owner = userRepository.findById(ownerId).orElseThrow(...);
List<Menu> menus = menuRepository.findAllById(menuIds);
// 이하 동일
}
이걸 이해하려면 OSIV와 영속성 컨텍스트부터 정리해야 한다.
EntityManager가 가지고 있는 공간이다. 여기에 올라온(관리 상태) 엔티티는
UPDATE를 날린다(더티 체킹).Spring Boot는 기본값이 spring.jpa.open-in-view=true이다. 켜져 있으면
요청 하나 동안 EntityManager 하나를 계속 열어 둔다. 응답이 나갈 때 닫는다.
| OSIV 켜짐 | OSIV 꺼짐 | |
|---|---|---|
| 영속성 컨텍스트 수명 | 요청 시작 ~ 응답 | 트랜잭션(또는 리포지토리 호출) 단위 |
| 트랜잭션 밖 지연 로딩 | 가능 | LazyInitializationException |
| 트랜잭션 밖에서 읽은 엔티티 | 관리 상태로 유지 | 준영속(detached) |
컨텍스트가 열려 있다고 해서 커넥션을 계속 쥐고 있는 건 아니다. 커넥션은 쿼리나 트랜잭션이 필요할 때 빌리고,
기본 설정에서는 트랜잭션이 끝나면 돌려준다. 다만 OSIV에서 트랜잭션 밖에서 지연 로딩이 일어나면
트랜잭션 종료라는 반납 시점이 없어서, 알려진 바로는 요청이 끝날 때까지 커넥션이 묶이는 경우가 있다.
OSIV 자체보다 "트랜잭션 밖에서 DB를 건드린다"가 원인이고, OSIV는 그걸 에러 없이 가능하게 해 줘서 눈에 안 띄게 만든다.
핵심 한 줄: 엔티티가 트랜잭션에 참여하는 게 아니라, 트랜잭션이 자기와 묶인 영속성 컨텍스트의 엔티티만 처리한다.
트랜잭션 밖에서 읽은 Member를 새 트랜잭션에 넘겨서 값을 바꾼다고 해 보자.
| OSIV 켜짐 | OSIV 꺼짐 | |
|---|---|---|
| 넘긴 엔티티의 상태 | 같은 EntityManager의 관리 상태 | 준영속 (읽은 쪽 EntityManager는 이미 닫힘) |
| 필드만 변경 | 더티 체킹으로 UPDATE 나감 | 조용히 무시됨 (에러도 없음) |
save() 호출 | 이미 반영됨 | merge: DB에서 다시 읽고 값을 덮어써서 UPDATE |
OSIV 꺼짐에서 save()로 살릴 수는 있지만, 준영속 객체를 읽은 뒤 다른 요청이 바꾼 값까지 덮어쓸 수 있다
(낙관적 락 @Version이 없으면 못 막는다). 그래서 트랜잭션을 넘나들며 엔티티를 수정해야 한다면
ID만 넘기고 안에서 다시 조회해서 수정하는 게 안전하다.
@Transactional
public void rename(Long memberId, String name) {
Member member = memberRepository.findById(memberId).orElseThrow(...);
member.changeName(name); // 관리 상태라서 더티 체킹으로 반영
}
OrderProcessor는 넘겨받은 customer, owner, menu를 수정하지 않고 Order가 참조하게만 한다.
이 경우 OSIV 켜짐/꺼짐에서 차이가 있는지, 그리고 한 사장님이 롤백된 뒤 다음 사장님이 정상 저장되는지가 궁금했다.
orders 테이블에 아래 제약을 걸어서 실패용 사장님의 주문 INSERT만 DB가 거절하게 했다.ALTER TABLE orders ADD CONSTRAINT chk_fail CHECK (owner_id <> {실패용 사장님 id});
OpenEntityManagerInViewInterceptor가 하는 일을 직접 흉내 냈다.EntityManager em = emf.createEntityManager();
TransactionSynchronizationManager.bindResource(emf, new EntityManagerHolder(em));
try {
return orderService.createOrder(customer.getLoginId(), request);
} finally {
TransactionSynchronizationManager.unbindResource(emf);
em.close();
}
OSIV 꺼짐은 서비스를 그냥 호출한다. 테스트 클래스에는 @Transactional을 붙이지 않는다(붙으면 전체가 한 트랜잭션이 된다).
========== OSIV 꺼짐 ==========
응답 성공: [owner=3 total=7000]
응답 실패: [owner=2 (주문 처리 중 오류가 발생했습니다.)]
DB 실패 사장님 orders : 0 (기대 0)
DB 성공 사장님 orders : 1 (기대 1)
DB 성공 사장님 order_menu: 2 (기대 2)
========== OSIV 켜짐 ==========
응답 성공: [owner=6 total=7000]
응답 실패: [owner=5 (주문 처리 중 오류가 발생했습니다.)]
DB 실패 사장님 orders : 0 (기대 0)
DB 성공 사장님 orders : 1 (기대 1)
DB 성공 사장님 order_menu: 2 (기대 2)
두 경우 모두 의도한 대로 동작했다. (로그에 보이는 Check constraint violation 경고는 일부러 만든 제약이 거절한 INSERT를 Hibernate가 남긴 것이다.)
Order는 User, Menu를 @ManyToOne으로 참조만 한다. 외래키를 채우려면 상대 엔티티의 id만 있으면 되고,Order → OrderMenu PERSIST뿐이라 User, Menu를 다시 저장하려 들지 않는다.menu.getPrice(), owner.getId())이다.menu.getUser()는 findAllByIdIn에 건 @EntityGraph(attributePaths = "user")로 미리 가져왔다.JpaTransactionManager),설정이 아니라 코드가 바뀔 때 깨진다.
OrderProcessor 안에서 넘겨받은 엔티티의 지연 로딩 필드를 읽게 될 때 (@EntityGraph 없이 menu.getUser() 등)LazyInitializationException.Order → User/Menu에 cascade PERSIST/MERGE를 걸 때detached entity passed to persist.| 엔티티로 넘기기 (A) | ID로 넘기기 (B) | |
|---|---|---|
| 쿼리 수 | 적다 (이미 읽은 걸 재사용) | 트랜잭션마다 재조회 |
| OSIV 의존 | 읽기/참조만 하면 없음, 수정하면 있음 | 없음 |
| 코드 | 간단 | 재조회, 존재 검증 코드가 늘어남 |
| 안전성 | 코드가 바뀌면 깨질 수 있음 | 항상 트랜잭션 안에서 새로 읽은 관리 상태 |
내 기준은 이렇게 정했다.
spring.jpa.open-in-view=false). 트랜잭션 밖 지연 로딩 실수가조회는 QueryDSL 프로젝션 DTO로 필요한 컬럼만 가져오고 있어서, 이 프로젝트에서는 OSIV가 주는 이점(트랜잭션 밖 지연 로딩)을
쓸 일이 거의 없다.
spring:
jpa:
open-in-view: false