[친구하자] Agora Cloud Recording 구현 시 Hibernate 에러 트러블슈팅
음성 통화 녹음 기능 구현 중 비동기 처리에서 발생한 LazyInitializationException과 OptimisticLockingFailureException을 해결한 과정을 정리했습니다.
- 1. 문제 발견
- 2. LazyInitializationException 이해하기
- 3. OptimisticLockingFailureException 이해하기
- 4. 문제 원인 분석
- 5. 해결 방법
- 6. 핵심 개념 정리
음성 통화 녹음, 기능은 동작하는데 에러가?
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;
}
문제 흐름:
@Async로 비동기 스레드에서 녹음 중지 로직 실행CallRecording조회 (이때Call은 프록시 상태)- DTO 변환 시
call.getChannelName()호출 - 프록시 초기화 시도하지만 이미 세션 종료 → 에러!
핵심은 비동기 스레드는 별도의 영속성 컨텍스트를 가지며, 연관 엔티티가 LAZY로 설정되어 있으면 세션 밖에서 접근 시 문제가 발생한다는 점이다.
3. OptimisticLockingFailureException 이해하기
에러의 의미
OptimisticLockingFailureException은 낙관적 락(Optimistic Lock) 검증 실패 시 발생한다. JPA의 @Version을 사용한 엔티티에서 다른 트랜잭션이 이미 해당 레코드를 수정하여 버전이 증가했을 때 발생한다.
낙관적 락이란?
동시성 제어 방법 중 하나로, 실제 충돌이 자주 발생하지 않을 것이라고 “낙관적으로” 가정한다.
동작 방식:
@Entity
class CallRecording {
@Version
private Integer version; // 수정될 때마다 자동 증가
}
- 엔티티 조회 시 version 값 함께 조회
- 업데이트 시 WHERE 절에 version 조건 추가
UPDATE call_recordings
SET status = 'COMPLETED', version = version + 1
WHERE id = 1 AND version = 0
- 영향받은 행이 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이라 실패!
내 경우의 원인
문제 흐름:
- 메인 로직에서
recording.complete()호출 → version 증가 - 비동기 스레드에서도 같은 recording 접근
- 에러 발생 →
recording.fail()호출 - 저장 시도 → 이미 version이 변경되어 충돌!
4. 문제 원인 분석
근본 원인
두 에러가 동시에 발생한 이유는 비동기 처리와 동시성 제어의 조합 때문이었다.
시나리오:
1. 통화 종료 이벤트 발생
2. @Async 메서드로 녹음 중지 로직 실행 (별도 스레드)
3. CallRecording 조회 (Call은 LAZY 프록시 상태)
4. DTO 변환 시 Call 접근 → LazyInitializationException
5. 예외 처리 로직에서 recording.fail() 호출
6. 다른 트랜잭션이 이미 recording을 수정 → OptimisticLockingFailureException
핵심 문제점:
- LAZY 로딩된 연관 엔티티를 비동기 컨텍스트에서 접근
- 동일 엔티티에 대한 동시 업데이트 시도
5. 해결 방법
해결 전략
- LazyInitializationException: 연관 엔티티를 미리 로드
- 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);
효과:
- 한 번의 쿼리로
CallRecording과Call을 함께 조회 - 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. 학습 포인트
이번 트러블슈팅을 통해 배운 것
비동기 처리 시 영속성 컨텍스트 관리의 중요성
- JOIN FETCH로 필요한 데이터 미리 로드
- 비동기 컨텍스트에는 별도의 트랜잭션 필요
동시성 제어 전략 선택
- 낙관적 락: 충돌이 적을 때 효율적
- 예외 처리로 재시도 또는 무시 전략 구현
트랜잭션 범위와 데이터 일관성
saveAndFlush()로 즉시 반영- 에러 처리 시 최신 데이터 재조회 고려
더 나은 설계를 위한 고민
- 비동기 작업에서는 ID만 전달하고 내부에서 재조회
- DTO 변환은 서비스 레이어에서 완료
- 동시성이 중요한 엔티티는 비관적 락 고려