[친구하자] Agora Cloud Recording 구현 시 Hibernate 에러 트러블슈팅

음성 통화 녹음 기능 구현 중 비동기 처리에서 발생한 LazyInitializationException과 OptimisticLockingFailureException을 해결한 과정을 정리했습니다.

음성 통화 녹음, 기능은 동작하는데 에러가?

Agora Cloud Recording을 이용한 음성 통화 녹음 기능을 구현했다. 녹음 시작과 중지는 정상적으로 동작하는 것처럼 보였지만, 통화 종료 직후 콘솔에 예외 스택이 연속으로 출력되었다. LazyInitializationException과 OptimisticLockingFailureException… 둘 다 뭔가 익숙한데 동시에 발생하다니..🥲

1. 문제 발견

상황

Agora Cloud Recording을 사용하여 음성 통화 녹음 기능을 구현하던 중, 통화는 정상적으로 시작되고 녹음도 시작되지만, 통화 종료 시점에 예외가 연달아 발생했다.

구조는 다음과 같았다:

  • 통화 종료 → 비동기로 녹음 중지 API 호출
  • 녹음 정보를 DB에 저장하는 과정에서 에러 발생

에러 로그

첫 번째 에러:

org.hibernate.LazyInitializationException: Could not initialize proxy
[com.example.domain.Call#1] - no session
	at org.hibernate.proxy.AbstractLazyInitializer.initialize(...)
	at RecordingResponse.from(RecordingResponse.java:76)

두 번째 에러:

org.springframework.orm.ObjectOptimisticLockingFailureException:
Row was updated or deleted by another transaction

Caused by: org.hibernate.StaleObjectStateException:
Row was updated or deleted by another transaction

“분명히 @Transactional도 붙였고, 엔티티도 제대로 조회했는데 왜 세션이 없다는 거지? 그리고 왜 낙관적 락 충돌이 발생하는 거야?”


2. LazyInitializationException 이해하기

에러의 의미

LazyInitializationException은 Hibernate에서 지연 로딩(Lazy Loading)으로 설정된 연관 엔티티에 접근하려 할 때, 이미 영속성 컨텍스트(Persistence Context)가 닫혀있어서 발생하는 에러다.

일반적인 발생 상황

1. 트랜잭션 밖에서 Lazy 프록시 접근

@Transactional
public User findUser(Long id) {
    return userRepository.findById(id);  // User.orders는 LAZY
}

// 트랜잭션 밖
user.getOrders().size();  // ❌ LazyInitializationException!

2. 비동기 작업에서 접근

@Transactional
public void processOrder(Long orderId) {
    Order order = findOrder(orderId);
    CompletableFuture.runAsync(() -> {
        order.getUser().getName();  // ❌ 세션 종료 후 접근
    });
}

내 경우의 원인

엔티티 구조:

@Entity
class CallRecording {
    @ManyToOne(fetch = FetchType.LAZY)  // ← LAZY 로딩
    private Call call;
}

문제 흐름:

  1. @Async로 비동기 스레드에서 녹음 중지 로직 실행
  2. CallRecording 조회 (이때 Call은 프록시 상태)
  3. DTO 변환 시 call.getChannelName() 호출
  4. 프록시 초기화 시도하지만 이미 세션 종료 → 에러!

핵심은 비동기 스레드는 별도의 영속성 컨텍스트를 가지며, 연관 엔티티가 LAZY로 설정되어 있으면 세션 밖에서 접근 시 문제가 발생한다는 점이다.


3. OptimisticLockingFailureException 이해하기

에러의 의미

OptimisticLockingFailureException낙관적 락(Optimistic Lock) 검증 실패 시 발생한다. JPA의 @Version을 사용한 엔티티에서 다른 트랜잭션이 이미 해당 레코드를 수정하여 버전이 증가했을 때 발생한다.

낙관적 락이란?

동시성 제어 방법 중 하나로, 실제 충돌이 자주 발생하지 않을 것이라고 “낙관적으로” 가정한다.

동작 방식:

@Entity
class CallRecording {
    @Version
    private Integer version;  // 수정될 때마다 자동 증가
}
  1. 엔티티 조회 시 version 값 함께 조회
  2. 업데이트 시 WHERE 절에 version 조건 추가
   UPDATE call_recordings
   SET status = 'COMPLETED', version = version + 1
   WHERE id = 1 AND version = 0
  1. 영향받은 행이 0개면 → OptimisticLockingFailureException!

일반적인 발생 상황

동시에 같은 엔티티 수정:

// 트랜잭션 1
Recording rec = repo.findById(1L);  // version = 0
rec.setStatus(COMPLETED);
repo.save(rec);  // version = 1

// 트랜잭션 2 (동시 실행)
Recording rec = repo.findById(1L);  // version = 0 (조회 시점)
rec.setStatus(FAILED);
repo.save(rec);  // ❌ version이 이미 1이라 실패!

내 경우의 원인

문제 흐름:

  1. 메인 로직에서 recording.complete() 호출 → version 증가
  2. 비동기 스레드에서도 같은 recording 접근
  3. 에러 발생 → recording.fail() 호출
  4. 저장 시도 → 이미 version이 변경되어 충돌!

4. 문제 원인 분석

근본 원인

두 에러가 동시에 발생한 이유는 비동기 처리와 동시성 제어의 조합 때문이었다.

시나리오:

1. 통화 종료 이벤트 발생
2. @Async 메서드로 녹음 중지 로직 실행 (별도 스레드)
3. CallRecording 조회 (Call은 LAZY 프록시 상태)
4. DTO 변환 시 Call 접근 → LazyInitializationException
5. 예외 처리 로직에서 recording.fail() 호출
6. 다른 트랜잭션이 이미 recording을 수정 → OptimisticLockingFailureException

핵심 문제점:

  • LAZY 로딩된 연관 엔티티를 비동기 컨텍스트에서 접근
  • 동일 엔티티에 대한 동시 업데이트 시도

5. 해결 방법

해결 전략

  1. LazyInitializationException: 연관 엔티티를 미리 로드
  2. OptimisticLockingFailureException: 예외 처리 전략 수립

1) JOIN FETCH로 연관 엔티티 즉시 로딩

