2025-09-21 11:50
-
세마포어는 여러 프로세스나 스레드가 공유 자원에 동시에 접근하는 것을 제어하기 위한 동기화 도구다.
-
정수형 변수(카운터)와 대기 큐로 구성되며,
wait와signal이라는 원자적 연산을 통해 자원의 가용 상태를 관리한다. -
세마포어는 상호 배제뿐만 아니라 실행 순서 제어에도 사용되며, 데드락과 기아 상태를 주의해야 한다.
멀티스레딩 동기화 완벽 정복 세마포어 핸드북
컴퓨터 과학의 발전은 동시에 여러 작업을 처리하는 ‘병렬 처리’의 시대를 열었다. 하지만 여러 실행 흐름이 하나의 자원을 놓고 경쟁하기 시작하면서 새로운 문제가 발생했다. 바로 ‘동기화 문제’다. 이 문제를 해결하기 위해 수많은 기법이 등장했으며, 그중 가장 고전적이면서도 강력한 도구가 바로 **세마포어(Semaphore)**다.
이 핸드북은 세마포어가 왜 만들어졌는지부터 시작하여 그 내부 구조와 사용법, 그리고 현업에서 마주할 수 있는 심화 문제까지, 세마포어의 모든 것을 체계적으로 다룬다.
1. 세마포어는 왜 만들어졌나 공유 자원의 비극
컴퓨터가 하나의 작업만 순차적으로 처리하던 시절에는 문제가 없었다. 하지만 운영체제가 발전하고 여러 프로세스(혹은 스레드)가 CPU 시간을 나눠 쓰기 시작하면서 문제가 발생했다. 여러 실행 단위가 전역 변수, 파일, 데이터베이스 같은 ‘공유 자원(Shared Resource)‘에 동시에 접근하려고 시도했기 때문이다.
경쟁 상태와 임계 구역
두 개의 스레드가 하나의 은행 계좌 잔액을 동시에 변경한다고 상상해 보자.
-
현재 잔액: 10,000원
-
스레드 A: 5,000원을 입금하려 한다.
-
스레드 B: 3,000원을 출금하려 한다.
이상적인 결과는 10,000 + 5,000 - 3,000 = 12,000원이다. 하지만 실제 동작은 다음과 같이 꼬일 수 있다.
| 시간 | 스레드 A 동작 | 스레드 B 동작 | 메모리(잔액) |
|---|---|---|---|
| 1 | 잔액(10,000)을 읽음 | 10,000 | |
| 2 | 잔액(10,000)을 읽음 | 10,000 | |
| 3 | 읽은 값에 5,000 더함 (결과: 15,000) | 10,000 | |
| 4 | 읽은 값에서 3,000 뺌 (결과: 7,000) | 10,000 | |
| 5 | 계산 결과(15,000)를 잔액에 씀 | 15,000 | |
| 6 | 계산 결과(7,000)를 잔액에 씀 | 7,000 |
스레드 A의 입금 작업이 무시되고 데이터가 오염되었다. 이처럼 여러 실행 주체가 하나의 공유 자원을 동시에 수정하려 할 때 실행 순서에 따라 결과가 달라지는 현상을 **경쟁 상태(Race Condition)**라고 한다. 그리고 이런 문제가 발생할 수 있는 코드 영역을 **임계 구역(Critical Section)**이라 부른다.
세마포어는 바로 이 임계 구역 문제를 해결하기 위해 탄생했다. 즉, 한 번에 하나의 스레드만 임계 구역에 진입하도록 보장하는 ‘상호 배제(Mutual Exclusion)‘를 구현하는 것이 핵심 목표다.
비유: 인기 있는 화장실 세마포어를 ‘화장실이 1개뿐인 인기 식당’에 비유할 수 있다. 화장실 문 앞에는 ‘사용 가능’ 또는 ‘사용 중’ 상태를 나타내는 표시와 열쇠가 하나 있다.
-
공유 자원: 화장실
-
세마포어: 화장실 열쇠
-
프로세스/스레드: 화장실을 이용하려는 손님
손님(프로세스)은 화장실을 쓰고 싶으면 먼저 열쇠가 있는지 확인한다. 열쇠가 있으면(자원 사용 가능) 열쇠를 가지고 들어가 문을 잠근다. 다른 손님은 열쇠가 없으므로 밖에서 줄을 서서 기다려야 한다. 사용을 마친 손님이 열쇠를 제자리에 돌려놓으면, 기다리던 다음 손님이 그 열쇠를 가지고 들어갈 수 있다. 이 열쇠와 같은 역할을 하는 것이 바로 세마포어다.
2. 세마포어의 구조 원리와 종류
세마포어는 네덜란드의 컴퓨터 과학자 에츠허르 데이크스트라(Edsger W. Dijkstra)가 제안했다. 그 구조는 의외로 단순하다.
-
정수형 변수 (S): 현재 사용 가능한 자원의 개수를 나타내는 카운터.
-
대기 큐 (Queue): 자원을 기다리는 프로세스(PCB, Process Control Block)들이 대기하는 공간.
세마포어는 이 두 가지 요소를 가지고 단 두 개의 원자적(atomic) 연산으로 동작한다. ‘원자적’이라는 말은 연산이 실행되는 도중에 누구도 끼어들 수 없이 한 번에 완료된다는 의미다.
세마포어의 핵심 연산: P와 V
데이크스트라는 네덜란드어 단어를 사용하여 두 연산을 정의했다.
-
P 연산 (wait):
Proberen(시험하다, 검사하다)에서 유래. -
V 연산 (signal):
Verhogen(증가시키다, 알리다)에서 유래.
wait() 동작 원리
wait(S) {
S의 값을 1 감소시킨다.
if (S의 값이 0보다 작으면) {
// 자원이 없으므로, 이 프로세스를 대기 큐에 넣고 '대기(sleep)' 상태로 전환한다.
}
}
wait()는 자원을 획득하는 과정이다. 일단 카운터를 줄이고 본다. 만약 카운터가 음수가 되면, 자원이 없다는 뜻이므로 해당 프로세스는 대기 상태에 들어간다.
signal() 동작 원리
signal(S) {
S의 값을 1 증가시킨다.
if (S의 값이 0보다 작거나 같으면) {
// 자원을 기다리던 프로세스가 있었으므로,
// 대기 큐에서 프로세스 하나를 꺼내 '준비(ready)' 상태로 전환한다.
}
}
signal()은 자원을 반납하는 과정이다. 카운터를 늘린다. 만약 카운터가 0 이하이면, 누군가 이 자원을 애타게 기다리고 있었다는 의미이므로 대기 큐에서 잠자던 프로세스 하나를 깨워준다.
세마포어의 종류
세마포어는 카운터 S가 가질 수 있는 값의 범위에 따라 두 종류로 나뉜다.
| 구분 | 이진 세마포어 (Binary Semaphore) | 카운팅 세마포어 (Counting Semaphore) |
|---|---|---|
| 카운터 값 | 0 또는 1 | 0 이상의 정수 |
| 주요 용도 | 상호 배제(Mutual Exclusion) 구현 | 여러 개 존재하는 자원의 할당 관리 |
| 다른 이름 | **뮤텍스(Mutex)**라고도 불림 | 일반적인 세마포어 |
| 예시 | 화장실 열쇠 (1개) | 주차장 남은 자리 표시 (N개) |
이진 세마포어는 오직 하나의 자원에 대한 접근을 제어할 때 사용된다. 카운터가 1이면 자원 사용 가능, 0이면 사용 중이라는 의미다. 우리가 흔히 ‘뮤텍스’라고 부르는 것이 바로 이것이다.
카운팅 세마포어는 사용 가능한 자원이 여러 개일 때 유용하다. 예를 들어, 데이터베이스 커넥션 풀에 10개의 커넥션이 있다면, 세마포어 카운터를 10으로 초기화한다. 스레드가 커넥션을 하나 사용할 때마다 wait()를 호출하여 카운터를 줄이고, 반납할 때 signal()을 호출하여 카운터를 늘린다. 카운터가 0이 되면 모든 커넥션이 사용 중이므로, 다음 요청 스레드는 대기해야 한다.
3. 세마포어 사용법 실제 예제
세마포어는 두 가지 주요 시나리오에서 강력한 힘을 발휘한다.
시나리오 1: 임계 구역 보호 (상호 배제)
가장 기본적인 사용법이다. 이진 세마포어(뮤텍스)를 사용하여 임계 구역을 감싸면 된다.
Semaphore S = 1; // 1로 초기화 (자원 사용 가능)
process_logic() {
...
wait(S); // 임계 구역 진입 전, 잠금을 시도
// --- 임계 구역 시작 ---
// 공유 자원 접근 코드
// 예: bank_balance = bank_balance + 100;
// --- 임계 구역 종료 ---
signal(S); // 임계 구역 작업 후, 잠금을 해제
...
}
wait()와 signal() 호출이 쌍을 이루어 임계 구역을 안전하게 보호한다.
시나리오 2: 실행 순서 제어
세마포어는 단순히 자원을 잠그는 것 외에, 특정 작업이 완료될 때까지 다른 작업을 기다리게 만드는 ‘실행 순서 제어’에도 사용할 수 있다.
스레드 A의 작업(taskA)이 끝난 후에 스레드 B의 작업(taskB)이 실행되어야 하는 상황을 가정해 보자.
Semaphore S = 0; // 0으로 초기화 (자원이 없음 = taskA가 아직 안 끝남)
// 스레드 A
taskA() {
// 작업 수행
...
signal(S); // 작업이 끝났음을 알림
}
// 스레드 B
taskB() {
wait(S); // S가 1이 될 때까지 (taskA가 끝날 때까지) 대기
// taskA 이후에 수행할 작업
...
}
S를 0으로 초기화하는 것이 핵심이다. 스레드 B는 시작하자마자 wait(S)를 호출하고, S가 0이므로 즉시 대기 상태에 들어간다. 스레드 A가 모든 작업을 마치고 signal(S)를 호출하여 S를 1로 만들어주기 전까지 스레드 B는 깨어날 수 없다.
4. 심화: 세마포어의 그림자
세마포어는 강력하지만, 잘못 사용하면 시스템 전체를 마비시키는 심각한 문제를 일으킬 수 있다.
교착 상태 (Deadlock)
두 개 이상의 프로세스가 서로 상대방의 작업이 끝나기만을 기다리며 다음 단계를 진행하지 못하는 상태다.
-
자원: 세마포어 A, 세마포어 B (둘 다 1로 초기화)
-
프로세스 1:
wait(A)→wait(B)순서로 자원 획득 -
프로세스 2:
wait(B)→wait(A)순서로 자원 획득
다음과 같은 시나리오를 상상해 보자.
-
프로세스 1이
wait(A)를 호출하여 세마포어 A를 획득. -
동시에, 프로세스 2가
wait(B)를 호출하여 세마포어 B를 획득. -
프로세스 1은 세마포어 B를 얻기 위해
wait(B)를 호출하지만, B는 프로세스 2가 점유 중이므로 대기. -
프로세스 2는 세마포어 A를 얻기 위해
wait(A)를 호출하지만, A는 프로세스 1이 점유 중이므로 대기.
이제 두 프로세스는 서로가 가진 자원을 무한히 기다리며 영원히 멈추게 된다. 이를 교착 상태라고 한다. 해결책은 모든 프로세스가 자원을 동일한 순서로 획득하도록 강제하는 것이다.
기아 상태 (Starvation)
특정 프로세스가 자원을 얻을 기회를 영원히 (또는 매우 오래) 얻지 못하고 계속 대기하는 상태다. 예를 들어, 세마포어의 대기 큐에서 프로세스를 깨우는 정책이 ‘우선순위 기반’일 때, 우선순위가 낮은 프로세스는 우선순위 높은 프로세스들에게 계속 밀려 자원을 할당받지 못할 수 있다.
뮤텍스와 세마포어의 근본적 차이
이진 세마포어는 뮤텍스와 매우 유사하게 동작하여 종종 혼용되지만, 개념적으로 중요한 차이가 있다.
| 특징 | 뮤텍스 (Mutex) | 세마포어 (Semaphore) |
|---|---|---|
| 소유권 | 잠금을 획득한 스레드만이 해제할 수 있음 | 잠금을 획득한 스레드가 아니어도 해제할 수 있음 |
| 목적 | 상호 배제 (자원 보호) | 상호 배제 + 실행 순서 제어 |
| 상태 | 잠김 / 잠기지 않음 (Locked/Unlocked) | 카운터 값 (정수) |
| 스코프 | 주로 스레드 간 동기화에 사용 | 프로세스 간 동기화에도 사용 가능 (System-wide) |
가장 큰 차이는 소유권 개념이다. 뮤텍스는 열쇠를 가져간 사람(스레드)만이 다시 제자리에 돌려놓을 수 있는 ‘소유권’ 기반 메커니즘이다. 반면 세마포어는 signal 연산을 아무나 호출할 수 있다. 이 차이점 때문에 세마포어는 실행 순서 제어와 같은 더 유연한 동기화 설계가 가능하다.
결론: 여전히 강력한 동기화의 초석
세마포어는 수십 년 전에 등장한 고전적인 동기화 도구지만, 그 원리와 개념은 현대 운영체제와 병렬 프로그래밍의 근간을 이룬다. 뮤텍스, 모니터, 조건 변수 등 더 고수준의 동기화 도구들도 내부적으로는 세마포어와 유사한 원리로 동작하는 경우가 많다.
경쟁 상태를 방지하고, 데이터 무결성을 지키며, 복잡한 작업 흐름을 제어해야 하는 모든 개발자에게 세마포어에 대한 깊은 이해는 필수적이다. 비록 교착 상태나 기아 상태와 같은 함정이 존재하지만, 그 구조와 동작 원리를 명확히 파악하고 신중하게 사용한다면, 세마포어는 가장 복잡한 동기화 문제도 해결할 수 있는 강력하고 신뢰성 높은 도구가 될 것이다.