공공부하자개발 · 영어 학습 노트
자바
고급모던 자바와 성능0/10 완료
  • 01제네릭과 와일드카드
  • 02멀티스레드와 동기화
  • 03람다식과 함수형 인터페이스
  • 04Stream API와 병렬 처리
  • 05Optional로 NPE 방지
  • 06메서드 활용 패턴 (고급)
  • 07Java 21 모던 문법
  • 08어노테이션·리플렉션·동적 프록시
  • 09CompletableFuture 심화와 가상 스레드 실전
  • 10JVM 메모리·GC·OOM 진단
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 고급 › 02 / 10

멀티스레드와 동기화

섹션 7진행 0 / 10
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 프로세스 vs 스레드

프로세스(Process) 스레드(Thread)
정의 실행 중인 프로그램. OS 가 자원(메모리, 파일 핸들)을 배정하는 단위 프로세스 안에서 실행되는 흐름. CPU 스케줄링 단위
메모리 독립된 주소 공간. 다른 프로세스 메모리 접근 불가 같은 프로세스의 힙을 공유. 스택만 각자 소유
생성 비용 큼 (주소 공간 복제) 작음 (스택 하나 할당)
통신 IPC (파이프, 소켓, 공유 메모리) 필요 그냥 같은 변수를 읽고 쓰면 됨 — 그래서 위험
하나가 죽으면 다른 프로세스 무관 프로세스 전체(JVM)가 영향받을 수 있음

JVM 은 하나의 프로세스이고, 그 안에서 여러 스레드가 돕니다. main 도 스레드이고, GC 스레드, JIT 컴파일러 스레드가 백그라운드에서 함께 돕니다.

2.2 JVM 스레드 메모리 모델 — 스택은 분리, 힙은 공유

text
┌──────────────────────── JVM 프로세스 ────────────────────────┐
│                                                              │
│  Thread-1 스택        Thread-2 스택        main 스택           │
│  ┌──────────┐        ┌──────────┐        ┌──────────┐        │
│  │ 지역변수 i │        │ 지역변수 i │        │ 지역변수   │        │
│  │ 참조 acc ─┼──┐     │ 참조 acc ─┼──┐     │ ...      │        │
│  └──────────┘  │     └──────────┘  │     └──────────┘        │
│                │                   │                          │
│  ┌─────────────▼───────────────────▼──────── 힙(Heap) ──────┐ │
│  │   BankAccount 객체 { balance = 1000 }  ← 모두가 공유       │ │
│  │   String 상수, 배열, 컬렉션 ...                             │ │
│  └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
  • 스택(stack): 스레드마다 하나씩. 지역 변수, 메서드 호출 프레임. 다른 스레드가 볼 수 없으므로 지역 변수는 항상 스레드 안전합니다.
  • 힙(heap): 하나. 모든 객체가 여기 있고 모든 스레드가 참조할 수 있습니다. 동시성 문제는 전부 힙에 있는 공유 객체의 상태를 여러 스레드가 바꿀 때 생깁니다.

핵심 판단 기준: "이 데이터를 두 스레드 이상이 동시에 접근하는가? 그중 하나라도 쓰기(write)를 하는가?" 둘 다 예 → 동기화 필요.

2.3 Thread vs Runnable — 왜 Runnable 을 권장하는가

java
// 방법 1: Thread 상속
class Worker extends Thread { public void run() { ... } }
new Worker().start();

// 방법 2: Runnable 구현 (권장)
Runnable task = () -> { ... };
new Thread(task).start();

Runnable 을 권장하는 이유:

이유 설명
단일 상속 제약 자바는 클래스 하나만 상속 가능. Thread 를 상속하면 다른 클래스를 상속할 수 없음
관심사 분리 무엇을(작업) 과 어떻게(스레드) 를 분리. 같은 작업을 어떤 실행기에도 넘김
람다 사용 Runnable 은 함수형 인터페이스라 람다로 간결하게 작성
재사용 하나의 Runnable 을 여러 스레드가 공유 실행 가능

start() 와 run() 의 차이: run() 을 직접 호출하면 현재 스레드에서 그냥 메서드가 실행됩니다(새 스레드 없음). start() 가 OS 에 새 스레드를 요청하고, 그 스레드가 run() 을 호출합니다.

2.4 스레드 생명주기(Thread Lifecycle)

