데이터 동시성과 해결 방법

박태현·2025년 6월 3일
0

예약 프로젝트

목록 보기
1/8
post-thumbnail

동시성 제어


다수의 트랜잭션이 동시에 실행될 때, 데이터의 일관성과 무결성을 유지하면서 자원에 대한 접근 순서를 조정하는 기법

[ 동시성을 제어하지 않을 때의 문제점 ]

  1. 갱신 손실 ( Lost Update )

    하나의 트랜잭션이 갱신한 내용을 다른 트랜잭션이 덮어씀으로써 갱신이 무효화 되는 것

    두 개 이상의 트랜잭션이 하나의 데이터를 동시에 갱신할 때 발생

  2. 현황 파악 오류 ( Dirty Read )

    읽기 작업을 하는 트랜잭션이 쓰기 작업을 하는 트랜잭션이 작업한 중간 데이터를 읽음으로 발생하는 문제

    ⇒ 만약 쓰기를 한 트랜잭션이 롤백된 경우 읽어들인 데이터는 잘못된 결과를 도출함

  3. 모순성

    트랜잭션이 데이터를 수정하는 중에 다른 트랜잭션이 수정 전 또는 중간 값을 읽어, 데이터 불일치가 발생하는 상황

    Ex ) 트랜잭션 1이 계좌A에서 10만원을 출금하고, 계좌B에 입금하기 전에 계좌들의 잔액 합계를 조회한다면

    실제 돈은 사라지지 않았지만, 시스템 상 잔액이 줄어든 것 처럼 보이는 모순된 상태가 발생할 수 있음

  4. 연쇄 복귀

    하나의 트랜잭션이 실패하거나 롤백되면서, 이 트랜잭션에 의존하고 있던 다른 트랜잭션들도 자동으로 롤백되는 현상

낙관적 락 & 비낙관적 락


따로 포스팅 참조

분산 락


분산 시스템 환경에서 여러 노드가 동일한 자원에 접근하여 데이터를 수정하거나 읽을 때, 데이터의 일관성과 무결성을 보장하기 위해 사용하는 락 방식

분산 락은 여러 서버나 프로세스가 서로 다른 물리적 위치에 있는 상황에서도 자원의 동시성을 제어할 수 있게 함

분산 시스템에서 각 서버는 독립적으로 실행되며 네트워크를 통해 자원에 접근하는데, 이 과정에서 문제가 발생할 수 있음

  • 데이터 일관성 문제
    여러 서버가 동일한 자원을 동시에 업데이트하면 데이터의 일관성이 깨질 수 있음

  • 동시성 제어
    한 서버가 자원을 업데이트 하는 동안 다른 서버는 해당 자원에 접근해서는 안됨

  • 멀티 노드 환경
    여러 노드에서 동일한 자원에 접근할 때, 단일 서버에서 락을 거는 방식으로는 관리할 수 없음
    ⇒ 여러 노드가 있는 환경에서는 다른 서버의 락 상태를 알 수 없기 때문에 경쟁 상태가 발생 가능

    ⇒ 서버 A,B가 있을 때 A가 공유 자원에 접근하여 락을 걸었지만, B는 A의 락 정보를 모르기에 동시에 공유자원에 접근 가능

분산 시스템에서 공유 자원 자체에 락 상태를 저장하는 것은 위험합니다. 이는 노드 간 동기화 지연이나 네트워크 장애 발생 시 락 상태의 불일치를 발생시키며, 락이 걸려 있는 자원에 다른 노드가 접근하거나, 락이 풀리지 않는 Deadlock 상태가 될 수 있기 때문입니다.

따라서 락 상태를 자원 자체에 저장하는 방식은 신뢰성이 낮고 안전하지 않기 때문에 외부 분산 락 시스템을 활용하여 원자적이고 일관된 락 관리를 하는 것이 필요합니다.

대표적으로 Redis, Zookeeper, Consul 등과 같은 도구를 사용하여 분산 락을 관리할 수 있음

Redlock

단일 장애점이 있는 Master-Slave 구조 대신, 여러 개의 독립적인 Redis 마스터 인스턴스를 사용하여 과반수의 합의를 얻는 방식으로 동작 ( Slave 구조 없이 여러 개의 Redis 마스터 인스턴스를 사용 )

Master-Slave 구조는 왜 문제가 되는가

클라이언트가 마스터에서 락을 획득했더라도, 그 정보가 슬레이브에 복제되기 전에 마스터가 다운되면, 슬레이브는 락이 없는 상태로 승격되어 다른 클라이언트에게 동일한 락을 허용하는 이중 락 문제가 발생할 수 있습니다.

