멀티스레드 프로그래밍에서 여러 스레드가 동일한 자원에 동시에 접근할 때, 적절한 제어가 이루어지지 않으면 데이터의 일관성이 깨지는 '스레드 안전(Thread Safety)' 문제가 발생합니다. 예를 들어, 여러 스레드가 하나의 공유 변수 값을 증가시키는 단순한 작업조차도 예상치 못한 결과를 초래할 수 있습니다.
데이터 불일치 현상의 발생
공유 자원에 여러 스레드가 동시에 쓰기 작업을 수행할 때 발생하는 문제를 코드로 확인해 보겠습니다. 다음은 두 개의 스레드가 하나의 변수를 각각 10,000번씩 증가시키는 예제입니다.
public class CounterCollision {
private static int globalCount = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increaseTask = () -> {
for (int i = 0; i < 10000; i++) {
globalCount++;
}
};
Thread threadA = new Thread(increaseTask);
Thread threadB = new Thread(increaseTask);
threadA.start();
threadB.start();
threadA.join();
threadB.join();
// 기대값: 20000, 실제 출력: 20000보다 작은 임의의 값
System.out.println("최종 결과: " + globalCount);
}
}
이론적으로는 20,000이 출력되어야 하지만, 실제로는 그보다 적은 숫자가 출력됩니다. 이는 globalCount++ 연산이 CPU 수준에서 단일 명령어가 아닌 '읽기-수정-쓰기'의 세 단계로 이루어지기 때문입니다.
Java 메모리 모델(JMM)과 동기화 이슈
자바 가속기(JVM)의 메모리 구조에서 각 스레드는 성능 향상을 위해 메인 메모리에서 데이터를 직접 다루지 않고, CPU 캐시나 로컬 변수 테이블에 복사본을 만들어 작업합니다.
- 원자성(Atomicity) 부족: 스레드 A가 값을 읽어 1을 더하는 사이, 스레드 B가 동일한 이전 값을 읽어버리면 두 번의 증가 연산이 한 번만 반영되는 결과가 발생합니다.
- 가시성(Visibility) 문제: 한 스레드가 수정한 값이 즉시 메인 메모리에 반영되지 않아 다른 스레드가 최신 값을 보지 못하는 현상입니다.
해결책 1: synchronized 키워드를 이용한 상호 배제
synchronized 블록은 특정 코드 영역을 한 번에 하나의 스레드만 실행할 수 있도록 보장합니다. 이를 통해 '원자성'을 확보할 수 있습니다. 락(Lock)을 획득한 스레드만이 임계 영역(Critical Section)에 진입할 수 있으며, 작업이 끝날 때까지 다른 스레드는 대기 상태가 됩니다.
public class SynchronizedCounter {
private static int safeCount = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
synchronized (lock) {
safeCount++;
}
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("안전한 결과: " + safeCount); // 항상 20000 출력
}
}
이 방식은 데이터의 정확성을 완벽히 보장하지만, 락을 획득하고 해제하는 과정에서 발생하는 오버헤드로 인해 과도한 사용 시 성능 저하가 발생할 수 있습니다.
해결책 2: volatile 키워드를 통한 가시성 보장
volatile 키워드는 변수의 값을 CPU 캐시가 아닌 메인 메모리에서 직접 읽고 쓰도록 강제합니다. 이는 변수의 '가시성' 문제를 해결해 줍니다. 하지만 주의할 점은 volatile이 '원자성'까지 보장하지는 않는다는 것입니다.
public class StatusChecker extends Thread {
private static volatile boolean stopRequested = false;
@Override
public void run() {
System.out.println("감시 스레드 시작");
while (!stopRequested) {
// 플래그 변경을 대기
}
System.out.println("스레드가 안전하게 종료되었습니다.");
}
public static void main(String[] args) throws InterruptedException {
StatusChecker checker = new StatusChecker();
checker.start();
Thread.sleep(1000);
stopRequested = true; // volatile 덕분에 checker 스레드가 즉시 인지함
}
}
위 예제에서 volatile이 없다면, checker 스레드는 자신의 로컬 캐시에 저장된 stopRequested의 이전 값(false)을 계속 참조하여 영원히 루프를 돌 가능성이 있습니다.
요약 및 권장 사항
스레드 안전한 애플리케이션을 설계할 때는 다음과 같은 기준을 고려해야 합니다.
- 단순히 변수의 최신 값만 참조해야 하는 상태 플래그에는
volatile을 사용합니다. - 복합 연산(증가, 감소, 조건부 업데이트)이 포함된 경우
synchronized또는ReentrantLock을 사용합니다. - 성능이 중요하다면
java.util.concurrent.atomic패키지의AtomicInteger와 같은 논블로킹(Non-blocking) 자료구조를 활용하는 것이 효과적입니다.