Kafka Listener의 LazyInitializationException 트러블슈팅

이정빈·2025년 6월 15일
0

트러블슈팅

목록 보기
8/8
post-thumbnail

오류 상황

Kafka로 주문 완료 메시지를 주고받는 스마트ETA 시스템을 개발 중이었다.
Spring Boot + JPA 기반의 애플리케이션에서 Kafka 메시지를 수신한 후, Order 엔티티를 조회하고 관련 Store 정보를 활용해 지연 통계를 갱신하는 로직을 구현했다.
하지만 메시지 수신 처리 도중 예상치 못한 예외가 발생했다.

오류 내용

Kafka 메시지 리스너에서 예외 발생:

org.hibernate.LazyInitializationException: Could not initialize proxy [com.example.delivery.store.domain.Store#1] - no session

관련 로그 중 일부:

DeliveryCompletedListener.listen(DeliveryCompletedEvent event) → processCompletedOrder(order)
→ order.getStore().getName() 호출 시 예외 발생

오류 코드

OrderService.java 일부

/**
     * 배달을 완료하는 메서드
     * @param orderId 주문 ID
     * @param deliveredAt 배달 완료 시각
     */
    public void completeDelivery(Long orderId, LocalDateTime deliveredAt) {
        Optional<Order> order = orderRepository.findById(orderId);
        if(order.isEmpty()) throw new RuntimeException("주문을 찾을 수 없습니다.");

        Order o = order.get();
        o.completeDelivery(deliveredAt);
        orderRepository.save(o);

        // Kafka 메시지 발행
        DeliveryCompletedEvent event = new DeliveryCompletedEvent(o.getId(), deliveredAt);
        producer.send("delivery-status", event);
    }

DeliveryCompletedListener.java 일부

    /**
     * Kafka로부터 배달 완료 이벤트를 수신하고 처리하는 리스너
     *
     * - 토픽: delivery-status
     * - 그룹 ID: smarteta-group
     * - 메시지 형식: DeliveryCompletedEvent (주문 ID, 배달 완료 시간 포함)
     *
     * 처리 로직:
     * 1. 전달받은 주문 ID로 Order를 조회
     * 2. 해당 Order의 배달 완료 시간(deliveredAt)을 업데이트
     * 3. StoreDelaySummaryService를 통해 지연 통계를 갱신
     */
    @KafkaListener(topics = "delivery-status", groupId = "smarteta-group")
    public void listen(DeliveryCompletedEvent event) {
        try {
            log.info("배달 완료 메시지 수신: {}", event);

            Optional<Order> optionalOrder = orderRepository.findById(event.orderId());
            if (optionalOrder.isEmpty()) {
                log.warn("주문 ID={} 에 해당하는 주문을 찾을 수 없습니다.", event.orderId());
                return;
            }

            Order order = optionalOrder.get();
            order.completeDelivery(event.deliveredAt());
            summaryService.processCompletedOrder(order);

            log.info("주문 처리 완료 → 주문 ID: {}", order.getId());
        }catch (Exception e){
            log.error(" Kafka 메시지 처리 중 예외 발생", e);
        }
    }

Order.java의 일부

/* 주문 도메인 */
@Table(name = "orders")
public class Order {

    // 주문 아이디
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 유저 ID
    private Long userId;

    // 매장
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "store_id")
    private Store store;

    // 매장과 목적지까지의 거리
    private double distanceKm;

하나의 EC2 안에 Kafka, MySQL, 그리고 Spring 서버(Docker 컨테이너)가 포함되어 있음
Spring 서버는 Kafka 메시지를 수신하여 JPA를 통해 DB 조회 후 지연 통계 업데이트 로직을 실행함


현재 흐름

  1. Kafka Listener가 비동기적으로 메시지를 수신함

  2. Listener는 orderRepository.findById(event.getOrderId()) 로 Order 객체를 가져옴

  3. 이 Order는 내부에 Store를 갖고 있지만, 아직 store는 프록시 상태

  4. store.getName()을 호출하는 순간 JPA가 Store를 로딩하려 함

  5. 문제는 이 시점엔 이미 Hibernate 세션이 닫혀 있음 (Kafka Listener는 트랜잭션 바깥에서 실행됨)
    → 따라서 DB에 접근할 수 없어 LazyInitializationException 예외 발생


예상 원인

LazyInitializationException이란?

JPA는 @ManyToOne(fetch = LAZY) 관계에서 실제 연관 객체(Store)를 처음부터 조회하지 않고, 프록시 객체로 감싸 놓는다.
이 프록시는 실제 데이터가 필요한 시점(getName() 호출 등)에 DB에 접근해 데이터를 가져오려 한다.

하지만 이 시점에 JPA 세션(Session) 이 이미 닫혀 있으면 프록시가 초기화되지 못하고 예외가 발생한다.


LazyInitializationException 발생원인

Kafka 메시지를 수신하는 메서드 listen() 내부에서는 트랜잭션이 자동으로 생성되지 않는다.
따라서 orderRepository.findById(orderId)로 조회한 Order는 프록시 상태로 존재하며,
해당 Order에 연결된 Store를 조회하려고 할 때 세션이 이미 닫혀 예외가 발생했다.

즉,

Order order = orderRepository.findById(...).get();
order.getStore().getName(); // 여기서 LazyInitializationException 발생

실제 해결 방법

@Transactional 추가

@Transactional
public void listen(DeliveryCompletedEvent event) {
    ...
}

Kafka 메시지를 처리하는 메서드에 @Transactional을 붙이면,
Spring은 해당 메서드 수행 중 JPA 세션을 유지시켜 프록시 초기화가 가능하도록 한다.

결과적으로 LazyInitializationException이 해결되었다.


트랜잭션 없이 세션이 닫히는 기준은?

  • JPA의 세션(Session)은 트랜잭션 단위로 열리고 닫힌다.
  • 트랜잭션이 없을 경우, Spring Data JPA가 리포지토리 메서드 수행 중 임시로 세션을 열고 바로 닫는다.
  • 따라서 리포지토리에서 반환된 엔티티는 더 이상 DB 접근이 불가능한 "프록시 객체"로 남게 된다.

🧾 참고


profile
사용자의 입장에서 생각하며 문제를 해결하는 백엔드 개발자입니다✍

0개의 댓글