TIL 5일차

HanEol~·약 4시간 전
post-thumbnail

OSIV와 트랜잭션 사이, 엔티티로 넘길까 ID로 넘길까

배달 주문 서비스를 만들다가 "트랜잭션 밖에서 읽은 엔티티를 새 트랜잭션에 넘겨도 되나?"라는 의문이 생겼다.
직접 실험해 보고 정리한 글이다. (Spring Boot 4, JPA/Hibernate, H2 기준)

1. 상황: 사장님별로 독립된 주문

고객이 한 번에 여러 가게의 메뉴를 주문할 수 있다.

사장님 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이 적용되지 않기 때문이다.

2. 고민: 엔티티를 그대로 넘겨도 될까

위 코드는 customer, owner, menus 엔티티를 OrderProcessor로 넘긴다.
그런데 이 엔티티들은 OrderService에서 트랜잭션 없이 읽어 온 것이다.

  • 이 엔티티가 새 트랜잭션에 "참여"하는 걸까?
  • 안에서 값을 바꾸면 DB에 반영될까?
  • OSIV 설정에 따라 달라질까?

반대로 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와 영속성 컨텍스트부터 정리해야 한다.

3. 먼저 정리: 영속성 컨텍스트와 OSIV

영속성 컨텍스트 = 엔티티를 관리하는 메모리 보관소

EntityManager가 가지고 있는 공간이다. 여기에 올라온(관리 상태) 엔티티는

  • 한 번 읽으면 캐시되고(1차 캐시),
  • 필드를 바꾸면 트랜잭션이 끝날 때 변경을 감지해서 UPDATE를 날린다(더티 체킹).

OSIV(Open Session In View)

Spring Boot는 기본값이 spring.jpa.open-in-view=true이다. 켜져 있으면
요청 하나 동안 EntityManager 하나를 계속 열어 둔다. 응답이 나갈 때 닫는다.

OSIV 켜짐OSIV 꺼짐
영속성 컨텍스트 수명요청 시작 ~ 응답트랜잭션(또는 리포지토리 호출) 단위
트랜잭션 밖 지연 로딩가능LazyInitializationException
트랜잭션 밖에서 읽은 엔티티관리 상태로 유지준영속(detached)

영속성 컨텍스트 ≠ DB 커넥션

컨텍스트가 열려 있다고 해서 커넥션을 계속 쥐고 있는 건 아니다. 커넥션은 쿼리나 트랜잭션이 필요할 때 빌리고,
기본 설정에서는 트랜잭션이 끝나면 돌려준다. 다만 OSIV에서 트랜잭션 밖에서 지연 로딩이 일어나면
트랜잭션 종료라는 반납 시점이 없어서, 알려진 바로는 요청이 끝날 때까지 커넥션이 묶이는 경우가 있다.
OSIV 자체보다 "트랜잭션 밖에서 DB를 건드린다"가 원인이고, OSIV는 그걸 에러 없이 가능하게 해 줘서 눈에 안 띄게 만든다.

4. 트랜잭션 밖에서 읽은 엔티티는 어떻게 되나

핵심 한 줄: 엔티티가 트랜잭션에 참여하는 게 아니라, 트랜잭션이 자기와 묶인 영속성 컨텍스트의 엔티티만 처리한다.

트랜잭션 밖에서 읽은 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);   // 관리 상태라서 더티 체킹으로 반영
}

5. 그럼 주문 생성 코드는? 직접 실험해 봤다

OrderProcessor는 넘겨받은 customer, owner, menu를 수정하지 않고 Order가 참조하게만 한다.
이 경우 OSIV 켜짐/꺼짐에서 차이가 있는지, 그리고 한 사장님이 롤백된 뒤 다음 사장님이 정상 저장되는지가 궁금했다.

실험 설계

  • 고객 1명, 사장님 2명(실패용, 성공용), 메뉴는 실패용 1개 / 성공용 2개.
  • 실패용 사장님의 메뉴를 먼저 저장해서 먼저 처리되게 한다(롤백 직후 다음 사장님이 성공하는지 보려고).
  • 실패는 mock 없이 DB 제약으로 만든다. orders 테이블에 아래 제약을 걸어서 실패용 사장님의 주문 INSERT만 DB가 거절하게 했다.
