1. 경쟁 상태 (race condition) 알아보기
경쟁 상태(race condition)를 떠올려 봅시다 — 프로그램의 결과가 스레드들이 공유 데이터나 리소스에 접근하는 순서에 따라 달라지는 상황입니다. 실행 순서가 바뀌면 결과는 예측 불가능해집니다. 이는 친구와 동시에 같은 문서를 편집하려고 할 때와 비슷합니다. 누가 더 빨리 입력하느냐에 따라 승패가 갈리고, 최종 텍스트는 매우 이상해질 수 있습니다.
Java(그리고 멀티스레딩을 지원하는 다른 모든 언어)에서 race condition은 여러 스레드가 적절한 동기화 없이 동일한 변수를 동시에 읽거나/또는 수정할 때 발생합니다.
왜 race condition이 발생할까요?
Java 스레드는 병렬로 동작합니다. 두 스레드가 동시에 하나의 변수에 접근한다면(예: 공용 카운터를 증가) 서로의 작업을 “덮어쓸” 수 있습니다. 연산이 겉보기에는 원자적이어도(예: counter++) 실제로는 그렇지 않습니다!
어떻게 동작하나요 counter++?
인크리먼트 연산은 다음 단계로 이루어집니다:
- 메모리에서 변수의 현재 값을 읽는다.
- 그 값을 1만큼 증가시킨다.
- 새 값을 메모리에 다시 쓴다.
이때 다른 스레드도 동시에 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) 같은 “마법 같은” 일시 정지를 넣는 것. 이런 접근은 문제를 해결하지 못하고 단지 가릴 뿐입니다. 진짜 해결책은 동기화 또는 원자적 연산입니다.
GO TO FULL VERSION