OSIV

hisol·2025년 11월 25일

문제 상황

스프링에서 예약 로직을 만들고, 예약이 완료되면 이메일을 보내는 코드를 작성했습니다.

@Service
@RequiredArgsConstructor
@Slf4j
public class ReservationNotificationService {

    private final EmailSender emailSender;

    public void sendReservationCreatedNotification(Reservation reservation) {
        doSendMail(reservation); // 동기 호출
    }

    @Async("mailTaskExecutor")
    public void sendReservationCreatedNotificationAsync(Reservation reservation) {
        doSendMail(reservation); // 비동기 호출
    }

    private void doSendMail(Reservation reservation) {
        try {
            emailSender.sendReservationEmail(reservation);
            log.info("예약 완료 메일 발송. reservationId={}", reservation.getId());
        } catch (Exception e) {
            log.error("예약 완료 메일 발송 중 예외 발생. reservationId={}", reservation.getId(), e);
        }
    }
}

컨트롤러에서 테스트용으로 이렇게 호출했습니다.

@PostMapping("/reservations/{reservationId}/sync")
public ResponseEntity<Void> sendSync(@PathVariable Long reservationId) {
    Reservation reservation = reservationRepository.findById(reservationId)
            .orElseThrow(...);

    reservationNotificationService.sendReservationCreatedNotification(reservation);
    return ResponseEntity.ok().build();
}

@PostMapping("/reservations/{reservationId}/async")
public ResponseEntity<Void> sendAsync(@PathVariable Long reservationId) {
    Reservation reservation = reservationRepository.findById(reservationId)
            .orElseThrow(...);

    reservationNotificationService.sendReservationCreatedNotificationAsync(reservation);
    return ResponseEntity.ok().build();
}
  • 동기(/sync)로 호출하면 메일 발송이 잘 된다. 그런데 비동기(/async)로 호출하면 아래와 같은 에러가 난다.

org.hibernate.sql.exec.ExecutionException:
 Error advancing (next) ResultSet position [Operation not allowed after ResultSet closed]
...
Caused by: java.sql.SQLException: Operation not allowed after ResultSet closed
...
at com.goorm.tablepick.domain.member.entity.Member$HibernateProxy.getEmail(Unknown Source)
at com.goorm.tablepick.infra.MailhogEmailSender.sendReservationEmail(MailhogEmailSender.java:28)

즉, 같은 Reservation 엔티티를 넘겼는데 동기 호출은 되고, 비동기 호출에서는 터지는 문제가 발생한다.

이 상황을 이해하려면 OSIV, 엔티티 vs DTO, 스레드와 EntityManager의 관계를 같이 봐야 한다.

OSIV(Open Session In View)란 무엇인가

스프링 부트에서는 보통 아래 설정으로 보게 된다.


spring.jpa.open-in-view: true

의미는 간단하다.

“HTTP 요청이 들어와서 응답이 나갈 때까지, JPA 세션(EntityManager / Hibernate Session)을 열어둔다.”

그래서 컨트롤러, 서비스 어디에서든 같은 요청을 처리하는 동안에는 LAZY 로딩을 사용할 수 있다.

OSIV가 켜져 있으면:
• 요청 시작: 세션/EntityManager 열림
• 요청 처리: 서비스, 리포지토리, 컨트롤러에서 엔티티 사용 가능
• 응답 완료: 세션/EntityManager 닫힘

트랜잭션, EntityManager, Session, 그리고 스레드

EntityManager vs Session

•	JPA 표준 용어: EntityManager
•	Hibernate 구현체 용어: Session

스프링 + Hibernate 조합에서는 EntityManager가 내부적으로 Hibernate Session을 감싸고 있는 구조라고 보면 된다.

“세션이 열린다” = “그 요청에서 사용할 EntityManager/Session이 생성되어 스레드에 바인딩된다”는 의미.

어떻게 스레드와 묶여 있는가

스프링은 내부적으로 ThreadLocal을 사용해 현재 요청을 처리 중인 스레드에 이 스레드가 사용할 EntityManager/Session을 붙여 둔다.”

•	HTTP 요청 A → 스레드 T1이 처리
•	T1에 EntityManager(세션) 하나 바인딩
•	HTTP 요청 B → 스레드 T2가 처리
•	T2에 또 다른 EntityManager 바인딩

따라서 같은 요청 안에서는, 서비스/리포지토리/컨트롤러 어디서든 현재 스레드에 붙어 있는 EntityManager를 사용한다.

스레드가 바뀌면 그 EntityManager 바인딩도 달라진다 → 다른 스레드에서는 기존 세션을 쓸 수 없다.

엔티티와 Lazy 로딩, 그리고 왜 비동기에서 터지는가

Reservation 엔티티를 예로 보자.

@Entity
public class Reservation {

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;

    ...
}

reservationRepository.findById(id) 를 호출하면 Reservation 객체는 실제로 만들어지고 member 필드는 진짜 Member 객체가 아니라 프록시(proxy) 로 채워진다. 실제 member 정보를 DB에서 읽어오는 시점은 reservation.getMember().getEmail() 처럼 필드를 처음 사용하는 순간이다. (Lazy 로딩)

