본문 바로가기
TYLER SONGBlog
블로그 목록
CS학습

Critical Section: 공유 자원 접근을 보호하는 코드 구간

Critical Section은 여러 프로세스나 스레드가 동시에 실행될 때 공유 자원에 접근하는 코드 영역을 말한다. 이 구간을 제대로 보호하지 않으면 경쟁 상태(Race Condition)가 발생해 데이터 일관성이 깨진다. 뮤텍스(Mutex), 세마포어(Semaphore) 같은 동기화 도구로 상호 배제(Mutual Exclusion)를 구현해 이 문제를

송민성7분 읽기

1. 개념

Critical Section(임계 구역)은 여러 개의 프로세스 또는 스레드가 동시에 실행될 수 있는 환경에서, 공유 자원(변수, 파일, 데이터베이스 레코드 등)에 접근하고 수정하는 코드 블록을 말한다.

문제는 두 개 이상의 실행 흐름이 이 구간에 동시에 진입했을 때 발생한다. 각 실행 흐름의 명령어가 임의의 순서로 인터리빙(interleaving)되면서 예측할 수 없는 결과가 나올 수 있다. 이런 현상을 경쟁 상태(Race Condition)라고 부른다.

Critical Section 문제를 해결한다는 것은, 한 번에 하나의 실행 흐름만 이 구간에 들어갈 수 있도록 보장하는 메커니즘을 만드는 일이다.

2. 왜 사용하는가

은행 계좌 잔액에서 출금하는 코드를 생각해보자.

python
balance = 1000 def withdraw(amount): global balance temp = balance # 읽기 temp = temp - amount balance = temp # 쓰기

스레드 A와 B가 동시에 withdraw(100)을 호출한다고 하자. 둘 다 temp = balance에서 1000을 읽은 뒤 각자 900을 계산해서 저장하면, 최종 잔액은 800이 아니라 900이 된다. 200이 사라진 셈이다.

이런 문제를 막으려면 "읽기 → 계산 → 쓰기"로 이어지는 구간을 하나의 원자적(atomic) 단위로 취급해야 한다. 이 구간이 바로 Critical Section이고, 이를 보호하는 것이 동기화(Synchronization)의 핵심 목적이다.

3. 동작 원리

Critical Section 문제를 해결하는 메커니즘은 다음 세 가지 조건을 만족해야 한다.

  1. 상호 배제(Mutual Exclusion): 한 번에 하나의 프로세스/스레드만 Critical Section에 진입할 수 있다.
  2. 진행(Progress): Critical Section에 아무도 없다면, 진입을 원하는 프로세스 중 하나는 반드시 진입할 수 있어야 한다. 무한정 대기시키면 안 된다.
  3. 한정 대기(Bounded Waiting): 한 프로세스가 Critical Section 진입을 요청한 뒤, 다른 프로세스들이 그 프로세스보다 먼저 진입할 수 있는 횟수에 상한이 있어야 한다. 즉 특정 프로세스가 영원히 굶주리면(starvation) 안 된다.

이 조건을 만족시키는 대표적인 도구는 다음과 같다.

  • 뮤텍스(Mutex, Mutual Exclusion Lock): 락(lock)을 획득한 스레드만 Critical Section에 진입하고, 나머지는 대기한다. 소유권 개념이 있어서 락을 잡은 스레드만 해제할 수 있다.
  • 세마포어(Semaphore): 정수 카운터를 기반으로 동시에 접근 가능한 스레드 수를 제한한다. 카운터가 1이면 이진 세마포어(Binary Semaphore)로 뮤텍스와 유사하게 동작한다.
  • 모니터(Monitor): 락과 조건 변수(Condition Variable)를 언어 차원에서 캡슐화한 고수준 동기화 도구. 자바의 synchronized, 파이썬의 with lock: 구문이 여기 해당한다.

하드웨어 레벨에서는 Test-and-Set, Compare-and-Swap(CAS) 같은 원자적 명령어가 이런 동기화 도구의 기반이 된다.

4. 코드 예제

Python threading 모듈로 Critical Section 문제와 해결을 비교한다.

python
import threading import time # 문제 상황: 락 없이 공유 변수 접근 balance = 1000 def withdraw_unsafe(amount): global balance temp = balance time.sleep(0.0001) # 스케줄러가 컨텍스트 스위칭할 여지를 만듦 balance = temp - amount threads = [threading.Thread(target=withdraw_unsafe, args=(100,)) for _ in range(5)] for t in threads: t.start() for t in threads: t.join() print(f"락 없이 실행한 결과 잔액: {balance}") # 500이 아닌 값이 나올 수 있음
python
import threading balance = 1000 lock = threading.Lock() def withdraw_safe(amount): global balance with lock: # Critical Section 진입 temp = balance temp = temp - amount balance = temp # Critical Section 탈출, 락 자동 해제 threads = [threading.Thread(target=withdraw_safe, args=(100,)) for _ in range(5)] for t in threads: t.start() for t in threads: t.join() print(f"락 사용 후 잔액: {balance}") # 항상 500

