[친구하자] 실시간 음성 매칭 서비스의 동시성 제어 - 비관적 락 적용기

친구하자를 개발하면서 실시간 음성 매칭 서비스를 개발하면서 비관적 락을 적용하여 동시성을 처리했던 경험에 대해 정리했습니다.

동시 요청으로 통화가 두 번 종료되는 문제 해결하기..!


서론

이번 포스트에서는 제가 개발한 실시간 음성 매칭 서비스에서 발생한 동시성 문제를 해결하기 위해 비관적 락(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 ⭐ 예측 가능

내 프로젝트에서는?

통화 종료는 비관적 락이 적합한 이유:

  1. 충돌 빈도: 두 사용자가 거의 동시에 종료 버튼 클릭 가능성 높음
  2. 💰 부수 효과: Recording 중지, Session 업데이트 등 비용 큰 작업
  3. 🎯 재시도 불가: 통화 종료는 “한 번만” 처리되어야 함
  4. 📊 데이터 정확성: endAt, durationSeconds 등 정확한 값 필수

9. 요약 비교표

구분낙관적 락비관적 락
철학충돌 드물 것충돌 많을 것
락 시점커밋 시조회 시
DB 락없음있음
충돌 처리예외 + 재시도대기
성능빠름 (충돌 없을 때)느림 (항상)
예측성낮음높음
데드락없음가능
사용 사례조회 많음쓰기 많음

내 프로젝트 선택: 비관적 락 (PESSIMISTIC_WRITE)

  • 통화 종료는 충돌 가능성 높고
  • 정확성이 중요하며
  • 약간의 대기는 허용 가능