임계영역과 동시성 제어: 공유 자원을 안전하게 다루는 법

임계영역의 발생 조건과 Race Condition 원리, Mutex·Semaphore·Monitor·Spinlock 기반 동시성 제어를 정리한다.

2026-08-14 · 최초 발행 2026-01-02

공유 자원을 건드리는 코드가 위험해지는 순간

여러 스레드가 전역 변수, static 변수, 힙 메모리, 파일 또는 데이터베이스처럼 같은 자원에 접근하면 실행 순서에 따라 결과가 달라질 수 있다. 임계영역(Critical Section)은 이런 공유 자원 접근 코드 중 둘 이상의 스레드가 동시에 실행할 때 데이터 불일치나 예측하기 어려운 결과를 만드는 구간이다.

이 영역에서는 한 순간에 하나의 스레드만 실행하도록 배타적 접근이 필요하다. Mutex, Semaphore, Monitor 같은 동기화 메커니즘은 Race Condition을 막고 데이터 무결성을 유지하는 데 쓰인다.

임계영역은 보통 다음 조건이 겹칠 때 생긴다.

  • 전역 변수, static 변수, 힙 메모리, 파일, 데이터베이스처럼 공유 자원이 존재한다.
  • 여러 스레드 또는 프로세스가 같은 자원에 접근하려 한다.
  • 읽기와 쓰기가 함께 일어나며, 연산이 여러 단계로 나뉘어 중간 상태가 인터럽트될 수 있다.

total++이 보장하지 못하는 것

Race Condition은 여러 실행 흐름이 공유 데이터를 읽고 수정하는 순서가 겹치면서 발생한다. 아래 예제에서 total++이 바로 임계영역이다.

// 공유 변수
int total = 0;

// Worker 스레드 함수
void* worker(void* arg) {
    for (int i = 0; i < 100000; i++) {
        total++;  // 임계영역
    }
    return NULL;
}

// 메인 함수
int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, worker, NULL);
    pthread_create(&t2, NULL, worker, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("Total: %d\n", total);  // 예상: 200000, 실제: 불확실
    return 0;
}

total++은 하나의 동작처럼 보여도 내부적으로는 다음 순서를 거친다.

  1. Load: 메모리의 total 값을 레지스터로 읽는다.
  2. Add: 레지스터 값에 1을 더한다.
  3. Store: 계산한 값을 메모리에 기록한다.

두 스레드가 이 단계 사이에 끼어들면 같은 값을 읽은 뒤 각자 계산 결과를 덮어쓸 수 있다.

시간  | 스레드 1            | 스레드 2            | total (메모리)
------------------------------------------------------------
T1    | Load (total=0)     |                     | 0
T2    | Add (0+1=1)        | Load (total=0)      | 0
T3    | Store (total=1)    | Add (0+1=1)         | 1
T4    |                    | Store (total=1)     | 1

두 번 증가를 시도했어도 total은 1이 된다. 기대값은 2다.

은행 잔액도 같은 방식으로 깨질 수 있다.

int balance = 1000;

void withdraw(int amount) {
    // 임계영역 시작
    int temp = balance;      // Read
    temp = temp - amount;    // Modify
    balance = temp;          // Write
    // 임계영역 끝
}

스레드 A가 500원, 스레드 B가 300원을 동시에 출금하면 기대 결과는 balance = 200이다. 하지만 둘이 같은 잔액을 읽고 갱신하면 한 번의 출금만 반영되어 balance = 700이 될 수 있다.

진입을 제어할 때 만족해야 할 성질

임계영역 보호는 단순히 Lock을 거는 일로 끝나지 않는다. 올바른 해법은 상호 배제, 진행, 한정 대기를 함께 만족해야 한다.

Mutual Exclusion

상호 배제는 한 번에 하나의 스레드만 임계영역에 들어갈 수 있어야 한다는 조건이다. Lock을 획득한 스레드만 진입하고, 나머지 스레드는 Lock이 해제될 때까지 기다린다.

Progress

임계영역 밖에 있는 스레드는 다른 스레드의 진입을 막아서는 안 된다. 누가 다음에 진입할지는 유한한 시간 안에 결정되어야 하며, Deadlock이나 무한 대기는 피해야 한다.

Bounded Waiting

스레드가 진입을 요청한 뒤 허용될 때까지 다른 스레드가 진입할 수 있는 횟수에는 상한이 있어야 한다. 이 조건은 기아(Starvation)를 막고, 공정성·우선순위 역전 방지·최대 대기 시간 보장과 연결된다.

보호 방식은 대기 특성과 자원 모델에 맞춘다

Mutex

