CodeGym /행동 /JAVA 25 SELF /경쟁 상태 (race condition)

경쟁 상태 (race condition)

JAVA 25 SELF
레벨 51 , 레슨 4
사용 가능

1. 경쟁 상태 (race condition) 알아보기

경쟁 상태(race condition)를 떠올려 봅시다 — 프로그램의 결과가 스레드들이 공유 데이터나 리소스에 접근하는 순서에 따라 달라지는 상황입니다. 실행 순서가 바뀌면 결과는 예측 불가능해집니다. 이는 친구와 동시에 같은 문서를 편집하려고 할 때와 비슷합니다. 누가 더 빨리 입력하느냐에 따라 승패가 갈리고, 최종 텍스트는 매우 이상해질 수 있습니다.

Java(그리고 멀티스레딩을 지원하는 다른 모든 언어)에서 race condition은 여러 스레드가 적절한 동기화 없이 동일한 변수를 동시에 읽거나/또는 수정할 때 발생합니다.

왜 race condition이 발생할까요?

Java 스레드는 병렬로 동작합니다. 두 스레드가 동시에 하나의 변수에 접근한다면(예: 공용 카운터를 증가) 서로의 작업을 “덮어쓸” 수 있습니다. 연산이 겉보기에는 원자적이어도(예: counter++) 실제로는 그렇지 않습니다!

어떻게 동작하나요 counter++?

인크리먼트 연산은 다음 단계로 이루어집니다:

  1. 메모리에서 변수의 현재 값을 읽는다.
  2. 그 값을 1만큼 증가시킨다.
  3. 새 값을 메모리에 다시 쓴다.

이때 다른 스레드도 동시에 counter++를 수행하면, 둘 다 같은 값을 읽고, 둘 다 증가시키고, 둘 다 같은 결과를 기록할 수 있습니다 — 결국 인크리먼트 하나가 “사라집니다”.

2. race condition 예: 카운터 인크리먼트

여러 스레드를 실행하고, 각각이 공용 카운터를 1씩 증가시키는 간단한 프로그램을 작성해 봅시다. 겉보기에는 1000개의 스레드를 실행하면 최종 카운터 값이 1000이어야 할 것 같습니다. 확인해 보죠!

public class RaceConditionDemo {
    static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        int threads = 1000;
        Thread[] threadArray = new Thread[threads];

        for (int i = 0; i < threads; i++) {
            threadArray[i] = new Thread(() -> {
                counter++; // 위험한 연산!
            });
            threadArray[i].start();
        }

        // 모든 스레드가 종료될 때까지 대기
        for (int i = 0; i < threads; i++) {
            threadArray[i].join();
        }

        System.out.println("기대값: " + threads);
        System.out.println("실제값: " + counter);
    }
}

예상 출력:

기대값: 1000
실제값: 843

값은 실행할 때마다 달라질 수 있습니다: 어떤 때는 900, 어떤 때는 700, 드물게는 1000이 나오기도 합니다 — 하지만 매우 드뭅니다.

왜 이런 일이 벌어질까요?

스레드들이 동시에 counter 값을 읽고, 증가시키고, 다시 기록합니다. 두 스레드가 동일한 값을 읽어 둘 다 증가시키고 둘 다 기록하면 — 인크리먼트 하나가 사라집니다. 그 결과 최종 값은 항상 기대값보다 작습니다.

3. 또 다른 예: 동기화 없는 은행 계좌

은행 계좌가 있고, 두 스레드가 동시에 돈을 인출한다고 가정해 봅시다.

public class BankAccount {
    private int balance = 100;

    public void withdraw(int amount) {
        if (balance >= amount) {
            // 오래 걸리는 작업 흉내내기
            try { Thread.sleep(1); } catch (InterruptedException ignored) {}
            balance -= amount;
        }
    }

    public int getBalance() {
        return balance;
    }
}

public class BankDemo {
    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccount();

        Thread t1 = new Thread(() -> account.withdraw(100));
        Thread t2 = new Thread(() -> account.withdraw(100));

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("기대값: 0 또는 100");
        System.out.println("실제 잔액: " + account.getBalance());
    }
}