단일 레디스 노드를 사용하는 경우

Redis 노드에 장애가 발생하면 복구될 때까지 모든 락 획득 요청이 실패되는 SPOF가 될 수 있으며, 락을 획득한 클라이언트가 아직 작업을 끝내지 않았더라도, Redis가 다운되어 락 정보가 사라지면, 다른 클라이언트가 동일한 자원에 대한 락을 획득할 수 있게되는 문제가 발생할 수 있습니다.

Redlock 방식

  1. 현재 시간을 ms 단위로 가져옴

  2. 모든 인스턴스에 순차적으로 lock 획득을 시도하고, 각 인스턴스에 락을 설정할 때 서버는 TTL ( 락이 유효한 시간 )보다 짧은 타임 아웃 ( lock을 요청하고 기다리는 시간 )을 설정

    ex ) TTL이 10초면, 각 인스턴스에 타임아웃을 5~50ms 정도로 제한하는 방식

    ⇒ 장애가 발생한 Redis 인스턴스와의 통신 지연으로 블로킹되는 문제를 방지할 수 있음

    • 락 획득 서버가 특정 리소스에 접근하기 전에 여러 redis 인스턴스에 같은 키로 락을 시도
      SET <key> <value> NX PX <TTL>SET [리소스 식별값] [서버 식별 값] NX PX 60000
      
      NX ( Not Exists ) : 키가 존재하지 않을 때만 값을 설정
      PX ( TTL ) : TTL 설정
      특정 서버가 과반수 이상의 락을 가진다면 해당 서버는 리소스에 대한 접근 권한을 획득했다고 판단

    • TTL
      락에는 TTL이 설정되어 있어 일정 시간이 지나면 자동 해제됨

    • 락 해제
      작업 완료 후 DEL key와 같은 명령어를 통해 명시적으로 락을 해제해야 함

      하지만, 이러한 DEL 연산을 검증 없이 허용하면 lock을 획득하지도 않은 서버가 잠금을 해제할 수 있으므로 키가 존재하고, 키에 저장된 값이 클라이언트의 값과 일치하는 경우와 같은 조건을 만족하는 경우에만 해제할 수 있도록 해야함
  3. lock 획득에 걸린 전체 시간을 계산

    lock 획득 시간 : 현재 시간 - 1단계에서의 타임 스탬프

  4. 특정 서버가 과반수 이상의 Redis 인스턴스에서 lock을 획득하고, lock 획득 시간이 lock의 TTL보다 짧다면, lock을 성공적으로 획득한 것으로 간주

    서버가 다음과 같은 설정을 한다고 가정
    
    - TTL: 10- Redis 인스턴스: 5 ( A, B, C, D, E )
    - 타임아웃: 30ms
    
    1. A에 락 요청 → 10ms만에 성공
    2. B에 락 요청 → 28ms만에 성공
    3. C에 락 요청 → 31ms 걸림 → 타임아웃 초과 → 실패 → 다음 노드로 이동
    4. D에 락 요청 → 15ms만에 성공
    5. E에 락 요청 → 12ms만에 성공
    
    → 과반수 이상 락 획득 + 총 소요 시간 96ms → TTL(10)보다 훨씬 짧음 → 락 획득 성공

  1. lock 획득에 성공했다면, 실제 락의 유효 시간은 TTL - 락 획득 시간으로 간주

    TTL이 10초일 때, 과반수 lock 획득에 성공한 시점이 2초라면 실제 lock 유효 시간은 8초로 계산됨

    이렇게 하는 이유는, lock의 남은 시간을 정확이 판단하여 중복 락이나 Split Brain을 방지하기 위함임

  1. 만약 lock 획득에 실패한 경우 ( 과반수 미만, 경과 시간이 너무 길 경우 ), 서버는 모든 Redis 인스턴스에 lock 해제를 시도

lock 획득에 실패했을 때의 재시도 전략 : Random Delay

lock 획득에 실패한 서버는 즉시 재시도하면 여러 서버가 동시에 락을 요청할 수 있으므로 서버마다 무작위로 짧은 시간만큼 기다렸다가 다시 시도하도록 유도하는 것

RedLock은 Split Brain Condition를 유발 가능

Split Brain Condition : 여러 노드가 하나의 리소스에 대한 락을 서로 가지고 있다고 믿고 있는 상태

