백엔드

Race Condition과 Deadlock

땅다람쥐 2026. 5. 30. 21:00

 

개발을 하다보면 멀티쓰레딩을 쓰고 싶은 마음이 굴뚝 같은데, 경쟁 상태(Race Condition)이나 데드락(Deadlock) 등 여러 장애 때문에 조심하게 됩니다. 이번 포스트에서는 Race Condition과 Deadlock이 왜 발생하는지, 어떻게 하면 해결할 수 있는지 한 번 알아보겠습니다.

 

스레드 안전 (Thread Safety)


Race Condition과 Deadlock에 대해 이야기 하기 전에 스레드 안전에 대해서 알아보겠습니다. 스레드 안전이란 "어떤 코드가 주어졌을 때, 그 코드가 스레드 몇 개에서 호출되든 이 스레드들이 어떤 순서로 호출되든 간에 상관없이 올바른 결과가 나오는 것"을 스레드 안전이라고 합니다.

 

간단히 말해서 항상 싱글 스레드이든 멀티 스레딩이든 항상 올바른 결과가 나와야된다는 의미죠. 그러면 어떻게 스레드 안전하게 코드를 짤 수 있을까요? 이 걸 이해하기 위해서는, 스레드끼리 어떤 리소스를 공유하는지 먼저 알아야합니다.

 

공유 리소스 (Shared Resource)


 

스레드가 전용 리소스로 사용하는건 함수의 지역 변수(local variable), 스택 영역(Stack), 스레드 전용 저장소(Thread-local storage)이고, 그 외의 영역(Heap, Data, Code)은 공유 리소스로 활용이 됩니다. 하지만 코드 영역은 읽기 전용이라서, 크게 걱정할 필요가 없습니다.

 

이 공유 리소스를 사용하는 스레드는 반드시 특정 순서를 따라야 합니다. 이 순서는 공유 리소스를 사용하는 스레드가 다른 스레드의 작업을 방해할 수 없는거죠. 이거를 위해서 잠금(Lock), 세마포어(Semaphore)같은 장치를 사용할 수 있습니다.

 

Race Condition


그럼 스레드 안전하지 않은 코드를 짜면 어떤 결과가 나올까요? 대표적인 예로는 Race Condition 이 있습니다.  Race Condition은 스레드의 실행 순서에 따라서 결과가 달라지면서 데이터의 정합성(Consistency)가 깨지는 경우를 말합니다.

 

말 보다는 보여주는게 더 빠르니, 코드로 한 번 확인해볼까요?

class Main {
    private static int count = 0;

    // 공유 자원의 값을 증가시키는 메서드
    public static void increment() {
        count++;
    }

    public static int getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        // 10,000번씩 증가시키는 스레드 2개 생성
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                increment();
            }
        });

        // 스레드 레이스 시작!
        thread1.start();
        thread2.start();

        // 두 스레드가 모두 종료될 때까지 대기
        thread1.join();
        thread2.join();

        // 결과 출력
        System.out.println("기대하는 결과: 20000");
        System.out.println("실제 실행 결과: " + getCount());
    }
}

 

 

전역 변수(Global Variable)인 count를 하나씩 올리는 간단한 코드입니다. 하지만 실제 결과는 우리가 기대한 대로 나오기가 어렵습니다.

왜 그런 걸까요? 바로 count 변수를 하나씩 올리는 과정에서 컨텍스트 스위칭(Context Switching) 꼬이기 때문입니다.

예를 들어, thread 1count를 사용할 때 0이었고, 이걸 한 칸 올려서 1로 만들려는 '찰나'에 CPU 주도권이 넘어갑니다. 이때 thread 2도 여전히 0인 상태의 count를 읽어가서 1을 만들어버리는 거죠. 결국 분명 두 번을 더했는데, 결과는 2가 아니라 1이 되어버리는 경우가 발생합니다. 이게 바로 Race Condition 입니다.

 

이거를 해결하기 위해서는 임계구역 (Critical Section)을 설정하고 다른 스레드가 못들어 오게 막아야합니다.

 

임계구역 (Critical Section), 뮤텍스 (Mutex), 세마포어 (Semaphore)


임계구역 (Critical Section)

임계구역은 여러 스레드가 동시에 접근해서 데이터를 수정하면 데이터가 오염되는 코드 영역을 말합니다.

 

우리가 방금 봤던 코드에서 "count++"가 실행되던 한 줄이 임계구역에 해당합니다. 멀티스레드 환경에서는 이 구역에 스레드가 한 번에 하나씩만 들어오도록 통제해야 데이터가 오염되지 않습니다. 그럼 어떻게 통제할 수 있을까요? 

 

뮤텍스 (Mutex)

뮤텍스는 임계구역을 보호하기 위한 대표적인  방법 중 하나입니다. 자물쇠(lock) 개념을 사용해서, 다른 스레드가 접근 못하게 막는 것이지요.

 

예를들어 식당에 1인용 화장실이 딱 하나 있고, 카운터에 열쇠가 하나만 놓여있는 상황입니다. 그러면 화장실에 한 번에 한 사람 밖에 못들어가겠지요? 화장실을 다 쓴 사람이 열쇠를 제자리에 돌려 놓을 때 까지 다른 사람들은 기다려야 합니다.

 

이 비유를 코드로 한 번 옮겨 볼까요?

 

public synchronized static void increment() {
        count++;
    }

 

