dev.syw

스레드, 메모리 가시성, 동기화, java.util.concurrent와 CompletableFuture로 안전한 병렬 코드를 작성한다.

동시성과 멀티스레드 프로그래밍

현대 서버 애플리케이션은 수백, 수천 개의 요청을 동시에 처리합니다. Java는 JVM 위에서 운영체제 스레드를 직접 다루는 언어로서, 멀티스레드 환경에서 발생하는 미묘한 버그들이 단일 스레드 환경에서는 절대 재현되지 않는 경우가 많습니다. 결국 동시성 문제는 "실행하면 동작한다"가 아니라 "어떤 조건에서도 올바르게 동작함을 증명할 수 있다"는 수준의 이해가 필요합니다.

이 레슨은 입문편 컬렉션 프레임워크와 람다/스트림 이후, 애플리케이션 규모가 커졌을 때 마주치는 경쟁 조건(race condition), 가시성(visibility) 문제, 교착상태(deadlock)를 정면으로 다룹니다. java.util.concurrent 패키지가 왜 그렇게 설계되었는지 원리부터 이해하고, 실무에서 바로 쓸 수 있는 패턴을 익힙니다.

학습 목표

  • Java **메모리 모델(JMM)**의 가시성과 happens-before 규칙을 설명할 수 있다.
  • synchronized, volatile, Atomic 변수의 차이를 정확히 구분하고 적재적소에 사용한다.
  • ExecutorService와 스레드 풀을 올바르게 설계하고 생명주기를 관리한다.
  • ConcurrentHashMap 등 동시성 컬렉션의 내부 락 전략을 이해하고 활용한다.
  • CompletableFuture로 비동기 파이프라인을 구성하고, 교착상태와 경쟁 조건을 진단·회피한다.

스레드 생명주기와 Java 메모리 모델

스레드 생명주기

Java 스레드는 NEW → RUNNABLE → (BLOCKED/WAITING/TIMED_WAITING) → TERMINATED 상태를 거칩니다. Thread.getState()로 현재 상태를 확인할 수 있으며, jstack 또는 VisualVM으로 프로덕션 덤프를 떠서 진단하는 것이 실무 관행입니다.

public class ThreadLifecycleDemo {
    public static void main(String[] args) throws InterruptedException {
        Object lock = new Object();

        Thread worker = new Thread(() -> {
            synchronized (lock) {
                try {
                    lock.wait(2000); // TIMED_WAITING
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }, "worker-thread");

        System.out.println("Before start: " + worker.getState()); // NEW
        worker.start();
        Thread.sleep(50); // worker가 wait()에 진입할 시간
        System.out.println("During wait: " + worker.getState()); // TIMED_WAITING
        worker.join();
        System.out.println("After join: " + worker.getState());  // TERMINATED
    }
}

Java 메모리 모델(JMM)과 가시성

JMM은 "어떤 쓰기 결과가 어떤 읽기에서 보이는가"를 정의합니다. 멀티코어 CPU는 각 코어마다 L1/L2 캐시를 가지므로, 한 스레드가 값을 변경해도 다른 스레드의 캐시에는 즉시 반영되지 않을 수 있습니다.

happens-before 관계가 성립하면 이전 스레드의 모든 쓰기 결과가 이후 스레드에 보장됩니다. 주요 happens-before 규칙은 다음과 같습니다.

규칙설명
프로그램 순서같은 스레드 내에서 앞 코드는 뒤 코드 전에 실행
모니터 락unlock → 같은 락의 lock
volatile 쓰기volatile 쓰기 → 같은 변수의 읽기
스레드 시작Thread.start() → 해당 스레드의 첫 줄
스레드 종료스레드의 마지막 줄 → Thread.join() 반환
// ❌ 가시성 보장 없음 — stop 플래그가 CPU 캐시에만 머물 수 있음
public class BrokenLoop {
    private static boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (!stop) { /* spin */ }
            System.out.println("stopped");
        }).start();

        Thread.sleep(100);
        stop = true; // 다른 스레드에서 이 쓰기가 보이지 않을 수 있음
    }
}

// ✅ volatile로 가시성 보장
public class CorrectLoop {
    private static volatile boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (!stop) { /* spin */ }
            System.out.println("stopped");
        }).start();

        Thread.sleep(100);
        stop = true;
    }
}

⚠️ 주의 volatile은 가시성만 보장합니다. count++ 같은 복합 연산(읽기-수정-쓰기)의 원자성은 보장하지 않습니다.

synchronized, volatile, Atomic 변수의 차이

synchronized — 상호 배제 + 가시성

