[친구하자] 실시간 음성 매칭 서비스의 동시성 제어 - 비관적 락 적용기
친구하자를 개발하면서 실시간 음성 매칭 서비스를 개발하면서 비관적 락을 적용하여 동시성을 처리했던 경험에 대해 정리했습니다.
동시 요청으로 통화가 두 번 종료되는 문제 해결하기..!
서론
이번 포스트에서는 제가 개발한 실시간 음성 매칭 서비스에서 발생한 동시성 문제를 해결하기 위해 비관적 락(Pessimistic Lock) 을 적용한 경험을 공유하고자 한다.
“동시에 두 요청이 들어오면 어떻게 될까?”
실시간 음성 매칭 플랫폼 “친구하자”를 개발하면서 이 질문에 직면했다. 특히 통화 종료는 Agora Cloud Recording 중지, Session 종료, 통화 기록 저장 등 여러 부수 효과를 동반하는 복잡하고 중요한 작업이였다. 초기에는 낙관적락(@Version)으로 동시성을 제어했다. 하지만 사용성테스트를 하면서 네트워크 불안정이나 사용자의 중복 클릭 등으로 동시 요청이 발생한 경우, 한 요청은 성공하고 다른 요청은 OptimisticLockException을 발생시키지만, 이미 Recording은 두번 중지 요청을 받은 후였다.
통화 종료는 단 한번만 수행되어야 하는 작업이기 때문에 이를 안전하게 처리할 수 있는 방법을 고민했다. 결국 “충돌이 일어나지 않도록 원천 차단”하는 비관적 락을 도입하기로 결정했다.
이 글에서는 두 락 방식의 차이와, 실제 프로젝트에서 비관적 락을 적용한 과정을 정리한다.
1. 락(Lock)이란?
여러 사용자가 동시에 같은 데이터를 수정하려 할 때 데이터 일관성을 보장하기 위한 메커니즘이다.
실생활 비유
- 비관적 락: 화장실 문을 잠그고 들어가기 (다른 사람은 기다려야 함)
- 낙관적 락: 화장실에 그냥 들어가되, 나올 때 다른 사람이 먼저 사용했는지 확인 (충돌 시 재시도)
2. 낙관적 락 (Optimistic Lock)
개념
“충돌은 드물게 일어날 것“이라고 가정하고, 실제 충돌이 발생했을 때만 처리합니다.
동작 방식
@Entity
public class Call {
@Id
private Long id;
@Version // 👈 낙관적 락의 핵심
private Long version;
private CallStatus status;
}
시나리오:
시간 순서:
T1: User1이 Call 조회 (version=0, status=IN_PROGRESS)
T2: User2가 Call 조회 (version=0, status=IN_PROGRESS)
T3: User1이 status=COMPLETED 저장 (version=1로 증가) ✅ 성공
T4: User2가 status=COMPLETED 저장 시도
→ version=0으로 저장하려 함
→ DB의 version은 이미 1
→ OptimisticLockException 발생! ❌
장점
- 성능 우수: DB 락을 걸지 않아 대기 시간 없음
- 데드락 없음: 물리적 락이 없어 데드락 불가능
- 읽기 작업 많을 때 유리
단점
- 충돌 시 재시도 필요: 예외 발생 후 애플리케이션에서 재시도 로직 구현 필요
- 충돌 빈번하면 비효율적: 재시도가 많아지면 오히려 성능 저하
예제 코드
@Transactional
public void endCallWithOptimisticLock(Long callId) {
try {
Call call = callRepository.findById(callId).orElseThrow();
call.endCall(); // status 변경
callRepository.save(call); // version 자동 증가 체크
} catch (OptimisticLockException e) {
// 다른 사람이 먼저 수정함 → 재시도 필요
log.warn("낙관적 락 충돌 - 재시도 필요");
throw new CustomException(ErrorCode.CONCURRENT_UPDATE);
}
}
3. 비관적 락 (Pessimistic Lock)
개념
“충돌이 자주 일어날 것“이라고 가정하고, 데이터를 읽는 순간부터 DB 레벨에서 락을 걸어 다른 트랜잭션의 접근을 차단합니다.
동작 방식
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Call c WHERE c.id = :callId")
Optional<Call> findByIdWithLock(@Param("callId") Long callId);
시나리오:
시간 순서:
T1: User1이 Call 조회 + 락 획득 🔒 (status=IN_PROGRESS)
T2: User2가 Call 조회 시도
→ User1의 락 때문에 대기... ⏳
T3: User1이 status=COMPLETED 저장 후 커밋
→ 락 해제 🔓
T4: User2가 락 획득 🔒 (status=COMPLETED)
→ "이미 종료됨" 확인 후 종료
종류
PESSIMISTIC_READ (공유 락, S Lock)
@Lock(LockModeType.PESSIMISTIC_READ)
- 읽기는 허용, 쓰기는 차단
- 여러 트랜잭션이 동시에 읽기 가능
- 하나라도 쓰기 시도하면 대기
PESSIMISTIC_WRITE (배타 락, X Lock) ⭐ 주로 사용
@Lock(LockModeType.PESSIMISTIC_WRITE)
- 읽기/쓰기 모두 차단
- 완전히 독점적으로 사용
- 상태 변경 작업에 적합
장점
- 충돌 완전 방지: 동시 수정 100% 차단
- 재시도 불필요: 순차 처리로 예외 발생 안 함
- 쓰기 작업 많을 때 유리
단점
- 성능 저하: 락 대기 시간으로 응답 지연
- 데드락 가능성: 여러 리소스에 락 걸면 순환 대기 발생 가능
- 확장성 제한: 동시 처리량 감소
예제 코드
@Transactional
public void endCallWithPessimisticLock(Long callId) {
// 🔒 이 순간 락 획득 - 다른 트랜잭션 대기
Call call = callRepository.findByIdWithLock(callId).orElseThrow();
if (call.getCallStatus() == CallStatus.COMPLETED) {
// 이미 종료됨 - 중복 요청 무시
return;
}
call.endCall(); // status 변경
callRepository.save(call);
// 🔓 커밋 시점에 락 해제
}
4. 내 프로젝트 기존 코드 분석
기존 코드 (문제점)
// CallStatusService.java - 기존
@Transactional
public CallStatusResponse endCall(Long callId, Long userId) {
// ❌ 일반 조회 - 락 없음
Call call = callRepository.findById(callId)
.orElseThrow(() -> new CustomException(ErrorCode.CALL_NOT_FOUND));
// ❌ 상태 체크만으로는 동시성 문제 해결 안 됨
if (call.getCallStatus() == CallStatus.COMPLETED) {
log.info("이미 종료된 통화");
// ...
}
callService.endCall(callId);
// ...
}
문제 시나리오
상황: User1과 User2가 거의 동시에 통화 종료 버튼 클릭
시간축:
─────────────────────────────────────────────────────
User1 Thread User2 Thread
─────────────────────────────────────────────────────
T1: findById(callId)
→ status=IN_PROGRESS
→ version=0
T2: findById(callId)
→ status=IN_PROGRESS
→ version=0
T3: if (status == COMPLETED)
→ false (통과)
T4: if (status == COMPLETED)
→ false (통과)
T5: call.endCall()
→ status=COMPLETED
→ version=1
→ save() ✅
T6: call.endCall()
→ status=COMPLETED
→ version=2
→ save() ✅
─────────────────────────────────────────────────────
발생 가능한 문제들
문제 1: 중복 종료 처리
// User1의 call 객체: status=IN_PROGRESS (조회 시점)
// User2의 call 객체: status=IN_PROGRESS (조회 시점)
// 둘 다 검증 통과 → 둘 다 endCall() 실행
// 결과:
// - Recording 2번 중지 시도
// - Session 2번 업데이트 시도
// - 불필요한 리소스 낭비
문제 2: 낙관적 락만으로는 불완전
@Entity
public class Call {
@Version // 낙관적 락은 있었음
private Long version;
}
// User1: version=0 → 1로 저장 성공 ✅
// User2: version=0 → 저장 시도
// → OptimisticLockException 발생! ❌
// → 하지만 이미 Recording 중지 등의 부수 효과 발생됨
문제 3: endAt 중복 설정
// User1이 먼저 저장
call.endAt = 2025-01-22 10:00:00
call.durationSeconds = 120
// User2가 나중에 저장 (낙관적 락 실패 안 했다면)
call.endAt = 2025-01-22 10:00:01 // 1초 차이!
call.durationSeconds = 121 // 잘못된 값
5. 비관적 락 적용 후 개선
개선된 코드
// CallStatusService.java - 개선
@Transactional
public CallStatusResponse endCall(Long callId, Long userId) {
// ✅ 비관적 락으로 조회 - DB 레벨에서 락 획득
Call call = callRepository.findByIdWithLock(callId)
.orElseThrow(() -> new CustomException(ErrorCode.CALL_NOT_FOUND));
// ✅ 이 시점에는 다른 트랜잭션이 접근 불가능
if (call.getCallStatus() == CallStatus.COMPLETED) {
log.info("이미 종료된 통화 - 중복 요청 무시");
return createResponse(call); // 바로 반환
}
callService.endCall(callId);
// ...
}
개선된 시나리오
시간축:
─────────────────────────────────────────────────────
User1 Thread User2 Thread
─────────────────────────────────────────────────────
T1: findByIdWithLock(callId)
🔒 락 획득
→ status=IN_PROGRESS
T2: findByIdWithLock(callId)
⏳ 대기... (락 때문에 블록)
T3: if (status == COMPLETED)
→ false (통과)
T4: call.endCall()
→ status=COMPLETED
→ save()
T5: 트랜잭션 커밋
🔓 락 해제
T6: 🔒 락 획득 (대기 끝)
→ status=COMPLETED (이미 변경됨!)
T7: if (status == COMPLETED)
→ true
→ "이미 종료됨" 반환 ✅
→ 중복 처리 없음!
─────────────────────────────────────────────────────
핵심 개선 사항
1. 완전한 순차 처리
// User1: 락 획득 → 처리 → 커밋 → 락 해제
// User2: 대기 → 락 획득 → "이미 종료" 확인 → 종료
// 결과:
// ✅ Recording 1번만 중지
// ✅ Session 1번만 업데이트
// ✅ endAt 정확히 1번만 설정
2. 예외 발생 없음
// 낙관적 락: OptimisticLockException 발생 → 재시도 로직 필요
// 비관적 락: 예외 없음 → 자연스럽게 순차 처리
3. 데이터 무결성 보장
// endAt: 2025-01-22 10:00:00 (정확히 1번만 설정)
// durationSeconds: 120 (정확한 값)
// callStatus: COMPLETED (명확한 상태)
6. 비관적 락 + 낙관적 락 이중 방어
현재 코드는 둘 다 사용하고 있습니다:
@Entity
public class Call {
@Version // 낙관적 락 (보험)
private Long version;
}
// 비관적 락 (주 방어선)
Call call = callRepository.findByIdWithLock(callId);
이중 방어의 이유
시나리오: 개발자 실수로 일반 findById() 사용
// 실수로 락 없이 조회
Call call = callRepository.findById(callId); // ❌ 실수!
// 낙관적 락이 여전히 작동
call.endCall();
callRepository.save(call);
// → 충돌 시 OptimisticLockException 발생 ✅
7. 언제 무엇을 사용할까?
낙관적 락 사용 사례
// ✅ 좋은 경우
- 조회가 많고 수정이 적은 경우
- 충돌이 거의 없는 경우
- 성능이 최우선인 경우
예시:
- 블로그 게시글 조회수 증가
- 상품 상세 정보 조회
- 사용자 프로필 업데이트
비관적 락 사용 사례
// ✅ 좋은 경우
- 충돌이 빈번한 경우
- 데이터 정합성이 매우 중요한 경우
- 재시도 로직 구현이 복잡한 경우
예시:
- 재고 차감 (동시 주문)
- 좌석 예약 (티케팅)
- 통화 종료 (본 프로젝트) ⭐
- 계좌 이체
8. 성능 비교
응답 시간 예상
낙관적 락 (충돌 없을 때)
User1: 50ms (조회 10ms + 저장 40ms)
User2: 50ms (조회 10ms + 저장 40ms)
평균: 50ms ✅ 빠름
낙관적 락 (충돌 발생 시)
User1: 50ms (성공)
User2: 150ms (실패 50ms + 재시도 100ms)
평균: 100ms
비관적 락
User1: 50ms (조회+락 10ms + 처리 40ms)
User2: 100ms (락 대기 50ms + 처리 50ms)
평균: 75ms ⭐ 예측 가능
내 프로젝트에서는?
통화 종료는 비관적 락이 적합한 이유:
- ⏰ 충돌 빈도: 두 사용자가 거의 동시에 종료 버튼 클릭 가능성 높음
- 💰 부수 효과: Recording 중지, Session 업데이트 등 비용 큰 작업
- 🎯 재시도 불가: 통화 종료는 “한 번만” 처리되어야 함
- 📊 데이터 정확성: endAt, durationSeconds 등 정확한 값 필수
9. 요약 비교표
| 구분 | 낙관적 락 | 비관적 락 |
|---|---|---|
| 철학 | 충돌 드물 것 | 충돌 많을 것 |
| 락 시점 | 커밋 시 | 조회 시 |
| DB 락 | 없음 | 있음 |
| 충돌 처리 | 예외 + 재시도 | 대기 |
| 성능 | 빠름 (충돌 없을 때) | 느림 (항상) |
| 예측성 | 낮음 | 높음 |
| 데드락 | 없음 | 가능 |
| 사용 사례 | 조회 많음 | 쓰기 많음 |
내 프로젝트 선택: 비관적 락 (PESSIMISTIC_WRITE)
- 통화 종료는 충돌 가능성 높고
- 정확성이 중요하며
- 약간의 대기는 허용 가능