Mutex는 이진 세마포어이며 Lock과 Unlock 연산, 그리고 소유권 개념을 가진다. 소유한 스레드만 unlock할 수 있고, 재귀 진입이 가능한 Recursive mutex와 우선순위 상속도 특징으로 언급된다.

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* worker(void* arg) {
    pthread_mutex_lock(&mutex);  // 임계영역 시작
    total++;
    pthread_mutex_unlock(&mutex);  // 임계영역 끝
    return NULL;
}

Semaphore

Semaphore는 카운팅 변수로 자원 수를 관리하며 wait(P)signal(V) 연산을 사용한다. 이진 세마포어는 배타적 접근에, 카운팅 세마포어는 여러 자원을 가진 풀 관리에 사용할 수 있다.

sem_t sem;
sem_init(&sem, 0, 1);  // 초기값 1

void* worker(void* arg) {
    sem_wait(&sem);  // P 연산
    total++;
    sem_post(&sem);  // V 연산
    return NULL;
}

초기값이 N인 카운팅 세마포어는 N개의 동시 접근을 허용한다. 데이터베이스 연결 풀이 한 예다.

Monitor

Monitor는 객체 지향 방식의 동기화 메커니즘이다. 자동 상호 배제와 Condition Variable을 제공하며, Java에서는 synchronized를 통해 메서드 또는 블록 수준에서 암묵적 lock을 적용할 수 있다. wait/notify 메커니즘도 함께 사용된다.

class Counter {
    private int total = 0;

    public synchronized void increment() {
        total++;  // 자동 상호 배제
    }
}

Spinlock

Spinlock은 Lock을 얻을 때까지 루프를 반복하는 Busy-waiting 방식이다. 짧은 임계영역에서는 컨텍스트 스위칭이 없어 빠를 수 있지만, 대기가 길어지면 CPU 사이클을 낭비한다.

volatile int lock = 0;

void acquire() {
    while (__sync_lock_test_and_set(&lock, 1)) {
        // Busy-wait
    }
}

void release() {
    __sync_lock_release(&lock);
}

Lock 범위와 순서가 만드는 차이

임계영역에는 공유 자원을 다루는 부분만 넣어야 한다. 공유 자원과 무관한 계산까지 Lock 안에 두면 병렬성이 줄어든다.

// 나쁜 예
pthread_mutex_lock(&mutex);
int temp = calculate_something();  // 공유 자원 무관
total += temp;
pthread_mutex_unlock(&mutex);

// 좋은 예
int temp = calculate_something();  // Lock 외부
pthread_mutex_lock(&mutex);
total += temp;
pthread_mutex_unlock(&mutex);

같은 자원에는 같은 Lock을 사용하고, 여러 Lock이 필요하다면 획득 순서를 일정하게 유지해야 한다. 아래 코드는 주소 순서에 따라 Lock을 획득한다.

// 항상 같은 순서로 Lock 획득
void transfer(Account* from, Account* to, int amount) {
    if (from < to) {
        lock(from);
        lock(to);
    } else {
        lock(to);
        lock(from);
    }
    // Transfer logic
    unlock(from);
    unlock(to);
}

Deadlock은 Mutual Exclusion, Hold and Wait, No Preemption, Circular Wait 조건에서 발생한다. Lock 순서 정의, Timeout 사용, Deadlock Detection은 이를 예방하는 방법이다.

Lock 세분성도 함께 검토해야 한다. Fine-grained locking은 병렬성을 높이는 대신 복잡도를 키우고, Coarse-grained locking은 구현은 단순하지만 병렬성이 낮다. 읽기 비중이 높다면 읽기는 여러 실행 흐름에 허용하고 쓰기는 배타적으로 처리하는 Read-Write Lock을 고려할 수 있다.

병렬 시스템에서 만나는 임계영역

운영체제 커널에서는 프로세스 테이블, 파일 디스크립터 테이블, 메모리 관리 구조를 보호해야 한다. 인터럽트 컨텍스트에는 Spinlock, 프로세스 컨텍스트에는 Mutex를 사용한다.

데이터베이스는 ACID와 Isolation Level을 바탕으로 Lock 기반 동시성 제어를 수행한다. 2PL(Two-Phase Locking)은 Growing Phase에서 Lock을 획득하고 Shrinking Phase에서 Lock을 해제한다.

웹 서버의 세션 관리에서는 동시에 들어오는 요청으로부터 세션 데이터를 지켜야 하므로 Mutex 또는 Read-Write Lock을 사용할 수 있다. 멀티스레드 애플리케이션의 생산자-소비자 구조에서는 버퍼를 보호하고 Semaphore와 Condition Variable을 사용한다.

임계영역동시성 제어운영체제뮤텍스세마포어