Redlock에서 Split-Brain Condition은 클라이언트가 락 유효 시간(TTL)보다 길게 정지(GC)되고, 그 사이에 
클럭 드리프트로 인해 일부 Redis 노드의 락이 조기에 만료되면, 다른 클라이언트가 남은 노드와 풀린 노드를 합쳐 새로운 
과반수를 획득하여 이중 락 상태가 되는 상황이 발생할 수 있습니다.

Redlock의 한계

  1. Clock Drift로 인한 문제

    시스템 시계가 실제 시간보다 앞서거나 뒤처지는 현상

    노드 간 시간 동기화는 없지만, 각 서버의 시계가 비슷한 속도로 흐른다는 가정에 의존하지만, 현실에서는 이 가정이 항상 성립하지 않으며, 이 지점에서 Clock Drift 문제가 발생할 수 있음

    예시 )
    Redis 노드 5: A, B, C, D, E | 클라이언트 1, 클라이언트 2가 있을 때
    
    클라이언트 1
    	•	노드 A, B, C에서 락 획득 (3개 → 과반수)D, E에서는 네트워크 지연 등으로 실패
    	→ 락 획득 성공
    	
    문제 발생
    	•	노드 C의 시계가 실제 시간보다 앞서 있음 ( Clock Drift )
    	→ 노드 CTTL이 지나지 않았지만 락이 만료된 것처럼 판단
    	
    클라이언트 2C, D, E에서 락 획득 (3개 → 과반수)A, B에서는 실패
    	•	→ 클라이언트 2도 락을 획득했다고 판단
    	
    결과
    	•	클라이언트 12가 동시에 동일 리소스를 제어하는 상황 발생
    	→ Redlock이 보호해야 할 동시성 제어가 깨짐
    	→ Split Brain 발생

    또한, 특정 노드의 재시작으로 인한 문제가 발생할 수 있음

    만약 노드 C가 다운되고 재시작 됐을 때, lock 정보를 디스크에 저장하지 못한 채 재시작하면, 노드 C는 해당 lock이 없다고 판단하고 다른 서버에게 lock 요청을 허용해줄 수 있음

    ⇒ 이를 해결하기 위해, 노드 재시작을 최소한 가장 긴 TTL 만큼 지연시키긴 하는데, 이 역시 정확한 시계 측정에 의존해야하고, 시계가 갑자기 앞당겨지거나 뒤쳐지면 실패할 수 있음

  1. 애플리케이션 중단 또는 네트워크 지연으로 인한 문제

    서버 1이 중지되면서 문제가 발생하는 경우

    image.png

    1. 서버 1이 Redlock을 통해 분산락을 획득

    2. 그러나 서버 1의 서버 일시 중지 가 발생

      ex ) Stop-the-World GC, OS 스케줄링 지연, 네트워크 중단 등등 …

    3. 그 사이 락의 TTL이 만료되어 Redis에서 락이 사라짐

    4. 클라이언트 2가 락을 획득하고, 리소스를 수정

    5. 이후 클라이언트 1의 애플리케이션이 복구되고, 자신이 여전히 락을 보유 중이라 판단해 같은 자원을 수정

    6. 결과적으로 클라이언트 1과 2가 동시에 동일 리소스를 수정하게 되어 동시성 충돌이 발생

    ⇒ 갱신 손실이 발생할 수 있으며, 락 기반 동기화의 핵심 목적이 깨져버림

    쓰기 직전에 락 만료 여부를 확인해본다는 방법도 있지만, 락 상태 확인과 쓰기 사이에 중단이 발생할 수도 있으므로 근본적인 해결책이 되지는 못함

    따라서, 서버 2가 lock을 획득하면 이전 락 보유자 ( 서버1 )가 작업을 하지 못하도록 해야함

    ⇒ 팬싱 토큰 또는 버전을 포함시키는 방법을 고려 ( lock의 유효성을 판단하기 위함 )

    팬싱 토큰 또는 버전을 포함시키는 방법

    image.png

    하지만, Redlock 방식은 팬싱 토큰 기능이 없으므로 ( 단지 lock을 얻었는지만 판단함 )

    단일 Redis 서버에 카운터를 둔다던가 여러 서버에 카운터를 분산하여 저장할 수 있지만

    SPOF가 발생할 수도 있고, 각 서버의 상태가 다를 수 있어 카운터가 불일치, 중복 등의 문제가 발생 할 수 있음

Zookeeper 기반 분산 락

여러 서버에서 분산되어 사용되는 정보를 중앙에서 관리해주는 시스템 ( Leader-Follower 구조, Raft 기반 )