text
                    start()
   NEW ───────────────────────► RUNNABLE ◄──────────────────┐
                                  │  ▲                       │
          synchronized 락 대기     │  │  락 획득               │ notify() / 시간 만료 /
                                  ▼  │                       │ join 대상 종료
                               BLOCKED                       │
                                  │                          │
     wait() / join() / park()     │                          │
   ────────────────────────────►  WAITING ───────────────────┤
                                                             │
     sleep(ms) / wait(ms) / join(ms)                         │
   ────────────────────────────►  TIMED_WAITING ─────────────┘

   run() 종료 / 예외 ──────────►  TERMINATED
상태 의미
NEW new Thread() 만 함. 아직 start() 전
RUNNABLE 실행 중이거나 CPU 를 기다리는 중(OS 스케줄러 대기 포함)
BLOCKED synchronized 진입을 위해 모니터 락을 기다리는 중
WAITING wait(), join(), LockSupport.park() 로 무기한 대기
TIMED_WAITING sleep(ms), wait(ms) 등 시간 제한 대기
TERMINATED run() 이 끝남. 다시 start() 불가

운영에서 스레드 덤프(jstack)를 뜨면 이 상태들이 보입니다. BLOCKED 가 많으면 락 경합, WAITING 이 많으면 외부 I/O 대기나 데드락을 의심합니다.

2.5 경쟁 조건(Race Condition) — count++ 는 왜 위험한가

count++ 는 한 줄이지만 CPU 입장에서는 세 단계입니다.

text
1. LOAD   : 메모리(힙)의 count 값을 CPU 레지스터로 읽는다     (읽기)
2. ADD    : 레지스터 값에 1 을 더한다                        (계산)
3. STORE  : 레지스터 값을 메모리 count 에 쓴다               (쓰기)

바이트코드로도 getfield → iconst_1 → iadd → putfield 네 명령입니다. 두 스레드가 이 사이에 끼어들면:

시각 Thread-A Thread-B count
t1 LOAD (0) 0
t2 LOAD (0) 0
t3 ADD → 1 0
t4 ADD → 1 0
t5 STORE 1 1
t6 STORE 1 1 (2 여야 함)

두 번 증가했는데 결과는 1 — 갱신 손실(lost update) 입니다. 이렇게 "여러 스레드의 실행 순서(interleaving, 끼어들기)에 따라 결과가 달라지는 상황"을 경쟁 조건이라 합니다. 발생 조건은 (1) 공유 상태 (2) 최소 하나의 쓰기 (3) 원자적이지 않은 연산 — 이 세 가지의 결합입니다.

해결 방향은 세 가지뿐입니다.

  1. 공유하지 않기 — 지역 변수, 불변 객체(record), ThreadLocal
  2. 원자적으로 만들기 — AtomicInteger, CAS
  3. 상호 배제(mutual exclusion) — synchronized, Lock 으로 한 번에 한 스레드만 진입

2.6 synchronized — 모니터 락과 재진입

synchronized 는 모니터 락(monitor lock, 또는 intrinsic lock) 을 사용합니다. 자바의 모든 객체는 자기만의 모니터를 하나씩 갖고 있습니다. 어떤 스레드가 그 모니터를 잡으면(acquire), 다른 스레드는 같은 모니터를 필요로 하는 코드에 들어올 수 없고 BLOCKED 상태로 기다립니다.

java
// 1) 메서드 동기화: 락 = this (static 이면 락 = Counter.class)
public synchronized void increment() { count++; }

// 2) 블록 동기화: 락 객체를 명시. 임계 영역(critical section)을 최소화할 수 있음
private final Object lock = new Object();
public void increment() {
    // 락 없이 할 수 있는 일은 밖에서
    synchronized (lock) { count++; }   // 꼭 필요한 부분만 잠금
}
방식 락 대상 특징
synchronized 인스턴스 메서드 this 간단하지만 외부에서 this 로 락을 잡을 수 있어 통제 불가
synchronized static 메서드 Class 객체 모든 인스턴스가 하나의 락을 공유
synchronized (obj) 블록 obj 락 범위 최소화 가능. 전용 private final Object lock 권장

재진입(reentrancy): 같은 스레드가 이미 잡은 락을 다시 요청하면 바로 통과합니다. 모니터는 "소유 스레드 + 재진입 횟수"를 기억하고, 카운트가 0 이 될 때 해제됩니다. 그래서 synchronized 메서드 안에서 같은 객체의 다른 synchronized 메서드를 호출해도 데드락이 나지 않습니다.

synchronized 가 보장하는 두 가지:

  1. 원자성(atomicity): 블록 안의 코드가 통째로 실행됨 (중간에 다른 스레드 끼어들기 없음)
  2. 가시성(visibility): 락을 해제할 때 변경 내용이 메인 메모리에 반영되고, 락을 획득할 때 메인 메모리에서 다시 읽음

