05-02 동시성 제어
Q. 동시에 여러 사용자가 같은 ID로 회원가입을 시도할 때 발생할 수 있는 문제점은 무엇이며, 어떻게 해결 할 수 있을까요?
동시에 여러 사용자가 같은 ID로 회원가입을 시도하는 상황에서는 Race Condition
이 발생할 수 있습니다.
예를 들어, A와 B 사용자가 동시에 'user123'이라는 ID로 가입을 시도한다고 가정했을때, 두 사용자가 거의 동시에 ID 중복 체크를 하면, 둘 다 해당 ID가 존재하지 않는다고 판단하고 회원가입을 진행할 수 있습니다. 이런 경우 데이터 정합성이 깨질 수 있습니다. 이를 해결하기 위한 방법으로는 크게 세 가지를 고려할 수 있습니다.
첫째, 가장 쉬운 방법으로 데이터베이스 컬럼에 유니크 제약조건을 설정하는 방법입니다. ID 컬럼에 unique constraint
를 걸어두면, 중복 데이터 삽입 시도 시 에러가 발생하여 하나의 회원만 가입되도록 보장할 수 있습니다.
두번째, 애플리케이션 레벨에서 ConcurrentHashMap
을 사용하여 atomic
한 처리를 할 수 있습니다. putIfAbsent()
메소드를 활용하면 체크와 등록을 원자적으로 수행할 수 있어서 안전합니다.
세번째, MSA같은 분산 환경에서는 Redis
를 활용한 분산 락을 고려할 수 있습니다.
실제 프로젝트에서는 이러한 방법들을 상황에 맞게 조합하여 사용하는 것이 좋습니다. 저는 주로 데이터베이스의 유니크 제약조건을 기본으로 하고, 추가적인 안정장치로 애플리케이션 레벨에 ConcurrnetHashMap
을 구현하는 방식을 선호합니다.
Q. Redis 분산락에 대해 조금 더 자세히 설명해주세요.
Redis 분산락
은 여러 애플리케이션 인스턴스들이 공유 자원에 동시에 접근하는 것을 제어하기 위해 Redis를 활용하는 동시성 제어 메커니즘입니다.
락을 획득하려는 클라이언트는 Redis의 SET
명령어를 사용하는데, 이때 NX(Not eXists)
옵션을 통해 키가 존재하지 않을 때만 값을 설정하도록 합니다. 이 연산은 원자적으로 수행되며, 락의 값으로 클라이언트 고유 식별자(예: UUID)를 설정하여 소유자를 식별합니다. 동시에 PX(밀리초)
또는 EX(초)
옵션으로 락의 만료 시간(TTL)
을 설정하여, 클라이언트가 비정상 종료되더라도 락이 영원히 유지되는 데드락 상황을 방지합니다.
락을 획득한 클라이언트는 공유 자원 작업을 수행한 후, Redis에서 해당 락 키를 삭제(DEL)
하여 락을 해제합니다. 이때 중요한 점은, 자신이 설정한 락인지 확인하기 위해 락 값에 고유한 ID를 포함시키고, Lua 스크립트를 사용하여 "키의 값이 내가 설정한 값과 일치하면 키를 삭제하라" 는 원자적인 연산을 수행하는 것입니다. 이는 다른 클라이언트가 획득한 락을 실수로 해제하는 문제를 방지합니다.
Redis는 인메모리 데이터 스토어로, 락 작업이 빠르고 구현이 간단하다는 장점이 있습니다. 그러나 단일 인스턴스에서는 안정적이지만, 클러스터 환경에서는 네트워크 분할로 인한 스플릿 브레인
문제가 발생할 수 있습니다.
Q. 만약 Lua 스크립트를 사용하지 않고, 애플리케이션에서 GET
명령으로 락의 소유자를 확인한 뒤 DEL
명령으로 락을 삭제하는 방식을 사용한다면 구체적으로 어떤 위험이 발생할 수 있을까요?
GET
명령으로 락의 소유자를 확인한 뒤 DEL
명령으로 락을 삭제하는 방식을 사용한다면 구체적으로 어떤 위험이 발생할 수 있을까요?Lua 스크립트
없이 GET
과 DEL
을 순차적으로 실행하면, 두 명령어 사이의 짧은 시간 틈 때문에 동시성 문제가 발생할 수 있습니다.
예를들어, 클라이언트 A가 락 해제를 위해 먼저 GET
명령으로 자신이 락의 소유자임을 확인했다고 가정해보겠습니다. 하지만 바로 그 직후, A가 DEL
명령을 보내기 전에 A가 가진 락의 TTL
이 만료될 수 있습니다. 그리고 그 찰나의 순간에 클라이언트 B가 동일한 락을 새롭게 획득할 수 있죠.
결과적으로 A는 이 사실을 모른채 DEL
명령을 실행하게 되고, 자신이 소유하지 않은, 즉 B가 막 획득한 락을 삭제해버리는 상황이 발생할 수있습니다. 이렇게 되면 B는 락의 보호를 받지 못한 채 작업을 수행하게 되어 데이터가 오염될 수 있습니다.
Lua 스크립트
는 이러한 '확인 후 삭제' 과정을 하나의 원자적 연산으로 묶어주기 때문에 이런 위험을 원천적으로 차단할 수 있습니다.
Q. Redis가 아닌 다른 분산락을 구현하는 기술을 아시는게 있으신가요?
레디스 외에 주키퍼(Zookeeper)
를 사용해 분산 락을 구현할 수도 있습니다.
주키퍼는 레디스처럼 단순히 정해진 시간(TTL)이 다 되기를 기다리는 방식이 아니라, 실제 클라이언트와의 연결 상태를 기반으로 락을 관리하기 때문에 훨씬 안정적입니다.
이해를 돕기 위해 '단방향 1차선 다리' 상황에 빗대어 설명해 보겠습니다. 교통 통제관 역할을 하는 주키퍼가 다리를 건너려는 차들을 순서대로 줄을 세우고, 각 차는 앞차가 출발하는 것을 보고 자기 차례를 기다리기 때문에 매우 효율적입니다.
그리고 여기서 가장 중요한 특징은, 통제관이 다리를 건너는 차를 드론, 즉 세션(Session)
으로 계속 지켜본다는 점입니다. 만약 차가 다리 위에서 고장 나 멈추면, 통제관은 이 사실을 즉시 알아채고 견인차를 보내 길을 터준 뒤 다음 차를 출발시킵니다.
이처럼 주키퍼는 특정 프로세스에 문제가 생겨도 실시간으로 감지하여 락을 자동으로 풀어주므로, 전체 시스템의 안정성을 높이는 데 아주 효과적인 방법입니다.
Q. ConcurrnetHashMap에 대해 조금 더 자세히 설명해주세요.
동시성 관련 문제에서 기존 synchronizedMap
은 모든 메소드 호출 시 Map 객체 전체에 락을 거는 방식이라, 여러 스레드가 동시에 접근할 때 병목 현상이 발생했습니다.
이를 해결하기 위해 ConcurrentHashMap
이 개발되었습니다.
Java 7 이전에는 데이터를 여러 세그먼트(Segment)
로 나누고, 각 세그먼트에만 개별적으로 락을 거는 '분할 잠금' 방식을 사용했습니다. 덕분에 전체 맵을 잠그는 것보다 훨씬 높은 동시성을 확보할 수 있었죠.
Java 8 이후부터는 여기서 더 나아가 세그먼트 개념을 없앴습니다. 대신 데이터 삽입 시, 먼저 락 없이 CAS(Compare-And-Swap) 연산
을 시도합니다. 만약 여러 스레드가 같은 위치에 접근해 충돌이 날 경우에만, 해당 위치(버킷)에 대해서만 synchronized
를 이용해 최소한의 락을 거는 방식으로 동작합니다.
이러한 구조 덕분에 읽기 연산은 대부분의 경우 락 없이 수행되며, 쓰기 연산 시에도 락 경합 가능성을 크게 줄여 높은 동시성과 처리량을 보장하는 스레드 안전한 자료구조입니다.
Last updated