Atomic
하나의 숫자나 flag처럼 작은 상태를 빠르게 바꿀 때 쓴다. 여러 필드 불변식에는 부족하다.
counter, once flag, ref count동시성 제어와 동기화는 여러 실행 흐름이 같은 자원을 읽고 쓸 때, 결과가 실행 타이밍에 따라 깨지지 않도록 접근 순서와 대기 규칙을 정하는 기술이다.
첫 회독에서는 아래 순서만 따라간다.
종료 조건은 다음과 같다.
count += 1이 왜 LOAD/ADD/STORE로 쪼개지고 lost update가 생기는지 설명할 수 있다.if가 아니라 while로 조건을 재확인해야 하는 이유를 말할 수 있다.await 사이에서 async race가 생기는 이유를 설명할 수 있다.선택 심화 절: 5.25.3절(futex/spinlock), 9절(memory barrier/false sharing), 10.4절(SharedArrayBuffer), 12절(distributed lock), 16.216.3절(DB/Redis 실습), 17.2절(lock-free/wait-free), 17.4절(distributed lock과 idempotency), 18절(실무 스니펫), 19절의 선택 심화 체크다. 첫 회독의 로컬·DB 상태 범위가 분명해진 뒤에만 이 경계를 넓힌다.
여기서 실행 흐름은 OS 스레드, 프로세스, Node.js의 비동기 요청 흐름, DB 트랜잭션, 메시지 큐 consumer처럼 “언젠가 같은 상태에 접근할 수 있는 작업 단위”를 넓게 가리킨다. L4 관점의 중심은 OS 스레드와 공유 메모리지만, 실무에서는 같은 사고가 DB 락, Redis 분산 락, idempotency key까지 이어진다.
동시성 문서는 용어를 대충 넘기면 뒤에서 모두 헷갈린다. 아래 단어는 처음 읽을 때 한 번 잡고 간다.
| 용어 | 첫 정의 | 왜 필요한가 |
|---|---|---|
| Concurrency, 동시성 | 여러 작업이 겹쳐 진행되는 구조 | 실제로 동시에 실행되지 않아도 실행 순서가 섞이면 문제가 생긴다 |
| Parallelism, 병렬성 | 여러 작업이 물리적으로 동시에 실행되는 구조 | 멀티코어에서 같은 메모리를 동시에 건드릴 수 있다 |
| Shared mutable state | 둘 이상이 읽고 쓸 수 있는 가변 상태 | race condition의 대부분이 여기서 시작한다 |
| Critical section | 공유 자원을 읽거나 쓰는 코드 구간 | 이 구간에는 보통 한 번에 하나만 들어가야 한다 |
| Race condition | 실행 타이밍에 따라 결과가 달라지는 상태 | 테스트에서는 통과하고 운영에서만 깨지는 버그가 된다 |
| Atomicity, 원자성 | 중간 상태가 관찰되지 않는 하나의 연산처럼 보이는 성질 | count += 1이 원자적이지 않다는 사실이 핵심 출발점이다 |
| Mutex | 한 번에 하나만 임계 구역에 들어가게 하는 소유권 있는 잠금 | 데이터 보호의 기본 도구다 |
| Semaphore | N개까지 동시에 들어가게 하는 카운터 기반 동기화 도구 | 커넥션 풀, worker pool처럼 개수 제한에 맞다 |
| Condition variable | 특정 조건이 만족될 때까지 잠든 뒤 깨우는 대기 도구 | ”락은 잡았지만 아직 처리 조건이 아님”을 표현한다 |
| Monitor | mutex와 condition variable을 한 객체/언어 구조로 묶은 모델 | Java synchronized처럼 안전한 사용 패턴을 강제한다 |
| Futex | Linux의 fast userspace mutex 메커니즘 | 경합이 없을 때 커널 진입 없이 빠르게 lock/unlock한다 |
| Spinlock | 잠이 들지 않고 반복 확인하며 기다리는 lock | 매우 짧은 대기에는 유리하지만 길어지면 CPU를 태운다 |
| Deadlock | 서로가 가진 자원을 기다리며 모두 멈춘 상태 | 단순 timeout보다 자원 획득 순서 설계가 중요하다 |
| Starvation | 어떤 작업이 계속 밀려 실행 기회를 얻지 못하는 상태 | 공정성 없는 lock/priority 정책에서 생긴다 |
| Livelock | 모두 움직이지만 양보만 반복해 진전이 없는 상태 | ”멈춘 것 같지는 않은데 완료가 안 됨”으로 보인다 |
| Distributed lock | 여러 서버 인스턴스 사이에서 하나만 실행하게 하는 외부 lock | 단일 프로세스 mutex와 실패 조건이 다르다 |
이 문서의 핵심 문장은 다음과 같다.
동시성 문제는 “동시에 실행된다”보다 “같은 상태의 읽기와 쓰기 순서가 섞인다”에서 시작한다.
단일 프로그램이 혼자 실행되던 환경에서는 공유 상태 충돌이 드러나기 어렵다. 문제가 커진 것은 여러 프로세스와 스레드가 같은 파일, 같은 메모리, 같은 DB 행, 같은 커넥션 풀을 사용하기 시작하면서다.
가장 단순한 해결책은 “공유 자원에 접근하는 동안 아무도 끼어들지 못하게 하자”다. 초기 단일 코어 커널에서는 짧은 임계 구역에서 인터럽트를 끄면 컨텍스트 스위치가 일어나지 않아 그럭저럭 동작했다. 하지만 이 방식은 현대 시스템에서 곧 깨진다.
| 단순 해결책 | 깨지는 이유 | 현대적 응답 |
|---|---|---|
| 인터럽트를 잠깐 끈다 | 멀티코어에서는 다른 코어가 계속 실행된다 | CPU 원자 명령과 lock을 쓴다 |
| 사용자 코드가 직접 CPU를 독점한다 | 사용자 프로세스 하나가 OS 전체 응답성을 망칠 수 있다 | 커널이 scheduler와 sleep/wakeup을 관리한다 |
| 모든 동기화를 시스템 콜로 처리한다 | lock이 대부분 무경합인데 매번 커널 진입 비용을 낸다 | futex처럼 무경합 fast path를 사용자 공간에 둔다 |
| 모든 서버에 로컬 mutex를 둔다 | 여러 인스턴스가 있으면 서로의 mutex를 볼 수 없다 | DB lock, Redis lock, MQ, idempotency key로 범위를 넓힌다 |
Dijkstra의 semaphore는 “프로세스들이 협력할 때 wait/signal이라는 추상 연산으로 순서를 맞춘다”는 출발점을 제공했다. 이후 mutex, monitor, condition variable은 같은 문제를 더 안전하게 표현하는 방향으로 발전했다.
Linux의 futex는 또 다른 전환점이다. lock을 잡으려는 대부분의 시도는 실제 경합이 없다. 그래서 현대 mutex는 먼저 사용자 공간에서 원자 연산으로 lock 획득을 시도하고, 이미 누가 잡고 있을 때만 커널에 “나를 재워 달라”고 요청한다. 이 구조 때문에 uncontended mutex는 빠르고, contended mutex는 sleep/wakeup과 context switch 비용을 낸다.
이 lineage는 실무 판단에도 바로 쓰인다.
count += 1도 안전하지 않은가공유 가변 상태(shared mutable state) 는 둘 이상의 실행 흐름이 읽고 쓸 수 있는 값이다. 예를 들면 전역 카운터, 같은 DB 행의 재고 수량, Redis key, 파일의 현재 offset, 한 프로세스 안의 Map이 여기에 들어간다.
읽기만 한다면 대부분 안전하다. 문제는 하나가 읽고, 계산하고, 다시 쓰는 사이에 다른 실행 흐름이 같은 값을 읽거나 쓰는 순간이다.
count += 1
학습용으로 쪼개면:1. LOAD count # 메모리에서 현재 값 읽기2. ADD 1 # CPU 레지스터에서 1 더하기3. STORE count # 결과를 다시 메모리에 쓰기고급 언어에서는 한 줄이지만 CPU와 OS 입장에서는 여러 단계다. CPU scheduler는 이 단계들 사이에서 다른 스레드로 전환할 수 있다. 멀티코어라면 두 코어가 거의 동시에 같은 메모리 주소를 읽을 수도 있다.
초기값 count = 0에서 두 스레드가 count += 1을 한 번씩 실행한다고 하자. 기대값은 2다.
시간 Thread A Thread B 메모리 count---- -------------------- -------------------- ------------T1 LOAD count -> 0 0T2 LOAD count -> 0 0T3 ADD 1 -> 1 0T4 ADD 1 -> 1 0T5 STORE 1 1T6 STORE 1 1두 번 증가했지만 최종값은 1이다. A의 갱신이 B의 쓰기에 덮였으므로 lost update다.
작은 Python 예시로 논리 분해를 보면 다음과 같다. 이 코드는 race의 구조를 보여주기 위한 모델이며, 실제 재현성은 OS 스케줄링과 Python 런타임의 실행 타이밍에 영향을 받는다. 실행 관찰은 뒤의 Node.js async lost update 실습이 더 안정적이다.
import threading
count = 0
def add_one(): global count current = count count = current + 1
t1 = threading.Thread(target=add_one)t2 = threading.Thread(target=add_one)t1.start()t2.start()t1.join()t2.join()
print(count) # 기대는 2, 스케줄링에 따라 1이 될 수 있다이 예시는 일부 실행에서는 2가 나올 수 있다. 그래서 race condition은 더 위험하다. “항상 실패”가 아니라 “타이밍이 맞을 때만 실패”하므로 테스트에서 놓치기 쉽다.
원자적 연산(atomic operation) 은 다른 실행 흐름이 중간 상태를 볼 수 없도록 하나의 연산처럼 보장되는 작업이다. CPU는 compare-and-swap(CAS), fetch-and-add 같은 원자 명령을 제공한다.
CAS는 보통 이렇게 읽는다.
CAS(address, expected, newValue)
1. address의 현재 값이 expected와 같으면 newValue로 바꾼다.2. 다르면 아무것도 바꾸지 않고 실패를 반환한다.3. 비교와 쓰기는 중간에 끼어들 수 없는 하나의 원자 연산이다.CAS로 카운터를 올리면 한 스레드가 실패하더라도 다시 읽고 재시도할 수 있다.
Thread A: expected=0, CAS(0 -> 1) 성공Thread B: expected=0, CAS(0 -> 1) 실패Thread B: 다시 읽음 expected=1, CAS(1 -> 2) 성공하지만 atomic이 모든 문제를 해결하지는 않는다.
여기서 ABA 문제는 값이 A에서 B로 바뀌었다가 다시 A가 되어 CAS가 “변화가 없었다”고 오해하는 상황이다. memory reclamation은 lock-free 구조에서 다른 스레드가 아직 참조할 수 있는 메모리를 언제 안전하게 해제할지 정하는 문제다. 둘 다 첫 회독에서는 선택 심화로 두고, atomic이 만능이 아니라는 신호로만 잡아도 된다.
반례: “atomic counter를 쓰면 thread-safe하다”는 말은 범위가 좁다. stock과 order_status 두 값을 함께 바꿔야 하는 도메인 불변식은 counter 하나의 atomic 증가로 보호되지 않는다. 이때는 더 큰 임계 구역이나 DB 트랜잭션이 필요하다.
Critical section(임계 구역) 은 공유 자원에 접근해 불변식을 깨뜨릴 수 있는 코드 구간이다.
재고 차감은 전형적인 임계 구역이다.
1. 현재 재고를 읽는다.2. 재고가 1 이상인지 확인한다.3. 주문을 만든다.4. 재고를 1 줄인다.이 네 줄이 따로따로 실행되면 두 요청이 모두 “재고 있음”을 보고 주문을 만들 수 있다. 그래서 이 구간을 하나의 원자적 단위처럼 보호해야 한다.
임계 구역 설계의 기본 요구는 세 가지다.
| 요구 | 의미 | 깨졌을 때 |
|---|---|---|
| Mutual exclusion | 한 번에 하나만 들어간다 | lost update, 중복 발급 |
| Progress | 비어 있으면 누군가는 들어간다 | 모두 기다리기만 함 |
| Bounded waiting | 언젠가는 들어간다 | starvation |
Race condition 은 코드의 논리만으로 결과가 결정되지 않고, 실행 순서와 타이밍에 따라 결과가 달라지는 상태다.
중요한 구분은 다음과 같다.
| 상황 | race인가 | 이유 |
|---|---|---|
| 두 요청이 같은 상품 상세를 동시에 읽음 | 보통 아님 | 읽기만 하면 값이 깨지지 않는다 |
| 두 요청이 같은 쿠폰을 동시에 사용 처리 | 예 | read-check-write가 겹친다 |
| 두 worker가 서로 다른 파일에 씀 | 보통 아님 | 공유 자원이 다르다 |
| 두 worker가 같은 로그 파일 offset을 직접 관리 | 예 | 파일 내 쓰기 위치가 공유 상태다 |
Node.js에서 await 사이에 다른 요청이 같은 DB 행을 읽음 | 예 | 싱글 스레드여도 논리 흐름이 섞인다 |
동시성 문제를 만나면 바로 lock부터 떠올리기 전에 세 방향을 순서대로 본다.
UPDATE stock = stock - 1 WHERE stock > 0처럼 read-check-write를 DB 한 문장에 넣는다.INCR, Lua script도 여기에 가깝다.좋은 설계는 lock을 많이 쓰는 설계가 아니다. 공유할 필요가 없는 상태를 먼저 줄이고, 남은 진짜 공유 상태만 보호한다.
Mutex(mutual exclusion lock) 는 임계 구역에 한 번에 하나의 실행 흐름만 들어가게 하는 잠금이다. 보통 mutex에는 소유권(ownership) 이 있다. lock을 획득한 실행 흐름이 unlock해야 한다.
import threading
count = 0lock = threading.Lock()
def safe_add_one(): global count with lock: current = count count = current + 1with lock: 안쪽이 임계 구역이다. A가 lock을 잡으면 B는 A가 lock을 놓을 때까지 기다린다.
Mutex가 해결하는 문제는 “공유 상태를 동시에 수정하지 못하게 하는 것”이다. Mutex가 해결하지 않는 문제도 분명하다.
현대 mutex는 대략 두 경로를 가진다.
무경합 fast path:1. 사용자 공간에서 atomic CAS로 "잠김" 표시를 시도한다.2. 성공하면 시스템 콜 없이 바로 임계 구역에 들어간다.
경합 slow path:1. 이미 잠겨 있으면 커널에 futex wait를 요청한다.2. 커널은 스레드를 sleep 상태로 둔다.3. unlock 시점에 futex wake로 잠든 스레드를 깨운다.이 설계는 “대부분의 lock은 실제로 경합하지 않는다”는 관찰에 맞춰져 있다. 경합이 없으면 빠르고, 경합이 있으면 CPU를 계속 태우지 않고 sleep한다.
Spinlock 은 lock이 풀릴 때까지 반복 확인하는 방식이다.
while locked: 계속 확인대기 시간이 매우 짧고, sleep/wakeup 비용이 더 클 때는 spinlock이 유리할 수 있다. 커널 내부에서 인터럽트 컨텍스트나 매우 짧은 critical section에 쓰이는 이유가 여기에 있다.
하지만 일반 서버 코드에서는 조심해야 한다.
| 조건 | spinlock 판단 |
|---|---|
| critical section이 수십 ns~수백 ns 수준 | 가능성이 있다 |
| lock 보유 중 I/O, DB, network await가 있음 | 피해야 한다 |
| 대기 시간이 예측 불가 | 피해야 한다 |
| 단일 코어에서 lock 보유자가 선점될 수 있음 | 매우 위험하다 |
반례: “busy-wait는 context switch가 없어서 항상 빠르다”는 말은 틀리다. lock 보유자가 I/O를 기다리거나 scheduler에 의해 밀려나면 대기자는 CPU를 쓰면서 아무 일도 못 한다. 이때는 sleep 기반 mutex가 훨씬 낫다.
Semaphore 는 동시에 들어갈 수 있는 수를 카운터로 관리한다. 카운터가 3이면 최대 3개 작업이 들어갈 수 있고, 네 번째는 누군가 나올 때까지 기다린다.
wait/acquire: counter > 0 이면 counter를 1 줄이고 진입 counter == 0 이면 대기
signal/release: counter를 1 늘리고 대기자를 깨움Mutex와 비슷해 보이지만 목적이 다르다.
| 구분 | Mutex | Semaphore |
|---|---|---|
| 핵심 질문 | ”이 상태를 한 명만 수정해야 하는가?" | "이 자원을 N개까지만 써야 하는가?” |
| 동시 접근 수 | 1 | N |
| 소유권 | 보통 있음 | 보통 없음 |
| 대표 사례 | 쿠폰 사용 임계 구역, 파일 쓰기 | DB 커넥션 풀, worker pool, rate limit |
DB 서버가 동시에 안정적으로 처리할 수 있는 연결 수가 100이라고 하자. 애플리케이션 인스턴스 4개가 각각 pool size 50을 잡으면 총 200개 연결을 만들 수 있다. DB는 연결 자체를 처리하느라 CPU와 메모리를 쓰고, 실제 쿼리 처리량은 오히려 줄 수 있다.
Semaphore 관점으로 다시 쓰면 다음과 같다.
DB가 감당할 수 있는 전체 연결 예산: 100앱 인스턴스 수: 4인스턴스당 기본 pool 상한: 100 / 4 = 25여유와 admin connection을 남기면: 20 전후부터 측정풀 크기는 “일단 크게”가 아니다. queueing 지연과 DB 부하를 같이 봐야 한다.
Little’s Law는 대기열 시스템의 기본 관계다.
L = lambda * W
L = 동시에 시스템 안에 있는 작업 수lambda = 초당 도착 작업 수W = 작업 하나가 시스템에 머무는 평균 시간API가 1,000 req/s를 받고, DB를 쓰는 요청의 평균 DB 점유 시간이 50ms라면:
lambda = 1000 / sW = 0.05 sL = 50평균적으로 DB connection이 50개 필요하다는 뜻이다. p99와 burst를 생각하면 안전 계수 1.5~2를 붙일 수 있지만, 이 값이 DB 전체 예산을 넘으면 앱 풀 크기만 늘리는 것이 답이 아니다. 쿼리 시간을 줄이거나, 캐시를 넣거나, 쓰기 경로를 큐로 분리해야 한다.
Mutex는 “한 번에 하나”를 보장한다. 하지만 실제 문제에는 “지금 들어갈 수는 있는데 처리 조건이 아직 아니다”가 많다.
예를 들어 bounded queue가 있다.
이때 쓰는 도구가 condition variable이다.
consumer:1. mutex를 잡는다.2. queue가 비어 있으면 condition variable에서 wait한다.3. wait는 mutex를 놓고 sleep한다.4. producer가 signal하면 깨어난다.5. 다시 mutex를 잡고 queue 상태를 재확인한다.중요한 규칙은 “깨어났다고 조건이 참이라고 믿지 않는다”다. 항상 while로 조건을 다시 확인한다.
with condition: while queue_is_empty(): condition.wait() item = queue.pop()왜 if가 아니라 while인가?
Monitor 는 공유 상태, mutex, condition variable을 하나의 언어/객체 구조로 묶은 모델이다. Java의 synchronized 블록과 wait/notify, C#의 lock/Monitor가 대표적이다.
Monitor의 장점은 “상태를 보호하는 lock이 무엇인지”를 구조적으로 묶어 실수를 줄인다는 점이다. 단점은 내부에서 어떤 조건에 기다리고 깨우는지 잘못 설계하면 deadlock이나 missed signal이 여전히 생긴다는 점이다.
Deadlock 은 둘 이상의 실행 흐름이 서로가 가진 자원을 기다리며 아무도 진행하지 못하는 상태다.
작은 예시는 lock 두 개로 충분하다.
Thread A: lock account:1 획득 -> lock account:2 대기Thread B: lock account:2 획득 -> lock account:1 대기둘 다 자신이 가진 lock을 놓지 않고 상대 lock을 기다린다.
Deadlock은 네 조건이 모두 성립할 때 가능하다.
| 조건 | 의미 | 깨는 방법 |
|---|---|---|
| Mutual exclusion | 자원은 한 번에 하나만 쓴다 | 공유 가능 자원으로 바꾸거나 immutable로 만든다 |
| Hold and wait | 자원을 가진 채 다른 자원을 기다린다 | 필요한 자원을 한 번에 잡거나, 실패 시 모두 놓는다 |
| No preemption | 자원을 강제로 빼앗지 못한다 | timeout, abort, rollback을 둔다 |
| Circular wait | A -> B -> C -> A 대기 고리가 있다 | 자원 획득 순서를 전역으로 통일한다 |
실무에서 가장 자주 쓰는 예방책은 락 획득 순서 통일이다. 계좌 이체라면 항상 작은 account id부터 lock을 잡는다.
transfer(2 -> 1): lock account:1 lock account:2 이체
transfer(1 -> 2): lock account:1 lock account:2 이체두 요청의 방향이 달라도 lock 순서는 같다. 순환 대기가 생기지 않는다.
초기: lock A, lock B 모두 비어 있음
T1: Thread 1이 lock A 획득
T2: Thread 2가 lock B 획득
T3: Thread 1이 lock B를 기다림
T4: Thread 2가 lock A를 기다림
결과: Thread 1은 B가 필요하고, Thread 2는 A가 필요하다. 둘 다 이미 가진 lock을 놓지 않으므로 영원히 진행하지 못한다.Python으로 쓰면 다음처럼 보인다.
import threadingimport time
lock_a = threading.Lock()lock_b = threading.Lock()
def t1(): with lock_a: time.sleep(0.1) with lock_b: pass
def t2(): with lock_b: time.sleep(0.1) with lock_a: pass이 코드는 항상 deadlock이 나는 것은 아니지만, time.sleep(0.1)이 순서를 꼬이게 만들어 deadlock 가능성을 높인다. 운영에서는 sleep 없이도 I/O 지연, GC, scheduler preemption이 같은 역할을 한다.
Starvation 은 어떤 작업이 계속 밀려 실행 기회를 얻지 못하는 상태다. 시스템 전체는 움직인다. 다만 특정 작업만 영원히 뒤로 밀린다.
예:
Livelock 은 실행 흐름들이 계속 움직이지만 실질적 진전이 없는 상태다.
예:
두 작업이 충돌을 피하려고 동시에 양보한다.둘 다 "상대가 먼저 하세요"라고 상태를 바꾼다.다음 순간 둘 다 다시 시도한다.다시 동시에 양보한다.Deadlock은 “멈춤”, livelock은 “움직이지만 완료되지 않음”, starvation은 “누군가만 계속 굶음”으로 구분한다.
멀티코어 CPU는 각 코어마다 캐시를 가진다. 한 코어가 변수에 쓴 값이 다른 코어에 즉시 보인다고 단정할 수 없다. 컴파일러와 CPU는 성능을 위해 명령 순서를 바꿀 수도 있다.
그래서 동기화는 두 가지를 함께 다룬다.
Memory barrier 는 CPU와 컴파일러에게 특정 경계 전후의 메모리 연산 순서를 지키라고 요구하는 장치다.
| 종류 | 학습용 의미 |
|---|---|
| Store barrier | 이 이전의 쓰기가 뒤의 쓰기보다 늦게 보이지 않게 한다 |
| Load barrier | 이 이후의 읽기가 너무 앞당겨지지 않게 한다 |
| Full barrier | 읽기와 쓰기 양쪽 순서를 강하게 제한한다 |
Java volatile, Go sync/atomic, C++ std::atomic, JavaScript Atomics 같은 API는 내부적으로 이런 memory ordering 규칙을 제공한다.
Node.js 일반 JavaScript 코드에서는 직접 memory barrier를 다룰 일이 거의 없다. 하지만 worker_threads와 SharedArrayBuffer를 쓰면 이야기가 달라진다.
const shared = new SharedArrayBuffer(4);const view = new Int32Array(shared);
Atomics.store(view, 0, 1);const value = Atomics.load(view, 0);Atomics.store/load는 단순 배열 접근이 아니라 스레드 간 가시성과 순서를 고려한 접근이다.
CPU 캐시는 보통 64바이트 단위의 cache line으로 움직인다. 서로 다른 두 변수가 같은 cache line에 있으면, 두 스레드가 서로 다른 변수를 수정해도 같은 cache line 소유권을 주고받느라 느려질 수 있다. 이것이 false sharing이다.
cache line 64B:[ counterA ][ counterB ][ padding ... ]
Thread A는 counterA만 증가Thread B는 counterB만 증가
논리적으로 공유 상태가 아닌데,물리적으로 같은 cache line이라 서로의 캐시를 무효화한다.처음 읽을 때는 false sharing을 튜닝 주제로 외울 필요는 없다. 다만 “서로 다른 변수도 메모리 배치 때문에 경쟁할 수 있다”는 감각은 lock-free 구조를 읽을 때 도움이 된다.
Node.js의 일반 JavaScript 실행은 한 순간에 한 콜백만 실행한다. 그래서 같은 call stack 안에서는 두 JavaScript 문장이 물리적으로 동시에 실행되지 않는다.
하지만 await는 제어권을 이벤트 루프에 돌려준다. 그 사이 다른 요청이 같은 DB 행, Redis key, 파일, 인메모리 객체를 읽고 쓸 수 있다.
sequenceDiagram participant ReqA as 요청 A participant Loop as Event Loop participant DB participant ReqB as 요청 B ReqA->>DB: findCoupon(id) DB-->>ReqA: isUsed=false ReqA->>Loop: await로 제어권 반납 ReqB->>DB: findCoupon(id) DB-->>ReqB: isUsed=false ReqA->>DB: update(isUsed=true) ReqB->>DB: update(isUsed=true)
싱글 스레드라도 await 사이에 다른 요청이 같은 상태를 읽으면 lost update가 발생한다.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let count = 0;
async function increment() { const current = count; await sleep(10); count = current + 1;}
await Promise.all(Array.from({ length: 10 }, increment));console.log(count); // 기대는 10, 실제는 1에 가까울 수 있다모든 호출이 await sleep(10) 전에 count = 0을 읽으면, 나중에 모두 count = 1을 쓴다. 이 예시는 OS 스레드 race가 아니라 비동기 interleaving race다.
단일 Node.js 프로세스 안의 인메모리 상태나 같은 프로세스 안에서만 보호하면 되는 코드에는 async-mutex 같은 라이브러리가 도움이 될 수 있다.
import { Mutex } from "async-mutex";
const mutex = new Mutex();let count = 0;
async function safeIncrement() { await mutex.runExclusive(async () => { const current = count; await Promise.resolve(); count = current + 1; });}하지만 범위를 정확히 알아야 한다.
| 환경 | async-mutex 보호 범위 |
|---|---|
| 단일 Node.js 프로세스 | 유효 |
worker_threads가 같은 JS 객체를 공유하지 않는 경우 | 대부분 별도 보호 필요 없음 |
| PM2 cluster 여러 worker | worker마다 mutex가 따로 있어 전체 보호 안 됨 |
| ECS task 여러 개 | task마다 mutex가 따로 있어 전체 보호 안 됨 |
| DB 행/Redis key 같은 외부 상태 | DB/Redis 쪽 원자성도 같이 필요 |
반례: “Node는 싱글 스레드니까 lock이 필요 없다”도 틀리고, “Node에서도 mutex를 쓰면 모든 배포 환경에서 안전하다”도 틀리다. race가 생기는 상태의 범위가 프로세스 안인지, 서버 안인지, 클러스터 전체인지 먼저 봐야 한다.
worker_threads는 같은 프로세스 안의 여러 스레드다. 기본 메시지 전달은 structured clone이나 Transferable을 쓰기 때문에 모든 데이터가 자동 공유되는 것은 아니다. 진짜 공유 메모리는 SharedArrayBuffer를 써야 한다.
const shared = new SharedArrayBuffer(4);const counter = new Int32Array(shared);
Atomics.add(counter, 0, 1);이때는 OS 스레드 동기화 문제에 가까워진다. Atomics 없이 단순 counter[0]++를 쓰면 lost update가 생길 수 있다.
OS lock은 같은 프로세스나 같은 머신 안의 실행 흐름을 주로 다룬다. DB transaction은 여러 쿼리를 하나의 논리 작업으로 묶고, ACID(Atomicity, Consistency, Isolation, Durability)의 isolation을 제공한다.
| 개념 | 보호하는 것 | 대표 도구 |
|---|---|---|
| Mutex | 프로세스 안의 공유 메모리나 코드 구간 | pthread_mutex, async-mutex |
| DB row lock | DB 행의 동시 변경 | SELECT ... FOR UPDATE |
| Transaction | 여러 DB 변경의 원자성/격리성 | BEGIN, COMMIT, ROLLBACK |
| Optimistic lock | 충돌이 낮은 상태의 version 검증 | version 컬럼, compare-and-update |
| Distributed lock | 여러 서버 인스턴스 사이의 실행 제한 | Redis SET NX PX, etcd lease |
재고를 읽고 애플리케이션에서 검사한 뒤 업데이트하면 race가 생긴다.
// 위험한 형태const item = await repo.findOne({ where: { id } });if (item.stock < 1) throw new Error("no stock");await repo.update(id, { stock: item.stock - 1 });DB가 같은 행을 원자적으로 갱신하게 만들면 race 범위가 줄어든다.
UPDATE itemsSET stock = stock - 1WHERE id = $1 AND stock > 0;영향 받은 row 수가 1이면 성공, 0이면 재고 없음이다. 이 방식은 애플리케이션 mutex보다 범위가 명확하다. 여러 앱 인스턴스가 있어도 같은 DB가 원자성을 제공한다.
SELECT FOR UPDATE는 언제 쓰나SELECT FOR UPDATE는 조회한 행을 현재 transaction이 끝날 때까지 다른 transaction이 수정하지 못하게 잠근다.
BEGIN;
SELECT id, stockFROM itemsWHERE id = 42FOR UPDATE;
UPDATE itemsSET stock = stock - 1WHERE id = 42;
COMMIT;사용 기준은 다음과 같다.
| 상황 | 권장 |
|---|---|
| 단일 행을 조건부로 감소 | 가능하면 원자 UPDATE ... WHERE 먼저 고려 |
| 여러 행을 읽고 도메인 규칙을 검사한 뒤 갱신 | SELECT FOR UPDATE가 유용 |
| 충돌 빈도가 낮고 재시도가 쉬움 | optimistic lock도 후보 |
| 충돌 빈도가 높고 재시도 비용이 큼 | pessimistic lock이 유리 |
| transaction 안에서 외부 API 호출 | 피한다. lock 보유 시간이 길어진다 |
PostgreSQL은 lock 대기 그래프를 보고 순환을 찾는다. deadlock_timeout 동안 기다려도 lock을 못 얻으면 deadlock 탐지를 수행하고, cycle이 있으면 한 transaction을 abort한다.
에러는 보통 다음 형태다.
ERROR: deadlock detectedDETAIL: Process 12345 waits for ShareLock on transaction 67890; blocked by process 67890. Process 67890 waits for ShareLock on transaction 12345; blocked by process 12345.여기서 중요한 것은 deadlock_timeout이 deadlock을 방지하는 설정이 아니라 탐지 시점이라는 점이다. 방지는 lock 순서 통일, transaction 범위 축소, idempotent retry 설계가 담당한다.
로컬 mutex는 같은 프로세스 안에서만 보인다. PM2 cluster worker가 4개면 mutex도 4개다. ECS task가 10개면 각 task가 자기 메모리에 mutex를 가진다.
클러스터 전체에서 하나만 실행해야 하는 작업은 외부 상태가 필요하다.
분산 lock은 보통 “외부 저장소에 특정 key를 내가 선점했다는 기록을 원자적으로 남기는 것”이다.
SET NX PX의 최소 모델SET lock:job token NX PX 30000뜻은 다음과 같다.
| 옵션 | 의미 |
|---|---|
NX | key가 없을 때만 set한다 |
PX 30000 | 30초 뒤 자동 만료한다 |
token | 내가 잡은 lock인지 확인하기 위한 고유 값이다 |
해제할 때는 “내 token이면 삭제”를 원자적으로 해야 한다. 그냥 DEL lock:job을 하면 다른 인스턴스가 새로 잡은 lock을 지울 수 있다.
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1])else return 0endTTL이 없으면 lock 소유자가 죽었을 때 lock이 영원히 남는다. TTL이 있으면 소유자가 죽어도 언젠가 풀린다. 하지만 TTL이 작업 시간보다 짧으면 작업 중에 lock이 풀리고, 다른 인스턴스가 같은 작업을 시작할 수 있다.
T0 A가 lock 획득, TTL 30sT5 A가 긴 작업 시작T30 lock 만료T31 B가 lock 획득T35 A가 아직 작업 중결과: A와 B가 같은 자원을 동시에 처리분산 lock을 쓸 때는 다음 질문이 필요하다.
| 질문 | 이유 |
|---|---|
| 최악 작업 시간이 TTL보다 짧은가 | 아니면 lock 만료 중복 실행이 생긴다 |
| heartbeat로 TTL을 연장할 수 있는가 | 긴 작업에서는 필요할 수 있다 |
| lock 해제에 token 검증이 있는가 | 다른 소유자의 lock 삭제를 막는다 |
| lock 실패 시 요청은 실패/대기/큐잉 중 무엇인가 | 사용자 경험과 중복 처리 정책이 달라진다 |
| lock 외에 idempotency key가 있는가 | silent duplicate을 사후 탐지/차단한다 |
단일 Redis master에 lock을 쓰면 failover 시점에 silent failure가 생길 수 있다.
1. A가 master에 SET NX 성공2. master가 replica에 복제하기 전에 죽음3. replica가 새 master로 승격4. 새 master에는 lock key가 없음5. B도 SET NX 성공6. A와 B가 모두 lock을 가졌다고 믿음이 상황은 애플리케이션 입장에서는 예외 없이 지나갈 수 있다. 그래서 금융/결제처럼 단 한 번의 중복도 허용할 수 없으면 Redis lock만으로는 부족하다. DB unique constraint, transaction, idempotency key, 또는 합의 기반 lock(etcd/Consul/Raft 계열)을 검토해야 한다.
같은 coupon id별로 하나씩 처리하면 lock 대신 queue partitioning으로 race를 줄일 수 있다.
coupon:42 요청은 항상 partition 7로 라우팅partition 7은 consumer 하나가 순서대로 처리이 모델은 TTL 만료 문제를 줄인다. 대신 queue lag, consumer 장애, 재처리 idempotency가 새로 중요해진다. 동시성 문제는 사라지는 것이 아니라 다른 형태의 순서 보장 문제로 이동한다.
숫자는 환경마다 달라진다. 그래도 규모감은 필요하다.
| 메커니즘 | 대략적 비용/처리량 감각 | 잘 맞는 곳 | 조심할 점 |
|---|---|---|---|
| Atomic CAS | 수십 ns | 단일 counter, flag | 충돌 많으면 retry 폭증 |
| Uncontended mutex | 수십 ns | 짧은 임계 구역 | lock 범위가 커지면 바로 느려짐 |
| Contended mutex | 수십~수백 us 이상 가능 | sleep이 나은 긴 대기 | context switch와 wakeup 지연 |
| Spinlock | 대기 중 CPU 계속 사용 | 아주 짧은 커널 내부 대기 | I/O나 긴 작업과 함께 쓰면 위험 |
| DB row lock | 네트워크+DB scheduler 비용 포함 | 데이터 정합성이 DB에 있을 때 | transaction 범위가 길면 대기 확산 |
| Redis lock | 네트워크 RTT + Redis 처리 | 짧은 클러스터 임계 구역 | TTL, failover, token 검증 필요 |
| MQ serialization | queue latency + consumer 처리 | 순서가 key별로 중요할 때 | lag와 재처리 idempotency 필요 |
이 숫자는 벤치마크 결과가 아니라 순서 감각이다. p99 latency는 평균보다 훨씬 먼저 나빠질 수 있으므로, “평균 lock 비용이 작다”보다 “경합이 생겼을 때 꼬리가 얼마나 늘어나는가”를 본다.
lock 비용은 lock 자체보다 경합률(contention rate) 에 크게 좌우된다.
요청 10,000개 중 같은 lock을 기다린 요청0개 -> 경합률 0%, mutex overhead만 있음100개 -> 경합률 1%, 대부분 빠름5,000개 -> 경합률 50%, lock이 처리량의 중심 병목10,000개 -> 경합률 100%, 사실상 단일 큐 처리같은 mutex라도 임계 구역이 1us면 괜찮을 수 있고, 50ms DB 호출을 품고 있으면 재앙이 된다.
경합 요청 수 = 100critical section = 1ms최악 대기열 시간 = 100ms 근처
critical section = 50ms최악 대기열 시간 = 5s 근처그래서 lock 안에서는 다음을 피한다.
RWLock(read-write lock)은 reader는 여러 명 허용하고 writer는 하나만 허용한다.
| 읽기 비율 | RWLock 판단 |
|---|---|
| 50% | mutex보다 복잡도만 늘 수 있다 |
| 90% 이상 | reader 동시성이 이득일 수 있다 |
| writer가 latency 민감 | reader 우선 정책이면 starvation 위험 |
| critical section이 매우 짧음 | RWLock 관리 비용이 이득을 먹을 수 있다 |
반례: “읽기가 많으니 RWLock”은 항상 맞지 않다. read section이 짧고 write가 조금만 있어도 lock 관리 비용과 writer starvation이 mutex보다 나쁠 수 있다.
동시성 전략의 첫 질문은 “상태가 어디까지 공유되는가”다.
| 상태 공유 범위 | 예시 | 후보 |
|---|---|---|
| 함수 호출 안 | 지역 변수 | 별도 동기화 불필요 |
| 단일 프로세스 | 인메모리 Map, worker thread shared buffer | mutex, atomic, monitor |
| 같은 서버의 여러 프로세스 | PM2 cluster worker | 외부 저장소, IPC, DB lock |
| 여러 서버/task | ECS service, K8s deployment | DB transaction, Redis/etcd lock, MQ, idempotency |
| 외부 시스템까지 포함 | 결제 승인, 배송 요청 | idempotency key, saga, outbox |
상태 범위를 잘못 잡으면 도구가 맞아도 실패한다. 로컬 mutex로 클러스터 race를 막을 수 없고, Redis lock으로 DB transaction 불변식을 완전히 대체할 수도 없다.
실패했을 때의 질문도 같이 둔다. “이 작업은 중간 실패 후 원자적으로 되돌릴 수 있는가?”, “보상 작업이 필요한가?”, “재시도해도 같은 idempotency key로 한 번만 반영되는가?”를 답하지 못하면 lock 선택만으로는 충분하지 않다.
| 조건 | 더 적합한 전략 |
|---|---|
| 충돌 낮음, 재시도 쉬움 | optimistic lock |
| 충돌 높음, 실패 비용 큼 | pessimistic lock, queue serialization |
| 작업 매우 짧음, 단일 프로세스 | mutex/atomic |
| 작업 길고 외부 I/O 포함 | lock보다 queue/idempotency 고려 |
| 클러스터 전체 단일 실행 | distributed lock + idempotency |
| 단 한 번의 중복도 치명적 | DB unique/transaction 또는 합의 기반 lock |
하나의 숫자나 flag처럼 작은 상태를 빠르게 바꿀 때 쓴다. 여러 필드 불변식에는 부족하다.
counter, once flag, ref count단일 프로세스 안의 임계 구역을 하나만 통과시킨다. lock 범위가 짧고 명확해야 한다.
인메모리 cache 갱신, worker thread 공유 상태동시 실행 개수를 제한한다. 상태 보호보다 자원 수 제한이 목적이다.
DB connection pool, worker pool, API concurrency limit정합성의 원본이 DB에 있을 때 가장 강한 기본 선택지다. lock 순서와 transaction 길이를 관리해야 한다.
재고, 계좌, 주문 상태여러 서버에서 하나만 실행하게 한다. TTL, token, failover, idempotency를 같이 설계해야 한다.
짧은 배치 단일 실행, 중복 cron 방지동시 수정을 순차 처리로 바꾼다. lock 대신 lag, 재시도, 중복 메시지 처리가 중요해진다.
key별 순서 처리, 긴 작업, 재시도 필요 작업운영에서 중요한 것은 명령어를 외우는 것보다 증상을 보고 어떤 동시성 개념이 깨졌는지 좁히는 것이다.
| 증상 | 의미 | 먼저 의심할 개념 |
|---|---|---|
| 기대보다 카운터가 작다 | lost update 가능성 | atomicity, critical section |
| 재고가 음수가 된다 | read-check-write가 분리됨 | DB transaction, row lock |
| API가 특정 endpoint에서 줄줄이 대기한다 | lock 안의 작업이 길거나 해제 누락 | mutex 범위, release 보장 |
PostgreSQL deadlock detected | 대기 그래프 cycle | lock 순서, transaction 범위 |
| DB pool acquire timeout | semaphore 자원 고갈 | pool size, slow query, connection leak |
| Redis lock이 있는데 배치가 두 번 돈다 | TTL 만료, failover, token 누락 | distributed lock 경계 |
| CPU 사용률이 높은데 처리량이 낮다 | spin/retry/context switch 과다 | contention, spinlock, CAS retry |
| reader는 빠른데 writer가 계속 밀린다 | writer starvation | RWLock fairness |
| 로그상 계속 재시도하지만 완료가 없다 | livelock 가능성 | backoff jitter, retry 조건 |
| deadlock 에러는 없는데 중복 데이터가 생긴다 | silent race | idempotency key, unique constraint |
lock은 잡는 것보다 놓는 것이 더 중요하다.
const release = await mutex.acquire();try { await riskyOperation();} finally { release();}finally가 없으면 예외가 난 순간 이후 요청이 모두 대기할 수 있다. 라이브러리가 runExclusive 같은 API를 제공하면 자동 해제를 우선 고려한다.
DB lock은 transaction이 끝날 때 풀린다. transaction 안에서 외부 API를 호출하면 DB 행을 잡은 채 네트워크를 기다리는 셈이다.
나쁜 흐름:BEGINSELECT ... FOR UPDATE외부 결제 API 호출 2초 대기UPDATECOMMIT
문제:2초 동안 같은 행을 원하는 모든 transaction이 대기한다.가능하면 외부 호출 전에 필요한 검증을 끝내고, DB lock을 잡는 구간은 짧게 만든다. 외부 시스템과 DB를 함께 묶어야 하면 outbox, saga, idempotency를 별도로 설계한다.
이 절은 긴 런북이 아니라 개념을 눈으로 확인하는 작은 실습이다.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let count = 0;
async function increment() { const current = count; await sleep(10); count = current + 1;}
Promise.all(Array.from({ length: 10 }, increment)).then(() => { console.log(count);});관찰 포인트:
await 전의 read와 await 후의 write 사이가 임계 구역이다.두 세션에서 같은 두 행을 반대 순서로 업데이트한다.
-- session ABEGIN;UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- session BBEGIN;UPDATE accounts SET balance = balance - 100 WHERE id = 2;UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- session AUPDATE accounts SET balance = balance - 100 WHERE id = 2;관찰 포인트:
deadlock_timeout이 아니라 lock 순서 통일이다.lock TTL = 30s작업 p99 = 10s작업 max = 90s질문:
최소 감지 예시는 아래처럼 잡을 수 있다.
CREATE UNIQUE INDEX uniq_job_key ON processed_jobs(job_key);consumer가 같은 job_key를 두 번 처리하려 하면 두 번째 insert가 실패한다. 이 신호를 보고 “lock이 새서 중복 실행이 발생했다”는 사실을 관측하거나, 이미 처리된 결과를 반환해 idempotent retry로 만들 수 있다.
동시성은 작업이 겹쳐 진행되는 구조다. 병렬성은 물리적으로 동시에 실행되는 구조다.
Node.js 단일 이벤트 루프는 병렬 JavaScript 실행은 아니지만 동시 요청을 interleaving한다. 그래서 async race가 생긴다. 멀티코어 worker thread는 병렬 실행이므로 shared memory race도 생긴다.
Lock-free 는 전체 시스템 차원에서 누군가는 진행한다는 뜻이다. 특정 스레드가 굶지 않는다는 보장은 아니다. Wait-free 는 각 작업이 제한된 단계 안에 끝난다는 더 강한 보장이다.
실무에서 lock-free라는 단어를 보면 “lock이 없으니 단순하다”가 아니라 “진행 보장은 무엇이고 memory reclamation은 어떻게 하는가”를 물어야 한다.
thread-safe는 여러 스레드가 동시에 호출해도 문서화된 불변식이 깨지지 않는다는 뜻이다. 내부에 lock이 있는지, atomic을 쓰는지, 아예 공유 상태가 없는지는 구현마다 다르므로 thread-safe라는 단어만 보고 성능이나 분산 안전성까지 추정하면 안 된다.
| 구분 | Optimistic lock | Pessimistic lock |
|---|---|---|
| 철학 | 일단 진행하고 충돌을 나중에 확인 | 먼저 잠그고 진행 |
| 좋은 조건 | 충돌 낮음, 재시도 쉬움 | 충돌 높음, 실패 비용 큼 |
| 구현 예 | version 컬럼 비교 | SELECT FOR UPDATE |
| 실패 방식 | update count 0, version mismatch | lock wait, timeout, deadlock |
낙관적 lock은 “lock이 없다”가 아니라 “충돌 검사를 commit 시점으로 미룬다”에 가깝다.
분산 lock은 동시에 실행되지 않게 하려는 장치다. idempotency는 두 번 실행되어도 결과가 한 번 실행과 같게 만드는 장치다.
둘은 대체재가 아니라 보완재다.
분산 lock만 있음: lock이 새면 중복 실행이 실제 중복 결과가 된다.
idempotency만 있음: 중복 결과는 막지만 비싼 작업이 여러 번 실행될 수 있다.
둘 다 있음: 대부분은 lock으로 줄이고, 새는 경우는 idempotency로 막거나 탐지한다.본문 이해에 필수인 코드는 앞에서 짧게 다뤘다. 아래는 실무에서 확인할 때 유용하지만, 첫 회독에서는 펼치지 않아도 된다.
SELECT FOR UPDATE를 쓰는 최소 형태await dataSource.transaction(async (manager) => { const item = await manager .createQueryBuilder(Item, "item") .where("item.id = :id", { id: itemId }) .setLock("pessimistic_write") .getOne();
if (!item || item.stock < 1) { throw new Error("no stock"); }
await manager.decrement(Item, { id: itemId }, "stock", 1);});관찰 포인트:
async function retryDeadlock<T>( fn: () => Promise<T>, maxRetries = 3,): Promise<T> { for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await fn(); } catch (error: any) { if (error.code === "40P01" && attempt < maxRetries - 1) { const delay = 100 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, delay)); continue; } throw error; } } throw new Error("unreachable");}관찰 포인트:
const token = crypto.randomUUID();
const acquired = await redis.set("lock:job", token, "NX", "PX", 30_000);
try { if (acquired !== "OK") return; await runJob();} finally { await redis.eval( ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end `, 1, "lock:job", token, );}관찰 포인트:
const pool = (dataSource.driver as any).pool;
console.log({ total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount,});관찰 포인트:
waitingCount가 지속적으로 높으면 semaphore 대기열이 쌓인 것이다.| 키워드 | 한 줄 설명 |
|---|---|
| Concurrency | 여러 작업이 겹쳐 진행되는 구조 |
| Parallelism | 여러 작업이 물리적으로 동시에 실행되는 구조 |
| Shared mutable state | 둘 이상이 읽고 쓸 수 있는 가변 상태 |
| Critical section | 공유 자원을 보호해야 하는 코드 구간 |
| Race condition | 실행 순서에 따라 결과가 달라지는 상태 |
| Lost update | 두 갱신 중 하나가 덮여 사라지는 현상 |
| Atomic operation | 중간 상태 없이 하나처럼 보장되는 연산 |
| CAS | 값이 기대값과 같을 때만 바꾸는 원자 연산 |
| Mutex | 소유권 있는 단일 진입 lock |
| Semaphore | N개까지 허용하는 카운터 기반 동기화 |
| Condition variable | 조건이 만족될 때까지 sleep/wakeup하는 동기화 도구 |
| Monitor | mutex와 condition variable을 묶은 구조 |
| Futex | Linux의 fast userspace mutex 메커니즘 |
| Spinlock | sleep하지 않고 반복 확인하는 lock |
| Deadlock | 서로의 자원을 기다리며 모두 멈춘 상태 |
| Starvation | 특정 작업이 계속 실행 기회를 얻지 못하는 상태 |
| Livelock | 계속 움직이지만 실질적 진전이 없는 상태 |
| Memory barrier | 메모리 연산 순서와 가시성을 강제하는 경계 |
| False sharing | 다른 변수라도 같은 cache line 때문에 경합하는 현상 |
| Pessimistic lock | 먼저 잠그고 작업하는 전략 |
| Optimistic lock | 작업 후 version 등으로 충돌을 확인하는 전략 |
| Distributed lock | 여러 서버 인스턴스 사이의 단일 실행을 위한 외부 lock |
| Idempotency key | 중복 실행을 한 번 실행과 같은 결과로 만들거나 감지하는 키 |
FOR UPDATE, deadlock detection, lock conflict를 DB 원본 문서 기준으로 확인할 때 본다.SET NX PX, token 기반 해제, Redlock 논쟁의 공식 출발점으로 본다.동시성 제어의 핵심은 공유 가변 상태의 읽기와 쓰기 순서를 명시적으로 설계하는 것이다. 단일 프로세스에서는 atomic, mutex, semaphore, condition variable을 쓰고, DB나 분산 환경에서는 transaction, row lock, distributed lock, queue, idempotency key로 같은 문제를 더 넓은 범위에서 다시 푼다.