csct3434
메모리 가시성 문제로 인한 Java의 동시성 버그 본문
최근 자바 동시성 프로그래밍 스터디를 진행하면서 흥미로운 문제 상황을 경험했다. 세탁물 처리 파이프라인을 모델링한 멀티스레드 프로그램에서 무한 Busy Waiting이 발생했는데, 이는 JVM 메모리 모델과 CPU 캐시 가시성(visibility) 문제와 직접적인 관련이 있었다. 이 글에서는 해당 문제의 원인과 다양한 해결책을 살펴보고자 한다.
문제 상황: 세탁물 처리 파이프라인
먼저 문제가 발생한 코드를 간략히 살펴보자. 아래 코드는 세탁소 파이프라인을 모델링한 멀티스레드 프로그램이다:
public class MissionA2 {
// 세탁물을 표현하는 레코드
private record Washload(int number) {}
// 세탁을 담당하는 스레드
private static class Washer extends Thread {
private static final long WASHING_TIME = 4000L;
private static final String THREAD_NAME = "Washer";
private final Queue<Washload> inQueue;
private final Queue<Washload> outQueue;
public Washer(Queue<Washload> inQueue, Queue<Washload> outQueue) {
super(THREAD_NAME);
this.inQueue = inQueue;
this.outQueue = outQueue;
}
@Override
public void run() {
while (!inQueue.isEmpty()) {
Washload washload = inQueue.poll();
System.out.printf("%s: washing Washload #%d...%n", THREAD_NAME, washload.number());
try {
Thread.sleep(WASHING_TIME);
} catch (InterruptedException e) {
e.printStackTrace();
}
outQueue.add(washload);
}
}
}
// 건조를 담당하는 스레드
private static class Dryer extends Thread {
private static final long DRYING_TIME = 2000L;
private static final String THREAD_NAME = "Dryer";
private final Queue<Washload> inQueue;
private final Queue<Washload> outQueue;
public Dryer(Queue<Washload> inQueue, Queue<Washload> outQueue) {
super(THREAD_NAME);
this.inQueue = inQueue;
this.outQueue = outQueue;
}
@Override
public void run() {
try {
while (true) {
if (inQueue.isEmpty()) {
// Thread.sleep(10); // 주석 처리된 중요한 코드
continue;
}
System.out.println("PASSED");
Washload washload = inQueue.poll();
System.out.printf("%s: drying Washload #%d...%n", THREAD_NAME, washload.number());
Thread.sleep(DRYING_TIME);
outQueue.add(washload);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 접기를 담당하는 스레드
private static class Folder extends Thread {
// Dryer와 유사한 구현 생략...
}
// 파이프라인을 관리하는 클래스
private static class Pipeline {
private static final int WASH_LOAD_COUNT = 4;
private void runConcurrently() {
Queue<Washload> toBeWashed = assembleLaundryForWashing();
Queue<Washload> toBeDried = new LinkedList<>();
Queue<Washload> toBeFolded = new LinkedList<>();
Washer washer = new Washer(toBeWashed, toBeDried);
Dryer dryer = new Dryer(toBeDried, toBeFolded);
Folder folder = new Folder(toBeFolded);
try {
washer.start();
dryer.start();
folder.start();
washer.join();
dryer.join();
folder.join();
System.out.println("All done!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private Queue<Washload> assembleLaundryForWashing() {
// 세탁물 준비 로직 생략...
}
}
}
위 코드에서 핵심적인 문제는 Dryer와 Folder 클래스의 run() 메서드에서 발생한다. 주석 처리된 Thread.sleep(10) 라인의 활성화 여부에 따라 프로그램의 동작이 완전히 달라진다. 이 라인이 주석 처리된 경우 프로그램은 무한 루프에 빠지며, 활성화되면 정상적으로 동작한다.
JVM 메모리 모델과 CPU 캐시 구조 이해하기
이 문제를 이해하기 위해서는 먼저 JVM 메모리 모델과 CPU 캐시 구조에 대한 이해가 필요하다.
CPU 캐시와 메인 메모리
현대 컴퓨터 아키텍처에서는 CPU와 메인 메모리 사이에 여러 계층의 캐시가 존재한다:
- L1 캐시: 각 CPU 코어에 가장 가까운 캐시로, 접근 속도가 가장 빠르다
- L2 캐시: L1보다 크고 약간 느리지만 여전히 빠른 캐시
- L3 캐시: 여러 코어가 공유하는 더 큰 캐시
- 메인 메모리(RAM): 모든 프로세스가 공유하는 메모리
이러한 계층적 캐시 구조는 성능 향상에 큰 도움을 주지만, 멀티스레드 환경에서는 캐시 일관성(cache coherence) 문제를 발생시킨다.
스레드와 캐시 가시성
멀티코어 환경에서 각 스레드는 서로 다른 CPU 코어에서 실행될 수 있다. 각 코어는 자체 캐시를 가지므로 한 스레드가 공유 변수를 변경해도 다른 스레드는 즉시 이 변경을 볼 수 없다. 이것이 바로 **가시성 문제(visibility problem)**다.
Busy Waiting과 가시성 문제의 관계
Busy Waiting이란?
Busy Waiting은 스레드가 어떤 조건이 충족될 때까지 계속해서 루프를 돌며 CPU 자원을 소비하는 현상이다. 예제 코드에서는 다음과 같은 패턴으로 나타난다:
while (true) {
if (inQueue.isEmpty()) {
continue; // 여기서 Busy Waiting 발생
}
// 작업 처리...
}
문제 발생 시나리오
가시성 문제로 인한 무한 Busy Waiting이 발생하는 과정을 단계별로 살펴보자:
- Washer 스레드가 작업을 완료하고 toBeDried 큐에 항목을 추가한다.
- 이 변경은 먼저 Washer 스레드가 실행되는 CPU 코어의 캐시에만 반영된다.
- Dryer 스레드는 다른 CPU 코어에서 실행 중이며, 자신의 캐시에 있는 toBeDried 상태만 볼 수 있다.
- 메인 메모리와 CPU 캐시 간 동기화가 발생하지 않으면, Dryer는 toBeDried 큐가 여전히 비어있다고 판단한다.
- 결과적으로 Dryer는 계속해서 빈 큐를 확인하는 무한 루프에 빠진다.
Thread.sleep()이 문제를 해결하는 이유
주석 처리된 Thread.sleep(10)을 활성화하면 왜 문제가 해결될까?
- sleep() 호출은 스레드의 컨텍스트 스위칭을 발생시킨다.
- 컨텍스트 스위칭 시 메모리 배리어(memory barrier) 효과가 있어 CPU 캐시와 메인 메모리 간 동기화가 발생한다.
- 스레드가 깨어날 때 최신 메모리 값을 다시 읽어와 큐에 항목이 추가된 것을 인식할 수 있다.
즉, sleep()은 가시성 문제를 우연히 해결하는 효과를 가져왔던 것이다. 그러나 이는 해결책으로 권장되지 않는 방법이다.
해결 방법
1. volatile 키워드 사용
가시성 문제를 해결하는 가장 직접적인 방법은 공유 변수에 volatile 키워드를 사용하는 것이다:
private final volatile Queue<Washload> inQueue;
volatile로 선언된 변수는:
- 항상 메인 메모리에서 읽고 쓰도록 보장한다.
- 변수 접근 전후로 메모리 배리어가 생성되어 캐시 동기화를 강제한다.
주의사항: volatile은 가시성만 보장하고 원자성(atomicity)은 보장하지 않는다. 복합 연산(읽고-수정하고-쓰기)에는 적합하지 않을 수 있다.
2. 동기화(synchronized) 사용
좀 더 강력한 방법으로, 공유 자원 접근 시 synchronized를 사용할 수 있다:
@Override
public void run() {
try {
while (true) {
Washload washload = null;
synchronized (inQueue) {
if (!inQueue.isEmpty()) {
washload = inQueue.poll();
}
}
if (washload == null) {
Thread.sleep(10);
continue;
}
// 작업 처리...
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized 블록:
- 메모리 배리어 역할을 하여 블록 진입/종료 시 메모리 동기화를 강제한다.
- 가시성과 원자성 모두 보장한다.
- 하지만 상대적으로 비용이 크고 성능 저하를 가져올 수 있다.
3. 동시성 컬렉션 사용
가장 권장되는 방법은 java.util.concurrent 패키지의 스레드 안전한 컬렉션을 사용하는 것이다:
import java.util.concurrent.ConcurrentLinkedQueue;
// 또는
import java.util.concurrent.LinkedBlockingQueue;
// 파이프라인 클래스에서
private void runConcurrently() {
Queue<Washload> toBeWashed = assembleLaundryForWashing();
Queue<Washload> toBeDried = new ConcurrentLinkedQueue<>();
Queue<Washload> toBeFolded = new ConcurrentLinkedQueue<>();
// 또는
// BlockingQueue<Washload> toBeDried = new LinkedBlockingQueue<>();
// BlockingQueue<Washload> toBeFolded = new LinkedBlockingQueue<>();
// 나머지 코드...
}
4. BlockingQueue 활용
BlockingQueue를 사용하면 Busy Waiting 문제도 함께 해결할 수 있다:
private final BlockingQueue<Washload> inQueue;
@Override
public void run() {
try {
while (true) {
// take()는 큐가 비어있으면 항목이 추가될 때까지 효율적으로 대기
Washload washload = inQueue.take();
// 작업 처리...
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
BlockingQueue.take() 메서드:
- 큐가 비어있으면 항목이 추가될 때까지 효율적으로 대기한다.
- 내부적으로 적절한 동기화 메커니즘을 사용하여 가시성을 보장한다.
- CPU 자원을 낭비하지 않는다.
권장 해결책: LinkedBlockingQueue 사용
위 문제를 해결하는 가장 이상적인 방법은 다음과 같다:
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.BlockingQueue;
private static class Pipeline {
private static final int WASH_LOAD_COUNT = 4;
private void runConcurrently() {
Queue<Washload> toBeWashed = assembleLaundryForWashing();
BlockingQueue<Washload> toBeDried = new LinkedBlockingQueue<>();
BlockingQueue<Washload> toBeFolded = new LinkedBlockingQueue<>();
Washer washer = new Washer(toBeWashed, toBeDried);
Dryer dryer = new Dryer(toBeDried, toBeFolded);
Folder folder = new Folder(toBeFolded);
// 나머지 코드...
}
}
private static class Dryer extends Thread {
private static final long DRYING_TIME = 2000L;
private static final String THREAD_NAME = "Dryer";
private final Queue<Washload> inQueue;
private final BlockingQueue<Washload> outQueue;
// 생성자...
@Override
public void run() {
try {
while (true) {
Washload washload;
// LinkedList를 사용하는 경우
while (true) {
synchronized (inQueue) {
if (!inQueue.isEmpty()) {
washload = inQueue.poll();
break;
}
}
Thread.sleep(10); // 짧은 대기
}
// BlockingQueue를 사용하는 경우
// washload = ((BlockingQueue<Washload>)inQueue).take();
System.out.printf("%s: drying Washload #%d...%n", THREAD_NAME, washload.number());
Thread.sleep(DRYING_TIME);
outQueue.add(washload);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
결론
JVM 캐시 가시성 문제는 멀티코어 환경에서 멀티스레드 프로그래밍의 중요한 도전 과제다. 이러한 문제는 직관적이지 않아 디버깅이 어려울 수 있으며, 특히 Thread.sleep()과 같은 우연한 해결책이 문제를 가리는 경우가 많다.
효과적인 해결을 위해서는:
- 가시성 보장: volatile, synchronized, 동시성 컬렉션을 통해 메모리 가시성을 보장해야 한다.
- 효율적인 대기: BlockingQueue, CountDownLatch 등을 사용해 효율적인 대기/통지 메커니즘을 구현해야 한다.
- 적절한 설계: 공유 자원 최소화와 불변 객체 사용으로 동시성 문제를 근본적으로 회피해야 한다.
스레드 간 통신에 있어 가장 권장되는 방법은 BlockingQueue를 사용하는 것이다. 이는 가시성과 효율적인 대기를 자동으로 처리해주며, 코드를 더 간결하고 견고하게 만들어준다.
멀티스레드 프로그래밍에서 이러한 가시성 문제와 관련된 버그는 재현이 어렵고 상황에 따라 간헐적으로 발생하므로, 적절한 동시성 제어 메커니즘을 처음부터 적용하는 것이 중요하다.
'개발 일지' 카테고리의 다른 글
Redis의 낙관적 락(Optimistic Lock)과 분산 락(Distributed Lock) 비교 (0) | 2025.03.10 |
---|---|
CAS(Compare-And-Swap): 논블로킹 동시성 제어의 핵심 (0) | 2025.03.02 |
메시지 유실을 고려한 채팅 시스템 클러스터 개발기 (0) | 2025.02.25 |
[Spring Cloud Config Server] Github Private Repository 연동하기 (0) | 2025.02.08 |
실시간 통신 방식 비교 : Polling, Long Polling, Server Sent Event, WebSocket (0) | 2025.01.17 |