DB 동시성 문제 해결 (Exclusive lock과 Redis 비교)
문제 상황: 채팅방 동시 입장
채팅방 인원을 100명으로 제한하고, 200명이 동시에 입장 요청을 보내는 테스트를 진행했습니다. 이론적으로는 100명에서 차단되어야 하지만, 실제로는 106명이 입장하며 테스트가 실패했습니다. 이는 여러 스레드가 동시에 자원에 접근하며 발생하는 레이스 컨디션(Race Condition) 때문입니다.


해결을 위한 선택지
1. Synchronized
가장 간단한 방법이지만, 단일 프로세스 내에서만 정합성을 보장합니다. 현재는 단일 인스턴스일지라도 향후 서버의 Scale-out(수평 확장) 가능성을 고려한다면 동기화도 고민해야 하며, 현재는 적절한 선택지가 아니라고 판단하여 제외했습니다.
2. 낙관적 락 (Optimistic Lock)
낙관적 락은 대부분의 트랜잭션 충돌이 발생하지 않을 것을 낙관적으로 가정하는 방법입니다.
DB가 제공하는 Lock 기능을 사용하지 않고, 도메인 엔티티의 version 컬럼을 통해 동시성을 컨트롤한다.
즉, Lock을 이용하지 않고 Version을 이용하여 정합성을 맞추는 방법입니다. 데이터를 읽은 후 update를 수행할 때, 읽은 데이터의 버전이 맞는지 확인하고 업데이트를 진행합니다.
읽은 버전에서 이미 업데이트가 발생했을 경우에는 애플리케이션에서 다시 읽은 후 작업을 수행해야 합니다.
OPTIMISTIC LOCK은 충돌하지 않을 것을 가정하고 접근하기 때문에 트랜잭션이 커밋하기 전까지 충돌 여부를 확인할 수 없어서 COMMIT 시점에서나 충돌을 알 수 있습니다.
충돌이 발생하면 다음과 같은 예외가 발생합니다.
javax.persistence.OptimisticLockExceptionin JPAorg.hibernate.StaleObjectStateExceptionin Hibernateorg.springframework.orm.ObjectOptimisticLockingFailureExceptionin Spring
낙관적 락은 구현이 어렵지 않으나, "반드시 예외 처리"를 해야합니다.
애플리케이션에서 트랜잭션 커밋 시 버전 값이 달라 발생하는 예외를 처리하고 재시도(Retry) 로직을 직접 구현해야 합니다. 만약 예외를 처리하지 않으면 요청은 버려질 수 있습니다.
동시성 제어 로직안에 FK가 존재하는 테이블을 수정하게 된다면 데드락을 발생할 수 있습니다.
재시도 횟수에 비례하여 DB I/O가 발생합니다. -> 충돌이 빈번하면 I/O 급증으로 오히려 성능이 저하될 수 있습니다.
데드락 발생 시 로그 확인 : mysql> show engine innodb status\\G;
3. 비관적 락 (Pessimistic Lock)
비관적 락은 트랜잭션 충돌이 빈번하게 발생할 것이라고 가정하는 동시성 제어 방식입니다. (미리 락을 거는 방식)
이는 쓰기락(Write Lock) 또는 배타락(Exclusive lock)으로도 부릅니다.
동작 원리:
DB에서 제공하는 Lock 기능을 사용하여 데이터에 직접 락을 걸고, 충돌이 발생하면 DBMS가 자동으로 트랜잭션을 롤백합니다.
이로 인해 데이터의 정합성을 유지할 수 있으며, 배타락이 걸린 데이터는 락이 해제되기 전까지 다른 트랜잭션에서 접근할 수 없습니다.
SELECT FOR UPDATE 구문을 통해 DB 레벨에서 배타락을 겁니다.
주의사항:
DB에 직접 쓰기 락을 걸기 때문에, 락을 적절히 해제하지 않으면 데드락이 발생할 수 있습니다.
MySQL은 InnoDB 스토리지 엔진 기반이기 때문에 인덱스를 제대로 사용하지 않으면 오히려 여러 레코드를 잠그거나 테이블 전체에 락이 걸릴 수 있습니다.
4. Redis
Redis는 숫자를 제어하는 INCR 커맨드를 제공합니다. Redis는 기본적으로 싱글 스레드로 동작하며 명령어를 순차적으로 처리하기 때문에, 동시에 수많은 요청이 들어와도 정합성을 유지할 수 있다는 장점이 있습니다.
낙관적 락을 이용한 동시성 제어


