[로봇활용_14주차] C# 스레드 동기화(Thread Synchronization)

최윤호·2025년 11월 8일
post-thumbnail

스레드의 협업 규칙

여러 개의 스레드를 사용하여 작업을 병렬로 처리하다 보면
하나의 데이터에 스레드가 동시에 접근하려는 상황이 발생할 수 있습니다.
마치 작업장에서 여러 일꾼이 하나의 망치를 쓰려고 다투는 것처럼요.
이번 글에서는 여러 스레드가 공유된 자원을 사용할 때 발생하는 문제를 예방하고,
질서를 부여하는 기술인 스레드 동기화(Thread Synchronization)에 대해 알아보겠습니다.

1)경쟁 상태(Race Condition)

가장 먼저 마주하는 문제는 경쟁 상태(Race Condition)입니다.
이는 여러 스레드가 동시에 공유 자원(Shared Resource)에 접근하여 값을 변경하려고 할 때,
실행 순서를 예측할 수 없어 잘못된 결과가 나오는 현상을 말합니다.

비유: 동시에 출금하는 부부

부부가 잔액이 10,000원인 공유 계좌를 가지고 있다고 상상해 봅시다.
두 사람이 각자 다른 ATM에서 동시에 10,000원을 출금하려고 합니다.

1. 아내: ATM에서 잔액 10,000원을 확인합니다.
2. 남편: 다른 ATM에서 잔액 10,000원을 확인합니다.
3. 아내: 10,000원을 출금합니다. (계좌 잔액: 0원)
4. 남편: (아내가 출금하기 직전!) 10,000원을 출금합니다. (계좌 잔액: -10,000원)

분명 잔액은 10,000원이었는데, 은행은 20,000원을 내주게 되는 대참사가 발생했습니다!

C# 코드로 보는 경쟁 상태

아래 코드는 두 개의 스레드가 _counter라는 공유 변수를
각각 1,000,000번씩 증가시키는 예제입니다. 결과는 당연히 2,000,000이 되어야겠죠?

[코드]

class Program
{
    private static int _counter = 0;

    static void Main()
    {
        Thread t1 = new Thread(IncrementCounter);
        Thread t2 = new Thread(IncrementCounter);

        t1.Start();
        t2.Start();

        t1.Join(); // t1 스레드가 끝날 때까지 대기
        t2.Join(); // t2 스레드가 끝날 때까지 대기

        // 과연 결과는 2,000,000이 나올까요?
        Console.WriteLine($"최종 결과: {_counter}");
    }

    static void IncrementCounter()
    {
        for (int i = 0; i < 1_000_000; i++)
        {
            _counter++; // 여기가 바로 문제의 지점! (임계 영역)
        }
    }
}

[실행 결과]

최종 결과: 1623970

코드를 실행해 보면, 2,000,000이 아닌 그보다 훨씬 작은 값이 나옵니다.
_counter++는 사실 '읽기-수정-쓰기' 3단계로 이루어지는데,
이 3단계 사이에 다른 스레드가 끼어들어 값을 변경해 버리기 때문입니다.

2)해결책: 임계 영역 잠그기

이럴 때 공유 자원에 접근하는 코드 영역, 즉 임계 영역(Critical Section)
한 번에 단 하나의 스레드만 실행할 수 있도록 보호해야 합니다.
마치 탈의실에 들어갈 때 문을 잠그는 것과 같아요.

1. lock - 간단한 문 잠그기

C#에서 기본적이고 널리 쓰이는 동기화 방법입니다.
lock 키워드는 특정 객체(_lockObject)를 '열쇠'로 사용하여 코드 블록을 잠급니다.

[코드]

class Program
{
    private static int _counter = 0;
    // lock을 위한 '열쇠' 객체. 보통 private readonly object 타입으로 만든다.
    private static readonly object _lockObject = new object();

    static void Main()
    {
        Thread t1 = new Thread(IncrementCounter);
        Thread t2 = new Thread(IncrementCounter);

        t1.Start();
        t2.Start();

        t1.Join();
        t2.Join();

        Console.WriteLine($"최종 결과: {_counter}");
    }

    static void IncrementCounter()
    {
        for (int i = 0; i < 1_000_000; i++)
        {
            lock (_lockObject) // 이 문이 열릴 때까지 기다린다.
            {
                // 이제 이 블록 안은 한 번에 하나의 스레드만 들어올 수 있다.
                _counter++;
            } // 블록을 벗어나면 문이 자동으로 열린다.
        }
    }
}

[실행 결과]

최종 결과: 2000000

이제 코드를 실행하면 정확히 2,000,000이라는 결과가 나옵니다!

2. 그 외 동기화 기법들

lock외에도 다양한 상황에 맞는 동기화 도구들이 있습니다.

도구비유특징
Monitorlock의 수동 버전lock이 내부적으로 사용하는 클래스. 더 세밀한 제어가 가능
Mutex시스템 전체의 VIP 룸 열쇠lock과 비슷하지만, 여러 프로세스 간의 동기화도 가능
Semaphore스터디 카페의 좌석 (N개)정해진 개수(N개)의 스레드만 임계 영역에 들어오도록 허용

3)스레드 동기화의 함정

동기화는 강력하지만, 잘못 사용하면 치명적인 문제를 일으킬 수 있습니다.

1. 교착 상태(Deadlock)

비유: 철수와 영희가 좁은 외나무다리 양 끝에서 마주쳤습니다.
철수는 "영희가 비켜야 내가 간다!"라고 생각하고,
영희는 "철수가 비켜야 내가 간다!"라고 생각합니다.

  • 결국 둘은 움직이지 못하는 교착 상태(Deadlock)에 빠집니다!

두 개 이상의 스레드가 서로가 가진 '열쇠(lock)'를 기다릴 때 교착 상태에 빠질 수 있습니다.
이런 상황을 피하려면 lock의 순서를 일정하게 유지하는 등 신중한 설계가 필요합니다.

2. 긴 작업에는 부적합

lock은 가급적 짧은 시간 동안만 유지되어야 합니다.
한 스레드가 일을 마칠 때까지 다른 스레드는 아무것도 못 하고 기다려야 합니다.
이것을 블로킹(Blocking) 현상이라고 하며 자원 낭비로 이어집니다.

4)정리하며

스레드가 안전하게 협업하기 위한 규칙, '동기화'에 대해 알아봤습니다.

  • 경쟁 상태: 여러 스레드가 공유 자원을 동시에 수정하려 할 때 발생
  • 임계 영역: 공유 자원에 동시에 접근해서는 안 되는 코드 영역
  • lock: 임계 영역을 한 번에 한 스레드만 실행하도록 잠그는 가장 기본적인 방법
  • 교착 상태: 스레드들이 서로의 작업이 끝나기만을 기다리며 멈춰버리는 위험한 상태
  • 블로킹(Blocking): 한 스레드가 다른 스레드의 작업을 마칠 때까지 기다리는 상태
profile
🚀 미래의 엔지니어를 꿈꾸는 훈련생의 기록 📝

0개의 댓글