ALTER TABLE orders ADD CONSTRAINT chk_fail CHECK (owner_id <> {실패용 사장님 id});
  • OSIV 켜짐은 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만 있으면 되고,
    그 엔티티가 영속성 컨텍스트에 올라와 있는지는 필요 없다.
  • 연관관계에 cascade가 Order → OrderMenu PERSIST뿐이라 User, Menu를 다시 저장하려 들지 않는다.
  • 안에서 읽는 값은 이미 로딩된 일반 컬럼(menu.getPrice(), owner.getId())이다.
    menu.getUser()는 findAllByIdIn에 건 @EntityGraph(attributePaths = "user")로 미리 가져왔다.
  • 한 사장님이 롤백되면 영속성 컨텍스트가 비워지는 걸로 알고 있는데(Spring의 JpaTransactionManager),
    그 뒤에도 다음 사장님 저장이 정상이었다. 위 이유로 영향이 없었다.

6. 그럼 언제 깨질까

설정이 아니라 코드가 바뀔 때 깨진다.

  1. OrderProcessor 안에서 넘겨받은 엔티티의 지연 로딩 필드를 읽게 될 때 (@EntityGraph 없이 menu.getUser() 등)
    → OSIV 꺼짐에서 LazyInitializationException.
  2. 넘겨받은 엔티티를 수정하는 코드를 넣을 때 (예: 재고 차감, 포인트 적립)
    → OSIV 켜짐은 반영되고, 꺼짐은 조용히 무시된다. 설정에 따라 결과가 달라지는 가장 위험한 경우.
  3. Order → User/Menu에 cascade PERSIST/MERGE를 걸 때
    → 준영속 엔티티를 다시 저장하려다 detached entity passed to persist.

7. 엔티티로 넘길까, ID로 넘길까

엔티티로 넘기기 (A)ID로 넘기기 (B)
쿼리 수적다 (이미 읽은 걸 재사용)트랜잭션마다 재조회
OSIV 의존읽기/참조만 하면 없음, 수정하면 있음없음
코드간단재조회, 존재 검증 코드가 늘어남
안전성코드가 바뀌면 깨질 수 있음항상 트랜잭션 안에서 새로 읽은 관리 상태

내 기준은 이렇게 정했다.

  • 참조만 하고 수정하지 않는다 → 엔티티로 넘겨도 된다 (이번 주문 생성).
  • 트랜잭션 안에서 엔티티를 수정한다 → ID로 넘기고 안에서 다시 조회한다.
  • 어느 쪽이든 OSIV는 끈다(spring.jpa.open-in-view=false). 트랜잭션 밖 지연 로딩 실수가
    개발 중에 바로 예외로 드러나서, 나중에 N+1이나 커넥션 점유로 조용히 터지는 것보다 낫다.

조회는 QueryDSL 프로젝션 DTO로 필요한 컬럼만 가져오고 있어서, 이 프로젝트에서는 OSIV가 주는 이점(트랜잭션 밖 지연 로딩)을
쓸 일이 거의 없다.

spring:
  jpa:
    open-in-view: false

정리

  • 영속성 컨텍스트가 열려 있다고 DB 커넥션을 계속 쥐는 건 아니다. 문제는 트랜잭션 밖에서 DB를 건드릴 때다.
  • 엔티티는 트랜잭션에 참여하는 게 아니라, 트랜잭션과 묶인 영속성 컨텍스트가 관리한다.
  • 트랜잭션 밖에서 읽은 엔티티를 수정하면 OSIV 켜짐은 반영되고 꺼짐은 조용히 무시된다.
  • 참조만 하는 주문 생성은 두 경우 모두 정상 동작했다. 실험으로 확인한 결과다.
  • 수정이 필요해지면 ID로 넘겨서 안에서 다시 조회하자. 설정에 기대지 않는 코드가 된다.
profile
기록을 통해 앞으로 나아가자!

0개의 댓글