2025-10-06 21:19
-
공유 자원에 여러 프로세스나 스레드가 동시에 접근하며 발생하는 문제인 경쟁 상태(Race Condition)는 예측 불가능한 결과를 초래.
-
이 문제를 해결하기 위해 임계 구역(Critical Section)을 설정하고, 상호 배제(Mutual Exclusion)를 보장하는 동기화 도구(뮤텍스, 세마포어 등)를 사용.
-
경쟁 상태를 올바르게 이해하고 관리하는 것은 동시성 프로그래밍 환경에서 데이터의 무결성과 시스템의 안정성을 지키는 핵심.
개발자 필독 운영체제 경쟁 상태 완벽 정복 핸드북
우리가 매일 사용하는 컴퓨터와 스마트폰은 수많은 프로그램이 동시에 실행되는 ‘멀티태스킹’ 환경이다. 사용자가 음악을 들으면서 웹 서핑을 하고, 백그라운드에서는 파일 다운로드가 진행되는 이 모든 일이 자연스럽게 처리되는 배경에는 운영체제(OS)의 정교한 관리 기술이 있다. 하지만 이처럼 여러 작업이 동시에 실행될 때, 보이지 않는 곳에서는 자원을 차지하기 위한 치열한 경쟁이 벌어진다. 이 경쟁이 잘못 관리될 때 발생하는 심각한 문제가 바로 **경쟁 상태(Race Condition)**이다.
이 핸드북은 개발자라면 반드시 알아야 할 경쟁 상태의 모든 것을 다룬다. 경쟁 상태가 왜 발생하는지 그 근본적인 원인부터 시작해, 어떤 구조로 문제를 일으키는지, 그리고 이를 해결하기 위한 정교한 해법들까지 깊이 있게 탐구할 것이다. 단순한 개념 설명을 넘어, 실제 코드 예시와 명쾌한 비유를 통해 독자들이 경쟁 상태를 손에 잡힐 듯이 이해하고, 나아가 자신의 코드에서 발생할 수 있는 잠재적 위험을 예방할 수 있도록 돕는 것을 목표로 한다.
1부 경쟁 상태의 탄생 배경
경쟁 상태는 왜 현대 컴퓨팅 환경의 숙명이 되었을까? 그 답을 찾기 위해서는 컴퓨터의 발전 과정과 운영체제의 역할을 먼저 이해해야 한다.
동시성(Concurrency)의 시대
초창기 컴퓨터는 한 번에 하나의 작업만 처리하는 단순한 기계였다. 하지만 컴퓨터의 성능이 비약적으로 발전하면서, 하나의 강력한 CPU를 여러 작업이 나누어 쓰는 **시분할 시스템(Time-Sharing System)**이 등장했다. 운영체제는 아주 짧은 시간(수 밀리초) 단위로 CPU의 사용 권한을 여러 프로세스에 번갈아 할당했다. 이 전환 속도가 워낙 빠르기 때문에 사용자는 마치 여러 프로그램이 ‘동시에’ 실행되는 것처럼 느끼게 된다. 이것이 바로 **동시성(Concurrency)**의 시작이다.
여기에 그치지 않고, 하드웨어 기술은 하나의 칩에 여러 개의 CPU 코어를 넣는 멀티코어(Multi-core) 시대를 열었다. 이제는 여러 작업이 논리적으로만 동시인 것을 넘어, 물리적으로도 **병렬적(Parallel)**으로 실행될 수 있게 되었다.
이러한 동시성 및 병렬성 환경은 시스템의 전반적인 처리율과 효율성을 극대화했지만, 동시에 새로운 문제를 낳았다. 바로 **공유 자원(Shared Resource)**에 대한 접근 문제다.
공유 자원과 스케줄러의 개입
여러 프로세스나 스레드(프로세스 내의 실행 흐름 단위)가 함께 사용하는 데이터나 자원을 공유 자원이라고 한다. 예를 들어, 여러 스레드가 동시에 접근하여 값을 변경할 수 있는 전역 변수, 공동으로 사용하는 파일, 프린터와 같은 하드웨어 장치 등이 모두 공유 자원에 해당한다.
문제는 이 공유 자원에 대한 접근이 원자적(Atomic)으로 보장되지 않는다는 점에서 시작된다. ‘원자적’이란, 연산이 중간에 끊기지 않고 한 번에 완료되는 것을 의미한다. 하지만 우리가 작성하는 고급 언어의 코드 한 줄(예: count++)은 실제로는 CPU에서 여러 개의 기계어 명령어로 나뉘어 실행된다.
-
메모리에서
count변수의 값을 레지스터로 가져온다 (Load). -
레지스터의 값을 1 증가시킨다 (Increment).
-
증가된 값을 다시 메모리의
count변수에 저장한다 (Store).
운영체제의 스케줄러는 이 3단계의 명령어 실행 중간에 언제든지 개입하여 CPU 사용 권한을 다른 스레드에게 넘겨버릴 수 있다(이를 문맥 교환, Context Switching이라 한다). 바로 이 지점에서 경쟁 상태의 싹이 튼다. 두 개의 스레드가 거의 동시에 count++를 실행한다고 상상해 보자. 어떤 순서로 실행되느냐에 따라 최종 결과는 완전히 달라질 수 있다. 이것이 바로 경쟁 상태의 본질이다. 실행 결과가 스케줄러의 변덕스러운 스케줄링 순서에 따라 달라지는 예측 불가능한 상태인 것이다.
2부 경쟁 상태의 구조와 원리
경쟁 상태가 발생하는 핵심 영역과 그 동작 원리를 구체적인 코드 예시를 통해 해부해 보자.
임계 구역 (Critical Section)
경쟁 상태가 발생할 수 있는, 즉 공유 자원에 접근하는 코드 영역을 **임계 구역(Critical Section)**이라고 부른다. 여러 프로세스나 스레드가 동시에 임계 구역 코드를 실행하려고 할 때 문제가 발생한다. 따라서 동시성 프로그래밍의 핵심 과제는 이 임계 구역에 한 번에 하나의 실행 흐름만 진입하도록 보장하는 것이다.
코드로 이해하는 경쟁 상태
은행 계좌 입금 시나리오를 통해 경쟁 상태가 어떻게 데이터를 망가뜨리는지 살펴보자.
C
// 전역 변수로 공유되는 은행 잔고
int balance = 1000;
// 100원을 입금하는 함수
void deposit() {
int temp = balance; // 1. 현재 잔고를 지역 변수 temp에 읽어온다.
temp = temp + 100; // 2. temp 값을 100 증가시킨다.
balance = temp; // 3. 증가된 값을 다시 전역 변수 balance에 쓴다.
}
시나리오: 스레드 A와 스레드 B가 동시에 deposit() 함수를 호출하여 각각 100원씩 입금하려고 한다. 초기 잔고는 1000원이므로, 정상적으로 실행된다면 최종 잔고는 1200원이 되어야 한다.
하지만 운영체제 스케줄러의 개입으로 다음과 같은 순서로 실행될 수 있다.
| 실행 순서 | 스레드 A 동작 | 스레드 B 동작 | balance 값 | 스레드 A (temp) | 스레드 B (temp) |
|---|---|---|---|---|---|
| 1 | temp = balance; 실행 | 1000 | 1000 | ||
| 2 | temp = temp + 100; 실행 | 1000 | 1100 | ||
| 3 | temp = balance; 실행 | 1000 | 1100 | 1000 | |
| 4 | temp = temp + 100; 실행 | 1000 | 1100 | 1100 | |
| 5 | balance = temp; 실행 | 1100 | 1100 | 1100 | |
| 6 | balance = temp; 실행 | 1100 | 1100 | 1100 |
결과 분석:
스레드 A가 잔고를 1100으로 계산했지만, 그 값을 balance에 최종적으로 저장하기 전에 문맥 교환이 발생했다. 그 사이 스레드 B는 아직 변경되지 않은 balance 값(1000)을 읽어 가서 자신의 계산을 수행했다. 결국 스레드 A가 입금한 100원은 스레드 B가 덮어써 버리면서 사라졌고, 최종 잔고는 1100원이 되는 심각한 데이터 불일치 문제가 발생했다.
이처럼 경쟁 상태는 **실행 순서(Timing)**에 따라 결과가 달라지기 때문에 디버깅하기 매우 까다롭다. 어떤 때는 정상적으로 동작하다가도, 특정 타이밍에만 문제가 발생하기 때문에 ‘간헐적으로 발생하는 버그’의 주된 원인이 되기도 한다.
3부 경쟁 상태 해결을 위한 여정
경쟁 상태 문제를 해결하기 위한 연구는 운영체제 역사에서 매우 중요한 부분을 차지한다. 이 문제, 즉 **임계 구역 문제(The Critical Section Problem)**를 해결하기 위한 방법론은 세 가지 핵심 조건을 만족해야 한다.
해결책의 3대 요구조건
-
상호 배제 (Mutual Exclusion)
- 가장 중요한 조건. 한 스레드(또는 프로세스)가 임계 구역에서 실행 중이라면, 다른 어떤 스레드도 해당 임계 구역에 진입할 수 없어야 한다. 비유하자면, 화장실에 한 사람이 들어가면 문을 잠가서 다른 사람이 들어오지 못하게 막는 것과 같다.
-
진행 (Progress)
- 임계 구역에서 실행 중인 스레드가 없고, 여러 스레드가 임계 구역에 진입하기를 기다리고 있다면, 어느 스레드가 다음에 진입할 것인지를 결정하는 과정이 무한정 연기되어서는 안 된다. 즉, “아무도 안 쓰고 있으면, 쓰려는 사람 중 한 명은 들여보내 줘야 한다”는 원칙이다.
-
한정된 대기 (Bounded Waiting)
- 한 스레드가 임계 구역에 진입하기 위해 요청을 한 후, 그 요청이 허용될 때까지 다른 스레드들이 임계 구역에 진입하는 횟수에는 제한이 있어야 한다. 특정 스레드만 계속해서 기회를 얻고 다른 스레드는 무한정 기다리는 **기아 상태(Starvation)**를 방지하기 위한 조건이다.
이 세 가지 조건을 만족시키기 위해 고안된 소프트웨어 및 하드웨어 도구들을 동기화(Synchronization) 도구라고 부른다.
대표적인 동기화 도구들
1. 뮤텍스 (Mutex)
**뮤텍스(Mutex, MUTual EXclusion)**는 이름에서 알 수 있듯이 상호 배제를 위한 가장 기본적이고 널리 사용되는 도구다. 임계 구역에 ‘자물쇠(Lock)‘를 채우는 방식으로 동작한다.
-
개념: 임계 구역에 들어가기 전에 스레드는 먼저 뮤텍스 락을 **획득(acquire)**해야 한다. 만약 다른 스레드가 이미 락을 획득한 상태라면, 락이 **해제(release)**될 때까지 기다려야 한다. 임계 구역에서의 작업을 마친 스레드는 반드시 락을 해제하여 다른 스레드가 들어올 수 있도록 해야 한다.
-
비유: ‘하나뿐인 회의실 열쇠’와 같다. 회의실(임계 구역)에 들어가려면 로비에서 열쇠(뮤텍스 락)를 가져가야 한다. 열쇠는 하나뿐이므로, 한 팀이 사용하는 동안 다른 팀은 열쇠가 반납될 때까지 기다려야 한다.
C
// 뮤텍스를 사용한 입금 함수
mutex_lock lock; // 뮤텍스 락 객체
void deposit_with_mutex() {
acquire(&lock); // 임계 구역 시작 전, 락을 획득
// ----- 임계 구역 시작 -----
int temp = balance;
temp = temp + 100;
balance = temp;
// ----- 임계 구역 종료 -----
release(&lock); // 임계 구역 종료 후, 락을 해제
}
2. 세마포어 (Semaphore)
세마포어는 뮤텍스보다 좀 더 일반화된 동기화 도구다. 정수형 변수 하나와 두 개의 원자적 연산 P(wait)와 V(signal)로 구성된다.
-
개념: 세마포어 변수는 현재 사용 가능한 자원의 개수를 나타낸다.
-
P(S)또는wait(S): 스레드가 자원을 사용하려 할 때 호출. 세마포어 변수S의 값을 1 감소시킨다. 만약S가 0보다 작아지면, 해당 스레드는 자원이 생길 때까지 대기(block)한다. -
V(S)또는signal(S): 스레드가 자원 사용을 마쳤을 때 호출. 세마포어 변수S의 값을 1 증가시킨다. 만약 대기 중인 스레드가 있다면, 그중 하나를 깨워서 작업을 계속하게 한다.
-
-
종류:
-
바이너리 세마포어 (Binary Semaphore): 값이 0 또는 1만 가질 수 있다. 뮤텍스와 거의 동일하게 동작한다.
-
카운팅 세마포어 (Counting Semaphore): 값이 0 이상의 정수가 될 수 있다. 여러 개 있는 자원을 관리할 때 유용하다.
-
-
비유: ‘주차장의 주차 가능 대수 전광판’과 같다. 주차장에 차가 들어올 때마다(
P연산) 전광판 숫자가 하나 줄어든다. 만약 숫자가 0이 되면, 입구 차단기가 내려와 다음 차는 기다려야 한다. 주차장에서 차가 나갈 때마다(V연산) 숫자가 하나 늘고, 기다리던 차가 있다면 들어갈 수 있게 된다.
3. 모니터 (Monitor)
뮤텍스와 세마포어는 강력하지만, 개발자가 acquire/release나 P/V 연산을 정확한 위치에 사용해야 하는 부담이 있다. 자칫 락을 해제하는 것을 잊으면 시스템 전체가 멈추는 **교착 상태(Deadlock)**에 빠질 수 있다.
모니터는 이러한 문제점을 해결하기 위해 등장한 고수준 동기화 구조체다. 프로그래밍 언어 차원에서 제공되며, 개발자가 동기화 로직을 좀 더 안전하고 쉽게 구현할 수 있도록 돕는다.
-
개념: 공유 자원(데이터)과 해당 자원에 접근할 수 있는 함수들을 하나의 ‘틀(모니터)’ 안에 캡슐화한다. 개발자는 이 틀 안에 있는 함수를 호출하기만 하면 되고, 모니터가 내부적으로 상호 배제를 보장해 준다. 즉, 모니터 내의 함수는 한 번에 하나의 스레드만 실행할 수 있도록 언어 차원에서 강제된다.
-
비유: ‘규칙이 엄격한 은행 창구’와 같다. 고객(스레드)은 직접 금고(공유 데이터)에 손댈 수 없고, 반드시 은행원(모니터 함수)을 통해서만 입출금 요청을 해야 한다. 은행 창구는 한 번에 한 명의 고객만 응대하므로(상호 배제), 여러 고객이 동시에 와도 순서대로 안전하게 처리된다.
4부 심화 탐구
경쟁 상태와 관련된 몇 가지 심화 주제를 통해 이해의 폭을 넓혀보자.
경쟁 상태 vs. 교착 상태 (Race Condition vs. Deadlock)
두 개념은 동시성 프로그래밍에서 발생하는 대표적인 문제지만, 본질적으로 다르다.
| 구분 | 경쟁 상태 (Race Condition) | 교착 상태 (Deadlock) |
|---|---|---|
| 문제의 본질 | 실행 순서에 따라 결과가 달라지는 결과의 비결정성 | 두 개 이상의 스레드가 서로가 가진 자원을 기다리며 무한 대기 |
| 증상 | 데이터가 깨지거나, 계산 결과가 틀리는 등 예측 불가능한 동작 | 프로그램이 더 이상 진행되지 않고 멈춤 (Hang) |
| 비유 | 두 요리사가 하나의 레시피 노트를 동시에 수정하려다 레시피가 엉망이 되는 상황 | 두 사람이 외나무다리 양 끝에서 서로 비켜주기만을 기다리며 꼼짝도 못 하는 상황 |
실제 사례와 예방 전략
-
테라크-25 (Therac-25) 방사선 치료기 사고: 1980년대에 발생한 이 비극적인 사고는 소프트웨어의 경쟁 상태가 얼마나 치명적인 결과를 낳을 수 있는지 보여주는 대표적인 사례다. 특정 조건에서 경쟁 상태가 발생하여 환자에게 과도한 방사선이 조사되었고, 이로 인해 여러 명이 사망하거나 심각한 부상을 입었다.
-
예방 전략:
-
공유 자원 최소화: 가능하면 스레드 간에 데이터를 공유하지 않도록 설계한다.
-
불변성(Immutability) 활용: 공유되는 데이터는 읽기 전용으로 만들어 변경 자체를 불가능하게 한다.
-
동기화 도구의 올바른 사용: 임계 구역의 범위를 최소화하고, 적절한 동기화 도구를 선택하여 일관성 있게 적용한다.
-
언어 및 프레임워크 지원 활용: 동시성을 안전하게 다룰 수 있도록 지원하는 최신 프로그래밍 언어(예: Rust, Go)나 라이브러리를 적극적으로 사용한다.
-
하드웨어 수준의 지원
사실 뮤텍스나 세마포어 같은 소프트웨어 동기화 도구들도 결국 하드웨어의 도움이 있어야 구현될 수 있다. lock을 걸고 푸는 과정 자체가 경쟁 상태에 빠질 수 있기 때문이다. 이를 해결하기 위해 대부분의 CPU는 **원자적 연산(Atomic Operation)**을 하드웨어 명령어로 제공한다.
-
Test-and-Set: 특정 메모리 위치의 값을 확인하고 변경하는 작업을 중단 없이 한 번에 처리하는 명령어.
-
Compare-and-Swap (CAS): 특정 메모리 위치의 값이 예상 값과 일치할 경우에만 새로운 값으로 교체하는 명령어.
이러한 원자적 연산은 동기화 도구를 만드는 기반 벽돌 역할을 하며, 운영체제가 안전하게 임계 구역 문제를 해결할 수 있도록 보장한다.
결론: 경쟁 상태를 넘어, 견고한 동시성 프로그래밍으로
경쟁 상태는 멀티코어와 분산 시스템이 표준이 된 오늘날, 모든 개발자가 피할 수 없는 도전 과제다. 그것은 단순히 코드 한 줄의 실수가 아니라, 동시성 환경의 본질적인 특성에서 비롯되는 문제다.
이 핸드북을 통해 우리는 경쟁 상태가 왜 생겨나고, 어떻게 동작하며, 어떤 도구들로 제어할 수 있는지 살펴보았다. 핵심은 공유 자원을 식별하고, 임계 구역을 정의하며, 상호 배제 원칙을 철저히 지키는 것이다. 뮤텍스, 세마포어, 모니터와 같은 고전적인 동기화 도구들의 원리를 이해하는 것은 견고하고 안정적인 소프트웨어를 만드는 데 필수적인 소양이다.
경쟁 상태를 두려워할 필요는 없다. 오히려 그 원리를 깊이 이해함으로써 우리는 동시성이라는 강력한 도구를 안전하게 활용하여 더 빠르고 효율적인 프로그램을 만들 수 있다. 코드를 작성할 때 항상 ‘이 코드가 여러 스레드에 의해 동시에 실행된다면?‘이라는 질문을 던지는 습관을 통해, 예측 불가능한 버그로부터 자유로운 견고한 소프트웨어 아키텍처를 구축해 나갈 수 있을 것이다.