Concurrency & Sync
동시성 제어와 동기화 (Concurrency & Synchronization)
섹션 제목: “동시성 제어와 동기화 (Concurrency & Synchronization)”1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”여러 스레드/프로세스가 공유 자원에 동시에 접근할 때 발생하는 충돌을 방지하고, 올바른 실행 순서를 보장하는 기법의 총칭이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”현대 서버는 단일 요청만 처리하지 않는다. Node.js의 이벤트 루프, DB 커넥션 풀, Redis 캐시 — 이 모든 것이 동시에 여러 요청을 처리한다. 동시성 제어를 제대로 이해하지 못하면:
- 데이터 손실: 두 요청이 동시에
재고 수량 감소를 처리하다가 한 쪽 갱신이 사라진다 - 잘못된 집계: 카운터 값이 예상보다 작게 집계된다
- 시스템 멈춤: 두 트랜잭션이 서로 상대방의 락 해제를 기다리며 영원히 대기한다 (Deadlock)
- 일관성 오류: 한 스레드가 쓰는 도중에 다른 스레드가 읽어 절반만 변경된 값을 본다
이 개념은 OS 과목에서 처음 등장하지만, 실무에서는 PostgreSQL Deadlock, Redis 분산 락, DB 커넥션 풀 설정 등으로 직접 마주친다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”3-1. Thread Safety (스레드 안전성)
섹션 제목: “3-1. Thread Safety (스레드 안전성)”비유: 공중화장실에 칸이 하나밖에 없다. 여러 사람이 동시에 들어가려 하면 충돌이 발생한다. 각자 기다렸다가 순서대로 쓰도록 규칙이 있어야 안전하다.
원리: “스레드 안전(Thread-safe)하다”는 것은 여러 스레드가 동시에 같은 코드를 실행하거나 같은 데이터를 읽고 써도 결과가 항상 올바르다는 의미다. 반대로 스레드 안전하지 않은 코드는 실행 순서에 따라 결과가 달라진다. 스레드 안전성 문제의 근본 원인은 공유 가변 상태(shared mutable state) — 여러 스레드가 동시에 수정할 수 있는 데이터의 존재다.
왜 이렇게 동작하는가? CPU는 고급 언어의 count += 1 같은 단일 문장을 하나의 원자적 연산으로 실행하지 않는다. 실제로는 ① 메모리에서 값을 레지스터로 로드(LOAD) → ② 레지스터에서 연산(ADD) → ③ 결과를 메모리에 저장(STORE) 이라는 3단계 기계어 명령으로 쪼개진다. OS의 스케줄러는 이 3단계 사이 어디에서든 다른 스레드로 전환할 수 있다. 이것이 동시성 문제의 물리적 원인이다. 이 문제를 해결하는 방법은 크게 세 가지다:
- 공유 상태 제거: 각 스레드가 자신만의 데이터를 사용 (함수형 프로그래밍, 불변 객체)
- 원자적 연산 사용: 하드웨어 수준에서 쪼개지지 않는 명령어 사용 (
Atomics,CAS) - 상호 배제: Mutex/Semaphore로 한 번에 하나만 접근 허용
📖 더 보기: Mastering Node.js Concurrency: Race Condition Detection and Prevention — Medium — Node.js에서 Race Condition이 발생하는 실제 패턴과 탐지/방지 전략
Node.js/Nest.js 연결 심화: Node.js는 싱글 스레드이므로 일반 JavaScript 코드에서는 Race Condition이 없다고 생각하기 쉽다. 그러나 await 지점에서 이벤트 루프가 다른 요청을 처리할 수 있어 비동기 Race Condition이 발생한다. 이것이 Node.js에서 Mutex가 필요한 핵심 이유다.
[요청 A] await db.findCoupon(id) ←← 이벤트 루프 해제 [요청 B] await db.findCoupon(id) ← 같은 쿠폰 조회[요청 A] coupon.isUsed = false ← 아직 false [요청 B] coupon.isUsed = false ← 역시 false[요청 A] await db.update(id, {isUsed: true}) [요청 B] await db.update(id, {isUsed: true}) ← 중복!async-mutex 라이브러리 실전 사용 (Node.js 20+, NestJS):
// npm install async-muteximport { Mutex } from "async-mutex";
@Injectable()export class CouponService { private readonly couponMutexes = new Map<number, Mutex>();
private getMutex(couponId: number): Mutex { if (!this.couponMutexes.has(couponId)) { this.couponMutexes.set(couponId, new Mutex()); } return this.couponMutexes.get(couponId)!; }
async useCoupon(couponId: number): Promise<void> { const mutex = this.getMutex(couponId); const release = await mutex.acquire(); // 동시 요청 중 하나만 통과 try { const coupon = await this.couponRepo.findOne(couponId); if (coupon.isUsed) throw new Error("이미 사용됨"); await this.couponRepo.update(couponId, { isUsed: true }); } finally { release(); // 반드시 해제 } // 미사용 mutex 정리 (메모리 누수 방지) this.couponMutexes.delete(couponId); }}예상 동작: 두 요청이 동시에 useCoupon(42) 호출 시 첫 번째가 mutex를 획득하고, 두 번째는 acquire() 에서 대기한다. 첫 번째가 release()를 호출하면 두 번째가 이어서 진행하며 isUsed = true를 확인 후 에러를 던진다.
⚠️ 주의:
async-mutex는 단일 Node.js 인스턴스 내에서만 유효하다. PM2 cluster 모드나 ECS 다중 태스크 환경에서는 반드시 Redis 분산 락을 사용해야 한다.
코드 예시 (Python, 멀티스레드 환경의 비안전 카운터):
import threading
counter = 0 # 공유 자원
def increment(): global counter for _ in range(100000): counter += 1 # 원자적이지 않은 연산!
threads = [threading.Thread(target=increment) for _ in range(2)]for t in threads: t.start()for t in threads: t.join()
print(counter) # 예상: 200000, 실제: 다를 수 있음 (예: 187432)예상 출력:
187432 # 실행마다 다른 값이 나온다counter += 1은 내부적으로 세 단계로 쪼개진다: ① 메모리에서 값 읽기 → ② +1 계산 → ③ 메모리에 쓰기. 두 스레드가 ①을 동시에 실행하면 같은 값을 두 번 읽고 두 번 쓰지만 결과는 1만 증가한다.
3-2. Critical Section & Race Condition
섹션 제목: “3-2. Critical Section & Race Condition”Critical Section (임계 구역)
비유: 한 번에 한 명만 서 있을 수 있는 다리. 이 다리가 Critical Section이다.
원리: Critical Section은 공유 자원에 접근하는 코드 구간이다. 이 구간에는 동시에 하나의 스레드만 진입할 수 있어야 한다. Critical Section의 조건:
- 상호 배제(Mutual Exclusion): 한 번에 하나의 스레드만 진입
- 진행(Progress): 아무도 없다면 대기 없이 진입 가능
- 한정 대기(Bounded Waiting): 무한정 기다리지 않음
Race Condition (경쟁 조건)
비유: 두 사람이 은행 ATM으로 동시에 같은 계좌에서 돈을 인출하려 할 때, 잔액 확인 → 차감 사이에 순서가 꼬이면 잔액보다 더 많이 인출될 수 있다.
원리: Race Condition은 실행 순서(타이밍)에 따라 결과가 달라지는 상태다. Critical Section이 보호되지 않았을 때 발생한다.
코드 예시 (두 스레드가 동시에 count++ 실행):
import threading
count = 0
def add_one(): global count # Critical Section 시작 temp = count # 1. 현재 값 읽기 # ← 여기서 컨텍스트 스위치 발생 가능 count = temp + 1 # 2. +1 후 저장 # Critical Section 끝
t1 = threading.Thread(target=add_one)t2 = threading.Thread(target=add_one)
t1.start()t2.start()t1.join()t2.join()
print(count)예상 출력 (Race Condition 발생 시):
# 시나리오: t1이 count=0을 읽고, t2도 count=0을 읽음# t1: count = 0+1 = 1 저장# t2: count = 0+1 = 1 저장 ← t1의 갱신을 덮어씀1 # 예상은 2이지만 1이 나옴3-3. Mutex & Semaphore
섹션 제목: “3-3. Mutex & Semaphore”Mutex (뮤텍스, Mutual Exclusion Lock)
섹션 제목: “Mutex (뮤텍스, Mutual Exclusion Lock)”비유: 열쇠가 1개뿐인 화장실. 열쇠를 가진 사람만 들어갈 수 있고, 나올 때 열쇠를 반납해야 다음 사람이 들어갈 수 있다.
원리: Mutex는 이진(binary) 잠금 장치다. 잠금(lock)과 해제(unlock) 두 가지 상태만 존재한다. 가장 중요한 특성은 소유권(ownership) — Mutex를 잠근 스레드만 해제할 수 있다. 다른 스레드는 잠금이 해제될 때까지 대기한다.
왜 Mutex 내부는 busy-wait 대신 sleep을 사용하는가? 초창기 구현에서는 Mutex를 얻지 못한 스레드가 CPU를 계속 사용하며 “아직 락이 풀렸나?” 반복 확인(spin)했다. 이를 spinlock이라 하며, 매우 짧은 대기에는 컨텍스트 스위칭 비용보다 유리하다. 그러나 대기 시간이 길면 CPU를 낭비한다. 현대 OS의 Mutex(futex 기반)는 경합이 없을 때는 사용자 공간 원자 연산으로 즉시 획득하고, 경합이 있을 때만 커널에 sleep을 요청한다. 이것이 Linux futex (fast userspace mutex) 시스템 콜이 존재하는 이유다.
코드 예시 (Python threading.Lock):
import threading
count = 0mutex = threading.Lock() # Mutex 생성
def safe_increment(): global count mutex.acquire() # 잠금 — 다른 스레드는 여기서 대기 try: count += 1 # Critical Section: 안전하게 실행 finally: mutex.release() # 반드시 해제 (finally로 보장)
threads = [threading.Thread(target=safe_increment) for _ in range(2)]for t in threads: t.start()for t in threads: t.join()
print(count)예상 출력:
2 # 항상 정확한 값코드 예시 (Node.js에서는 단일 스레드라 Mutex가 필요 없지만, async-mutex 라이브러리나 NestJS 전용 MurLock 데코레이터로 분산 환경의 비동기 경쟁 제어 가능):
MurLock — NestJS 분산 락 데코레이터
섹션 제목: “MurLock — NestJS 분산 락 데코레이터”NestJS 실무에서는 @MurLock() 데코레이터를 사용하면 Redis 기반 분산 락을 메서드 레벨에서 간결하게 적용할 수 있다.
// MurLock 설치: npm install murlockimport { MurLockModule } from "murlock";
@Module({ imports: [ MurLockModule.forRoot({ redisOptions: { url: process.env.REDIS_URL }, ttl: 5000, // 5초 후 자동 해제 (TTL) wait: 1000, // 재시도 대기 시간 (ms) maxAttempts: 3, // 최대 재시도 횟수 }), ],})export class AppModule {}
// order.service.tsimport { MurLock } from "murlock";
@Injectable()export class OrderService { // couponId 파라미터를 키로 하는 분산 락 자동 적용 @MurLock(5000, "couponId") async useCoupon(couponId: number) { // 이 메서드는 동시에 하나의 인스턴스에서만 실행 보장 const coupon = await this.couponRepo.findOne(couponId); if (coupon.isUsed) throw new Error("이미 사용됨"); await this.couponRepo.update(couponId, { isUsed: true }); return { success: true }; }}예상 동작: 두 인스턴스가 동시에 useCoupon(42)를 호출하면 하나는 락을 획득해 실행하고, 나머지는 최대 3회 재시도 후 실패한다.
주의: MurLock은 단일 Redis 인스턴스에서만 안전하다. Redis Cluster 환경에서는 Redlock 알고리즘(다수 Redis 노드 기반)을 사용해야 한다.
import { Mutex } from "async-mutex";
const mutex = new Mutex();let count = 0;
async function safeIncrement() { const release = await mutex.acquire(); // 잠금 획득 try { count += 1; // 비동기 Critical Section } finally { release(); // 반드시 해제 }}
// 동시에 실행await Promise.all([safeIncrement(), safeIncrement()]);console.log(count); // 항상 2예상 출력:
2Semaphore (세마포어)
섹션 제목: “Semaphore (세마포어)”비유: 주차장에 주차 가능 공간이 N개 있다. 입구에서 남은 자리 수를 보여주고, 자리가 있으면 들어가고, 가득 차면 기다린다. 나가면 자리 수가 증가한다.
원리: Semaphore는 카운터를 관리한다. 카운터가 0이면 대기, 0보다 크면 진입 가능(카운터 감소). 진입자가 나갈 때 카운터 증가. Mutex와 달리 소유권 개념이 없어 잠근 스레드가 아닌 다른 스레드도 해제(Signal)할 수 있다.
- Binary Semaphore: 카운터가 0 또는 1 (Mutex와 유사하지만 소유권 없음)
- Counting Semaphore: 카운터가 N까지 허용 (동시 N개 접근 허용)
코드 예시 (Python, 최대 3개 스레드 동시 접근):
import threadingimport time
semaphore = threading.Semaphore(3) # 최대 3개 동시 접근
def worker(worker_id): semaphore.acquire() # 자리 확보 (카운터 감소) print(f"Worker {worker_id} 진입") time.sleep(1) # 작업 수행 print(f"Worker {worker_id} 퇴장") semaphore.release() # 자리 반납 (카운터 증가)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(6)]for t in threads: t.start()for t in threads: t.join()예상 출력:
Worker 0 진입Worker 1 진입Worker 2 진입# Worker 3, 4, 5는 자리가 날 때까지 대기Worker 0 퇴장Worker 3 진입Worker 1 퇴장Worker 4 진입Worker 2 퇴장Worker 5 진입Worker 3 퇴장Worker 4 퇴장Worker 5 퇴장Mutex vs Semaphore 핵심 차이:
| 항목 | Mutex | Semaphore |
|---|---|---|
| 동시 접근 수 | 1개 | N개 |
| 소유권 | 있음 (잠근 스레드만 해제) | 없음 |
| 주요 용도 | Critical Section 보호 | 자원 개수 제한 |
| 실무 예시 | 파일 쓰기, 분산 락 | DB 커넥션 풀, API Rate Limit |
3-4. Deadlock (교착 상태)
섹션 제목: “3-4. Deadlock (교착 상태)”비유: 좁은 골목에서 차 두 대가 마주보고 서 있다. A는 “B가 비켜야 내가 간다”, B는 “A가 비켜야 내가 간다”고 서로 기다린다. 아무도 움직이지 못한다.
원리: Deadlock은 두 개 이상의 프로세스/스레드가 서로 상대방이 가진 자원을 기다리며 영원히 진행되지 못하는 상태다.
Deadlock 발생 4가지 필요 조건 (Coffman Conditions):
- 상호 배제(Mutual Exclusion): 한 번에 하나의 스레드만 자원 사용 가능
- 점유와 대기(Hold and Wait): 자원을 가진 채로 다른 자원을 기다림
- 비선점(Non-preemption): 자원을 강제로 빼앗을 수 없음
- 순환 대기(Circular Wait): A→B→C→A 형태의 순환 대기 구조
4가지가 모두 충족되어야 Deadlock이 발생한다. 하나만 깨도 Deadlock이 해소된다.
코드 예시 (Deadlock 시뮬레이션):
import threadingimport time
lock_a = threading.Lock()lock_b = threading.Lock()
def thread_1(): lock_a.acquire() print("Thread1: lock_a 획득") time.sleep(0.1) # 컨텍스트 스위치 유도 lock_b.acquire() # Thread2가 lock_b를 가진 채 대기 중 → DEADLOCK print("Thread1: lock_b 획득") lock_b.release() lock_a.release()
def thread_2(): lock_b.acquire() print("Thread2: lock_b 획득") time.sleep(0.1) lock_a.acquire() # Thread1이 lock_a를 가진 채 대기 중 → DEADLOCK print("Thread2: lock_a 획득") lock_a.release() lock_b.release()
t1 = threading.Thread(target=thread_1)t2 = threading.Thread(target=thread_2)t1.start()t2.start()t1.join(timeout=3)t2.join(timeout=3)print("프로그램이 여기까지 도달하지 못한다 (Deadlock)")예상 출력:
Thread1: lock_a 획득Thread2: lock_b 획득# 이후 무한 대기 — 프로그램이 멈춤왜 Deadlock은 설정으로 해결되지 않는가? Deadlock은 코드의 실행 순서 문제이지 설정(configuration) 문제가 아니다. PostgreSQL의 deadlock_timeout(기본 1초)은 Deadlock을 감지하는 시간일 뿐, 방지하지는 못한다. Deadlock을 근본적으로 해결하려면 모든 트랜잭션이 자원을 획득하는 순서를 통일해야 한다. 실무에서 TypeORM의 save() 메서드가 내부적으로 생성하는 UPDATE 쿼리 순서가 불확정적이어서 Deadlock을 유발하는 경우도 있다.
📖 더 보기: PostgreSQL Understanding Deadlocks — cybertec-postgresql.com — Deadlock의 원인, PostgreSQL 로그 분석, 실제 해결 사례
Deadlock 해결 전략:
| 전략 | 방법 | 예시 |
|---|---|---|
| 순환 대기 제거 | 모든 스레드가 자원을 같은 순서로 요청 | 항상 A 먼저, 그 다음 B |
| 타임아웃 | 일정 시간 기다려도 안 되면 포기 | LOCK TIMEOUT 1s |
| Deadlock Detection | 주기적으로 순환 대기 탐지 후 한쪽 강제 종료 | PostgreSQL의 자동 감지 |
| 자원 선점 | 다른 스레드 자원 강제 회수 | DB의 Rollback |
PostgreSQL Deadlock 감지 원리: PostgreSQL은 deadlock_timeout(기본 1초) 동안 락을 획득하지 못하면 Deadlock 감지 알고리즘을 실행한다. 내부적으로 대기 그래프(Wait-for Graph) 를 구성하여 순환(cycle)을 탐색한다. 순환이 발견되면 하나의 트랜잭션을 선택하여 강제 롤백하고 40P01 에러를 반환한다. 어떤 트랜잭션을 희생할지는 롤백 비용(지금까지 수행한 작업량)을 기준으로 결정한다.
-- PostgreSQL에서 Deadlock 관련 설정 확인SHOW deadlock_timeout; -- 기본: 1sSHOW log_lock_waits; -- on 설정 시 대기 로그 기록SHOW lock_timeout; -- 0 = 무제한 대기3-4-1. NestJS에서 Deadlock이 발생하는 실제 패턴
섹션 제목: “3-4-1. NestJS에서 Deadlock이 발생하는 실제 패턴”원리: NestJS + TypeORM 환경에서 Deadlock은 주로 두 가지 시나리오에서 발생한다.
시나리오 1 — 동시 주문 처리: 두 개의 API 요청이 거의 동시에 재고 감소 + 주문 생성을 처리할 때
// ❌ Deadlock 발생 가능: 두 트랜잭션이 다른 순서로 잠금// 요청 A: inventory(id=1) 잠금 → order 잠금 시도// 요청 B: order 잠금 → inventory(id=1) 잠금 시도 → DEADLOCK
@Injectable()export class OrderService { async createOrder(userId: number, itemId: number) { return this.dataSource.transaction(async (manager) => { // 재고 차감 (inventory 행 잠금) const item = await manager.findOne(Item, { where: { id: itemId }, lock: { mode: "pessimistic_write" }, }); if (item.stock < 1) throw new Error("재고 없음"); await manager.update(Item, itemId, { stock: item.stock - 1 });
// 주문 생성 (orders 행 잠금) return manager.save(Order, { userId, itemId }); }); }}
// ✅ 해결: 항상 동일한 순서로 잠금 + 재시도 로직@Injectable()export class OrderService { async createOrder(userId: number, itemId: number, retries = 3) { for (let i = 0; i < retries; i++) { try { return await this.dataSource.transaction(async (manager) => { // 두 테이블을 항상 [Item 먼저, Order 나중] 순서로 잠금 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("재고 없음"); await manager.decrement(Item, { id: itemId }, "stock", 1); return manager.save(Order, { userId, itemId }); }); } catch (err: any) { if (err.code === "40P01" && i < retries - 1) { await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i))); continue; } throw err; } } }}예상 출력 (정상 실행 시):
{ "id": 101, "userId": 1, "itemId": 5, "createdAt": "2026-04-02T10:00:00.000Z" }📖 더 보기: PostgreSQL Deadlock Detection — postgresql.org — PostgreSQL이 Deadlock을 감지하고 40P01 에러를 반환하는 메커니즘 설명
3-5. Memory Barrier (메모리 배리어)
섹션 제목: “3-5. Memory Barrier (메모리 배리어)”비유: 공장 조립 라인에서 작업 순서가 효율을 위해 재배치될 수 있다. 하지만 “A 작업은 반드시 B 작업 전에 끝나야 한다”는 규칙이 있을 때, 그 경계에 표지판을 세워 재배치를 막는다. 이것이 Memory Barrier다.
원리: 현대 CPU와 컴파일러는 성능 최적화를 위해 명령어 실행 순서를 재배치(reordering)한다. 또한 각 CPU 코어는 자체 캐시를 가지며, 메인 메모리와 동기화가 지연될 수 있다. 이로 인해 가시성(Visibility) 문제가 발생한다 — 스레드 A가 변수를 수정했는데 스레드 B는 아직 이전 값을 보고 있는 상황.
Memory Barrier는 이 재배치를 막고 캐시를 강제로 플러시하는 CPU 명령어다:
- Store Barrier: 쓰기 작업이 다른 CPU에 보이도록 강제
- Load Barrier: 읽기 이전의 모든 쓰기가 완료됐음을 보장
- Full Barrier: 양방향 보장
언어별 구현:
// Java: volatile 키워드 — 읽기/쓰기에 자동으로 Memory Barrier 삽입volatile boolean flag = false;
// 스레드 Aflag = true; // Store Barrier → 즉시 메인 메모리에 반영
// 스레드 Bif (flag) { // Load Barrier → 항상 최신 값을 읽음 // 안전하게 처리}// Go: sync/atomic 패키지로 원자적 연산 (내부적으로 Memory Barrier 사용)import "sync/atomic"
var flag int32 = 0
// 고루틴 Aatomic.StoreInt32(&flag, 1) // Memory Barrier 포함
// 고루틴 Bif atomic.LoadInt32(&flag) == 1 { // Memory Barrier 포함 // 안전하게 처리}Node.js에서의 위치: Node.js는 단일 스레드 기반이므로 일반 코드에서 Memory Barrier를 직접 다룰 일은 거의 없다. 그러나 Worker Threads를 사용하거나, SharedArrayBuffer로 메모리를 공유할 때는 Atomics API를 통해 이 개념이 적용된다.
// Node.js Worker Threads + SharedArrayBuffer 예시const sharedBuffer = new SharedArrayBuffer(4);const sharedArray = new Int32Array(sharedBuffer);
// Atomics.store: Memory Barrier 포함한 쓰기Atomics.store(sharedArray, 0, 1);
// Atomics.load: Memory Barrier 포함한 읽기const value = Atomics.load(sharedArray, 0);console.log(value); // 1예상 출력:
14. 실무에서 어떻게 쓰이나
섹션 제목: “4. 실무에서 어떻게 쓰이나”DB 커넥션 풀 (Semaphore 패턴)
섹션 제목: “DB 커넥션 풀 (Semaphore 패턴)”DB 서버가 최대 100개의 동시 연결을 허용한다고 가정하면, 애플리케이션에서 커넥션 풀 크기를 제한해야 한다. 이것이 Semaphore 패턴 — 최대 N개 동시 접근을 허용.
// TypeORM 커넥션 풀 설정 (NestJS)TypeOrmModule.forRoot({ type: "postgres", extra: { max: 20, // 최대 20개 동시 커넥션 (Semaphore 카운터) min: 5, // 최소 유지 커넥션 idleTimeoutMillis: 30000, },});Redis 분산 락 (Mutex 패턴)
섹션 제목: “Redis 분산 락 (Mutex 패턴)”여러 서버 인스턴스가 동시에 같은 작업을 수행하면 안 될 때 Redis를 Mutex로 사용한다.
// Redis SET NX EX: 원자적으로 "없으면 생성 + 만료시간 설정"async function acquireLock(key: string, ttlSeconds: number): Promise<boolean> { const result = await redis.set( `lock:${key}`, "locked", "NX", // NX: 키가 없을 때만 설정 (원자적) "EX", // EX: 만료 시간 설정 ttlSeconds, ); return result === "OK"; // OK면 락 획득 성공}
async function releaseLock(key: string): Promise<void> { // Lua 스크립트로 원자적 삭제 (내가 건 락만 삭제) const script = ` if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end `; await redis.eval(script, 1, `lock:${key}`, "locked");}PostgreSQL 트랜잭션 순서 통일 (Deadlock 방지)
섹션 제목: “PostgreSQL 트랜잭션 순서 통일 (Deadlock 방지)”// ❌ 위험: 두 트랜잭션이 다른 순서로 같은 행을 잠금// 트랜잭션 A: user_id=1 잠금 후 user_id=2 잠금// 트랜잭션 B: user_id=2 잠금 후 user_id=1 잠금 → Deadlock!
// ✅ 안전: 항상 작은 ID 순서로 잠금async function transferFunds(fromId: number, toId: number, amount: number) { const [firstId, secondId] = fromId < toId ? [fromId, toId] : [toId, fromId]; // 항상 작은 ID 먼저
await queryRunner.query( `SELECT * FROM accounts WHERE id IN ($1, $2) ORDER BY id FOR UPDATE`, [firstId, secondId], ); // ... 잔액 변경 로직}5. 내 업무와의 연결고리
섹션 제목: “5. 내 업무와의 연결고리”BackOps 엔지니어로서 이 개념들이 실제로 등장하는 시나리오:
| 상황 | 관련 개념 | 대응 방법 |
|---|---|---|
| 정산 배치가 중복 실행됨 | Race Condition / Mutex | Redis 분산 락으로 단일 실행 보장 |
| TypeORM 트랜잭션 오류 발생 | Deadlock | 락 획득 순서 통일, 재시도 로직 추가 |
| DB 연결이 고갈됨 | Semaphore (커넥션 풀) | 풀 사이즈 튜닝, 쿼리 최적화 |
| 동시 API 요청으로 재고 음수 | Critical Section | 트랜잭션 + SELECT FOR UPDATE |
| AWS Lambda 동시 실행 제한 | Semaphore | Reserved Concurrency 설정 |
6. 비슷한 개념과 비교
섹션 제목: “6. 비슷한 개념과 비교”Mutex vs Semaphore vs Monitor
섹션 제목: “Mutex vs Semaphore vs Monitor”| 속성 | Mutex | Semaphore | Monitor |
|---|---|---|---|
| 동시 접근 | 1개 | N개 | 1개 |
| 소유권 | 있음 | 없음 | 있음 |
| 조건 변수 | 없음 | 없음 | 있음 |
| 사용 용이성 | 중간 | 중간 | 높음 |
| 언어 지원 | 대부분 | 대부분 | Java, C# 등 |
Lock vs Transaction
섹션 제목: “Lock vs Transaction”- Lock: OS/런타임 수준의 동시성 제어 (스레드 간)
- Transaction: DB 수준의 동시성 제어 (쿼리 간), ACID 보장
Optimistic Lock vs Pessimistic Lock
섹션 제목: “Optimistic Lock vs Pessimistic Lock”| 구분 | Pessimistic Lock (비관적) | Optimistic Lock (낙관적) |
|---|---|---|
| 전략 | 먼저 잠그고 작업 | 작업 후 충돌 확인 |
| 사용 시점 | 충돌 가능성 높을 때 | 충돌 가능성 낮을 때 |
| DB 구현 | SELECT FOR UPDATE | version 컬럼 확인 |
| 단점 | 성능 저하 | 재시도 로직 필요 |
동기화 전략 선택 의사결정 표
섹션 제목: “동기화 전략 선택 의사결정 표”새로운 동시성 문제를 마주쳤을 때 어떤 전략을 선택할지 판단 기준을 정리한다. 핵심 변수는 상태 공유 범위와 충돌 빈도, 작업 시간이다.
| 상황 | 권장 전략 | 핵심 이유 |
|---|---|---|
| 단일 서버, 충돌 빈도 낮음 | Optimistic Lock (version 컬럼) | 재시도 비용이 낮고 락 오버헤드 없음 |
| 단일 서버, 충돌 빈도 높음 | async-mutex | 대기 비용이 낙관적 재시도보다 예측 가능 |
| 멀티 서버, 짧은 임계 구간 (< TTL) | Redis 분산 락 (SET NX PX) | 빠른 락 획득·해제, TTL이 안전망 역할 |
| 멀티 서버, 장시간 작업 | MQ + idempotency key | TTL 만료 위험 없이 처리 보장 |
| 금융·결제 (ACID 필수) | DB 트랜잭션 + SELECT FOR UPDATE | 커밋·롤백으로 원자성·격리성 보장 |
출처: Distributed Locking: A Practical Guide — architecture-weekly.com, Distributed Lock in Microservices — medium.com
6.5. 동시성 메커니즘 보편 분석 프레임워크
섹션 제목: “6.5. 동시성 메커니즘 보편 분석 프레임워크”새로운 동시성 기술(라이브러리, DB 기능, 메시지 브로커 등)을 처음 만났을 때, 다음 4가지 질문을 순서대로 적용하면 해당 기술의 핵심 특성과 적합 범위를 빠르게 파악할 수 있다.
| 질문 | 판단 기준 | 적용 예시 |
|---|---|---|
| Q1. 상태 공유 범위는? | Thread 내 / 프로세스 내 / 서버 내 / 클러스터 전체 | async-mutex → 서버 내, Redis 락 → 클러스터 전체 |
| Q2. 실패 시 원자성 보장 방법은? | rollback / compensation / idempotent retry | DB 트랜잭션 → rollback, MQ 처리 → idempotent retry |
| Q3. 성능-안전성 트레이드오프는? | Optimistic(충돌 낮을 때) vs Pessimistic(충돌 높을 때) | 재고 차감(높음) → Pessimistic, 프로필 수정(낮음) → Optimistic |
| Q4. Silent failure 탐지 방법은? | dead letter / saga log / idempotency key / monitoring | 분산 락 만료 → idempotency key + Prometheus 알람 |
적용 예시 — Actor Model 분석 (Akka, Orleans):
- Q1: 상태 공유 범위 → Actor 내부만 (단일 스레드 처리 보장, 외부 공유 없음)
- Q2: 실패 시 원자성 → supervision 전략으로 Actor 재시작 (compensation 패턴)
- Q3: 트레이드오프 → 충돌 자체가 없음(Optimistic에 가까움), 단 메시지 순서 보장 필요
- Q4: Silent failure → dead letter mailbox 모니터링
이 4가지 질문은 Mutex, Semaphore, DB Lock, 분산 락, Actor Model 모두에 공통 적용된다. 낯선 동시성 기술의 문서를 읽을 때 이 질문에 답하는 것으로 시작하면 핵심을 놓치지 않는다.
참고: Optimistic vs. Pessimistic Locking — vladmihalcea.com, Optimistic concurrency control — Wikipedia
6.6. 트러블슈팅
섹션 제목: “6.6. 트러블슈팅”Case 1: PostgreSQL Deadlock detected
섹션 제목: “Case 1: PostgreSQL Deadlock detected”증상/에러 메시지:
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.HINT: See server log for query details.원인: 두 개 이상의 트랜잭션이 서로 다른 순서로 같은 행에 락을 걸면서 순환 대기 발생.
해결 방법:
- 락 획득 순서 통일: 여러 행을 동시에 잠글 때 항상 ID 오름차순으로 정렬 후 잠금
- 트랜잭션 범위 축소: 불필요하게 긴 트랜잭션을 짧게 분리
- 재시도 로직 추가: TypeORM에서 Deadlock 에러 코드(40P01) 감지 시 재시도
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> { for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await fn(); } catch (err: any) { if (err.code === "40P01" && attempt < maxRetries - 1) { // PostgreSQL Deadlock 에러 코드: 40P01 const delay = Math.pow(2, attempt) * 100; // 지수 백오프 await new Promise((resolve) => setTimeout(resolve, delay)); continue; } throw err; } }}Case 2: Redis 분산 락 만료 후 이중 실행
섹션 제목: “Case 2: Redis 분산 락 만료 후 이중 실행”증상: 배치 작업이 동시에 두 번 실행됨. Redis 락을 설정했는데도 이중 실행 발생.
원인:
- 락 TTL이 실제 작업 시간보다 짧아 락이 만료되는 동안 다른 인스턴스가 락 획득
- 락 해제 시 “내 락”인지 확인 없이 무조건 삭제해서 다른 인스턴스의 락을 해제
해결 방법:
- 고유 토큰 사용: 락 획득 시 UUID 같은 고유 값을 값으로 저장, 해제 시 내 값과 일치할 때만 삭제
- TTL을 넉넉하게 설정: 최악의 경우 실행 시간 × 2 이상
- 락 갱신(Heartbeat): 작업이 긴 경우 주기적으로 TTL 갱신
import { v4 as uuidv4 } from "uuid";
class DistributedLock { private token: string;
async acquire(key: string, ttlMs: number): Promise<boolean> { this.token = uuidv4(); // 이 인스턴스 고유 토큰 const result = await redis.set( `lock:${key}`, this.token, "NX", "PX", ttlMs, ); return result === "OK"; }
async release(key: string): Promise<void> { // Lua 스크립트: 내 토큰인 경우에만 삭제 (원자적) const script = ` if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end return 0 `; await redis.eval(script, 1, `lock:${key}`, this.token); }}Silent Failure 사후 탐지 전략: 락 설정만으로는 부족하다. TTL 만료로 인한 이중 실행이 실제로 발생했는지 사후에 감지하는 체계를 함께 갖춰야 한다.
- DB idempotency key 테이블: 작업 실행 시 고유 키(배치 ID + 실행 날짜)를 INSERT — 중복 INSERT 시 Unique 제약 위반으로 감지
-- idempotency_log 테이블로 이중 실행 여부 감지CREATE TABLE job_execution_log ( job_key VARCHAR(255) PRIMARY KEY, -- e.g. 'settlement-2026-04-14' started_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
-- 작업 시작 시 삽입 시도 — 중복이면 UniqueViolation(23505) 예외INSERT INTO job_execution_log (job_key) VALUES ('settlement-2026-04-14');- 사후 중복 감지 쿼리: 쿠폰 중복 사용처럼 DB 레코드로 사후 검증 가능
-- 동일 coupon_id가 2회 이상 사용됐는지 확인SELECT coupon_id, COUNT(*) AS use_countFROM coupon_usageGROUP BY coupon_idHAVING COUNT(*) > 1;- Prometheus 알람: 동시 실행 횟수 이상 탐지 (메트릭 이름은 도메인에 맞게 조정)
# prometheus alert rule — 같은 job이 1분 안에 2회 이상 실행되면 알람- alert: DuplicateJobExecution expr: increase(job_execution_total{job="settlement"}[1m]) > 1 for: 0m annotations: summary: "정산 배치 이중 실행 감지 — Redis 락 TTL 점검 필요"출처: Idempotent message processing — redis.io, Data deduplication with Redis — redis.io
Case 3: DB 커넥션 풀 고갈 (Pool Exhausted)
섹션 제목: “Case 3: DB 커넥션 풀 고갈 (Pool Exhausted)”증상/에러 메시지:
Error: timeout exceeded when trying to connectConnectionTimeoutError: Connection timeout: Could not acquire connection from pool within 30000ms또는 TypeORM에서:
QueryRunnerAlreadyReleasedError: Query runner already released.원인:
- 커넥션을 획득한 후 반납하지 않는 코드 (finally 블록 누락)
- 커넥션을 장시간 점유하는 느린 쿼리
- 트래픽 급증으로 풀 사이즈 초과
해결 방법:
- 커넥션 누수 확인:
getConnection()후 반드시release()호출 확인
// ❌ 위험: 에러 시 커넥션 누수const conn = await dataSource.getConnection();await conn.query("SELECT ...");conn.release(); // 에러 발생하면 도달 못함
// ✅ 안전: finally로 보장const conn = await dataSource.getConnection();try { await conn.query("SELECT ...");} finally { conn.release(); // 에러 여부와 무관하게 항상 반납}- 풀 사이즈 모니터링: 현재 사용 중인 커넥션 수 모니터링
// TypeORM 커넥션 풀 상태 확인const pool = (dataSource.driver as any).pool;console.log("Total:", pool.totalCount);console.log("Idle:", pool.idleCount);console.log("Waiting:", pool.waitingCount);- slow query 최적화:
pg_stat_activity로 장시간 실행 중인 쿼리 확인
SELECT pid, now() - pg_stat_activity.query_start AS duration, queryFROM pg_stat_activityWHERE state = 'active' AND now() - pg_stat_activity.query_start > interval '5 seconds'ORDER BY duration DESC;Case 5: async-mutex TTL 없이 사용 시 데드락 (단일 인스턴스 환경)
섹션 제목: “Case 5: async-mutex TTL 없이 사용 시 데드락 (단일 인스턴스 환경)”증상: 특정 API 엔드포인트가 응답을 돌려주지 않고 무한 대기. CloudWatch에서 해당 Lambda/ECS 태스크의 타임아웃 에러가 간헐적으로 발생.
원인:
// ❌ 위험: acquire() 후 예외 발생 시 release()가 호출되지 않을 수 있음const release = await mutex.acquire();const data = await riskyOperation(); // 이 라인에서 예외 발생release(); // ← 여기 도달하지 못함 → 이후 모든 acquire() 영원히 대기해결 방법:
// ✅ 안전: runExclusive()로 자동 해제 보장await mutex.runExclusive(async () => { const data = await riskyOperation(); // 예외가 발생해도 자동으로 mutex 해제 return data;});
// 또는 try/finally 명시const release = await mutex.acquire();try { await riskyOperation();} finally { release(); // 예외 여부와 무관하게 항상 실행}Case 6: RabbitMQ를 활용한 분산 환경 Race Condition 방지
섹션 제목: “Case 6: RabbitMQ를 활용한 분산 환경 Race Condition 방지”증상: Redis 분산 락을 사용 중인데도 간헐적으로 재고가 음수가 됨. 락 TTL 만료 후 장시간 처리가 계속되는 경우 발생.
원인: 락을 획득한 인스턴스가 처리 중 TTL 만료 → 다른 인스턴스가 락 획득 → 같은 자원에 동시 접근.
해결 방법 (메시지 큐 패턴으로 순차 처리 보장):
// 쿠폰 사용 요청을 Queue에 넣고 단일 Consumer가 순차 처리@Injectable()export class CouponQueueProducer { constructor(@InjectRepository(Channel) private channel: Channel) {}
async publishUseCoupon(couponId: number, userId: number): Promise<void> { // couponId 기반 라우팅 키로 같은 큐에 전달 (순서 보장) await this.channel.publish( "coupon-exchange", `coupon.${couponId}`, // 라우팅 키 Buffer.from(JSON.stringify({ couponId, userId })), { persistent: true }, ); }}
// 단일 Consumer가 순차 처리 → Race Condition 원천 차단@Injectable()export class CouponQueueConsumer { @RabbitSubscribe({ exchange: "coupon-exchange", routingKey: "coupon.*", queue: "coupon-use-queue", }) async handleUseCoupon(msg: { couponId: number; userId: number }) { // prefetch=1로 한 번에 하나만 처리 → 순차 처리 보장 const coupon = await this.couponRepo.findOne(msg.couponId); if (coupon.isUsed) return; // 중복 처리 방지 await this.couponRepo.update(msg.couponId, { isUsed: true }); }}예상 동작: 동시에 100개 요청이 들어와도 Queue에 순서대로 쌓이고 Consumer가 하나씩 처리하므로 중복 발급이 불가능하다. TTL 만료 문제도 사라진다.
Case 4: Node.js async 코드에서 Race Condition
섹션 제목: “Case 4: Node.js async 코드에서 Race Condition”증상: 동시 요청 처리 시 재고가 음수가 되거나, 쿠폰이 초과 발급됨.
원인: Node.js는 단일 스레드지만, 비동기 코드에서 await 사이에 다른 요청이 끼어들 수 있어 Race Condition이 발생한다.
// ❌ 위험: await 사이에 다른 요청이 끼어들 수 있음async function useCoupon(couponId: number) { const coupon = await db.find(couponId); // await ← 여기서 다른 요청 가능 if (coupon.isUsed) throw new Error("이미 사용됨"); await db.update(couponId, { isUsed: true }); // 중복 실행될 수 있음}해결 방법:
// ✅ 안전: DB 트랜잭션 + SELECT FOR UPDATE로 원자적 처리async function useCoupon(couponId: number) { return await dataSource.transaction(async (manager) => { // FOR UPDATE: 이 행을 잠금, 다른 트랜잭션은 대기 const coupon = await manager.findOne(Coupon, { where: { id: couponId }, lock: { mode: "pessimistic_write" }, }); if (coupon.isUsed) throw new Error("이미 사용됨"); await manager.update(Coupon, couponId, { isUsed: true }); });}7. 체크리스트
섹션 제목: “7. 체크리스트”이해도 확인
섹션 제목: “이해도 확인”- Race Condition이 왜 발생하는지,
count++가 원자적이지 않은 이유를 설명할 수 있다 - Mutex와 Semaphore의 차이를 소유권 개념으로 설명할 수 있다
- Deadlock 발생 4가지 조건을 열거하고, 각 조건을 제거하는 방법을 알고 있다
- DB 커넥션 풀이 Semaphore 패턴임을 이해한다
- Redis 분산 락이 Mutex 패턴임을 이해하고 구현할 수 있다
- PostgreSQL
ERROR: deadlock detected에러를 받았을 때 원인 파악 절차를 안다 - TypeORM에서
SELECT FOR UPDATE로 비관적 락을 걸 수 있다 - Node.js에서도 비동기 Race Condition이 발생할 수 있음을 안다
실무 적용
섹션 제목: “실무 적용”- 커넥션 풀 사이즈를 무작정 늘리는 것이 왜 좋지 않은지 설명할 수 있다
- 분산 락에서 TTL 설정의 트레이드오프를 설명할 수 있다 (너무 짧으면 / 너무 길면)
- Deadlock 발생 시 재시도 로직에 지수 백오프를 사용하는 이유를 안다
- 낯선 동시성 기술을 접했을 때 Q1~Q4 프레임워크로 특성을 분석할 수 있다
- Redis 락 TTL 만료 후 이중 실행이 발생했는지 DB 쿼리와 모니터링으로 사후 탐지할 수 있다
- 상황별로 분산 락 / MQ / DB 트랜잭션 중 어떤 전략이 적합한지 근거와 함께 설명할 수 있다
8. 핵심 키워드
섹션 제목: “8. 핵심 키워드”| 키워드 | 한 줄 설명 |
|---|---|
| Thread Safety | 멀티스레드 환경에서도 올바르게 동작하는 성질 |
| Critical Section | 공유 자원에 접근하는 코드 구간 |
| Race Condition | 실행 순서에 따라 결과가 달라지는 버그 |
| Mutex | 소유권 있는 단일 잠금 (1개만 진입) |
| Semaphore | 소유권 없는 카운팅 잠금 (N개까지 진입) |
| Deadlock | 두 스레드가 서로의 자원을 무한 대기하는 상태 |
| Deadlock Coffman 조건 | 상호배제 + 점유대기 + 비선점 + 순환대기 |
| Memory Barrier | CPU 명령어 재배치 방지, 가시성 보장 |
| Distributed Lock | 여러 서버 인스턴스 간 분산 Mutex (Redis SET NX) |
| SELECT FOR UPDATE | DB에서 조회한 행에 비관적 락 설정 |
| Connection Pool | DB 커넥션을 미리 만들어 공유하는 Semaphore 패턴 |
| Optimistic Lock | 충돌 감지 후 재시도하는 낙관적 잠금 전략 |
| Atomic Operation | 더 이상 쪼개지지 않는 단일 연산 (중간 상태 없음) |
| volatile | Java/C에서 메모리 가시성 보장 키워드 |
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”📚 추천 리소스
섹션 제목: “📚 추천 리소스”- 📖 PostgreSQL Explicit Locking 공식 문서 — FOR UPDATE, FOR SHARE, Deadlock 처리 정책을 담은 공식 레퍼런스 (입문)
- 📖 Redis Distributed Locks 공식 가이드 — Redlock 알고리즘, SET NX EX 패턴 및 안전한 해제 방법 (중급)
- 📖 Node.js Race Conditions — nodejsdesignpatterns.com — Node.js가 싱글 스레드임에도 Race Condition이 발생하는 이유와 해결 패턴 (입문)
- 📖 Debugging Deadlocks in Postgres — incident.io Blog — 실제 프로덕션 Deadlock 디버깅 사례와 PostgreSQL 로그 분석 방법 (중급)
- 📖 How to Prevent Race Conditions Using Mutexes and RabbitMQ — DEV Community — Node.js 분산 환경에서 Mutex와 메시지 큐를 조합해 Race Condition을 근본적으로 제거하는 패턴 (중급)
9. 직접 확인해보기
섹션 제목: “9. 직접 확인해보기”실습 1: PostgreSQL Deadlock 재현 및 감지
섹션 제목: “실습 1: PostgreSQL Deadlock 재현 및 감지”두 개의 psql 세션을 열고 순서대로 실행:
-- 세션 ABEGIN;UPDATE accounts SET balance = balance - 100 WHERE id = 1;-- 여기서 잠깐 멈춤 (세션 B 진행)
-- 세션 BBEGIN;UPDATE accounts SET balance = balance - 100 WHERE id = 2;UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- A가 잡고 있으므로 대기
-- 세션 A (계속)UPDATE accounts SET balance = balance - 100 WHERE id = 2; -- B가 잡고 있으므로 DEADLOCK!예상 출력 (세션 A 또는 B에서):
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.HINT: See server log for query details.실습 2: Redis 분산 락 동작 확인
섹션 제목: “실습 2: Redis 분산 락 동작 확인”# Redis CLI로 분산 락 직접 확인
# 락 획득 (NX: 없을 때만, EX: 10초 만료)redis-cli SET "lock:my-job" "unique-token-123" NX EX 10# 예상 출력: OK (성공)
# 두 번째 획득 시도 (실패해야 함)redis-cli SET "lock:my-job" "unique-token-456" NX EX 10# 예상 출력: (nil) — 이미 락이 있으므로 실패
# 현재 락 확인redis-cli GET "lock:my-job"# 예상 출력: "unique-token-123"
# TTL 확인redis-cli TTL "lock:my-job"# 예상 출력: 9 (또는 남은 초)
# 락 해제 (내 토큰인 경우에만)redis-cli DEL "lock:my-job"# 예상 출력: (integer) 1
# 다시 획득 시도redis-cli SET "lock:my-job" "unique-token-456" NX EX 10# 예상 출력: OK (이제 성공)실습 3: TypeORM Pessimistic Lock 확인
섹션 제목: “실습 3: TypeORM Pessimistic Lock 확인”// NestJS 환경에서 실행import { DataSource } from "typeorm";
async function testPessimisticLock(dataSource: DataSource) { // FOR UPDATE로 행 잠금 const result = await dataSource.query(` BEGIN; SELECT id, balance FROM accounts WHERE id = 1 FOR UPDATE; -- 이 시점에서 다른 트랜잭션은 이 행을 수정할 수 없음 COMMIT; `); console.log(result);}예상 출력:
[ { id: 1, balance: '10000' } ]실습 4: Race Condition 재현
섹션 제목: “실습 4: Race Condition 재현”// Node.js 스크립트로 비동기 Race Condition 재현const { promisify } = require("util");const sleep = promisify(setTimeout);
let count = 0;
async function increment() { const current = count; // 읽기 await sleep(10); // 비동기 대기 — 다른 작업이 끼어듦 count = current + 1; // 쓰기}
// 동시에 10번 실행Promise.all(Array.from({ length: 10 }, increment)).then(() => console.log("count:", count),); // 예상: 10, 실제: 1예상 출력:
count: 1 # Race Condition으로 인해 1만 증가10. 한 줄 요약
섹션 제목: “10. 한 줄 요약”여러 실행 흐름이 공유 자원을 동시에 건드릴 때 순서와 규칙을 정해주는 것이 동시성 제어의 전부다 — Mutex로 하나씩 통제하고, Semaphore로 개수를 제한하며, 락 획득 순서를 통일해 Deadlock을 막는다.