2.7 volatile 과 가시성(Visibility) — CPU 캐시 문제

원자성과 별개로 가시성 문제가 있습니다. 현대 CPU 는 코어마다 캐시(L1/L2)를 갖고, 메모리 대신 캐시에서 읽고 씁니다. JIT 컴파일러도 "이 변수는 루프 안에서 안 바뀌네"라고 판단하면 읽기를 레지스터로 끌어올립니다(hoisting).

java
private boolean running = true;    // volatile 없음

// Thread-A
while (running) { /* work */ }     // JIT: running 을 한 번만 읽고 레지스터에 캐시 → 무한 루프 가능

// Thread-B
running = false;                   // 메인 메모리엔 썼지만 A 의 캐시/레지스터는 그대로

volatile 은 "이 변수는 항상 메인 메모리에서 읽고 메인 메모리에 써라, 재배치(reordering)하지 마라"는 지시입니다.

volatile synchronized
가시성 O O
원자성 X (count++ 는 여전히 위험) O
블로킹 없음 있음 (락 대기)
적합한 경우 플래그(on/off), 한 스레드만 쓰고 여럿이 읽는 설정 값 읽고-계산-쓰기 복합 연산

volatile int count; count++ 는 여전히 경쟁 조건입니다. volatile 은 읽기와 쓰기 각각을 메인 메모리와 동기화할 뿐, 읽기-계산-쓰기 세 단계를 하나로 묶어주지 않습니다.

2.8 happens-before — 자바 메모리 모델의 순서 보장

자바 메모리 모델(JMM)은 "어떤 쓰기가 어떤 읽기에 보이는가"를 happens-before 관계로 정의합니다. A happens-before B 이면, A 의 결과가 B 에 보장되어 보입니다. 주요 규칙:

규칙 내용
프로그램 순서 한 스레드 안에서 앞 문장은 뒤 문장보다 먼저
모니터 락 unlock 은 이후의 같은 락 lock 보다 먼저
volatile volatile 쓰기는 이후의 같은 변수 volatile 읽기보다 먼저
스레드 시작 t.start() 는 t 안의 모든 동작보다 먼저
스레드 종료 t 의 모든 동작은 t.join() 리턴보다 먼저
추이성 A→B, B→C 이면 A→C

실무 함의: Thread.join() 후에 결과를 읽으면 동기화 없이도 안전하게 보입니다. ExecutorService.submit() 전의 동작은 작업 안에서 보이고, Future.get() 후에는 작업의 결과가 보입니다. 이 규칙 덕분에 스레드 풀에 결과 객체를 넘겨받을 때 별도의 volatile 이 필요 없습니다.

2.9 Atomic 클래스와 CAS(Compare-And-Swap)

AtomicInteger, AtomicLong, AtomicReference 는 락 없이 원자적 갱신을 제공합니다. 내부는 CPU 의 CAS 명령(x86 의 cmpxchg)입니다.

text
CAS(주소, 기대값, 새값):
    if (메모리[주소] == 기대값) { 메모리[주소] = 새값; return true; }
    else return false;
    ← 이 비교-교체가 하드웨어 수준에서 원자적으로 실행됨

incrementAndGet() 의 실제 동작:

java
int current;
do {
    current = get();                       // 현재 값 읽기
} while (!compareAndSet(current, current + 1));  // 그 사이 안 바뀌었으면 교체, 바뀌었으면 재시도

락을 잡지 않으므로 BLOCKED 가 없고(non-blocking), 경합이 적을 때 synchronized 보다 훨씬 빠릅니다. 경합이 극심하면 재시도가 반복되어 오히려 느려질 수 있는데, 그럴 때는 LongAdder(내부적으로 셀을 분산)를 씁니다.

2.10 ReentrantLock — synchronized 의 명시적 버전

java.util.concurrent.locks.ReentrantLock 은 synchronized 와 같은 상호 배제를 제공하면서 추가 기능이 있습니다.

기능 synchronized ReentrantLock
타임아웃 시도 X tryLock(1, SECONDS)
인터럽트 가능 대기 X lockInterruptibly()
공정성(fairness, 대기 순서 보장) X new ReentrantLock(true)
조건 변수 여러 개 wait/notify 하나 newCondition() 여러 개
락 해제 블록 끝에서 자동 finally 에서 반드시 unlock()
java
private final ReentrantLock lock = new ReentrantLock();
public void transfer() {
    lock.lock();
    try { /* 임계 영역 */ }
    finally { lock.unlock(); }     // 예외가 나도 반드시 해제
}