세마포어로 동시 접근 수 자체를 제한하는 예제도 살펴본다.

python
import threading import time # 동시에 최대 2개의 스레드만 자원에 접근 가능 semaphore = threading.Semaphore(2) def access_resource(thread_id): with semaphore: print(f"스레드 {thread_id} 진입") time.sleep(1) print(f"스레드 {thread_id} 종료") threads = [threading.Thread(target=access_resource, args=(i,)) for i in range(5)] for t in threads: t.start() for t in threads: t.join()

5. 시간 복잡도 또는 성능 특성

Critical Section 자체는 알고리즘이 아니라 동기화 문제이므로 Big-O로 표현하는 시간 복잡도 개념이 직접 적용되지는 않는다. 대신 성능 관점에서 다음을 고려한다.

  • 락 획득/해제 오버헤드: 스핀락(Spinlock)은 커널 개입 없이 사용자 공간에서 반복적으로 확인(busy-waiting)하므로 짧은 Critical Section에서는 빠르지만, 대기 시간이 길어지면 CPU 사이클을 낭비한다.
  • **뮤텍스는 대기 중인 스레드를 잠재우고(sleep) 컨텍스트 스위칭 비용이 발생하지만, 긴 Critical Section이나 경쟁이 심한 상황에 유리하다.
  • Critical Section의 길이: 임계 구역이 길수록 다른 스레드의 대기 시간이 늘어나고 병렬성이 떨어진다. 따라서 Critical Section은 최소한의 코드만 포함하도록 설계해야 한다.
  • 락 경합(Lock Contention): 동시에 락을 요구하는 스레드가 많아질수록 처리량이 급격히 떨어질 수 있다. 이 경우 락을 잘게 쪼개는 세분화된 락(Fine-grained Locking) 전략을 고려한다.

6. 실무 사용 사례

  • 데이터베이스 트랜잭션: PostgreSQL 같은 RDBMS는 행 단위 락(Row Lock)으로 동시에 같은 행을 수정하는 트랜잭션을 직렬화한다.
sql
BEGIN; SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 해당 행에 락 획득, Critical Section 시작 UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT; -- 락 해제
  • 분산 시스템의 분산 락(Distributed Lock): Redis의 SETNX나 Redlock 알고리즘으로 여러 서버 인스턴스가 동시에 같은 작업을 수행하지 않도록 조율한다.
  • 웹 서버의 카운터 증가: Node.js는 싱글 스레드 이벤트 루프라 일반적인 동기 코드에서는 Critical Section 문제가 잘 드러나지 않지만, Promiseasync/await로 비동기 I/O 사이에 상태를 공유하면 유사한 경쟁 상태가 발생할 수 있다.
  • 캐시 갱신 로직: 여러 요청이 동시에 캐시 미스를 겪고 동일한 값을 재계산해서 덮어쓰는 캐시 스탬피드(Cache Stampede) 문제도 Critical Section 보호로 완화한다.

7. 주의할 점

  • 데드락(Deadlock): 두 개 이상의 락을 서로 다른 순서로 획득하면 서로가 서로를 기다리는 교착 상태에 빠질 수 있다. 락 획득 순서를 항상 일관되게 유지해야 한다.
  • 락 범위 최소화: Critical Section 안에 불필요한 연산(I/O, 로깅, 네트워크 호출)을 넣으면 락 보유 시간이 길어져 전체 처리량이 떨어진다.
  • 재진입성(Reentrancy): 같은 스레드가 이미 획득한 락을 다시 요청하면 일반 뮤텍스는 데드락에 빠진다. 재진입 가능한 락(Reentrant Lock, 예: Python threading.RLock)이 필요한 경우가 있다.
  • 기아 상태(Starvation): 락 획득 우선순위가 편향되면 특정 스레드가 계속 밀려날 수 있다. 공정한 락(Fair Lock) 구현이나 큐 기반 대기열로 완화한다.
  • 가짜 공유(False Sharing): 락과 직접 관련은 없지만, 여러 스레드가 같은 캐시 라인에 속한 서로 다른 변수를 수정하면 성능 저하가 발생할 수 있으므로 데이터 구조 설계 시 함께 고려한다.

8. 핵심 정리

Critical Section은 공유 자원에 접근하는 코드 구간이며, 여러 실행 흐름이 동시에 진입하면 경쟁 상태가 발생한다. 이를 막으려면 상호 배제, 진행, 한정 대기라는 세 조건을 만족하는 동기화 메커니즘이 필요하고, 뮤텍스와 세마포어가 대표적인 구현 도구다. 실무에서는 락 범위를 최소화하고 획득 순서를 일관되게 유지해 데드락과 성능 저하를 함께 관리해야 한다.

© 2026 Tyler Song