자바에서는 이렇게 synchronized를 사용하게 되면, 자바 객체 안에 있는 lock을 사용할 수 있게 돼서, increment() 함수 전체가 lock에 걸리게 됩니다. 그래서 한 스레드가 작업을 하고 있으면 다른 스레드가 들어오지 못하는 거죠.

 

세마포어 (Semaphore)

세마포어는 뮤텍스와 달리 한 개의 스레드가 아니라, 우리가 설정한 개수만큼 스레드가 동시에 임계 영역에 접근할 수 있도록 허용해 줍니다.

 

예를 들어 임계 영역에 스레드가 하나씩만 들어가는 게 아니라, 세마포어(Semaphore)가 허용해 준 숫자가 가득 찰 때까지 스레드들이 계속 들어가서 동시에 작업을 할 수 있는 거죠. 그리고 그 정원을 초과하는 스레드들만 문밖에서 대기하도록 막아주는 역할을 합니다.

 

이렇게 Race Condition을 막으려고 Lock를 여기저기에 사용하다 보면, 역설적으로 다른 이슈가 생길 수 있습니다. 데드락 (Deadlock)입니다.

 

데드락 (Deadlock)


데드락은 스레드들이 서로 상대방이 가진 Lock이 풀리기만을 기다리면서 프로그램이 완전히 얼어버리는 현상을 말합니다.

 

예를 들어 라면을 먹을 때 숟가락과 젓가락 둘 다 필요하다고 가정해봅시다. 철수는 숟가락을 먼저 집은 뒤 젓가락을 집으려고 하고, 영희는 젓가락을 먼저 집은 뒤 숟가락을 집으려고 합니다. 이렇게 되면 둘 다 라면은 구경도 못 하고, 서로 도구를 하나씩 쥔 채 상대방이 내려놓기만을 기다리는 데드락 현상이 생겨버립니다.

 

코드를 예시로 볼까요? 

private static final Object spoon = new Object();
    private static final Object chopsticks = new Object();

    public static void main(String[] args) {
        
        // Thread 1: 철수 (숟가락을 먼저 lock 하고, 젓가락을 기다림)
        Thread thread1 = new Thread(() -> {
            synchronized (spoon) {
                System.out.println("철수: 숟가락을 획득했습니다. 국물 한 입~");
                
                // Thread 2가 젓가락을 집을 시간을 주기 위한 아주 짧은 찰나의 대기
                try { Thread.sleep(50); } catch (InterruptedException e) {}

                System.out.println("철수: 면 먹게 젓가락 좀 줄래...?");
                synchronized (chopsticks) {
                    System.out.println("철수: 흡입 시작!"); // ❌ 절대 실행 안 됨
                }
            }
        });

        // Thread 2: 영희 (젓가락을 먼저 lock 하고, 숟가락을 기다림)
        Thread thread2 = new Thread(() -> {
            synchronized (chopsticks) {
                System.out.println("영희: 젓가락을 획득했습니다. 면치기 준비 완!");
                
                try { Thread.sleep(50); } catch (InterruptedException e) {}

                System.out.println("영희: 국물 먹게 숟가락 좀 줄래...?");
                synchronized (spoon) {
                    System.out.println("영희: 흡입 시작!"); // ❌ 절대 실행 안 됨
                }
            }
        });

        // 두 스레드가 동시에 달리기 시작합니다.
        thread1.start();
        thread2.start();
    }
// Thread 1 (철수)과 Thread 2 (영희) 모두 순서를 통일!
synchronized (spoon) {       // 1. 숟가락을 먼저 잠근다
    synchronized (chopsticks) { // 2. 그다음 젓가락을 잠근다
        // 안전하게 식사 시작!
    }
}

 

이 코드를 보시면 젓가락과 숟가락은 결국 잡지 못하고 아래와 같은 상태에서 멈춰버립니다.

철수: 숟가락을 획득했습니다. 국물 한 입~
영희: 젓가락을 획득했습니다. 면치기 준비 완!
철수: 면 먹게 젓가락 좀 줄래...?
영희: 국물 먹게 숟가락 좀 줄래...?

 

철수와 영희 둘 다 숟가락과 젓가락 때문에 라면을 더 이상 먹지 못하는 경우가 생겨버리는거죠.

 

이러한 데드락 현상을 해결하기 위해서는 대표적인 방법으로 간단하게 락 순서를 일치 시키는 방법이 있습니다.

// Thread 1 (철수)과 Thread 2 (영희) 모두 순서를 통일!
synchronized (spoon) {       // 1. 숟가락을 먼저 잠근다
    synchronized (chopsticks) { // 2. 그다음 젓가락을 잠근다
        // 안전하게 식사 시작!
    }
}

 

이렇게 순서를 일치시키면, 누군가 spoon을 먼저 잡는 순간 다른 스레드는 spoon 줄에서 대기하게 되므로 chopsticks를 쥐고 엇갈리는 상황 자체가 생기지 않습니다. 개발할 때 동기화 순서 규칙만 잘 정해도 데드락을 막을 수 있습니다. 

 

 

'백엔드' 카테고리의 다른 글

[Docker] 컨테이너와 이미지  (0) 2026.07.12
LLM 서버 만들기: Thread와 Process  (0) 2026.05.31
TCP의 혼잡 제어  (0) 2026.05.30
OSI 7계층과 TCP/IP 4계층  (0) 2026.05.30
CPU와 GPU: 왜 AI는 CPU말고 GPU를 사용할까?  (0) 2026.05.23