테스트는 단일 인스턴스로 진행했으며, 스레드를 점차 늘리는 방향으로 100개의 요청으로 진행했습니다.
코드는 단순 예시일 뿐이지만, 충돌 시 실패 처리 및 재시도 로직 구현은 반드시 필요합니다. 그 이유는 충돌을 감지하고 후속 조치를 정의하지 않으면 데이터의 정합성이 깨질 수 있습니다.
구현 특징: QueryDSL을 사용하지 않는다면
@Lock어노테이션으로 간단히 구현할 수 있습니다.주의사항: 충돌이 발생하면
OptimisticLockingFailureException등 예외가 발생하므로, 애플리케이션 레벨에서 반드시 재시도(Retry) 로직을 구현해야 합니다.로직 흐름: 데이터를 읽은 후 업데이트 시점에 버전이 맞는지 확인하며, 버전이 다르면 잠시 대기 후 데이터를 다시 읽어 재시도합니다.
보통은 Retry를 사용하는 것이 일반적입니다. 잠시 대기한 후 데이터를 다시 읽어(새로운 버전으로) 처음부터 작업을 다시 시도하며, 재시도 횟수나 간격에 대한 정책을 정하는 것이 좋습니다.
비관적 락을 이용한 동시성 제어


LockModeType.PESSIMISTIC_WRITE를 적용하면 베타(쓰기) 락을 얻을 수 있으며, 사용중인 데이터에 대해 읽거나 변경 작업을 할 수 없습니다. 쿼리 문에 select for update 구문이 추가됩니다.
만약, 락 대기 타임아웃이 필요하다면 @QueryHints 어노테이션을 활용할 수 있습니다.
Redis를 이용한 동시성 제어
INCR 명령만으로는 동시성을 제어할 수 없는 이유

단순히 Redis의 INCR 명령만 사용했을 때는 여전히 인원 제한을 초과하는 문제가 발생했습니다.
구현 코드를 보면, key를 읽고 참여자 수를 비교하여 더 이상 참여할 수 없으면 예외를 발생시키고 그렇지 않으면 다음 로직을 수행하다가 마지막에 increment 명령을 수행합니다.
과연 지금 로직이 동시성을 보장할 수 있을까요?
50명의 vUser가 총 2000개의 요청을 보내는 여러 테스트 결과에서 동시성 문제가 발생했습니다. 물론 타이밍이 잘 맞는 경우에는 동시성을 방지하는 경우도 있습니다.
찬성, 반대 유저는 최대 5명을 넘을 수 없지만, key의 value를 확인하면 5를 초과하고 있습니다.

왜 Race Condition이 발생했을까요?

get과 increment 사이에 시간 간격이 존재합니다.
따라서 읽기(get), 검증(validate), 쓰기(increment)가 분리된 작업으로 볼 수 있습니다.
이 시간동안 다른 스레드가 같은 키에 접근할 수 있습니다. (각 작접 사이에 다른 스레드 관여 가능)
Thread 1: get으로 4를 읽음
Trhead 2: get으로 4를 읽음
Thread 1: 4 < 5 체크 통과
Thread 2: 4< 5 체크 통과
Thread 1: increment(5) 실행
Thread 2: increment(6) 실행
결과적으로 두 스레드 모두 validation을 통과하여 의도치 않게 capacity(참여인원)을 초과했습니다.
해결: Lua Script를 통한 원자성(Atomic) 확보

단순 INCR 명령의 한계를 극복하기 위해, Lua Script를 도입하여 검증과 증가 로직을 하나로 묶었습니다. 구현에 추가된 부분은 아래와 같이 Lua Script 작성과 이를 Redis에서 실행하는 로직입니다.
테스트 결과:

이전과 동일하게 vUser 50명이 2000개의 요청을 동시에 보내는 부하 테스트를 진행해 보았습니다.
여러 차례의 테스트에도 불구하고, Redis Key의 Value를 확인하면 설정한 제한 수치인 5를 초과하지 않는 것을 확인할 수 있었습니다.
어떻게 동시성을 보장할 수 있었을까?

