CodeGym /행동 /C# SELF /Race Condition (레이스 컨디션)

Race Condition (레이스 컨디션)

C# SELF
레벨 55 , 레슨 4
사용 가능

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++; // << 여기서 문제가 발생할 수 있어!
        }
    }
}

우리가 기대하는 건?

각 스레드가 counter100000번씩 증가시키므로, 최종값은 200000이어야 해.

실제로는?

가끔은 — 맞아, 200000. 하지만 보통은 더 작게 나와 — 때로는 훨씬 작게. 실험을 반복하면 결과가 들쭉날쭉해!

왜 그런가?

연산 counter++는 원자적이지 않아. 실제로는 대략 이렇게 실행돼:

  1. 현재 값 counter를 읽음 (예: 0)
  2. 1을 더함 (결과 1)
  3. 다시 저장함 (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>는 스레드 안전이 아님
Interlocked.Increment
🟩 예 특별한 원자적 메서드

— "가끔"이라는 건, 한 스레드만 쓰고 다른 스레드는 읽기만 하는 경우는 안전하다는 뜻이야; 여러 스레드가 동시에 쓸 수 있다면 항상 경합 위험이 있어.

6. 흔한 실수와 함정

위 데모 코드에서 counter++가 문제였다는 걸 봤지. 또 다른 함정은 값 증가나 조건 검사에서 발생해.

예: "첫 실행" 관련 웃긴 버그

if (!alreadyStarted)
{
    alreadyStarted = true;
    // 초기화 작업...
}

이 조건을 여러 스레드가 동시에 실행하면, 각 스레드는 alreadyStarted == false를 보고 안으로 들어갈 수 있어! 결과적으로 뭔가가 두 번 초기화되어 실패로 이어질 수 있어.

1
설문조사/퀴즈
멀티스레딩 입문, 레벨 55, 레슨 4
사용 불가능
멀티스레딩 입문
C#에서 멀티스레딩의 기본
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION