1. 소개
멀티스레드 애플리케이션에서 race condition 존재 여부는 "만약"의 문제가 아니라 "언제"의 문제야. 코드가 튼튼하다고 생각하고 "작은 스레드 두 개뿐이라서 명백하고 간단해"라고 해도, 상태 경합은 가장 무해해 보이는 로직 구간에 숨어 있을 수 있어.
race condition이 도대체 뭐고 왜 그렇게 무서운가? 두 사람이 동시에 같은 종이에 쓰려고 한다고 상상해봐 — 한 사람은 쓰고, 다른 사람은 지운다. 가끔은 괜찮지만 가끔은 엉망진창이 되지. 프로그래밍에서는 결과가 더 재미있게 나타날 수 있어: 버그는 항상 발생하는 게 아니라 특정한, 거의 우연한 상황에서만 나타나.
Race condition (레이스 컨디션) — 프로그램 실행 결과가 어떤 스레드가 먼저 자원에 접근하거나 어떤 행동을 먼저 수행했느냐에 따라 달라지는 상황이야. 이 문제는 동시(멀티스레드) 접근이 있을 때만 발생하고, 두 개 이상의 스레드가 공유 데이터나 자원에 접근할 때 생겨.
경쟁이 일어날 때 무슨 일이 일어나나?
간단한 그림을 보자. 두 개의 스레드와 하나의 공유 자원(예: 변수 X)이 있다고 가정해:
+---------+ +---------+
| 스레드 1 | | 스레드 2 |
+----+----+ +----+----+
| |
| X 읽기 |
| <-------------------|
| |
| X 증가 |
|-------------------> |
| |
| X 쓰기 |
| <-------------------|
두 스레드가 동시에 변수 X를 읽고 증가시킨 뒤 다시 쓰면, 누군가의 변경이 덮어써져서 기대한 만큼 증가하지 않을 수 있어.
2. 전형적인 Race Condition 예제
예제로 보자. 여러 스레드에서 버튼 클릭 수나 처리한 작업 수를 세고 싶다고 하자.
간단한 변수와 그것을 증가시키는 몇 개의 스레드를 사용해 봐:
using System;
using System.Threading;
class Program
{
static int counter = 0; // 공유 리소스
static void Main()
{
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("예상 값: 200000");
Console.WriteLine("실제 값: " + counter);
}
static void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // << 여기서 문제가 발생할 수 있어!
}
}
}
우리가 기대하는 건?
각 스레드가 counter를 100000번씩 증가시키므로, 최종값은 200000이어야 해.
실제로는?
가끔은 — 맞아, 200000. 하지만 보통은 더 작게 나와 — 때로는 훨씬 작게. 실험을 반복하면 결과가 들쭉날쭉해!
왜 그런가?
연산 counter++는 원자적이지 않아. 실제로는 대략 이렇게 실행돼:
- 현재 값 counter를 읽음 (예: 0)
- 1을 더함 (결과 1)
- 다시 저장함 (counter = 1)
두 스레드가 동시에 같은 오래된 값을 읽으면, 둘 다 같은 새 값을 쓰게 되어 하나의 인크리먼트가 날아갈 수 있어.
두 스레드 예제로 시각화하면:
예를 들어 counter = 0 라고 하자.
- 스레드 1: 0 읽음
- 스레드 2: 0 읽음
- 스레드 1: 0 + 1 = 1 계산
- 스레드 2: 0 + 1 = 1 계산
- 스레드 1: 1 저장
- 스레드 2: 1 저장 (스레드 1의 인크리먼트가 사라짐)
"축하해", 하나의 증가를 잃었어! 수천, 수백만 번의 연산이 쌓이면 결과는 크게 흔들릴 거야.
3. 더 많은 예: 인크리먼트뿐만이 아냐!
주방의 소동
조금 더 재미있게 설명하면, 작은 카페를 생각해봐. 두 명의 요리사가 같은 프라이팬에서 오믈렛을 굽는데 서로 조율을 안 해:
- 첫 번째는 오믈렛 하나를 올리고, 두 번째가 바로 자기 것을 올리면 둘이 서로 섞여버려;
- 한쪽은 "난 이미 오믈렛 두 개를 올렸어"라고 생각하고, 다른 쪽도 같은 생각을 하지만 실제로는 팬에 세 개만 있고 둘 다 네 개라고 생각하고 있을 수 있어;
- 혼란이 시작돼...
프로그래밍에서 race condition은 똑같이 "혼란"을 일으켜: 결과가 빠르고 제어되지 않은 연산의 순서에 따라 달라져.
스레드가 서로 방해할 때: 데이터에 대한 동시 접근
예를 들어 은행 애플리케이션을 구현한다고 치자. 고객이 같은 계좌에서 동시에 입금과 출금을 다른 스레드로 수행하면(예: 하나는 온라인 이체, 다른 하나는 창구):
account.Balance += 500; // 스레드 1: 입금
account.Balance -= 300; // 스레드 2: 출금
이 연산들이 보호되지 않으면 최종 잔액이 잘못될 수 있어: 일부 연산이 단순히 "덮어써"져 버릴 수 있거든.
4. 유용한 포인트
왜 race condition이 문제인가?
잡아내기와 재현이 어렵다. 버그는 과부하된 머신이나 드문 조건에서만 나타날 수 있어.
디버깅이 어렵다. 디버깅할 때 스레드 타이밍이 달라져서 버그가 사라질 수 있어.
데이터 무결성 훼손. 잘못되거나 손상된 데이터를 얻을 수 있고 경우에 따라 눈에 띄지 않게 진행돼.
보안 문제. 중요 시스템에서는 race condition이 데이터 유출, 데이터 파괴, 심지어 취약점으로 이어질 수 있어.
레이스 타이밍 다이어그램
+-----------------------+ +-----------------------+
| 스레드 1 | | 스레드 2 |
+-----------------------+ +-----------------------+
| 1. counter 읽기 | | |
| 2. counter 증가 | | |
| (하지만 쓰지 않음) | | |
| | | 1. counter 읽기 |
| | | 2. counter 증가 |
| | | 3. counter 쓰기 |
| | | (counter = 1) |
| 3. counter 쓰기 | | |
| (counter = 1) | | |
+-----------------------+ +-----------------------+
두 스레드가 인크리먼트를 했지만, 실제로는 하나의 인크리먼트만 기록됐어!
어디에서 자주 발생하나
- 여러 스레드가 접근하는 전역 변수나 static 변수들.
- 여러 스레드에서 채워지는 리스트, 큐, 컬렉션들.
- 이벤트와 delegate — 구독/구독 해제가 동시에 일어나면(예: UI와 백그라운드 작업) 문제될 수 있어.
- 캐싱, dictionary, 연결 관리.
- 트랜잭션이나 잠금 없이 파일, 로그, DB와 상호작용할 때.
race condition을 피하는 방법: 간단한 소개
- 동기화! (자세한 내용은 다음 강의에서).
- 언어와 라이브러리가 제공하는 특별한 구조 사용: lock, Monitor, mutex, semaphore 등.
- 단순 연산에는 원자적 메서드 사용: Interlocked.Increment 등.
- 스레드 안전 컬렉션 사용: ConcurrentBag, ConcurrentDictionary.
- 항상 생각해: "내 함수 두 개가 동시에 호출되면 무슨 일이 일어나지?"
5. 유용한 팁
레이스 찾기와 진단 팁
- 여러 스레드를 쓰고 있다면 가장 단순한 연산(증가 연산 ++, 대입)도 신뢰하지 마.
- 가능하면 변수를 공유하지 않는 방향으로 설계해.
- "플로팅"하는 버그, 재현하기 어려운 오류가 보이면 — 레이스를 의심해!
- 스레드 분석 도구 사용: dotTrace, Concurrency Visualizer, Thread Sanitizer.
- 부하 테스트를 해 봐 — 스레드와 연산이 많을수록 버그를 발견할 확률이 높아.
동기화 없이 해도 되는 것과 해선 안 되는 것
| 연산 | 멀티스레드 환경에서 안전한가? | 설명 |
|---|---|---|
| int 대입 int | 🟩 가끔* | 한 스레드만 쓰고 나머지가 읽기만 한다면 안전, 그렇지 않으면 경합 |
| 증가 연산 (++/--) | 🟥 아님 | 원자적이지 않아! Race Condition 발생 |
| string 읽기 string | 🟩 가끔* | 객체가 생성된 후 변경되지 않는다면 안전 |
| 객체 대입 | 🟩 가끔* | 동일한 시간에 쓰기 작업이 없다면 |
| List에 추가 List<T> | 🟥 아님 | List<T>는 스레드 안전이 아님 |
|
🟩 예 | 특별한 원자적 메서드 |
— "가끔"이라는 건, 한 스레드만 쓰고 다른 스레드는 읽기만 하는 경우는 안전하다는 뜻이야; 여러 스레드가 동시에 쓸 수 있다면 항상 경합 위험이 있어.
6. 흔한 실수와 함정
위 데모 코드에서 counter++가 문제였다는 걸 봤지. 또 다른 함정은 값 증가나 조건 검사에서 발생해.
예: "첫 실행" 관련 웃긴 버그
if (!alreadyStarted)
{
alreadyStarted = true;
// 초기화 작업...
}
이 조건을 여러 스레드가 동시에 실행하면, 각 스레드는 alreadyStarted == false를 보고 안으로 들어갈 수 있어! 결과적으로 뭔가가 두 번 초기화되어 실패로 이어질 수 있어.
GO TO FULL VERSION