본문 바로가기

⛳️ 공동구매 서비스 총대마켓

⚠️ 비동기 스레드 내부에서 발생한 LazyInitializationException 해결하기

이 글은 트랜잭션과 지연 로딩, 비동기 이벤트 처리에 대해 알고 있는 독자를 대상으로 작성한 글입니다.

 

사전 상황 설명: 비동기로 알림 로직 처리

 

비즈니스 로직과 알림 로직이 스프링 이벤트를 통해 분리되어 있으며, 알림 로직은 비동기 스레드에서 처리됩니다. 비동기 스레드에서 발생한 예외는 호출 스레드에 전파시키지 않기 위해 로그만 출력하도록 한 상황입니다.

 

 

문제: LazyInitializationException

알림 로직에서 예외가 발생하였습니다. 비동기 스레드에서 발생했기 때문에 호출 스레드에 영향은 없었으나, 로컬 환경에서 개발 중 로그로 예외를 발견하였습니다.

 

LazyInitializationException은 트랜잭션(세션) 외부에서 지연 로딩으로 인해 초기화되기 전의 데이터에 접근을 시도할 때 발생하는 예외입니다. 지연 로딩 전략에 의해 프록시 객체가 생성되고, 같은 트랜잭션 내에서 프록시 객체에 접근하면 추가 쿼리로 객체를 초기화할 수 있지만, 트랜잭션이 닫힌 후 프록시 객체에 접근하면 쿼리가 발생하지 못해 LazyInitializationException이 발생합니다.

 

 

원인: 트랜잭션 전파 범위

아래는 알림 로직 중 일부입니다. 이 로직에서 LazyInitializationException 예외가 발생했습니다.

public Message messageWhenParticipate(OfferingMemberEntity offeringMember) {
    OfferingEntity offering = offeringMember.getOffering();
    MemberEntity proposer = offering.getMember(); // 프록시 객체
    FcmToken token = new FcmToken(proposer); // 프록시 객체의 특정 필드 접근
    // ...
}
// FcmToken.java
public class FcmToken {

    private final String value;

    public FcmToken(MemberEntity member) {
        this.value = member.getFcmToken();
    }
}

 

디버깅을 통해 확인한 결과, MemberEntity인 proposer 변수는 프록시 객체였고, proposer의 fcmToken 필드에 접근하는 과정에서 LazyInitializationException 예외가 발생했습니다.

 

위 MemberEntity는 왜 프록시 객체였을까요?

 

이 MemberEntity는 아래 메서드에서 생성된 OfferingMemberEntity saved의 OfferingEntity의 MemberEntity입니다. 이때 OfferingEntity의 MemberEntity는 지연 로딩 전략에 의해 조회됩니다. 그렇기 때문에 MemberEntity가 프록시 객체였던 것이죠.

@Transactional
public Long participate(ParticipationRequest request, MemberEntity member) {
    OfferingEntity offering = offeringRepository.findById(request.offeringId())
            .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND));
    OfferingMemberEntity offeringMember = new OfferingMemberEntity(member, offering);
    OfferingMemberEntity saved = offeringMemberRepository.save(offeringMember);

    eventPublisher.publishEvent(new ParticipateEvent(this, saved));
    return saved.getId();
}

 

그렇다면 프록시 객체의 필드에 접근할 때 초기화를 위해 추가 쿼리가 발생해야 하지 않을까요? 왜 예외가 발생했을까요?

 

이것은 트랜잭션 전파 범위와 관련이 있습니다. 지연 로딩 데이터를 초기화하기 위해서는 프록시 객체가 참조하고 있는 트랜잭션(세션)이 열려 있어야 합니다. 해당 트랜잭션 내에서 추가 쿼리가 발생해야 하기 때문이죠. 하지만, 저희의 알림 로직은 비동기 스레드에서 실행되며, 발행된 이벤트는 호출 스레드의 트랜잭션이 커밋된 이후 실행되도록 구현하였습니다. 즉, 비동기 스레드에서 프록시 객체를 초기화하려 할 때 트랜잭션이 닫힌 상태였기 때문에 예외가 발생했던 것이죠.

호출 스레드의 트랜잭션이 커밋된 이후 비동기 스레드에서 이벤트를 처리한다.

 

 

해결: 필요한 데이터를 호출 스레드의 트랜잭션에서 미리 조회하기

아래와 같이 네가지 해결 방안을 설계했습니다. 채택한 방안은 마지막 방안입니다.

 

1. 호출 스레드의 트랜잭션 범위를 비동기 스레드까지 확장하기

호출 스레드의 트랜잭션이 커밋된 이후에만 이벤트가 처리되기 때문에 트랜잭션 확장은 불가능합니다.

 

2. 지연 로딩 전략을 즉시 로딩 전략으로 변경하기

해당 기능이 아닌 다른 기능에서 불필요한 정보를 위해 추가적인 join 혹은 쿼리가 발생할 수 있기 때문에 채택하지 않았습니다.

 

3. fetch join을 통해 필요한 필드 미리 조회하기

알림 로직을 스프링 이벤트로 처리했던 이유 중 하나는 메인 로직과 알림 로직을 분리하기 위함이었습니다. 하지만 알림 로직에 필요한 정보를 위해 메인 로직의 쿼리를 변경하는 것은 의도에서 벗어난 행위라고 판단하였습니다.

 

4. [채택] 이벤트를 발행하기 전 트랜잭션이 아직 열려있을 때 미리 정보 조회하기

알림 로직에서 필요한 데이터들을 메인 로직의 event 객체에서 미리 초기화하였습니다. 이 방법이 가장 메인 로직으로의 침투가 적었던 방법이었기 때문에 이 방법으로 문제를 해결했습니다.

 

 

결론

지연 로딩으로 인한 프록시 객체를 초기화할 때 트랜잭션이 이미 닫혔다면 LazyInitializationException이 발생한다.

 

해결은 간단했으나 예외의 원인을 파악하는 과정과 해결 방법을 찾는 과정의 복잡도가 높아 재밌게 트러블슈팅할 수 있었습니다. JPA의 지연 로딩은 혁신입니다. 내부에서 많은 로직을 처리하여 개발자에게 편의성을 제공하죠. 하지만 그만큼 예기치 못한 예외가 발생할 수 있기 때문에 양날의 검이라고 생각합니다. 내부 동작 원리를 잘 파악하고 사용하는 것이 중요하겠습니다.