Chapter 3: 동시성 마스터리 (Deep Dive)
Java Memory Model, Visibility, Atomic Operations, ForkJoinPool
1. 동시성(Concurrency)의 두 얼굴
고성능 애플리케이션을 위해서는 동시성이 필수적이지만, 올바르게 다루지 못하면 재앙(Race Condition, Deadlock)이 됩니다.
- 표면적 이해: "여러 스레드가 동시에 일한다."
- 엔지니어링 관점: "Shared Mutable State(공유되는 변경 가능한 상태)를 어떻게 안전하게 관리할 것인가?"
2. Deep Dive: Java Memory Model (JMM)
면접 전 필독: 단순히 스레드를 만드는 게 중요한 게 아닙니다. CPU 캐시와 메인 메모리 사이의 불일치 문제를 이해해야 합니다.
2.1 가시성(Visibility) 문제
현대의 CPU는 성능을 위해 메인 메모리에서 값을 읽어와 L1/L2 캐시에 저장하고 작업합니다.
Architecture Visualization
- 문제 상황: 스레드 A가 값을 바꿨는데, 스레드 B의 캐시에는 아직 반영되지 않아 옛날 값을 읽는 현상.
- 해결책 (volatile): "작업할 때 캐시 쓰지 말고, 무조건 메인 메모리에서 읽고 써라"고 강제하는 키워드입니다.
2.2 원자성(Atomicity) 문제
count++ 같은 코드는 한 줄처럼 보이지만, 기계어로는 3단계(Read -> Modify -> Write)로 나뉩니다.
- 문제 상황: 스레드 A가 Read하고 Modify하는 도중에, 스레드 B가 끼어들어 값을 바꿔버리면(Context Switching) 연산 결과가 유실됩니다.
- 해결책 (Atomic Classes): CAS(Compare-And-Swap) 알고리즘을 이용해, 하드웨어 레벨에서 원자성을 보장합니다.
synchronized보다 훨씬 가볍고 빠릅니다.
java
1 // Thread-Unsafe2 int count = 0;3 void increment() { count++; } // 동시 접근 시 값 유실됨4 5 // Thread-Safe (Recommended)6 AtomicInteger atomicCount = new AtomicInteger(0);7 void incrementSafe() { atomicCount.incrementAndGet(); } // 안전함
3. 스레드 풀의 내부 동작 (ThreadPoolExecutor)
new Thread()를 직접 쓰면 안 되는 이유는 단순히 생성 비용 때문이 아닙니다. OS 리소스 고갈과 Context Switching 오버헤드 때문입니다.
Architecture Visualization
3.1 주요 파라미터 분석
java
1 new ThreadPoolExecutor(2 corePoolSize, // 평소에 유지할 직원 수3 maximumPoolSize, // 바쁠 때 임시로 늘릴 최대 직원 수4 keepAliveTime, // 임시 직원이 할 일 없을 때 해고 대기 시간5 new LinkedBlockingQueue<>(capacity) // 대기줄 (중요!)6 );
실무 주의사항: Executors.newFixedThreadPool()은 편해 보이지만, 내부적으로 **Unbounded Queue(무제한 대기열)**를 씁니다. 요청이 폭주하면 대기열이 무한정 늘어나다가 OutOfMemoryError로 서버가 죽습니다. 실무에선 반드시 대기열 크기가 제한된 Custom ThreadPool을 써야 합니다.
4. CompletableFuture와 ForkJoinPool
비동기 프로그래밍은 I/O Blocking으로 인한 스레드 낭비를 막는 핵심 기술입니다.
4.1 ForkJoinPool (Work Stealing)
CompletableFuture는 기본적으로 ForkJoinPool.commonPool()을 사용합니다.
Architecture Visualization
- Work Stealing: 할 일이 끝난 스레드가 노는 게 아니라, 바쁜 다른 스레드의 큐(Deque) 뒤에서 작업을 훔쳐와서 도와줍니다. CPU 활용률을 극대화하는 기법입니다.
- 주의점: CommonPool은 전역적으로 공유되므로, 여기서 오래 걸리는 작업(Blocking I/O)을 하면 애플리케이션 전체 성능이 저하됩니다. I/O 작업은 별도의 스레드 풀로 분리해야 합니다.
java
1 // I/O 작업은 별도 풀에서 실행 (권장)2 ExecutorService ioPool = Executors.newFixedThreadPool(10);3 4 CompletableFuture.supplyAsync(() -> dbQuery(), ioPool) // ioPool 지정5 .thenApply(result -> process(result));
5. Reference & Further Reading
기초를 넘어 깊이 있는 학습을 위해 아래 문서를 정독할 것을 권장합니다.
핵심 요약
- 1가시성(Visibility) 문제는 CPU 캐시 때문에 발생하며, volatile로 해결합니다.
- 2원자성(Atomicity) 문제는 연산의 쪼개짐 때문에 발생하며, Atomic 클래스나 Lock으로 해결합니다.
- 3Executors.new... 메서드는 편리하지만 OOM 위험이 있으므로, 실무에선 ThreadPoolExecutor를 직접 안전하게 설정해서 써야 합니다.
실전 코드 예제
| 1 | public class VisibilityDemo { |
| 2 | // volatile이 없으면 running 플래그의 변경사항이 |
| 3 | // 다른 스레드에게 영원히 보이지 않을 수 있음 (무한 루프) |
| 4 | private static volatile boolean running = true; |
| 5 | |
| 6 | public static void main(String[] args) throws InterruptedException { |
| 7 | new Thread(() -> { |
| 8 | System.out.println("Worker started."); |
| 9 | while (running) { |
| 10 | // busy-waiting |
| 11 | } |
| 12 | System.out.println("Worker stopped."); |
| 13 | }).start(); |
| 14 | |
| 15 | Thread.sleep(100); |
| 16 | running = false; // 메인 메모리에 즉시 쓰기 (Flush) |
| 17 | System.out.println("Main thread signaled stop."); |
| 18 | } |
| 19 | } |
| 1 | import java.util.concurrent.atomic.AtomicInteger; |
| 2 | |
| 3 | public class AtomicCounter { |
| 4 | private AtomicInteger count = new AtomicInteger(0); |
| 5 | |
| 6 | public void increment() { |
| 7 | // 내부적으로 CAS(Compare-And-Swap) 루프를 돕니다. |
| 8 | // synchronized보다 훨씬 빠릅니다. |
| 9 | count.incrementAndGet(); |
| 10 | } |
| 11 | |
| 12 | public int get() { |
| 13 | return count.get(); |
| 14 | } |
| 15 | } |
Tech Interview Challenge
현업 면접관이 실제로 던지는 질문입니다. 먼저 스스로 답해보세요.
HardVolatile 키워드는 원자성을 보장하나요?
Volatile 키워드는 원자성을 보장하나요?
아니요, 보장하지 않습니다. Volatile은 오직 '가시성(최신 값 읽기)'만 보장합니다. 여러 스레드가 동시에 값을 쓰고 읽는 상황(예: count++)에서는 여전히 Race Condition이 발생하므로 synchronized나 Atomic 변수가 필요합니다.
HellForkJoinPool의 Work Stealing 알고리즘에 대해 설명해보세요.
ForkJoinPool의 Work Stealing 알고리즘에 대해 설명해보세요.
각 스레드가 자신의 Deque(양방향 큐)를 가집니다. 본인의 큐가 비면, 다른 스레드 큐의 '꼬리(Tail)' 부분에서 작업을 훔쳐옵니다(Steal). 이를 통해 스레드 간의 락 경합(Coontention)을 줄이고 CPU 캐시 지역성(Locality)을 높여 성능을 극대화합니다.