때로는 두 스레드 모두 계좌에 100이 있다고 보고 동시에 돈을 인출합니다. 그 결과 잔액이 -100이 될 수 있습니다! (현실에서는 일어나지 않지만, 코드에서는 쉽게 발생합니다.)

4. 유용한 포인트

race condition의 결과

경쟁 상태는 단순히 “이상한” 결과가 아닙니다. 개발자에게 진짜 골칫거리인데, 그 이유는 다음과 같습니다:

  • 오류가 항상 나타나지 않습니다. 어떤 때는 프로그램이 올바르게 동작하고, 어떤 때는 그렇지 않습니다. 스레드가 “얼마나 빨리” 동작했는지에 달려 있습니다.
  • 테스트가 성공을 보장하지 않습니다. 프로그램을 여러 번 실행해서 괜찮아 보여도, 어느 순간 갑자기 문제가 터질 수 있습니다.
  • 오류를 잡기 어렵습니다. 동작이 프로세서 속도, 시스템 부하, 다른 실행 중인 프로그램 등에 따라 달라집니다.
  • 치명적인 장애로 이어질 수 있습니다: 데이터 유실, 잘못된 계산, 애플리케이션 크래시 등.

실제 사례

  • 금융 애플리케이션: 잘못된 잔액 계산, 이중 청구.
  • 서버: 메시지 유실, 요청의 잘못된 처리.
  • 게임: 캐릭터 “텔레포트”, 점수의 잘못된 집계.

왜 테스트로는 race condition을 막을 수 없나요?

race condition은 전형적인 Heisenbug(잡으려고 하면 사라지는 버그)입니다. 수천 번 테스트를 돌려도 오류가 보이지 않는다고 해서 — 오류가 없다는 뜻은 아닙니다! 모든 것은 OS가 스레드 실행을 어떻게 스케줄링하느냐에 달려 있습니다. 어떤 때는 매끄럽게 지나가고, 어떤 때는 스레드가 “충돌”하여 문제가 발생합니다.

race condition을 피하는 방법

  • 동기화: 메서드나 코드 블록에 synchronized를 사용해, 매 순간 오직 하나의 스레드만 공유 데이터를 변경할 수 있도록 합니다.
  • 원자적 연산: java.util.concurrent.atomic 패키지의 클래스들(예: AtomicInteger)을 사용해, 명시적 동기화 없이도 안전한 연산을 수행합니다.
  • 불변성: 객체를 변경할 수 없다면 race condition은 발생하지 않습니다.

동기화 예제

public class SafeCounter {
    private int counter = 0;

    public synchronized void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}

이제 여러 스레드가 increment()를 호출하더라도, 어느 순간엔 단 하나의 스레드만 이 메서드를 실행할 수 있습니다.

5. 스레드에서 공유 변수를 다룰 때 흔한 실수

실수 №1: 단순한 연산이라서 안전하다고 믿는 경우.
많은 사람이 counter++가 하나의 연산이라 문제가 없다고 생각합니다. 실제로는 세 단계의 연산이며, 그 사이에 다른 스레드가 “끼어들” 수 있습니다.

실수 №2: 스레드 간 교환에 일반 변수를 사용하는 경우.
여러 스레드가 동기화 없이 하나의 변수를 읽고 쓰면 — 바로 race condition입니다!

실수 №3: 오류가 항상 재현될 것이라 기대하는 경우.
race condition은 가끔만 드러나기 때문에 특히 까다롭습니다. 테스트에서 잘 동작했다고 해서 — 안전하다고 기대하지 마세요.

실수 №4: 컬렉션을 사용할 때 동기화를 무시하는 경우.
ArrayList 같은 일반 컬렉션은 스레드 안전하지 않습니다. 여러 스레드가 요소를 추가하거나 삭제하면 오동작하거나 심지어 프로그램이 크래시할 수 있습니다.

실수 №5: 지연 시간으로 race condition을 “고치려는” 시도.
예를 들어 Thread.sleep(10) 같은 “마법 같은” 일시 정지를 넣는 것. 이런 접근은 문제를 해결하지 못하고 단지 가릴 뿐입니다. 진짜 해결책은 동기화 또는 원자적 연산입니다.

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