Raft 기반 : 과반수 합의 원칙과 리더 중심의 로그 복제를 통해 데이터의 일관성과 시스템의 안정성을 확보하는 것


[ 아키텍처 ]

image.png

Zookeeper는 기본적으로 여러 개의 서버 집합으로 구성되며, Leader-Follower 구조를 사용하여, Leader가 Follower에게 동기화를 위한 명령을 내리게 됨

서버 집합 : Ensemble ( 앙상블 )


Leader가 새로운 트랜잭션을 수행하기 위해서는 자신을 포함하여 과반수 이상 서버의 합의를 얻어야함

모든 쓰기 작업은 Leader가 순차적으로 처리하고, Follower들은 읽기 전용 또는 리플리케이션을 수행

모든 쓰기 작업은 Leader를 통해 처리되지만, 읽기 작업은 Leader를 거치지 않고 Follower에서 직접 처리 가능
하지만, Follower에서 읽기 작업을 한다면 최신 정보를 보장할 수 없음

[ 트랜잭션 처리 ]

image.png

  1. 락 요청 및 리더 위임

    동일 자원에 접근하려는 모든 클라이언트는 새로운 순차적 ZNode 생성을 요청합니다.

    ( 요청이 팔로워에게 도착하면, 팔로워는 이를 반드시 리더에게 위임합니다. )

  2. 순서 부여 및 합의

    리더에게 도착한 요청들은 FIFO 순서로 처리되며, 리더는 ZNode를 자신의 로컬 메모리와 로그에 기록하고 001, 002, 003 …와 같은 순번을 부여하여 모든 클라이언트를 줄 세웁니다.

    리더는 각각의 생성 트랜잭션을 차례대로 팔로워들에게 제안하고, 자신을 포함한 과반수의 승인( ACK )을 받은 후, 이를 모든 서버의 데이터베이스에 최종 반영( Commit )하여 ZNode 정보의 일관성과 영속성을 보장합니다.

    과반수를 얻긴 했지만 어느 서버는 이 요청에 대해 ACK를 보내지 못했다면 이후 리더가 모든 팔로워에게 동시에 브로드캐스트하여 커밋 요청을 내릴 때 동기화 됩니다.

    Commit 명령을 놓친 팔로워는 이후 데이터 불일치를 감지하고, 누락된 트랜잭션 데이터를 요청하여 최종 상태로 동기화함으로써 강력한 일관성을 유지합니다.

    과반수의 합의를 얻지 못한다면, 해당 클라이언트의 요청은 실패됩니다.


    왜 굳이 팔로워에 동기화를 하나 ?

    리더 장애 발생 시에도 중단 없이 새로운 리더를 선출하고, 이전의 커밋된 상태를 보존하면서 새로운 트랜잭션의 순서를 보장하기 위해

  1. 독점적 락 획득

    ZNode 생성이 모든 서버에 반영된 후, 각 클라이언트는 자신이 생성한 ZNode의 순번이 현재 살아있는 노드 중 가장 작은 번호인지 확인합니다.

    가장 작은 순번을 가진 클라이언트가 독점적 락 획득에 성공하여 임계 영역 작업을 시작합니다.

  1. 이벤트 기반 대기 및 인계 ( Watcher )

    락 획득에 실패한 나머지 클라이언트들은 자신보다 바로 앞 순번의 ZNode에 Watcher를 설정하고 대기합니다.

    앞선 클라이언트가 작업을 완료하고 ZNode를 삭제하면, Watcher가 이벤트를 받고 깨어나 다음 순서임을 인지하고 락을 획득합니다.

    watcher : ZooKeeper에서 클라이언트는 특정 znode에 Watcher를 설정해, 해당 노드에 변경이 생기면 다른 노드들에게 실시간으로 변경 알림을 전파할 수 있습니다.


broadcast 할 때 ZAB( ZooKeeper Atomic Broadcast ) 알고리즘을 사용

ZAB 알고리즘 : 분산 시스템에서 모든 서버들이 동일한 상태를 동일한 순서로 반영하도록 보장하는 데 사용됩니다.

만약 동일한 순서대로 반영하지 않는다면 서버들 간의 데이터의 일관성이 깨지게 됨

⇒ 어느 서버는 1 , 2, 3 | 어느 서버는 2, 1, 3

image.png

[ ZooKeeper의 데이터 모델 - znode ]

ZNode는 ZooKeeper의 기본 데이터 저장 단위이며, 계층적 구조를 가지고 데이터를 저장합니다.

image.png

