Semaphore: 카운터 기반 공유 자원 접근 제어
세마포어(Semaphore)는 정수 카운터를 이용해 동시에 접근 가능한 자원의 개수를 제한하는 동기화(synchronization) 도구다. 뮤텍스(Mutex)가 단일 소유자 기반의 상호 배제(mutual exclusion)라면, 세마포어는 여러 스레드가 동시에 접근할 수 있는 자원 풀(pool)을 관리하는 데 쓰인다. P/V 연산(또는 wait/signa
1. 개념
세마포어(Semaphore)는 정수형 카운터와 대기 큐(wait queue)로 구성된 동기화 프리미티브(synchronization primitive)다. 1965년 에츠허르 데이크스트라(Edsger Dijkstra)가 제안했으며, 카운터 값은 "현재 사용 가능한 자원의 개수"를 의미한다.
두 가지 연산만 존재한다.
- P 연산(wait, acquire, down): 카운터를 1 감소시킨다. 카운터가 0이면 호출한 스레드는 블록(block)된다.
- V 연산(signal, release, up): 카운터를 1 증가시킨다. 대기 중인 스레드가 있으면 하나를 깨운다.
카운터의 초기값이 1인 경우를 이진 세마포어(binary semaphore)라 부르고, 2 이상인 경우를 카운팅 세마포어(counting semaphore)라 부른다. 이진 세마포어는 뮤텍스와 비슷해 보이지만, 뮤텍스는 "소유권(ownership)" 개념이 있어 잠근 스레드만 해제할 수 있는 반면 세마포어는 소유권 개념이 없어 다른 스레드가 V 연산을 호출해도 무방하다.
2. 왜 사용하는가
임계 구역(critical section)에 하나의 스레드만 들어가야 하는 상호 배제 문제는 뮤텍스로 충분하다. 하지만 실무에서는 "동시에 N개까지만 허용"하는 문제가 더 흔하다.
- DB 커넥션 풀에 최대 10개의 연결만 존재할 때, 11번째 요청은 대기해야 한다.
- 외부 API 호출을 초당 동시 요청 5개로 제한해야 한다.
- 생산자-소비자 문제(producer-consumer problem)에서 버퍼 슬롯 개수를 관리해야 한다.
이런 상황은 단순 on/off 잠금으로는 표현할 수 없고, "자원이 몇 개 남았는가"를 추적하는 카운터가 필요하다. 세마포어는 이 요구를 정확히 충족한다.
3. 동작 원리
세마포어 내부는 대략 다음과 같은 상태를 가진다.
struct Semaphore {
int count;
Queue waiting_threads;
}P(S) 연산 (원자적으로 수행됨):
P(S):
S.count = S.count - 1
if S.count < 0:
현재 스레드를 S.waiting_threads에 넣고 블록V(S) 연산 (원자적으로 수행됨):
V(S):
S.count = S.count + 1
if S.waiting_threads가 비어있지 않으면:
대기 큐에서 스레드 하나를 깨움핵심은 카운터 증감과 블록/언블록 판단이 원자적(atomic)으로 일어나야 한다는 점이다. 그렇지 않으면 두 스레드가 동시에 P를 호출했을 때 카운터가 잘못 감소하는 경쟁 상태(race condition)가 발생한다. 실제 구현에서는 내부적으로 스핀락(spinlock)이나 하드웨어의 원자적 명령어(compare-and-swap 등)로 이 구간을 보호한다.
블록된 스레드는 커널의 대기 큐에 들어가 CPU를 점유하지 않고 잠들며(sleep), V 연산이 호출되면 스케줄러(scheduler)에 의해 실행 가능 상태(ready)로 전환된다. 이는 스핀락처럼 CPU를 계속 소모하며 기다리는 바쁜 대기(busy waiting)와 대조된다.
4. 코드 예제
Python의 threading.Semaphore를 사용해 동시 접근 가능한 자원을 3개로 제한하는 예제다.
import threading
import time
import random
# 동시에 최대 3개의 스레드만 자원에 접근 가능
semaphore = threading.Semaphore(3)
def access_resource(worker_id: int) -> None:
print(f"[worker-{worker_id}] 자원 접근 대기 중")
semaphore.acquire() # P 연산
try:
print(f"[worker-{worker_id}] 자원 사용 시작")
time.sleep(random.uniform(0.5, 1.5)) # 자원 사용 시뮬레이션
print(f"[worker-{worker_id}] 자원 사용 완료")
finally:
semaphore.release() # V 연산
threads = [
threading.Thread(target=access_resource, args=(i,))
for i in range(6)
]
for t in threads:
t.start()
for t in threads:
t.join()이 코드를 실행하면 6개의 워커 중 항상 3개까지만 동시에 "자원 사용 시작" 상태가 되고, 나머지는 대기한다.
TypeScript(Node.js) 환경에서는 언어 내장 세마포어가 없으므로 직접 구현하거나 라이브러리를 쓴다. 개념 이해를 위해 간단히 구현하면 다음과 같다.
class Semaphore {
private count: number;
private waitQueue: Array<() => void> = [];
constructor(initialCount: number) {
this.count = initialCount;
}
async acquire(): Promise<void> {
if (this.count > 0) {
this.count -= 1;
return;
}
// 자원이 없으면 대기 큐에 등록하고 대기
await new Promise<void>((resolve) => {
this.waitQueue.push(resolve);
});
}
release(): void {
const next = this.waitQueue.shift();
if (next) {
next(); // 대기 중인 작업 하나를 깨움
} else {
this.count += 1;
}
}
}
// 동시 요청 2개로 제한된 API 호출 예시
const apiSemaphore = new Semaphore(2);
async function callExternalApi(id: number): Promise<void> {
await apiSemaphore.acquire();
try {
console.log(`[요청 ${id}] API 호출 시작`);
await new Promise((r) => setTimeout(r, 1000));
console.log(`[요청 ${id}] API 호출 완료`);
} finally {
apiSemaphore.release();
}
}
for (let i = 1; i <= 5; i++) {
callExternalApi(i);
}Node.js는 단일 스레드 이벤트 루프(event loop)이므로 이 구현은 진짜 경쟁 상태를 막는 게 아니라 비동기 작업의 동시 실행 개수를 제한하는 용도로 쓰인다. 진짜 멀티스레드 환경(Worker Threads 등)에서 원자성이 필요하다면 Atomics와 SharedArrayBuffer 기반의 별도 처리가 필요하다.
5. 시간 복잡도 또는 성능 특성
- P/V 연산 자체는 O(1)이다. 카운터 증감과 대기 큐에 push/pop하는 연산 모두 상수 시간이다.
- 다만 블록된 스레드를 깨우는 데는 컨텍스트 스위칭(context switching) 비용이 든다. 리눅스 기준 컨텍스트 스위칭은 대략 수 마이크로초(µs) 단위이며, 하드웨어와 캐시 상태에 따라 달라진다.
- 스핀락과 비교하면, 세마포어는 대기 중 CPU를 점유하지 않으므로 대기 시간이 긴 경우(디스크 I/O, 네트워크 호출 등) 유리하다. 반대로 대기 시간이 매우 짧다면 컨텍스트 스위칭 오버헤드 때문에 스핀락이 더 빠를 수 있다.
6. 실무 사용 사례
- DB 커넥션 풀(connection pool): 최대 커넥션 수를 세마포어 카운트로 설정해, 커넥션이 부족하면 요청이 자연스럽게 대기하도록 만든다.
- API 요청 제한(rate limiting): 외부 API의 동시 호출 수를 제한할 때 세마포어를 사용해 스로틀링(throttling)을 구현한다.
- 생산자-소비자 패턴: 버퍼가 가득 찼는지(full), 비었는지(empty)를 각각 세마포어 두 개로 관리하는 고전적인 구현이 있다.
- 스레드 풀(thread pool): 동시에 실행 가능한 작업자 스레드 수를 제한한다.
7. 주의할 점
- 교착 상태(deadlock): 여러 세마포어를 동시에 획득하는 순서가 스레드마다 다르면 교착 상태가 발생할 수 있다. 항상 같은 순서로 획득하는 규칙을 정해야 한다.
- 해제 누락: P 연산 후 예외가 발생해 V 연산이 호출되지 않으면 카운터가 영구히 감소한 상태로 남는다.
try/finally구문으로 반드시 해제를 보장해야 한다. - 우선순위 역전(priority inversion): 낮은 우선순위 스레드가 세마포어를 잡고 있으면 높은 우선순위 스레드가 오래 대기할 수 있다. 실시간 시스템에서는 우선순위 상속(priority inheritance) 프로토콜을 함께 고려한다.
- 뮤텍스와 혼동 주의: 이진 세마포어를 상호 배제 용도로 쓸 수는 있지만, 소유권 검증이 없어 획득하지 않은 스레드가 실수로 release를 호출해도 막을 방법이 없다. 단순 상호 배제가 목적이라면 뮤텍스가 더 안전하다.
8. 핵심 정리
세마포어는 카운터로 동시 접근 가능한 자원 개수를 제어하는 동기화 도구이며, P(감소/대기)와 V(증가/신호) 두 원자적 연산으로 동작한다. 뮤텍스와 달리 소유권 개념이 없고 카운트가 1보다 큰 값을 가질 수 있어, 커넥션 풀이나 동시 요청 제한처럼 "N개까지 허용"하는 문제에 적합하다. 연산 자체는 O(1)이지만 해제 누락과 교착 상태 가능성을 항상 염두에 두고 try/finally 같은 안전장치와 함께 사용해야 한다.