단순한 경우엔 synchronized 가 더 안전(unlock 깜빡 못 함)하고, 타임아웃/공정성이 필요할 때만 ReentrantLock 을 씁니다.

2.11 데드락(Deadlock) — 4가지 조건과 예방

두 스레드가 서로가 가진 락을 기다리며 영원히 멈추는 상태입니다.

text
Thread-A: lock(계좌1) → lock(계좌2) 대기 ─┐
                                          │ 서로 기다림 → 영원히 정지
Thread-B: lock(계좌2) → lock(계좌1) 대기 ─┘

데드락은 다음 4가지 조건이 모두 성립할 때만 발생합니다(Coffman 조건).

조건 의미 깨는 방법
상호 배제 자원을 한 번에 하나만 사용 락 자체를 없앰 (불변 객체, Atomic)
점유와 대기 자원을 쥔 채 다른 자원을 기다림 필요한 락을 한꺼번에 획득, 또는 tryLock 실패 시 전부 해제
비선점 남의 자원을 뺏을 수 없음 tryLock(timeout) 으로 포기
순환 대기 A→B→A 원형 대기 락 순서 고정 (가장 실용적)

실무 예방책 우선순위:

  1. 락 순서 고정: 계좌 이체라면 항상 ID 가 작은 계좌부터 잠근다.
  2. tryLock + 타임아웃: 못 잡으면 포기하고 재시도.
  3. 락 범위 최소화: 락 안에서 다른 락을 잡는 코드(외부 메서드 호출 포함)를 피한다.
  4. 감지: jstack 이 "Found one Java-level deadlock" 을 알려줌. ThreadMXBean.findDeadlockedThreads() 로 프로그램에서도 감지 가능.

2.12 ExecutorService — 스레드 풀

스레드를 매번 만들면 비용이 크고(OS 스레드 생성, 1MB 스택) 개수 제한도 없어 위험합니다. 스레드 풀은 스레드를 미리 만들어 재사용합니다.

팩토리 동작 적합한 경우
newFixedThreadPool(n) 정확히 n 개. 초과 작업은 무한 큐에 대기 CPU 바운드, 동시성 상한 필요할 때
newCachedThreadPool() 필요하면 계속 생성, 60초 놀면 제거. 큐 없음 짧은 작업 폭주. 무제한 생성 위험
newSingleThreadExecutor() 1개. 순서 보장 순차 처리가 필요한 이벤트
newVirtualThreadPerTaskExecutor() (21) 작업마다 가상 스레드 I/O 바운드 대량 작업
newScheduledThreadPool(n) 지연/주기 실행 스케줄러

newFixedThreadPool 의 큐가 무한(LinkedBlockingQueue)이라 작업이 쌓이면 OOM 이 날 수 있고, newCachedThreadPool 은 스레드가 무한히 늘 수 있습니다. 실무에서는 new ThreadPoolExecutor(core, max, keepAlive, unit, new ArrayBlockingQueue<>(cap), rejectionPolicy) 로 직접 상한을 정하는 것이 원칙입니다.

submit vs execute:

execute(Runnable) submit(Runnable/Callable)
반환 void Future<T>
예외 스레드의 UncaughtExceptionHandler 로 → 콘솔에 찍히고 사라짐 Future 안에 저장. get() 할 때 ExecutionException 으로 나옴
함정 get() 을 안 하면 예외가 조용히 사라짐

올바른 종료:

java
executor.shutdown();                          // 새 작업 거부, 기존 작업은 완료
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {   // 완료 대기
    executor.shutdownNow();                   // 시간 초과 시 강제 인터럽트
}

shutdown() 을 안 하면 non-daemon 워커 스레드가 살아 있어 JVM 이 종료되지 않습니다. JDK 19+ 에서는 ExecutorService 가 AutoCloseable 이라 try-with-resources 가 close() 에서 shutdown + awaitTermination 을 해줍니다.

2.13 CompletableFuture 기초

Future.get() 은 블로킹입니다. CompletableFuture 는 "완료되면 이걸 해라"를 체이닝합니다.

java
CompletableFuture<Order> orderF = CompletableFuture.supplyAsync(() -> fetchOrder(id), pool);
CompletableFuture<User> userF = CompletableFuture.supplyAsync(() -> fetchUser(id), pool);

CompletableFuture<String> result = orderF
    .thenCombine(userF, (order, user) -> user.name() + ":" + order.amount())   // 둘 다 끝나면
    .thenApply(String::toUpperCase)                                            // 변환
    .exceptionally(ex -> "FALLBACK");                                          // 실패 시
