[CS] Thread의 Race Condition 해결
다음은 멀티스레드 환경에서의 공유 변수 접근 시 발생할 수 있는 경쟁 상태(Race Condition)와 이를 해결하기 위한 방법을 Java의 관점에서 분석한 글이다. 이 글에서는 공유 변수의 단순 할당과 증감(++) 연산의 차이를 설명하고, 예제 코드를 통해 각 변수 유형 및 동기화 방식에 따른 결과를 정리하였다.
멀티스레드 환경에서의 공유 변수 접근과 Race Condition 분석
멀티스레드 환경에서는 하나의 변수를 여러 스레드가 동시에 접근하는 경우, 예상과 다른 결과가 나타나는 경우가 있다. 이는 주로 경쟁 상태(Race Condition) 때문이다. 특히 공유 변수에 단순 값 할당과 증감 연산(++)은 결과에 큰 차이를 보인다. 이에 대한 원인과 해결책을 구체적으로 살펴본다.
1. 경쟁 상태(Race Condition)의 개념
경쟁 상태란 두 개 이상의 스레드가 동일한 자원을 동시에 접근할 때, 접근 순서나 타이밍에 따라 예상치 못한 결과가 발생하는 현상을 의미한다.
예를 들어, 다음 코드와 같은 증감 연산(++)은 내부적으로 다음과 같이 분해된다.
counter++;
이는 실제로 다음과 같은 단계로 구성된다.
- counter 변수의 현재 값을 읽음 (Read)
- 읽은 값에 1을 더함 (Modify)
- 계산한 값을 다시 counter에 저장 (Write)
위 과정은 원자적(atomic)이지 않으므로, 여러 스레드가 동시에 수행할 경우 데이터 손실이 발생할 수 있다.
2. 단순 할당과 복합 연산의 차이점
연산 종류 원자성(Atomicity) 여부 동기화 필요성
단순 할당 (예: a = 10;) | O (원자적) | 동기화가 권장되나 필수는 아님 |
증감 연산 (예: a++;) | X (비원자적) | 반드시 동기화 필요 |
즉, 단순 할당의 경우 동기화가 없어도 결과가 일관될 가능성이 높으나, 증감 연산은 반드시 동기화 처리를 해야 일관된 결과를 보장할 수 있다.
3. 멀티스레드 환경에서 증감 연산의 예제와 분석
아래는 멀티스레드 환경에서 각 변수 유형과 동기화 방식별로 증감 연산을 수행하는 예제이다. 각 예제는 스레드 10개가 각각 100번씩 증가 연산을 수행한다. 예상 최종값은 1000이다.
(1) 일반 int 사용 시 문제점과 해결책
동기화 없는 경우 (경쟁 상태 발생)
public class IntIncrementNoSync {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 100; j++) {
counter++; // 비원자적 연산
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("최종 값: " + counter);
}
}
- 기대값: 1000
- 실제값: 1000보다 작은 불확정 값 (경쟁 상태 발생)
해결 방법①: synchronized 블록 사용
synchronized(lock) {
counter++;
}
해결 방법②: Lock 사용 (ReentrantLock)
lock.lock();
try {
counter++;
} finally {
lock.unlock();
}
이렇게 하면 정확한 결과(1000)를 얻을 수 있다.
(2) AtomicInteger를 사용한 해결 방법
AtomicInteger 클래스는 내부적으로 원자적 연산을 지원하며, 별도의 동기화 없이 경쟁 상태를 해결할 수 있다.
AtomicInteger를 활용한 코드 예제
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicIntegerExample {
static AtomicInteger counter = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 100; j++) {
counter.incrementAndGet(); // 원자적 연산
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("최종 값: " + counter.get());
}
}
- 기대값 및 실제값: 정확히 1000 (경쟁 상태 없음)
(3) volatile int 사용 시 주의사항
volatile 키워드는 공유 변수의 가시성(visibility)을 보장하지만, 증감 연산(++)의 원자성은 보장하지 않는다.
volatile을 사용한 문제 발생 예제
public class VolatileIntExample {
volatile static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 100; j++) {
counter++; // 비원자적 연산, 경쟁 상태 발생
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("최종 값: " + counter);
}
}
- 기대값: 1000
- 실제값: 1000보다 작은 불확정 값 (경쟁 상태 발생)
따라서 volatile 변수는 읽기/쓰기 연산에는 유효하지만, 증감 연산과 같은 복합 연산 시 반드시 추가적인 동기화 처리를 해야 한다.
4. 변수 유형과 동기화 방식별 권장사항 정리
변수 유형 ++ 연산 원자성 보장 추가 동기화 필요 여부 권장하는 동기화 방식
일반 int | X | O (필수) | synchronized / Lock |
AtomicInteger | O | X (불필요) | 별도 동기화 불필요 |
volatile int | X | O (필수) | synchronized / Lock |
5. 결론
멀티스레드 환경에서 공유 변수에 접근할 때는 경쟁 상태(Race Condition)의 가능성을 항상 고려해야 한다. 특히 증감 연산(++)과 같은 복합 연산은 반드시 적절한 동기화 방식을 적용해야만 예상한 결과를 얻을 수 있다.
이를 해결하기 위해 일반 int 변수는 synchronized나 Lock과 같은 명시적 동기화 기법을 사용해야 하며, AtomicInteger와 같은 특수 자료형을 사용하면 간편하게 원자성을 확보할 수 있다. volatile 키워드는 가시성만을 보장하므로 복합 연산 시 별도의 동기화가 필수적이다.