/
├── app1
│   ├── leader      ← 리더 정보 저장
│   ├── workers     ← 워커 서버 목록
│   └── tasks       ← 처리할 작업 목록
└── app2
    ├── db_config   ← DB 설정 정보
    └── api_config  ← API 설정 정보
    
조율 정보를 담고있음

모든 데이터는 디렉토리 구조를 기반인 znode라 불리는 key-value 구조의 데이터 저장 객체의 형태로 저장됩니다.

znode는 ZooKeeper가 데이터를 저장하기 위해 사용하는 가장 작은 단위의 데이터 저장 객체임

znode는 ZooKeeper 서버의 메모리에 저장되기 때문에 서버가 종료되면 메모리 상의 데이터는 사라지지만, 설정 값 등의 중요한 정보가 유실되지 않도록 모든 변경 사항은 서버의 로컬 디스크에 함께 기록됨

모든 서버( Leader, Follower )는 동일한 znode 트리를 메모리에 로딩하고 유지함

즉, 데이터 복제가 이루어짐 → 어떤 서버가 죽어도 나머지 서버들이 같은 상태를 유지할 수 있음

Consul 기반 분산 락

Consul이라는 서비스 디스커버리 및 분산 설정 관리 툴의 기능을 활용하여 구현하는 분산 동시성 제어 방식

Consul 락의 핵심은 Session과 KV 저장소의 연산을 결합하는 것

Consul 기반 분산락 방식도 리더-팔로워 구조로 작동 ( Raft 기반 )

Raft 기반 : 과반수 합의 원칙과 리더 중심의 로그 복제를 통해 데이터의 일관성과 시스템의 안정성을 확보하는 것


세션

  • Heartbeat
    클라이언트가 Consul에 락 획득 요청을 보내면, Consul은 세션을 생성합니다. 이 세션은 클라이언트가 주기적으로 Heartbeat을 보내 살아있음을 증명해야 합니다.

  • 자동 해제
    만약 클라이언트가 Heartbeat 전송을 멈추거나 네트워크 연결이 끊어지면, Consul은 자동으로 해당 세션을 만료시키고, 세션과 연결된 모든 락을 자동으로 해제합니다.

    ⇒ 데드락 방지


키-값 저장소와 CAS 연산

  • 락 키
    공유 자원에 해당하는 키를 Consul의 KV 저장소에 생성합니다.
  • CAS ( Check-and-Set )
    클라이언트는 락 획득을 시도합니다. 이 요청은 다음과 같은 원자적 연산을 수행합니다.

    확인 ( Check ) : 해당 락 키가 현재 어떤 세션과 연결되어 있는지 확인합니다.

    설정 ( Set ) : 키가 어떤 세션과도 연결되어 있지 않다면, 현재 클라이언트의 세션 ID를 그 키에 설정합니다. ( 락 획득 )

    즉, CAS 연산이 성공하면 락을 획득하고, 실패하면 이미 다른 클라이언트가 락을 쥐고 있음을 의미합니다.

[ 흐름 ]

서버가 데이터나 자원에 접근할 때, 충돌하지 않게 하기 위해 Consul을 통해 lock을 거는 과정

  1. 어떤 서버가 특정 리소스에 접근하기 전에 충돌을 방지하기 위해 Consul에 세션을 생성

  2. 생성한 세션을 이용해 Consul의 Key-Value 저장소에 특정 Key에 대한 값으로 생성한 세션 ID를 설정 ( 락 획득 )

  3. 락이 성공적으로 걸리면, 그 Key는 해당 세션( 서버 )이 독점하게 되어, 다른 서버들은 그 자원에 접근할 수 없게 됨

  4. 세션을 유지하기 위해 서버는 Consul에 주기적으로 heartbeat를 보내며, 세션이 살아있음을 알려야 함

  5. 만약 서버가 죽거나, 하트비트를 보내지 못해 세션이 끊기면, Consul은 자동으로 세션을 만료시키고 락을 해제함

    ( Key-Value 저장소에 특정 키에 대한 값을 삭제시키거나 값만 비워둠 )

  6. 락이 해제되면, 이제 다른 서버들이 동일한 Key에 대해 다시 락을 시도할 수 있게 되어, 자원에 대한 안전한 분산 접근이 가능

Consul 락은 Raft 기반의 강한 일관성과 세션 Heartbeat를 통한 자동 락 해제로 높은 안전성을 보장하지만, Heartbeat와 합의( Raft ) 과정 때문에 Redis 락 대비 지연 시간이 길고 복잡도와 운영 비용이 높습니다.

profile
꾸준하게

0개의 댓글