1. 더 복잡한 Race Condition 예제
Race Condition은 가장 교활한 버그 중 하나입니다. 왜일까요? 보통 드물게 발생하고, CPU 속도, OS 지연, 스레드 간의 우연한 전환에 의존하기 때문에 재현이 어렵습니다. 정적 분석기는 잡지 못하고, 유닛 테스트도(당신이 초능력이 없는 이상) 잡아내지 못합니다. 하지만 한번 발생하면 프로덕션에서 큰 골칫거리가 됩니다.
이런 불쾌한 놀라움을 피하려면 Race Condition이 어떻게 생기는지 잘 이해하고 있어야 합니다.
예제 1. 은행 윤리가 없는 은행
아주 단순한 은행 계좌 클래스가 있다고 해봅시다:
public class Account
{
public int Balance = 0;
public void Deposit(int amount)
{
Balance += amount;
}
public void Withdraw(int amount)
{
Balance -= amount;
}
}
두 스레드가 동시에 계좌에 100과 200을 입금한 뒤 각각 50을 출금한다고 가정해보세요. 단일 스레드 환경이라면 잔액은 항상 (0 + 100 + 200 - 50 - 50) = 200이 됩니다.
멀티스레드 현실에서는 어떻게 될까요? 실험해봅시다:
var acc = new Account();
var t1 = new Thread(() => {
acc.Deposit(100);
acc.Withdraw(50);
});
var t2 = new Thread(() => {
acc.Deposit(200);
acc.Withdraw(50);
});
t1.Start(); t2.Start();
t1.Join(); t2.Join();
Console.WriteLine(acc.Balance); // 여기서 실행할 때마다 다른 값이 나올 수 있다!
설명:
두 스레드가 모두 Balance를 0으로 읽고, 하나는 100을 쓰고 다른 하나는 200을 쓰는 사이에 스케줄링 순서가 바뀌면 잔액이 돈을 '잃어버리거나' '덧붙여질' 수 있습니다. 락이 없는 은행은 사기꾼의 꿈입니다!
예제 2. 플래그 변수
플래그 변수를 상태 표시기로 쓰는 실수가 흔합니다:
bool isReady = false;
void Worker()
{
while (!isReady)
{
// 기다림...
}
// 뭔가를 수행
}
다른 스레드가 isReady = true를 쓸 수 있습니다. 언뜻 보면 안전해 보이지만, 불리언 읽기/쓰기조차도 안전하지 않을 수 있습니다! 이유는 컴파일러 최적화, CPU 캐시, 멀티프로세서 환경에서의 명령 재정렬입니다.
무슨 일이 일어날 수 있나?
한 스레드는 플래그 변경을 절대 못 보고 무한 루프를 돌 수 있습니다. 스레드 간에 '플래그'를 전달할 때는 항상 특수 프리미티브를 사용하세요 (volatile, Interlocked, 이벤트 등 — 자세한 내용은 공식 문서 참조).
예제 3. null 검사와 객체 생성
싱글턴 클래스 예제를 생각해봅시다:
public class Singleton
{
private static Singleton _instance;
public static Singleton Instance
{
get
{
if (_instance == null)
{
_instance = new Singleton();
}
return _instance;
}
}
}
코드는 사소해 보이지만, 여러 스레드가 동시에 Instance를 호출하면 여러 인스턴스가 생성될 수 있습니다! 이는 동기화 없는 double-check 패턴의 고전적 문제입니다: _instance 필드는 보호되지 않으면 레이스로 초기화될 수 있습니다.
2. 코드에서 Race Condition이 생기는 원리
읽기-수정-쓰기 연산의 해부 — 악의 구조
다시 카운터 증가를 봅시다: counter++
CPU 관점에서 이건 다음과 같습니다:
- 메모리에서 읽기: register = counter;
- 증가: register = register + 1;
- 다시 쓰기: counter = register;
두 스레드가 이 블록을 거의 동시에 수행하면 무슨 일이 일어날까요?
- 둘 다 counter = 0을 읽는다.
- 둘 다 내부 레지스터를 1로 증가시킨다.
- 둘 다 1을 쓴다.
결과: 두 번 증가했지만 값은 단지 1만 증가했다! 한 증가가 '먹혔습니다' — 아무도 눈치채지 못합니다.
두 스레드 충돌의 시각화
스레드 A | 스레드 B | counter-------------------------------------------
읽음 (0) | | 0
| 읽음 (0) | 0
증가 (1) | | 0
| 증가 (1) | 0
쓰기 (1) | | 1
| 쓰기 (1) | 1 <-- 앗! 2를 기대했는데 1이 나왔다
왜 Race Condition 잡기 힘든가?
- Race Condition은 "가려집니다". Console.WriteLine을 몇 개 넣으면 오류가 사라질 수 있습니다. 스레드 스케줄링이 바뀌기 때문입니다.
- 오류는 코어 수, 부하, OS와 .NET 버전에 따라 달라집니다. 어제는 잘 되던 게 오늘은 망가질 수 있습니다.
- 결과가 불안정합니다: 항상 발생하지 않고 '특정 상황'에서만 드러납니다.
- 디버깅이 아주 골치 아픕니다.
3. 컬렉션과 .NET 클래스에서의 Race Condition
Race Condition은 변수뿐 아니라 컬렉션, 큐, 그리고 표준 .NET 클래스들에서도 발생합니다. 항상 안전하다고 가정하지 마세요.
예: List<T>는 thread-safe가 아니다
두 스레드가 동시에 보통의 List<T>에 .Add()를 호출하면 결과가 끔찍할 수 있습니다:
var list = new List<int>();
var tasks = new List<Task>();
for (int t = 0; t < 10; t++)
{
int threadNum = t;
tasks.Add(Task.Run(() => {
for (int i = 0; i < 1000; i++)
list.Add(threadNum * 1000 + i);
}));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // 아마도 10000보다 작을 가능성이 높다
여기서 Race Condition은 Add() 내부에서 발생합니다. 컬렉션이 내부 배열을 확장하고 요소를 복사하는 도중에 다른 스레드가 추가 작업을 하면 데이터 손실, 예외, 또는 손상된 컬렉션이 생길 수 있습니다.
예: 여러 스레드가 같은 인덱서 사용
var array = new int[10];
void Worker(int index, int value)
{
array[index] = value;
}
Parallel.Invoke(
() => Worker(5, 1),
() => Worker(5, 2)
);
결과적으로 array[5]에는 1이나 2가 들어갈 수 있습니다 — 누가 마지막으로 썼는지에 따라. 때론 이런 동작이 의도된 경우도 있지만 대부분은 미묘하고 잡기 힘든 버그의 근원입니다.
4. 유용한 뉘앙스
Race Condition과 비원자적(비-atomic) 연산
작아 보이지만 원자성을 보장하지 않는 연산들에 특히 주의하세요.
- i++, i--
- a = b
- myObject.Property = value
- "플래그를 확인하고 값 변경"
- "객체를 가져와 내부 상태를 변경"
이 모든 동작은 각각 원자적이지 않습니다! 단계 사이에 다른 스레드가 개입할 수 있습니다.
Race Condition 관련 신화와 함정
신화 1: "변수가 int이면 안전하다, 원시 타입이니까".
현실: int 연산도 volatile로 표시되거나 특수 API를 사용하지 않으면 원자성을 보장하지 않습니다.
신화 2: "한 스레드가 쓰고 다른 스레드가 읽기만 하면 괜찮다".
현실: 아니다! .NET 컬렉션은 동시에 읽기와 쓰기가 일어날 때 무결성을 보장하지 않습니다. 깨진 객체나 이상한 예외가 발생할 수 있습니다.
신화 3: "Race Condition은 오직 고부하 서비스에서만 발생한다".
현실: 소규모 유틸리티, 게임, 데스크탑 앱에서도 발생할 수 있습니다. 단지 드물게 또는 늦게 드러날 뿐입니다.
Race Condition을 막는 방법
- 동기화 프리미티브 사용하기 (lock, Mutex, Monitor 등).
- 숫자 증가/감소 같은 기본 연산에는 Interlocked 사용 (문서).
- 컬렉션에는 스레드 안전한 전용 클래스 사용 (예: ConcurrentBag, ConcurrentDictionary 등 — 문서 참조).
- 보호되지 않은 플래그나 상태 변수를 쓰지 않기 (필요하면 volatile, 이벤트, 동기화 프리미티브 사용).
언제 Race Condition이 발생하는가
| 시나리오 | Race Condition이 있는가? | 보호 방법 |
|---|---|---|
| 여러 스레드가 데이터를 읽음 | 아니오 (변경이 없을 때) | - |
| 한 스레드가 쓰고 다른 스레드가 읽음 | 예 | |
| 여러 스레드가 씀 | 예 | |
| 여러 스레드가 컬렉션을 변경 | 예 | 스레드 안전 컬렉션 사용 |
| 여러 스레드가 플래그 사용 | 예 | volatile, lock, 이벤트 |
원자성은 만병통치약이 아니다
가끔은 원자적 연산조차 논리적 '레이스'를 막지 못합니다.
예:
if (!cache.ContainsKey(key))
{
cache[key] = GetData(key);
}
두 스레드가 동시에 이 if에 들어가면 둘 다 키가 없다고 보고 둘 다 값을 생성해서, 두 번째가 첫 번째 결과를 덮어씁니다. 이런 경우에는 단순한 원자적 int 연산이나 Interlocked로는 해결되지 않습니다. 전체 락이 필요하거나 전문화된 함수(예: ConcurrentDictionary의 GetOrAdd)를 사용해야 합니다.
4. 학생들이 Race Condition 다루면서 흔히 하는 실수
실수 #1: lock은 "큰 것"에만 필요하다고 생각한다.
사실 간단한 변수도 보호 없이는 깨질 수 있다.
실수 #2: 잘못된 객체를 잠근다.
예: 외부에서 접근 가능한 문자열이나 객체를 lock에 사용하면 의도한 동기화가 동작하지 않는다.
실수 #3: "거의 항상 맞으니" 라고 믿는다.
버그가 드물게 나타난다고 보호를 안 해도 되는 건 아니다.
실수 #4: 비동기 메서드를 동기화 없이 사용한다.
운에 맡기면 예기치 못한 곳에서 엉망이 된다.
실수 #5: double-check 싱글턴을 lock 없이 구현한다.
메모리에 한 개의 객체만 있어야 하는데 여러 복사본이 생기고 디버깅이 지옥이 된다.
GO TO FULL VERSION