레이스 컨디션 데드락
멀티스레딩과 병렬 처리가 일반화된 현대 프로그래밍에서, 개발자들이 반드시 알아야 할 두 가지 핵심 개념이 있습니다. 바로 레이스 컨디션(Race Condition)과 데드락(Deadlock)입니다. 이 두 문제는 예측하기 어렵고 디버깅하기 까다로우며, 시스템의 안정성을 크게 해칠 수 있습니다.
레이스 컨디션(Race Condition)이란?
레이스 컨디션은 여러 스레드가 동시에 공유 자원에 접근하고 수정할 때, 스레드 간의 실행 순서에 따라 의도치 않은 결과가 나오는 상황을 말합니다.
레이스 컨디션이 발생하는 이유
레이스 컨디션의 핵심 원인은 공유 자원에 대한 동기화되지 않은 접근입니다. 간단한 예시로 살펴보겠습니다.
let balance = 100;
async function deposit(amount) {
const newBalance = balance + amount;
await new Promise(resolve => setTimeout(resolve, 100));
balance = newBalance;
}
async function withdraw(amount) {
const newBalance = balance - amount;
await new Promise(resolve => setTimeout(resolve, 100));
balance = newBalance;
}
deposit(50);
withdraw(30);
console.log(balance); // 예상: 120, 실제: 예측 불가능
위 코드에서 deposit(50)과 withdraw(30)이 동시에 실행되면, 두 함수가 모두 초기 balance 값(100)을 읽고 각각 계산을 수행하여 서로의 결과를 덮어쓸 수 있습니다.
레이스 컨디션의 문제점
- 예측 불가능한 동작: 실행할 때마다 다른 결과가 나올 수 있음
- 데이터 손상: 일부 스레드의 작업 결과가 유실될 수 있음
- 프로그램 비정상 종료: 예기치 못한 오류로 인한 시스템 불안정
🔐 레이스 컨디션 해결 방법
레이스 컨디션을 방지하기 위해서는 동기화(Synchronization) 기법을 사용해야 합니다.
1. 뮤텍스(Mutex)
뮤텍스는 Mutual Exclusion(상호 배제)의 약자로, 한 번에 하나의 스레드만 자원에 접근할 수 있도록 보장합니다.
class Mutex {
constructor() {
this.locked = false;
}
async lock() {
while (this.locked) {
await new Promise(resolve => setTimeout(resolve, 10));
}
this.locked = true;
}
unlock() {
this.locked = false;
}
}
const mutex = new Mutex();
async function safeDeposit(amount) {
await mutex.lock();
const newBalance = balance + amount;
await new Promise(resolve => setTimeout(resolve, 100));
balance = newBalance;
mutex.unlock();
}
2. 세마포어(Semaphore)
세마포어는 자원에 접근할 수 있는 스레드의 수를 제한하는 기법입니다.
class Semaphore {
constructor(max) {
this.max = max;
this.count = 0;
this.queue = [];
}
async acquire() {
if (this.count < this.max) {
this.count++;
} else {
await new Promise(resolve => this.queue.push(resolve));
}
}
release() {
this.count--;
if (this.queue.length > 0) {
const next = this.queue.shift();
next();
this.count++;
}
}
}
3. 읽기-쓰기 락(Read-Write Lock)
여러 스레드가 동시에 읽기 작업은 수행할 수 있지만, 쓰기 작업은 단일 스레드만 수행할 수 있도록 하는 고급 락입니다.
class ReadWriteLock {
constructor() {
this.readers = 0;
this.writing = false;
}
async readLock() {
while (this.writing) {
await new Promise(resolve => setTimeout(resolve, 10));
}
this.readers++;
}
readUnlock() {
this.readers--;
}
async writeLock() {
while (this.writing || this.readers > 0) {
await new Promise(resolve => setTimeout(resolve, 10));
}
this.writing = true;
}
writeUnlock() {
this.writing = false;
}
}
⚰️ 데드락(Deadlock)이란?
데드락은 여러 프로세스나 스레드가 서로의 자원을 기다리며 무한히 대기하는 상태에 빠지는 문제입니다. 이 상태가 발생하면 시스템이 아무 일도 수행하지 못하게 됩니다.
데드락의 4가지 발생 조건
데드락이 발생하기 위해서는 다음 네 조건이 모두 만족되어야 합니다.
- 상호 배제(Mutual Exclusion): 자원은 동시에 하나의 프로세스만 사용 가능
- 점유 대기(Hold and Wait): 자원을 점유한 상태에서 추가 자원을 대기
- 비선점(No Preemption): 다른 프로세스가 자원을 강제로 빼앗을 수 없음
- 환형 대기(Circular Wait): 프로세스 간 자원 요청이 순환 형태로 얽힘
데드락 발생 예시
const mutexA = new Mutex();
const mutexB = new Mutex();
async function process1() {
await mutexA.lock();
console.log("Process 1 locked mutexA");
await new Promise(resolve => setTimeout(resolve, 100));
await mutexB.lock(); // Process 2가 mutexB를 점유 중이면 무한 대기
console.log("Process 1 locked mutexB");
mutexB.unlock();
mutexA.unlock();
}
async function process2() {
await mutexB.lock();
console.log("Process 2 locked mutexB");
await new Promise(resolve => setTimeout(resolve, 100));
await mutexA.lock(); // Process 1이 mutexA를 점유 중이면 무한 대기
console.log("Process 2 locked mutexA");
mutexA.unlock();
mutexB.unlock();
}
process1();
process2();
// 데드락 발생! 서로의 자원을 기다리며 무한 대기
🛡️ 데드락 해결 방법
1. 데드락 예방(Prevention)
데드락 발생 조건 중 하나를 사전에 제거하는 방법입니다.
자원 요청 순서 제어
모든 프로세스가 자원을 동일한 순서로 요청하도록 하여 환형 대기를 방지합니다.
// 데드락 방지: 모든 프로세스가 mutexA → mutexB 순서로 요청
async function safeProcess1() {
await mutexA.lock();
console.log("Process 1 locked mutexA");
await mutexB.lock();
console.log("Process 1 locked mutexB");
mutexB.unlock();
mutexA.unlock();
}
async function safeProcess2() {
await mutexA.lock(); // 동일한 순서로 요청
console.log("Process 2 locked mutexA");
await mutexB.lock();
console.log("Process 2 locked mutexB");
mutexB.unlock();
mutexA.unlock();
}
선점 가능 자원 관리
필요한 경우 자원을 강제로 회수하여 데드락을 방지합니다.
class PreemptiveResource {
constructor() {
this.currentProcess = null;
}
request(process) {
if (this.currentProcess !== null) {
console.log(`Preempting resource from ${this.currentProcess} to ${process}`);
}
this.currentProcess = process;
}
release() {
if (this.currentProcess !== null) {
console.log(`Releasing resource from ${this.currentProcess}`);
this.currentProcess = null;
}
}
}
2. 데드락 회피(Avoidance)
은행가 알고리즘(Banker's Algorithm) 등을 사용하여 데드락이 발생할 수 있는 상황을 미리 예측하고 회피합니다.
3. 데드락 탐지 및 복구(Detection and Recovery)
데드락 발생을 탐지하고, 발생한 경우 특정 프로세스를 종료하거나 자원을 강제로 해제하여 해결합니다.
📝 실무에서의 고려사항
성능 vs 안전성
- 뮤텍스/락: 안전하지만 성능 오버헤드 존재
- 락-프리 프로그래밍: 고성능이지만 구현 복잡도 높음
- 읽기-쓰기 락: 읽기 작업이 많은 경우 성능 향상
디버깅 팁
- 로깅 강화: 자원 획득/해제 시점을 자세히 기록
- 타임아웃 설정: 무한 대기 상황 방지
- 데드락 탐지 도구: 개발 단계에서 정적 분석 활용
🎯 마무리
레이스 컨디션과 데드락은 멀티스레딩 환경에서 피할 수 없는 문제들입니다. 하지만 이들의 원리를 정확히 이해하고 적절한 동기화 기법을 사용한다면 안전하고 효율적인 병렬 프로그램을 작성할 수 있습니다.
핵심은 공유 자원에 대한 접근을 신중하게 설계하고, 자원 요청 순서를 일관되게 유지하며, 적절한 동기화 도구를 선택하는 것입니다.
복잡한 시스템일수록 이러한 문제들이 발생할 가능성이 높아지므로, 설계 단계부터 동시성 문제를 고려한 아키텍처를 구성하는 것이 중요합니다.