이 Lazy 로딩이 동작하려면 현재 스레드에 열려 있는 세션/EntityManager가 필요하다. 그래야 “지금 이 엔티티에 대해 추가 쿼리를 날려달라”고 요청할 수 있다.

이제 동기/비동기 두 경우를 비교해보자.

동기 호출 흐름

@PostMapping("/reservations/{reservationId}/sync")
public ResponseEntity<Void> sendSync(@PathVariable Long reservationId) {
    Reservation reservation = reservationRepository.findById(reservationId).orElseThrow();

    // 같은 스레드, 같은 세션
    reservationNotificationService.sendReservationCreatedNotification(reservation);
    return ResponseEntity.ok().build();
}

• sendReservationCreatedNotification() 내에서 emailSender.sendReservationEmail(reservation); 을 실행할 때, reservation.getMember().getEmail() 같은 Lazy 로딩이 발생해도, 세션이 살아 있으므로 추가 쿼리가 정상적으로 나간다.

→ 그래서 동기 호출에서는 문제가 없다.

비동기 호출 흐름

@PostMapping("/reservations/{reservationId}/async")
public ResponseEntity<Void> sendAsync(@PathVariable Long reservationId) {
    Reservation reservation = reservationRepository.findById(reservationId).orElseThrow();

    reservationNotificationService.sendReservationCreatedNotificationAsync(reservation);
    return ResponseEntity.ok().build(); // 여기서 응답 종료
}

• 하지만 @Async("mailTaskExecutor") 메서드는 별도의 스레드 풀에서 실행된다.

즉,
1. 컨트롤러 스레드에서 reservation 엔티티를 조회하고
2. 이 엔티티를 그대로 비동기 스레드로 넘기고
3. 컨트롤러 스레드는 응답을 반환하고 끝난다.
4. 나중에 비동기 스레드가 doSendMail(reservation)을 실행한다.

이때 문제:
• 비동기 스레드는 요청 스레드와 다르다.
• OSIV에 의해 관리되던 세션/EntityManager는 요청 스레드 기준으로 열려 있다가 응답과 함께 닫힌다.
• 비동기 스레드에서는 세션이 바인딩되어 있지 않다.

따라서 비동기 스레드에서 reservation.getMember().getEmail(); 를 호출하는 순간:
• Hibernate 입장: 추가로 DB에 물어봐야 하는데, 이 스레드에는 세션/커넥션이 없다.

• 결과: LazyInitializationException 이나 ResultSet closed 류의 예외가 발생하게 된다.

엔티티 대신 DTO/이벤트로 넘긴다

해결책은 간단하다.

비동기, @Async, 카프카, 외부 시스템으로 넘길 때는 엔티티를 직접 넘기지 말고, 필요한 값만 뽑아서 DTO/이벤트 객체로 만들어 넘긴다.

• 더 이상 DB에 접근할 필요가 없다.
• 세션/트랜잭션 여부와 관계 없이 순수 자바 객체만 사용하므로, 다른 스레드에서도 안전하게 동작한다.

다른 해결법도 살펴보자.

@Async 쪽에서 ID만 받고, 다시 조회하기

엔티티 전체를 넘기지 않고, PK만 넘겨서 비동기 메서드에서 다시 조회하는 방식도 있다.

엔티티를 넘기되, 필요한 연관 관계를 미리 초기화해두기

“엔티티를 넘기고 싶긴 한데, Lazy 때문에 깨지는 게 싫다”라면,
사용할 연관 관계를 요청 스레드에서 미리 초기화해둘 수도 있습니다. (fetch join)

@Query("""
    select r from Reservation r
    join fetch r.member
    join fetch r.restaurant
    where r.id = :id
""")
Optional<Reservation> findWithMemberAndRestaurantById(@Param("id") Long id);

정리

  1. OSIV(Open Session In View)
    • 요청 시작부터 응답까지 JPA 세션(EntityManager/Session)을 열어두는 전략
    • spring.jpa.open-in-view: true 가 그 설정
  2. 엔티티는 “세션 + 스레드 컨텍스트”에 의존
    • Lazy 로딩을 위해 현재 스레드에 바인딩된 EntityManager/Session이 필요
    • 다른 스레드(@Async)에서 사용하면 세션이 없어서 예외 발생 가능
  3. 스레드와 EntityManager
    • 스프링은 ThreadLocal 등을 사용해 “현재 스레드 전용 EntityManager”를 관리
    • 요청 스레드와 비동기 스레드의 컨텍스트는 서로 다름
  4. 왜 엔티티는 안 되고 DTO는 되나
    • 엔티티: “DB에 다시 물어볼 수도 있는 살아 있는 객체”
    • DTO: 이미 값이 복사된 “스냅샷”이라 세션이 필요 없음
    → 비동기/외부 시스템으로 넘길 땐 DTO/이벤트로 변환해야 안전

0개의 댓글