Race Condition과 임계 영역 동기화 설계
Race Condition의 발생 원인과 임계 영역 조건, 세마포어·뮤텍스·모니터를 통한 동기화와 Unix 임시 파일 대응을 정리한다.
2026-08-14 · 최초 발행 2025-12-30
공유 메모리, 파일, I/O 장치, 데이터베이스처럼 여러 실행 주체가 함께 쓰는 자원은 접근 순서 자체가 결과를 결정할 수 있다. 이 순서가 통제되지 않으면 어떤 프로세스가 먼저 자원을 얻을지 예측할 수 없고, 같은 프로그램도 실행 시점에 따라 다른 결과를 낸다. 이것이 Race Condition, 즉 경쟁 상황이다.
Unix 환경에서는 symbolic link와 tmp 파일을 겨냥한 공격으로 이어질 수 있다. 따라서 이 문제는 계산 결과의 정확성뿐 아니라 시스템 보안 관점에서도 다뤄야 한다.
실행 순서가 결과를 바꾸는 이유
Race Condition은 여러 프로세스나 쓰레드가 공유 자원에 동시에 접근하면서 발생한다. 읽기와 쓰기가 겹치고, 그 작업이 원자적으로 처리되지 않으면 실행 순서와 Context Switch 시점에 따라 결과가 달라진다. 재현이 어려운 이유도 여기에 있다.
공유 변수 증가 연산은 단순해 보여도 읽기, 증가, 쓰기가 분리되어 실행된다.
// 두 프로세스가 동시에 실행
int count = 0;
// 프로세스 A
count = count + 1;
// 프로세스 B
count = count + 1;
어셈블리 수준에서는 두 프로세스의 연산이 서로 끼어들 수 있다.
; 프로세스 A
LOAD R1, count ; R1 = 0
ADD R1, R1, 1 ; R1 = 1
STORE count, R1 ; count = 1
; 프로세스 B
LOAD R2, count ; R2 = ?
ADD R2, R2, 1 ; R2 = ?
STORE count, R2 ; count = ?
두 증가 연산이 순서대로 반영되면 count = 2가 된다. 하지만 두 프로세스가 같은 값을 읽은 뒤 각각 쓴다면 하나의 증가가 사라져 count = 1이 될 수 있다.
단일 CPU에서도 경쟁은 발생한다
단일 처리 시스템은 CPU 시간을 작은 조각인 Time Slice로 나누어 각 프로세스에 순차적으로 할당한다. 이 과정에서 Context Switch가 일어나면, 프로세스 A가 공유 자원을 읽은 뒤 멈추고 프로세스 B가 값을 수정한 다음 A가 오래된 값으로 다시 쓰는 상황이 생긴다.
공유 자원 접근을 제어하려면 세마포어 또는 Lock으로 임계 영역 진입을 제한해야 한다. 공유 자원에 접근하는 코드는 짧게 유지하고, 꼭 필요한 경우에만 공유 자원을 사용해야 Lock 점유 시간과 독점 가능성을 줄일 수 있다.
다중 처리기에서는 충돌 경로가 늘어난다
여러 프로세서가 실제로 병렬 실행되는 환경에서는 공유 메모리에 동시에 도달하는 실행 경로가 더 많아진다. 어떤 프로세서에서 실행되는지와 관계없이 Lock과 Release의 경우의 수가 늘어나므로, 동기화 설계도 복잡해진다.
Spinlock은 자원을 얻을 때까지 반복 확인하는 Busy Waiting 방식이다. CPU 사이클을 소모하지만 짧은 대기 시간에는 사용할 수 있다. Timeout을 두면 일정 시간 이상 기다렸을 때 포기하도록 해 무한 대기를 막고 Deadlock을 피할 수 있다.
// Spinlock with Timeout
int timeout = 1000000; // 1초
while (!try_lock(&lock) && timeout > 0) {
timeout--;
}
if (timeout == 0) {
// Timeout 발생, 에러 처리
return ERROR;
}
// 임계 영역
unlock(&lock);
임계 영역은 진입부터 해제까지 관리한다
프로세스 코드는 공유 자원과 무관한 잔류 영역, 임계 영역에 들어가기 위한 진입 영역, 공유 자원을 다루는 임계 영역, Lock을 해제하는 출구 영역으로 나눌 수 있다.
진입 영역에서는 Lock 획득을 시도하고 허가를 기다린다.
// Entry Section
pthread_mutex_lock(&mutex);
임계 영역에는 공유 자원에 접근하는 코드가 들어가며, 한 번에 하나의 프로세스만 실행해야 한다.
// Critical Section
count++;
출구 영역은 임계 영역 종료를 알리고 다음 프로세스가 접근할 수 있도록 Lock을 해제한다.
// Exit Section
pthread_mutex_unlock(&mutex);
이 영역은 다음과 같은 순서로 반복된다.
do {
// Entry Section
acquire_lock();
// Critical Section
// 공유 자원 접근
// Exit Section
release_lock();
// Remainder Section
// 일반적인 작업
} while (true);
임계 영역 문제를 해결하려면 세 조건이 필요하다. 먼저 한 프로세스가 임계 영역을 실행하는 동안 다른 프로세스는 진입할 수 없어야 한다. 임계 영역이 비어 있다면 진입을 원하는 프로세스는 들어갈 수 있어야 하며, 무한 대기를 막아야 한다. 마지막으로 진입을 요청한 프로세스가 허가를 받을 때까지 다른 프로세스가 진입하는 횟수에는 제한이 있어야 하며, 이는 기아를 방지한다.
동기화 기법의 역할
세마포어는 정수형 변수와 P(Wait), V(Signal) 원자 연산으로 자원 접근을 제어한다. 카운팅 세마포어는 n개 자원을 다룰 수 있다.
// 세마포어 S
P(S):
while (S <= 0); // Busy Wait
S--;
V(S):
S++;
이진 세마포어는 임계 영역 진입과 종료를 다음처럼 제어할 수 있다.
semaphore mutex = 1;
P(mutex); // Entry Section
// Critical Section
count++;
V(mutex); // Exit Section
뮤텍스는 Mutual Exclusion을 위한 이진 세마포어로, 값은 0 또는 1이며 Lock과 Unlock으로 사용한다.
pthread_mutex_t mutex;
pthread_mutex_lock(&mutex);
// Critical Section
count++;
pthread_mutex_unlock(&mutex);
모니터는 공유 자원과 그 연산을 캡슐화하는 고급 동기화 구조다. 자동 상호 배제를 제공하고 조건 변수를 지원하므로 프로그래머의 부담과 오류 가능성을 낮춘다.
임시 파일과 symbolic link 공격을 막는 방법
예측 가능한 /tmp 파일명은 symbolic link 공격의 대상이 될 수 있다. 아래 코드는 /tmp/data.txt를 열어 내용을 쓰지만, 공격자가 해당 경로를 /etc/passwd로 연결하면 시스템 파일에 쓰기가 발생할 수 있다.
// 취약한 코드
FILE *fp;
fp = fopen("/tmp/data.txt", "w");
fprintf(fp, "sensitive data");
fclose(fp);
공격자는 기존 파일을 지우고 악의적인 symbolic link를 만들 수 있다.
// 공격 코드
unlink("/tmp/data.txt"); // 기존 파일 삭제
symlink("/etc/passwd", "/tmp/data.txt"); // 악의적 링크
가능하면 작업을 메모리에서 직접 수행하고, /tmp 디렉터리 사용과 예측 가능한 파일명을 줄여야 한다. 중요 파일에는 symbolic link 사용을 피하고, 링크 정보를 확인할 때는 lstat()로 symbolic link를 감지할 수 있다.
struct stat st;
lstat("/tmp/data.txt", &st);
if (S_ISLNK(st.st_mode)) {
// 심볼릭 링크 감지, 거부
return ERROR;
}
파일 생성 권한은 umask()로 조정할 수 있다.
umask(022); // rw-r--r--
FILE *fp = fopen("/tmp/data.txt", "w");
임시 파일이 필요하다면 예측하기 어려운 파일명과 적절한 권한을 제공하는 mkstemp()를 사용할 수 있다.
char template[] = "/tmp/dataXXXXXX";
int fd = mkstemp(template); // 안전한 임시 파일