공유 자원을 다루는 동시성 제어의 핵심
동시성 문제는 여러 실행 흐름이 같은 자원을 건드릴 때 결과가 순서에 따라 흔들리는 문제다. Mutex, Semaphore, Deadlock, Memory Barrier를 중심으로 실무에서 어떤 실패가 생기고 어떻게 경계를 세우는지 정리한다.
Script Companion
오디오와 함께 스크립트 보기
- 01
현대 서버는 한 번에 하나의 요청만 처리하지 않는다. Node.js의 이벤트 루프, DB 커넥션 풀, Redis 캐시는 모두 여러 요청이 겹치는 상황을 전제로 움직인다. 이때 동시성 제어를 잘못 이해하면 재고 감소 갱신이 사라지거나, 카운터가 예상보다 작게 집계되거나, 트랜잭션이 서로의 락을 기다리며 멈출 수 있다. 핵심은 공유 자원을 동시에 건드릴 때 순서와 규칙을 정하는 것이다. Thread Safety, Critical Section, Race Condition 같은 단어는 바로 이 지점을 설명하기 위한 이름들이다.
- 02
Thread Safety, 즉 스레드 안전성은 여러 스레드가 같은 코드나 같은 데이터를 동시에 다뤄도 결과가 항상 올바른 성질이다. 반대로 스레드 안전하지 않은 코드는 실행 순서에 따라 결과가 달라진다. 근본 원인은 공유 가변 상태, 즉 여러 실행 흐름이 동시에 수정할 수 있는 데이터다. 특히 count += 1 같은 문장도 CPU 입장에서는 값을 읽고, 더하고, 다시 쓰는 단계로 쪼개진다. OS 스케줄러는 그 사이 어디에서든 다른 스레드로 전환할 수 있고, 그래서 두 실행 흐름이 같은 값을 읽고 각각 저장하면 증가가 한 번 사라질 수 있다.
- 03
Critical Section은 공유 자원에 접근하는 코드 구간이다. 이 구간에는 동시에 하나의 스레드만 들어와야 하며, 아무도 없다면 진입할 수 있어야 하고, 기다리는 쪽이 무한정 밀려나지 않아야 한다. Race Condition은 이 임계 구역이 제대로 보호되지 않을 때 나타난다. 은행 ATM에서 잔액 확인과 차감 사이의 순서가 꼬이면 잔액보다 더 많이 인출될 수 있는 것처럼, 실행 타이밍에 따라 결과가 바뀌는 상태가 경쟁 조건이다.
- 04
Node.js는 싱글 스레드이기 때문에 일반 JavaScript 코드에서는 Race Condition이 없다고 생각하기 쉽다. 하지만 await 지점에서 이벤트 루프가 다른 요청을 처리할 수 있으므로 비동기 Race Condition이 생길 수 있다. 예를 들어 두 요청이 동시에 쿠폰 사용 로직에 들어오면, 첫 요청이 상태를 확인한 뒤 기다리는 동안 두 번째 요청도 같은 상태를 보고 진행할 수 있다. async-mutex는 단일 Node.js 인스턴스 안에서는 이런 구간을 한 번에 하나만 실행하게 도와준다. 다만 PM2 cluster 모드나 ECS 다중 태스크처럼 인스턴스가 여러 개라면 Redis 분산 락이 필요하다.
- 05
Mutex는 한 번에 하나만 들어가게 하는 소유권 있는 잠금이다. 잠근 스레드만 해제할 수 있고, 다른 스레드는 잠금이 풀릴 때까지 기다린다. 대기 방식도 중요하다. 짧은 대기에는 계속 확인하는 spinlock이 유리할 수 있지만, 길어지면 CPU를 낭비한다. 현대 OS의 Mutex는 경합이 없을 때 사용자 공간 원자 연산으로 빠르게 얻고, 경합이 있을 때만 커널에 sleep을 요청한다. 반면 Semaphore는 카운터를 둔다. 0보다 크면 들어가고 카운터를 줄이며, 나갈 때 다시 늘린다. 그래서 DB 커넥션 풀이나 API Rate Limit처럼 최대 N개만 허용하는 상황에 어울린다.
- 06
Deadlock은 두 개 이상의 프로세스나 스레드가 서로 상대방이 가진 자원을 기다리며 진행하지 못하는 상태다. 발생에는 Coffman 조건 네 가지가 모두 필요하다. 한 번에 하나만 자원을 쓰는 상호 배제, 자원을 가진 채 다른 자원을 기다리는 점유와 대기, 강제로 빼앗을 수 없는 비선점, 그리고 A가 B를 기다리고 B가 다시 A를 기다리는 순환 대기다. 하나만 깨도 Deadlock은 풀릴 수 있다. PostgreSQL의 deadlock_timeout 기본 1초는 방지 설정이 아니라 감지까지 기다리는 시간이다. 근본적인 대응은 모든 트랜잭션이 자원을 같은 순서로 획득하게 만드는 것이다.
- 07
PostgreSQL은 deadlock_timeout 동안 락을 얻지 못하면 Deadlock 감지 알고리즘을 실행한다. 내부적으로 대기 그래프, Wait-for Graph를 만들고 순환이 있는지 찾는다. 순환이 발견되면 하나의 트랜잭션을 강제로 롤백하고 40P01 에러를 반환한다. 어떤 트랜잭션을 희생할지는 지금까지 수행한 작업량, 즉 롤백 비용을 기준으로 정한다. TypeORM의 save() 메서드가 내부적으로 만드는 UPDATE 쿼리 순서가 불확정적이면 이런 문제가 생길 수 있다. 그래서 여러 행을 잠글 때는 ID 오름차순처럼 락 획득 순서를 통일하는 습관이 중요하다.
- 08
Memory Barrier는 조금 더 낮은 층위의 동시성 문제를 다룬다. 현대 CPU와 컴파일러는 성능을 위해 명령어 순서를 재배치할 수 있고, 각 CPU 코어의 캐시가 메인 메모리와 늦게 동기화될 수도 있다. 그래서 한 스레드가 변수를 바꿨는데 다른 스레드는 아직 이전 값을 보는 가시성 문제가 생긴다. Memory Barrier는 재배치를 막고 캐시 반영을 강제하는 경계다. Store Barrier는 쓰기를 보이게 하고, Load Barrier는 읽기 전 쓰기 완료를 보장하며, Full Barrier는 양방향을 보장한다. Node.js 일반 코드에서는 거의 직접 다루지 않지만, Worker Threads나 SharedArrayBuffer에서는 Atomics API를 통해 이 개념이 드러난다.
- 09
실무에서는 이 개념들이 이름만 바뀌어 계속 등장한다. DB 커넥션 풀은 Semaphore 패턴이다. DB 서버가 최대 100개의 동시 연결을 허용한다면 애플리케이션도 풀 크기로 동시 접근 수를 제한해야 한다. Redis 분산 락은 여러 서버 인스턴스 사이의 Mutex처럼 쓰인다. 정산 배치가 중복 실행되면 Redis 분산 락으로 단일 실행을 보장하고, 동시 API 요청으로 재고가 음수가 되면 트랜잭션과 SELECT FOR UPDATE로 임계 구역을 보호한다. AWS Lambda 동시 실행 제한은 Reserved Concurrency 설정으로 Semaphore 성격의 제한을 건다.
- 10
트러블슈팅에서 자주 보는 첫 번째 문제는 PostgreSQL Deadlock detected다. 원인은 두 개 이상의 트랜잭션이 같은 행을 서로 다른 순서로 잠그며 순환 대기를 만드는 것이다. 해결 실마리는 락 획득 순서 통일, 트랜잭션 범위 축소, 그리고 TypeORM에서 40P01을 감지한 재시도다. 두 번째는 Redis 분산 락 만료 후 이중 실행이다. 락 TTL이 실제 작업 시간보다 짧거나, 해제할 때 내 락인지 확인하지 않으면 다른 인스턴스가 들어올 수 있다. 고유 토큰, 넉넉한 TTL, Heartbeat, idempotency key 테이블이 함께 필요하다. 세 번째는 DB 커넥션 풀 고갈이다. release() 누락, 느린 쿼리, 트래픽 급증을 확인하고 사용 중인 커넥션 수와 pg_stat_activity를 봐야 한다.
- 11
정리하면 동시성 제어는 공유 자원을 건드리는 여러 실행 흐름 사이에 규칙을 세우는 일이다. Mutex는 하나씩 통과시키고, Semaphore는 동시에 들어갈 수 있는 개수를 제한한다. Deadlock은 락의 존재만이 아니라 락을 얻는 순서에서 생기며, 순서를 통일하거나 타임아웃과 감지, 롤백을 조합해 다룬다. Memory Barrier는 더 아래에서 실행 순서 재배치와 가시성 문제를 제어한다. 이 네 가지를 구분하면 Node.js 비동기 코드, Redis 분산 락, PostgreSQL 트랜잭션, DB 커넥션 풀에서 같은 원리를 다시 볼 수 있다.
같은 레이어