이 구현이 원자적(Atomic)인 이유는 다음과 같습니다.
단일 트랜잭션 실행:Redis의 Lua Script는 단일 트랜잭션으로 실행됩니다.
스크립트가 실행되는 도중에는 다른 클라이언트의 명령이 중간에 끼어들 수 없습니다.
Race Condition 방지:체크(Check)와 증가(Increment)가 하나의 원자적 작업으로 묶여 실행되기 때문에 레이스 컨디션이 발생할 수 없는 구조입니다.
Lua Script 도입으로 얻은 추가적인 이점
단순히 동시성 문제를 해결하는 것 외에도 다음과 같은 이점을 얻을 수 있었습니다.
네트워크 호출 횟수 감소
기존 방식:
GET과INCREMENT를 위해 두 번의 네트워크 호출Lua script: 한 번의 호출만으로 모든 로직을 수행할 수 있어 오버헤드가 줄어듭니다.
비즈니스 로직 응집도
검사와 증가 로직이 하나의 스크립트 안에 존재하므로 코드 관리 및 가독성이 용이해집니다.
이처럼 원자적 작업을 보장함으로써 동시성 문제를 완벽히 해결하고 데이터의 정합성을 확보할 수 있었습니다.
동시성 제어를 위한 Exclusive lock과 Redis Lua scprit 비교
비관적 락과 Redis의 INCR 명령을 활용한 방법 모두 동시성 제어는 가능했습니다.
하지만 기술 선택에는 명확한 타당성이 필요했기에, 시스템 아키텍처에 이미 포함되어 있던 Redis를 활용하면서 효율성을 극대화할 수 있는 방법을 고민했습니다.

이에 따라 두 방법의 효율성을 TPS(Transactions Per Second) 기준으로 비교해 보았습니다.
TPS 및 지연 시간 비교:
테스트 결과, Lua Script를 사용했을 때 비관적 락 대비 지연 시간(Latency)이 약 15.12% 감소하는 것을 확인했습니다.
안정성:
p95 지연 시간(95th percentile Latency)을 비교했을 때도 Lua Script가 훨씬 안정적인 수치를 보여주었습니다.
부하 상황에서의 우위:
유저와 요청 수가 늘어날수록, 즉 부하가 높은 상황일수록 Lua Script의 성능 이점이 더 크게 나타났습니다.
이러한 데이터 분석을 바탕으로 비관적 락 대신 Lua Script 사용을 최종 결정했습니다.
Summary
이번 프로젝트를 통해 동시성 제어를 위해 고민했던 과정은 다음과 같습니다.
낙관적 락: 재시도 로직이 수반되므로 충돌이 잦은 상황에서는 비관적 락이 더 효율적이라고 판단했습니다.
Redis 활용: 이미 시스템 아키텍처에 Redis가 존재했기에 이를 적극 활용했습니다. 단순
INCR명령과 Lua Script를 비교하여 원자성(Atomic)을 완벽히 보장하는 방법을 찾아냈습니다.기술적 의사 결정: 최종적으로 TPS와 Latency 데이터를 근거로 비관적 락보다 효율적인 Lua Script를 선택했습니다.
분산 락(Distributed Lock)을 도입하지 않은 이유
다중 Redis 인스턴스 필요
단일 Redis 인스턴스 장애 시 전체 락 메커니즘이 실패할 수 있어, 보통 3개 이상의 인스턴스 구성이 권장됩니다.
이는 인프라 비용 증가와 관리 복잡성을 높이는 원인이 됩니다.
합의 알고리즘 관련 이슈
Redis의 분산 락 알고리즘인 Redlock은 과반수 이상의 노드에서 락을 획득해야 하는 Majority 투표 방식을 사용합니다.
하지만 네트워크 지연이나 클럭 드리프트(Clock Drift) 현상으로 인해 일관성 문제가 발생할 수 있다는 한계가 존재합니다.
이러한 레드락(Redlock) 알고리즘의 한계 등의 문제들이 존재한다고 한다.
결과적으로 분산 락의 문제점들을 직접 느껴보지 못했지만, 다중 인스턴스 사용은 현재 상황에서 다소 과도한 솔루션이 될 수 있다고 판단했습니다.
reference
Last updated