JAVA

Java 스레드 기초와 동기화(Thread, synchronized, volatile) 그리고 데드락까지 한 번에 정리

IT Lab 2026. 2. 24. 20:00

Java 17 기준으로 Thread/Runnable 차이, synchronized와 volatile의 의미, 데드락이 생기는 이유와 예방 패턴을 실무 관점에서 정리합니다.

도입 (문제 상황)

간단한 카운터를 여러 스레드에서 올렸는데 결과가 매번 달라지거나, 로그는 멀쩡한데 특정 환경에서만 간헐적으로 “멈춤”이 생긴 경험이 있으실 거예요. 멀티스레드는 “동시에 돌아가니 빨라지겠지”로 시작하지만, 동기화 규칙을 모르면 재현도 어려운 버그로 이어집니다.

핵심 개념: Java 스레드와 동기화가 중요한 이유

Java에서 스레드는 “CPU를 더 쓰는 기능”이라기보다 공유 상태(shared state)를 어떻게 안전하게 다룰지의 문제에 가깝습니다. 특히 아래 3가지를 구분해두면 사고가 크게 줄어듭니다.

Thread vs Runnable: “실행”과 “작업”을 분리하기

  • Thread실행 주체(스레드) 입니다.
  • Runnable실행할 작업(로직) 입니다.

Runnable로 작업을 분리해두면, 나중에 ExecutorService 같은 스레드 풀로 쉽게 옮겨탈 수 있어요(다음 글의 주제이기도 합니다). 반면 Thread를 직접 상속하는 방식은 “작업”과 “실행”이 한 몸이 되기 쉬워 확장성이 떨어집니다.

synchronized: “원자성 + 가시성”을 한 번에

synchronized는 흔히 “락 걸어서 한 번에 한 스레드만 실행” 정도로 이해하지만, 실무에서 더 중요한 포인트는 두 가지입니다.

  • 원자성(Atomicity): 임계 구역을 동시에 실행하지 못하게 해서 count++ 같은 복합 연산을 안전하게 만듭니다.
  • 가시성(Visibility): 락의 획득/해제 시점에 메모리 동기화가 일어나 다른 스레드가 최신 값을 보게 합니다.

즉, synchronized는 “서로 밀지 말고 한 줄로 서기”이면서, “업데이트된 공지사항을 모두가 보게 하기”까지 포함합니다.

volatile: “가시성”만 보장(원자성은 아님)

volatile한 스레드가 쓴 값을 다른 스레드가 즉시(정확히는 happens-before 규칙에 따라) 볼 수 있게 해줍니다. 다만 count++처럼 읽고-더하고-쓰는 복합 연산을 한 번에 묶어주지 않으므로 원자성은 보장하지 않습니다.

  • 적합: 종료 플래그, 설정 스위치, 상태 표시처럼 “읽기/쓰기 자체가 단순한 값”
  • 부적합: 증가/감소, 누적 합계처럼 “연산 과정이 있는 값”

데드락: “락 획득 순서”가 꼬이면 멈춥니다

데드락은 스레드가 서로가 가진 락을 기다리며 영원히 진행하지 못하는 상태입니다. 보통 아래 패턴으로 발생합니다.

  • 스레드 A: Lock1 획득 → Lock2 대기
  • 스레드 B: Lock2 획득 → Lock1 대기

한 번 걸리면 CPU를 많이 쓰지도 않고 조용히 멈춰서, 장애 대응 시 더 난감해집니다.

flowchart LR
  A["Thread A"] --> L1["Lock 1 acquired"]
  L1 --> W2["Waiting for Lock 2"]
  B["Thread B"] --> L2["Lock 2 acquired"]
  L2 --> W1["Waiting for Lock 1"]
  W2 --> L2
  W1 --> L1

락을 서로 반대 순서로 잡으려다 순환 대기가 생기는 데드락 흐름입니다.

한눈에 비교: synchronized vs volatile

synchronized는 임계 구역을 한 줄로 세우고 volatile은 최신 값을 공유하는 개념을 대비한 다이어그램

구분 synchronized volatile
보장 원자성 + 가시성 가시성(및 일부 순서 보장)
대표 용도 공유 자원 보호(카운터, 컬렉션 수정 등) 종료 플래그, 상태 값 publish
성능/특성 경합 시 블로킹 발생 가능 블로킹 없음(대신 원자 연산 아님)
주의점 락 순서 꼬이면 데드락 가능 count++ 같은 연산은 안전하지 않음

 

코드 예제: Thread/Runnable, synchronized, volatile, 데드락까지 한 번에 실행

아래 코드는 Java 17에서 그대로 실행할 수 있고, 네 가지를 모두 재현합니다.

  1. 레이스 컨디션(동기화 없음)
  2. synchronized로 해결
  3. volatile 종료 플래그
  4. 데드락 발생 예시(데몬 스레드로 실행해 프로그램이 영원히 안 끝나지는 않게 처리)
import java.util.ArrayList;
import java.util.List;

public class ThreadSyncDemo {

    private static final int THREADS = 4;
    private static final int INCREMENTS_PER_THREAD = 200_000;