synchronized는 한 번에 하나의 스레드만 임계 구역에 진입하도록 막고, 진입/퇴출 시 happens-before를 만들어 가시성도 동시에 보장합니다.

public class Counter {
    private int count = 0;

    // ✅ 메서드 전체를 동기화 — 느리지만 안전
    public synchronized void increment() {
        count++;
    }

    // ✅ 최소한의 블록만 동기화 — 더 나은 성능
    public void incrementBlock() {
        synchronized (this) {
            count++;
        }
    }

    public synchronized int getCount() {
        return count;
    }
}

synchronized의 비용은 작지 않습니다. 잠금 획득 실패 시 스레드가 BLOCKED 상태로 컨텍스트 스위칭이 발생하며, 고경합(high contention) 시나리오에서 병목이 됩니다.

volatile — 가시성 전용

volatile은 CPU 캐시를 우회해 메인 메모리를 직접 읽고 씁니다. 단일 읽기/쓰기 원자성은 보장되지만 복합 연산은 보장하지 않습니다. 상태 플래그, DCL(Double-Checked Locking) 패턴에 적합합니다.

// ✅ DCL 싱글톤 — volatile 없이는 부분 초기화 객체를 다른 스레드가 읽을 수 있음
public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {                   // 1차 검사 (락 없이, 동기화되지 않음)
            synchronized (Singleton.class) {
                if (instance == null) {           // 2차 검사 (락 보유, 동기화됨)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Atomic 변수 — 락-프리 원자 연산

java.util.concurrent.atomic 패키지는 CAS(Compare-And-Swap) CPU 명령어를 사용해 락 없이 원자적 복합 연산을 제공합니다. synchronized보다 경합이 낮을 때 훨씬 빠릅니다.

import java.util.concurrent.atomic.*;

public class AtomicDemo {
    private final AtomicInteger count = new AtomicInteger(0);
    private final AtomicReference<String> state = new AtomicReference<>("IDLE");

    public void increment() {
        count.incrementAndGet();          // ✅ 원자적 증가
    }

    public void changeState(String expected, String next) {
        boolean updated = state.compareAndSet(expected, next); // ✅ CAS
        if (!updated) {
            System.out.println("상태 충돌 발생, 재시도 필요");
        }
    }

    // LongAdder: 고경합에서 AtomicLong보다 훨씬 빠름 (내부 셀 분할)
    private final LongAdder hitCount = new LongAdder();

    public void recordHit() {
        hitCount.increment();
    }

    public long getHitCount() {
        return hitCount.sum(); // 정확한 최신값이 아닐 수 있음 — 통계용
    }
}
선택 기준권장
단순 플래그 (쓰기 1회 또는 가끔)volatile
카운터/참조 원자 갱신 (경합 낮음)AtomicInteger / AtomicReference
고경합 카운터 (통계, 히트수)LongAdder
여러 변수를 한꺼번에 변경synchronized 또는 락

ExecutorService와 스레드 풀 설계

스레드를 직접 new Thread()로 생성하면 스레드 생성/소멸 비용이 매 요청마다 발생하고, 스레드 수가 무한정 늘어날 위험이 있습니다. ExecutorService는 스레드를 풀(pool)로 관리해 재사용하고, 작업 큐로 과부하를 흡수합니다.

import java.util.concurrent.*;

public class ExecutorDemo {
    public static void main(String[] args) throws Exception {

        // CPU 집중 작업 — 코어 수만큼 스레드
        int cores = Runtime.getRuntime().availableProcessors();
        ExecutorService cpuPool = Executors.newFixedThreadPool(cores);

        // I/O 집중 작업 — 코어 수보다 많은 스레드 (대기 시간 많음)
        ExecutorService ioPool = new ThreadPoolExecutor(
            cores,           // corePoolSize
            cores * 4,       // maximumPoolSize
            60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(1000),  // ✅ 큐 크기 제한 필수
            new ThreadPoolExecutor.CallerRunsPolicy() // 큐 초과 시 호출자가 직접 실행
        );

        // 작업 제출 — Future로 결과 수신
        Future<String> future = cpuPool.submit(() -> {
            Thread.sleep(100);
            return "작업 완료";
        });

        System.out.println(future.get()); // 블로킹 대기

        // ✅ 반드시 종료 처리
        cpuPool.shutdown();
        ioPool.shutdown();
        if (!cpuPool.awaitTermination(5, TimeUnit.SECONDS)) {
            cpuPool.shutdownNow();
        }
    }
}

⚠️ 주의 Executors.newCachedThreadPool()은 큐 없이 스레드를 무한 생성합니다. I/O 폭발 시 OOM(Out of Memory)이 발생할 수 있으므로 프로덕션에서는 ThreadPoolExecutor로 직접 구성하는 것이 안전합니다.

💡 TIP Virtual Thread(Java 21 이상)를 사용하면 Executors.newVirtualThreadPerTaskExecutor()로 I/O 집중 작업을 OS 스레드 수 제한 없이 처리할 수 있습니다. 기존 synchronized 블록과 함께 쓸 때는 핀닝(pinning) 문제를 확인하세요.

스레드 풀 크기 공식 (Little's Law 기반)

최적 스레드 수 ≈ 코어 수 × (1 + 대기 시간 / 처리 시간)

CPU 바운드 작업은 코어 수와 같게, I/O 바운드 작업은 대기 비율에 따라 늘립니다. 이론값보다 실측(모니터링) 기반 튜닝이 더 정확합니다.

동시성 컬렉션과 락 전략

ConcurrentHashMap — 세그먼트 락에서 버킷 락으로

Java 8 이전의 ConcurrentHashMap은 세그먼트 단위 락을 사용했습니다. Java 8+ 부터는 버킷(노드) 단위 CAS + synchronized로 전환해 더 세밀한 동시성을 제공합니다. HashMap을 그냥 Collections.synchronizedMap()으로 감싸면 메서드 단위 락이라 경합이 심합니다.

import java.util.concurrent.*;

public class ConcurrentMapDemo {
    private final ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

    public void addOrIncrement(String key) {
        // ✅ 원자적 복합 연산 — merge 사용
        map.merge(key, 1, Integer::sum);
    }

    public void computeIfAbsentDemo() {
        // ✅ 키가 없을 때만 계산 — 람다는 락 보호 하에 실행
        map.computeIfAbsent("expensive", k -> compute(k));
    }

    // ❌ 이렇게 하면 check-then-act 경쟁 조건 발생
    public void badPattern(String key) {
        if (!map.containsKey(key)) {
            map.put(key, 0); // 다른 스레드가 사이에 끼어들 수 있음
        }
    }

    private int compute(String k) { return k.length(); }
}

다른 동시성 컬렉션

// BlockingQueue: 생산자-소비자 패턴의 핵심
BlockingQueue<String> queue = new LinkedBlockingQueue<>(100);

// 생산자 (큐가 꽉 차면 블로킹)
queue.put("task-1");

// 소비자 (큐가 비면 블로킹)
String task = queue.take();

// CopyOnWriteArrayList: 읽기 빈번, 쓰기 드문 경우
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>();
cowList.add("item"); // 쓰기 시 전체 배열 복사 — 쓰기 비용 큼
// 읽기는 락 없음 — 이벤트 리스너 목록 등에 적합

// Semaphore: 리소스 개수 제한 (예: DB 커넥션 10개)
Semaphore semaphore = new Semaphore(10);
semaphore.acquire(); // 허가 획득 (없으면 대기)
try {
    // DB 작업
} finally {
    semaphore.release(); // 반드시 반환
}

ReadWriteLock — 읽기 많은 시나리오

import java.util.concurrent.locks.*;

public class CachedData {
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private Map<String, String> cache = new HashMap<>();

    public String read(String key) {
        rwLock.readLock().lock();  // 여러 스레드가 동시에 읽기 가능
        try {
            return cache.get(key);
        } finally {
            rwLock.readLock().unlock();
        }
    }

    public void write(String key, String value) {
        rwLock.writeLock().lock(); // 쓰기 시 읽기/쓰기 모두 배제
        try {
            cache.put(key, value);
        } finally {
            rwLock.writeLock().unlock();
        }
    }
}

💡 TIP Java 8+에서는 StampedLock이 낙관적 읽기(optimistic read)를 지원해 ReadWriteLock보다 고성능입니다. 단, 낙관적 읽기 후 검증(validate()) 로직을 반드시 작성해야 합니다.

CompletableFuture를 이용한 비동기 조합

Future.get()은 블로킹 호출로 스레드를 낭비합니다. CompletableFuture는 비동기 작업을 콜백 체인으로 연결해 스레드를 블로킹하지 않고 파이프라인을 구성합니다.

import java.util.concurrent.*;

public class AsyncPipeline {

    // 외부 API 호출 시뮬레이션
    private static CompletableFuture<String> fetchUser(long id) {
        return CompletableFuture.supplyAsync(() -> {
            // I/O 작업 (별도 스레드에서 실행)
            sleep(100);
            return "User:" + id;
        });
    }

    private static CompletableFuture<String> fetchOrders(String user) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(80);
            return user + " orders:[1,2,3]";
        });
    }

    public static void main(String[] args) throws Exception {
        // 순차 조합: thenCompose (flatMap 역할)
        CompletableFuture<String> result = fetchUser(42L)
            .thenCompose(user -> fetchOrders(user))
            .thenApply(orders -> "결과: " + orders)
            .exceptionally(ex -> "오류 발생: " + ex.getMessage());

        System.out.println(result.get());

        // 병렬 조합: allOf (모두 완료 대기)
        CompletableFuture<String> cf1 = fetchUser(1L);
        CompletableFuture<String> cf2 = fetchUser(2L);
        CompletableFuture<String> cf3 = fetchUser(3L);

        CompletableFuture.allOf(cf1, cf2, cf3)
            .thenRun(() -> System.out.println("모든 사용자 로드 완료"))
            .get();

        // anyOf: 가장 빠른 결과 사용
        CompletableFuture<Object> fastest = CompletableFuture.anyOf(cf1, cf2, cf3);
        System.out.println("가장 빠른 응답: " + fastest.get());
    }

    private static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

커스텀 Executor와 타임아웃

ExecutorService ioExecutor = Executors.newFixedThreadPool(10);

CompletableFuture<String> withTimeout = CompletableFuture
    .supplyAsync(() -> slowApiCall(), ioExecutor)
    .orTimeout(3, TimeUnit.SECONDS)           // Java 9+ — 타임아웃 초과 시 TimeoutException
    .exceptionally(ex -> "기본값 반환");

// ✅ 항상 명시적 Executor 지정 — ForkJoinPool.commonPool()은 공유 자원
CompletableFuture<String> explicit = CompletableFuture
    .supplyAsync(() -> compute(), ioExecutor)
    .thenApplyAsync(result -> transform(result), ioExecutor);

⚠️ 주의 thenApply는 이전 단계와 같은 스레드 또는 호출자 스레드에서 실행됩니다. CPU 집중 변환이라면 thenApplyAsync에 별도 Executor를 지정해 스레드 풀 고갈을 방지하세요.

⚠️ 주의 parallelStream()은 내부적으로 ForkJoinPool.commonPool()을 사용합니다. 이는 CompletableFuture의 기본 풀과 공유되므로, I/O 작업을 parallelStream()에 넣으면 전체 앱 성능에 영향을 줄 수 있습니다. 동시성 제어가 필요한 스트림 처리는 커스텀 ForkJoinPool을 사용하세요.

교착상태·경쟁 조건 진단과 회피

교착상태(Deadlock) — 4가지 조건과 회피

교착상태는 두 스레드가 서로 상대방이 보유한 락을 기다릴 때 발생합니다. 4가지 조건(상호 배제, 점유 대기, 비선점, 순환 대기) 중 하나를 깨면 교착상태가 예방됩니다. 실무에서 가장 현실적인 방법은 락 획득 순서 고정입니다.

// ❌ 교착상태 유발 — 스레드 A: lockA → lockB, 스레드 B: lockB → lockA
public class DeadlockExample {
    private final Object lockA = new Object();
    private final Object lockB = new Object();

    public void methodA() {
        synchronized (lockA) {
            synchronized (lockB) { /* 작업 */ }
        }
    }

    public void methodB() {
        synchronized (lockB) {    // ❌ 반대 순서
            synchronized (lockA) { /* 작업 */ }
        }
    }
}

// ✅ 락 획득 순서 통일
public class DeadlockFixed {
    private final Object lockA = new Object();
    private final Object lockB = new Object();

    public void methodA() {
        synchronized (lockA) {
            synchronized (lockB) { /* 작업 */ }
        }
    }

    public void methodB() {
        synchronized (lockA) { // ✅ 동일 순서
            synchronized (lockB) { /* 작업 */ }
        }
    }
}

jstack <pid> 실행 후 "Found one Java-level deadlock" 메시지가 나오면 교착상태가 확인됩니다.

경쟁 조건(Race Condition) — Check-Then-Act 패턴

// ❌ 경쟁 조건 — 잔액 확인과 출금 사이에 다른 스레드가 끼어들 수 있음
public class BankAccount {
    private int balance = 1000;

    public void withdraw(int amount) {
        if (balance >= amount) {        // check
            balance -= amount;          // act — 원자적이지 않음
        }
    }
}

// ✅ synchronized로 임계 구역 보호
public class SafeBankAccount {
    private int balance = 1000;

    public synchronized void withdraw(int amount) {
        if (balance >= amount) {
            balance -= amount;
        } else {
            throw new IllegalStateException("잔액 부족");
        }
    }

    public synchronized int getBalance() {
        return balance;
    }
}

ThreadLocal — 스레드 격리

각 스레드마다 독립적인 상태가 필요할 때 ThreadLocal을 사용합니다. 웹 프레임워크의 요청 컨텍스트(트랜잭션 ID, 사용자 인증 정보)에 많이 쓰입니다.

public class RequestContext {
    private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();

    public static void setRequestId(String id) {
        REQUEST_ID.set(id);
    }

    public static String getRequestId() {
        return REQUEST_ID.get();
    }

    // ✅ 반드시 제거 — 스레드 풀 환경에서 값이 다음 요청으로 누수됨
    public static void clear() {
        REQUEST_ID.remove();
    }
}

⚠️ 주의 스레드 풀에서 ThreadLocalremove() 없이 사용하면, 이전 요청의 데이터가 다음 요청으로 유출되는 보안 버그가 발생합니다. try-finally로 항상 정리하세요.

요약

  • JMM의 happens-before를 이해해야 가시성 버그를 예방할 수 있다. volatile은 가시성, synchronized는 가시성 + 상호 배제를 제공한다.
  • Atomic 변수는 CAS 기반 락-프리 원자 연산으로 경합이 낮은 카운터·참조 교체에 적합하다. 고경합 카운터에는 LongAdder가 더 효율적이다.
  • ExecutorService 스레드 풀은 크기·큐·거부 정책을 명시적으로 설정해야 한다. newCachedThreadPool()은 프로덕션에서 위험하다.
  • ConcurrentHashMap은 버킷 단위 락으로 높은 동시성을 제공하며, merge/computeIfAbsent로 원자적 복합 연산을 수행한다.
  • CompletableFuture는 비동기 파이프라인 구성에 적합하며, 커스텀 Executor 지정과 orTimeout으로 안전하게 사용한다.
  • 교착상태는 락 획득 순서를 통일해 회피하고, 경쟁 조건은 임계 구역을 최소화한 synchronized 또는 Atomic 변수로 제거한다.

연습문제

  1. 다음 코드는 100개의 스레드가 counter를 각 1,000번 증가시켜 최종 100,000이 되어야 하지만 실제로는 그보다 작은 값이 나옵니다. 문제의 원인을 설명하고, AtomicInteger를 사용해 수정하세요.

    public class BrokenCounter {
        private static int counter = 0;
        public static void main(String[] args) throws InterruptedException {
            Thread[] threads = new Thread[100];
            for (int i = 0; i < 100; i++) {
                threads[i] = new Thread(() -> {
                    for (int j = 0; j < 1000; j++) counter++;
                });
                threads[i].start();
            }
            for (Thread t : threads) t.join();
            System.out.println(counter);
        }
    }
    

    힌트 counter++는 read-modify-write 세 단계로 이루어집니다. 두 스레드가 동시에 같은 값을 읽으면 증가가 하나 손실됩니다.

  2. ExecutorService 스레드 풀(고정 크기 4)을 이용해 "task-0" ~ "task-9" 문자열 목록을 병렬로 처리하는 프로그램을 작성하세요. 각 작업은 50ms 대기 후 "[처리됨] task-N" 형태의 문자열을 반환합니다. 모든 결과를 수집해 출력한 뒤 풀을 안전하게 종료하세요.

    힌트 invokeAll()List<Callable<T>>를 받아 List<Future<T>>를 반환합니다.

  3. CompletableFuture를 사용해 두 개의 비동기 작업(사용자 조회, 주문 조회)을 순서대로 체인으로 연결하고, 오류 발생 시 기본값을 반환하는 코드를 작성하세요. 단, 모든 비동기 작업은 별도의 ExecutorService에서 실행되어야 합니다.

    힌트 순차 비동기 체인에는 thenComposeAsync, 오류 복구에는 exceptionally를 사용하세요.

  4. 아래 코드에는 교착상태 가능성이 있습니다. 어떤 상황에서 교착상태가 발생하는지 설명하고, 락 순서를 통일해 수정하세요.

    public class Transfer {
        public void transfer(Account from, Account to, int amount) {
            synchronized (from) {
                synchronized (to) {
                    from.debit(amount);
                    to.credit(amount);
                }
            }
        }
    }
    

    힌트 transfer(A, B, 100)transfer(B, A, 100)이 동시에 실행될 때를 생각해 보세요. System.identityHashCode()로 객체 순서를 결정하는 방법을 고려하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Java 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.