@Query("SELECT cr FROM CallRecording cr " +
       "JOIN FETCH cr.call " +
       "WHERE cr.call.id = :callId")
Optional<CallRecording> findByCallIdWithCall(@Param("callId") Long callId);

효과:

  • 한 번의 쿼리로 CallRecordingCall을 함께 조회
  • LAZY 프록시 문제 해결

2) DTO 생성 시 필요한 데이터만 전달

기존의 프록시 객체에서 데이터를 꺼내는 대신, 이미 로드된 엔티티를 명시적으로 전달하는 방식으로 변경:

// Before: recording에서 call을 꺼내려 시도 (프록시 초기화)
RecordingResponse.from(recording);

// After: 이미 로드된 call을 직접 전달
RecordingResponse.from(recording, call);

3) 비동기 메서드에 @Transactional 추가

@Async
@Transactional  // ← 비동기 스레드에도 트랜잭션 컨텍스트
public CompletableFuture<Void> autoStopRecording(Long callId) {
    // 새로운 영속성 컨텍스트에서 실행
}

4) 낙관적 락 예외 처리

try {
    recording.fail();
    repository.saveAndFlush(recording);
} catch (OptimisticLockException e) {
    // 이미 다른 트랜잭션에서 처리됨 - 무시
    log.warn("낙관적 락 충돌 (무시): callId={}", callId);
}

전략:

  • 이미 처리된 경우 무시 (재시도 불필요)
  • saveAndFlush()로 즉시 DB 반영

5) 적용 후 개선된 흐름

1. 통화 종료
2. JOIN FETCH로 Call과 함께 CallRecording 조회
3. 필요한 데이터를 DTO에 전달 (프록시 접근 없음)
4. 업데이트 시 낙관적 락 충돌 발생 시 적절히 처리

6. 핵심 개념 정리

Lazy Loading

개념:
연관된 엔티티를 실제로 사용할 때까지 조회를 미루는 전략

장점:

  • 불필요한 쿼리 감소
  • 성능 최적화

단점:

  • 영속성 컨텍스트 밖에서 접근 시 LazyInitializationException

해결 방법:

  • JOIN FETCH로 즉시 로딩
  • DTO 변환 시점 조정
  • EAGER 로딩 (신중하게)

낙관적 락 vs 비관적 락

구분낙관적 락비관적 락
가정충돌이 적을 것충돌이 많을 것
락 시점업데이트 시조회 시
구현@Version@Lock(PESSIMISTIC_WRITE)
적합한 경우읽기가 많은 경우쓰기가 많은 경우

낙관적 락 동작:

-- 조회
SELECT * FROM recordings WHERE id = 1;  -- version = 0

-- 업데이트
UPDATE recordings
SET status = 'COMPLETED', version = 1
WHERE id = 1 AND version = 0;  -- 버전 체크

-- 영향받은 행이 0개면 실패 → 예외 발생

비동기 처리와 영속성 컨텍스트

주의사항:

  • @Async 메서드는 별도 스레드에서 실행
  • 각 스레드는 독립적인 영속성 컨텍스트
  • LAZY 엔티티는 미리 로드하거나 별도 트랜잭션 필요

7. 학습 포인트

이번 트러블슈팅을 통해 배운 것

  1. 비동기 처리 시 영속성 컨텍스트 관리의 중요성

    • JOIN FETCH로 필요한 데이터 미리 로드
    • 비동기 컨텍스트에는 별도의 트랜잭션 필요
  2. 동시성 제어 전략 선택

    • 낙관적 락: 충돌이 적을 때 효율적
    • 예외 처리로 재시도 또는 무시 전략 구현
  3. 트랜잭션 범위와 데이터 일관성

    • saveAndFlush()로 즉시 반영
    • 에러 처리 시 최신 데이터 재조회 고려

더 나은 설계를 위한 고민

  • 비동기 작업에서는 ID만 전달하고 내부에서 재조회
  • DTO 변환은 서비스 레이어에서 완료
  • 동시성이 중요한 엔티티는 비관적 락 고려

참고 자료