    // 동기화 없이 접근하면 레이스 컨디션이 발생할 수 있습니다.
    static class UnsafeCounter {
        private int value = 0;
        void increment() { value++; } // 원자적이지 않음
        int get() { return value; }
    }

    // synchronized로 임계 구역을 보호합니다(원자성 + 가시성).
    static class SafeCounter {
        private int value = 0;
        synchronized void increment() { value++; }
        synchronized int get() { return value; }
    }

    // volatile은 가시성 보장: 다른 스레드가 stop 변경을 잘 "보게" 합니다.
    static class StopFlag {
        volatile boolean stop = false;
    }

    public static void main(String[] args) throws Exception {
        System.out.println("=== 1) Race condition (no synchronization) ===");
        runCounterTest(new UnsafeCounter());

        System.out.println("\n=== 2) Fixed with synchronized ===");
        runCounterTest(new SafeCounter());

        System.out.println("\n=== 3) volatile stop flag demo ===");
        volatileStopDemo();

        System.out.println("\n=== 4) Deadlock demo (daemon threads) ===");
        deadlockDemo();

        System.out.println("\nDone.");
    }

    // Runnable로 작업을 분리해 Thread에 주입합니다.
    private static void runCounterTest(Object counter) throws InterruptedException {
        int expected = THREADS * INCREMENTS_PER_THREAD;

        Runnable task = () -> {
            for (int i = 0; i < INCREMENTS_PER_THREAD; i++) {
                if (counter instanceof UnsafeCounter uc) uc.increment();
                else if (counter instanceof SafeCounter sc) sc.increment();
            }
        };

        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < THREADS; i++) {
            threads.add(new Thread(task, "worker-" + i));
        }

        long start = System.currentTimeMillis();
        threads.forEach(Thread::start);
        for (Thread t : threads) t.join();
        long elapsed = System.currentTimeMillis() - start;

        int actual = (counter instanceof UnsafeCounter uc) ? uc.get() : ((SafeCounter) counter).get();
        System.out.printf("Expected=%d, Actual=%d, Elapsed=%dms%n", expected, actual, elapsed);
    }

    private static void volatileStopDemo() throws InterruptedException {
        StopFlag flag = new StopFlag();

        Thread spinner = new Thread(() -> {
            long loops = 0;
            while (!flag.stop) { // volatile 덕분에 다른 스레드 변경을 관측 가능
                loops++;
            }
            System.out.println("Spinner stopped. loops=" + loops);
        }, "spinner");

        spinner.start();

        Thread.sleep(200); // 잠깐 돌게 둠
        flag.stop = true;  // 다른 스레드에서 종료 신호
        spinner.join();
    }

    private static void deadlockDemo() throws InterruptedException {
        final Object lockA = new Object();
        final Object lockB = new Object();

        Runnable t1 = () -> {
            synchronized (lockA) {
                sleepSilently(50);
                synchronized (lockB) {
                    System.out.println("t1 acquired A then B (unlikely if deadlocked)");
                }
            }
        };

        Runnable t2 = () -> {
            synchronized (lockB) {
                sleepSilently(50);
                synchronized (lockA) {
                    System.out.println("t2 acquired B then A (unlikely if deadlocked)");
                }
            }
        };

        Thread th1 = new Thread(t1, "deadlock-1");
        Thread th2 = new Thread(t2, "deadlock-2");

        // 데드락은 영원히 풀리지 않을 수 있으니 데몬으로 만들어 데모가 끝나게 합니다.
        th1.setDaemon(true);
        th2.setDaemon(true);

        th1.start();
        th2.start();

        Thread.sleep(300);
        System.out.println("If you see no acquisition messages above, deadlock likely occurred.");
    }

    private static void sleepSilently(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
    }
}

실무 팁

💡 실무에서는: synchronized를 “작게” 잡고, 락 객체를 “명확하게” 두세요

  • 임계 구역이 크면 경합이 늘고 응답 시간이 튑니다. DB 호출/원격 호출 같은 느린 작업은 락 밖으로 빼는 편이 안전합니다.
  • synchronized(this)는 외부 코드가 같은 객체로 락을 잡을 여지를 만들어 예기치 않은 병목이 생길 수 있어요. 가능하면 private final Object lock = new Object(); 같은 전용 락을 두는 방식이 관리에 유리합니다.

💡 실무에서는: 데드락 예방의 1순위는 “락 획득 순서 규칙”입니다

  • 여러 락이 필요하면 항상 같은 순서로 획득하도록 규칙을 정해두세요(예: ID가 작은 리소스 락부터).
  • 장애 분석 시에는 jstack/스레드 덤프에서 Found one Java-level deadlock 메시지로 빠르게 확인할 수 있습니다. 재현이 어려운 멈춤 이슈일수록 스레드 덤프 자동 수집을 붙여두면 효과가 큽니다.

핵심 요약: synchronized는 원자성과 가시성을 함께 보장하고, volatile은 가시성 중심이라 연산 보호에는 부족합니다. 데드락은 락 획득 순서가 꼬일 때 생기므로 “순서 규칙”이 가장 강력한 예방책입니다.

다음 글: #26 모던 동시성 — ExecutorService & CompletableFuture