String s = result.join();
메서드 역할
supplyAsync(Supplier, executor) 비동기 시작. executor 생략 시 ForkJoinPool.commonPool()
thenApply(f) 결과 변환 (map)
thenCompose(f) 결과로 또 다른 CompletableFuture 실행 (flatMap)
thenCombine(other, f) 두 결과 합치기
allOf(...) / anyOf(...) 여러 개 모두/하나 완료 대기
exceptionally(f) / handle(f) 예외 처리
join() / get() 블로킹 결과 획득

2.14 JDK 21 가상 스레드(Virtual Thread)

기존 스레드(플랫폼 스레드)는 OS 스레드와 1:1 이라 무겁습니다(생성 ~1ms, 스택 1MB, 수천 개가 한계). 가상 스레드는 JVM 이 관리하는 가벼운 스레드로, 소수의 OS 스레드(캐리어) 위에 수백만 개를 올릴 수 있습니다.

text
가상 스레드 100만 개  ──┐
                       ├──  캐리어(플랫폼) 스레드 8개 (CPU 코어 수) ── OS
가상 스레드가 I/O 로 블로킹되면 → 캐리어에서 내려오고(unmount) 다른 가상 스레드가 올라감(mount)
java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> { Thread.sleep(1000); return 1; });   // 10만 개가 1초 만에 끝남
    }
}   // close() 가 모든 작업 완료 대기
플랫폼 스레드 가상 스레드
매핑 OS 스레드 1:1 M:N (JVM 스케줄링)
생성 비용 큼 매우 작음 (객체 하나)
최대 개수 수천 수백만
블로킹 I/O OS 스레드가 놀게 됨 캐리어를 양보 → 효율적
적합 CPU 바운드 계산 I/O 바운드 (HTTP 호출, DB, 파일)
풀링 필수 하지 말 것 (작업마다 새로 만듦)

언제 쓰나: 요청당 스레드 모델에서 I/O 대기가 대부분인 작업(외부 API 호출, DB 쿼리). CPU 계산은 어차피 코어 수만큼만 병렬이라 이득이 없습니다. 주의: synchronized 블록 안에서 블로킹하면 캐리어가 고정(pinned)되어 이점이 사라지므로(JDK 21 기준, 24에서 개선) ReentrantLock 을 쓰는 것이 좋습니다.

2.15 ThreadLocal — 스레드마다 다른 값

ThreadLocal<T> 은 "변수 하나인데 스레드마다 값이 다른" 저장소입니다. 스프링의 SecurityContextHolder, 트랜잭션 컨텍스트, SimpleDateFormat(스레드 안전하지 않음) 공유가 대표 용도입니다.

java
private static final ThreadLocal<String> REQUEST_ID = ThreadLocal.withInitial(() -> "none");
REQUEST_ID.set("req-42");            // 이 스레드에서만 보임
String id = REQUEST_ID.get();
REQUEST_ID.remove();                 // 스레드 풀에서는 반드시 remove! 안 하면 다음 요청이 이전 값을 봄

스레드 풀은 스레드를 재사용하므로 remove() 를 빼먹으면 다른 요청의 값이 새어나가는 보안 사고가 됩니다. 가상 스레드는 매번 새로 만들어지니 이 문제는 없지만 수백만 개에 ThreadLocal 을 쓰면 메모리가 부담이라 JDK 21 프리뷰 ScopedValue 가 대안으로 나왔습니다.

핵심 원리
  • 2.1 프로세스 vs 스레드
  • 2.2 JVM 스레드 메모리 모델 — 스택은 분리, 힙은 공유
  • 2.3 Thread vs Runnable — 왜 Runnable 을 권장하는가
  • 2.4 스레드 생명주기(Thread Lifecycle)
  • 2.5 경쟁 조건(Race Condition) — count++ 는 왜 위험한가
  • 2.6 synchronized — 모니터 락과 재진입
  • 2.7 volatile 과 가시성(Visibility) — CPU 캐시 문제
  • 2.8 happens-before — 자바 메모리 모델의 순서 보장
  • 2.9 Atomic 클래스와 CAS(Compare-And-Swap)
  • 2.10 ReentrantLock — synchronized 의 명시적 버전
  • 2.11 데드락(Deadlock) — 4가지 조건과 예방
  • 2.12 ExecutorService — 스레드 풀
  • 2.13 CompletableFuture 기초
  • 2.14 JDK 21 가상 스레드(Virtual Thread)
  • 2.15 ThreadLocal — 스레드마다 다른 값
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제