synchronized 하나로 동시성 문제를 다 해결할 수 있을까? 타임아웃이 필요하거나, 스레드를 수백 개 직접 만들기엔 비용이 크거나, 비동기 결과를 조합해야 할 때는 어떻게 해야 할까?

이 글에서는 synchronized의 한계에서 출발해서, ReentrantLock, ExecutorService, ConcurrentHashMap, CompletableFuture까지 — 자바 동시성 도구가 왜 이렇게 진화해왔는지 흐름을 따라가 봅니다.

Thread 생성 — 세 가지 방법

Java에서 스레드를 만드는 방법은 크게 세 가지예요.

1. Thread 상속

JAVA
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Thread: " + Thread.currentThread().getName());
    }
}

MyThread t = new MyThread();
t.start();

간단하긴 한데, Java는 단일 상속이라 다른 클래스를 상속받을 수 없게 됩니다. 실무에서 이렇게 쓰는 경우는 거의 없어요.

2. Runnable 구현

JAVA
class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Runnable: " + Thread.currentThread().getName());
    }
}

Thread t = new Thread(new MyRunnable());
t.start();

인터페이스 구현이니까 상속 제약이 없고, 람다로도 바로 쓸 수 있습니다.

JAVA
new Thread(() -> System.out.println("lambda")).start();

3. Callable + Future

Runnable은 반환값이 없습니다. 작업 결과를 받아야 한다면 Callable을 쓰면 돼요.

JAVA
Callable<Integer> task = () -> {
    Thread.sleep(1000);
    return 42;
};

ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(task);

Integer result = future.get(); // 블로킹, 결과가 나올 때까지 대기
System.out.println(result);    // 42
executor.shutdown();

Callablecall() 메서드에서 값을 리턴하고, 체크 예외도 던질 수 있습니다. Runnablerun()과 비교하면 확실히 유연해요.

구분Thread 상속RunnableCallable
반환값XXO
예외unchecked만unchecked만checked 가능
상속 제약있음없음없음
실무 사용거의 안 씀자주 씀Future와 함께

synchronized — 모니터 락

Java의 모든 객체는 하나의 모니터 락(intrinsic lock)을 가지고 있습니다. synchronized는 이 락을 이용해서 한 번에 하나의 스레드만 임계 영역에 진입하도록 보장해요.

인스턴스 락 vs 클래스 락

인스턴스 락은 this 객체의 모니터를 잡고, 클래스 락은 Counter.class 객체의 모니터를 잡습니다.

JAVA
// 인스턴스 락 — this 객체의 모니터
public synchronized void increment() { count++; }
// 위와 동일: synchronized (this) { count++; }

// 클래스 락 — Counter.class 객체의 모니터
public static synchronized void staticMethod() { /* ... */ }
// 위와 동일: synchronized (Counter.class) { /* ... */ }

핵심 차이는 동기화 범위 입니다. 인스턴스 락은 같은 인스턴스에 대해서만 동기화되고, 클래스 락은 모든 인스턴스에 걸쳐 동기화돼요. 인스턴스가 다르면 인스턴스 락은 서로 간섭하지 않습니다.

JAVA
Counter c1 = new Counter();
Counter c2 = new Counter();
// c1.increment()와 c2.increment()는 동시 실행됨 — 서로 다른 모니터 락

synchronized의 한계

  • 락을 잡은 스레드가 블로킹되면 다른 스레드도 전부 대기
  • 타임아웃 설정 불가
  • 읽기/쓰기 구분 없이 무조건 배타 락
  • 공정성(fairness) 보장 안 됨

이런 한계 때문에 java.util.concurrent.locks.ReentrantLock 같은 명시적 락이 등장했습니다.

JAVA
ReentrantLock lock = new ReentrantLock(true); // true = fair lock

lock.lock();
try {
    // 임계 영역
} finally {
    lock.unlock(); // 반드시 finally에서 해제
}

tryLock()으로 타임아웃도 걸 수 있고, ReadWriteLock으로 읽기는 공유, 쓰기만 배타적으로 처리할 수도 있습니다.

volatile — 가시성 보장

volatile은 락이 아닙니다. 원자성도 보장하지 않아요. 보장하는 건 가시성(visibility) 과 happens-before 관계뿐입니다.

가시성 문제

JAVA
public class StopFlag {
    private boolean running = true; // volatile 아님

    public void run() {
        while (running) {
            // 작업
        }
        System.out.println("stopped");
    }

    public void stop() {
        running = false;
    }
}

스레드 A가 run()을 실행하고, 스레드 B가 stop()을 호출해도 A가 멈추지 않을 수 있습니다. 이유가 뭘까요? A가 running 값을 CPU 캐시에서 읽고 있어서, B가 메인 메모리에 쓴 false를 못 보는 거예요.

JAVA
private volatile boolean running = true;

volatile을 붙이면 매번 메인 메모리에서 읽고 쓰도록 강제합니다. 이게 가시성 보장이에요.

happens-before

volatile 변수에 쓰기(write)가 일어나면, 그 ** 이전에 해당 스레드가 수행한 모든 쓰기 **가 다른 스레드에서 volatile 읽기(read) 이후에 보이게 됩니다.

JAVA
// Thread A
x = 10;                    // 일반 변수
volatileFlag = true;       // volatile 쓰기 → x = 10도 flush

// Thread B
if (volatileFlag) {        // volatile 읽기
    System.out.println(x); // 10이 보장됨
}

하지만 count++ 같은 복합 연산은 volatile로 보호할 수 없습니다. 읽기-수정-쓰기가 원자적이지 않기 때문이에요. 이런 경우엔 AtomicInteger를 쓰거나 synchronized로 감싸야 합니다.

ExecutorService와 ThreadPool

스레드를 직접 new Thread()로 만드는 건 비용이 큽니다. OS 레벨에서 스레드를 생성하고, 작업이 끝나면 소멸시키는 과정이 반복되니까요. ThreadPool은 미리 스레드를 만들어놓고 재사용합니다.

JAVA
ExecutorService executor = Executors.newFixedThreadPool(4);

for (int i = 0; i < 10; i++) {
    executor.submit(() -> {
        System.out.println(Thread.currentThread().getName());
    });
}

executor.shutdown();

ThreadPool 전략

팩토리 메서드내부 구현특징
newFixedThreadPool(n)new ThreadPoolExecutor(n, n, 0, ..., new LinkedBlockingQueue<>())고정 크기, 큐가 무한
newCachedThreadPool()new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60s, ..., new SynchronousQueue<>())유휴 스레드 재사용, 최대 무한
newSingleThreadExecutor()new ThreadPoolExecutor(1, 1, 0, ..., new LinkedBlockingQueue<>())순차 실행 보장
newScheduledThreadPool(n)ScheduledThreadPoolExecutor주기적/지연 실행

Executors의 함정 — 왜 직접 만들어야 하나

Executors 팩토리 메서드를 쓰지 말라는 이유는 내부 큐 설정에 있습니다.

  • newFixedThreadPool: LinkedBlockingQueue가 ** 무한 큐 **입니다. 요청이 폭증하면 큐에 계속 쌓여서 OOM(OutOfMemoryError)이 터져요.
  • newCachedThreadPool: 최대 스레드 수가 Integer.MAX_VALUE입니다. 요청마다 스레드를 만들어서 역시 OOM이에요.

그래서 실무에서는 ThreadPoolExecutor를 직접 생성합니다.

JAVA
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    4,                          // corePoolSize
    8,                          // maximumPoolSize
    60L, TimeUnit.SECONDS,      // keepAliveTime
    new ArrayBlockingQueue<>(100), // 유한 큐!
    new ThreadPoolExecutor.CallerRunsPolicy() // 큐가 꽉 차면 호출 스레드가 직접 실행
);

큐가 꽉 차고 최대 스레드도 다 바쁘면 RejectedExecutionHandler가 동작합니다.

정책동작
AbortPolicy (기본)RejectedExecutionException 발생
CallerRunsPolicy호출한 스레드가 직접 실행
DiscardPolicy조용히 버림
DiscardOldestPolicy가장 오래된 작업을 버리고 새 작업 추가

ConcurrentHashMap

일반 HashMap은 thread-safe하지 않습니다. Collections.synchronizedMap()으로 감쌀 수 있지만, 전체 맵에 하나의 락을 거는 거라 성능이 별로예요. Hashtable도 마찬가지입니다.

Java 7 — 세그먼트 락 (Segment Locking)

PLAINTEXT
Segment[0] → [Entry, Entry, ...]  ← 락 #0
Segment[1] → [Entry, Entry, ...]  ← 락 #1
...
Segment[15] → [Entry, Entry, ...] ← 락 #15

맵을 16개(기본값) 세그먼트로 나누고, 각 세그먼트마다 독립적인 ReentrantLock을 겁니다. 서로 다른 세그먼트에 접근하는 스레드끼리는 경쟁하지 않으니까 동시성이 올라가요.

Java 8 — CAS + synchronized (Node 단위)

Java 8에서 세그먼트 구조를 완전히 걷어냈습니다. 대신:

  • ** 빈 버킷에 삽입 **: CAS(Compare-And-Swap)로 lock-free 삽입
  • ** 기존 버킷에 추가/수정 **: 해당 노드(버킷의 head)에 synchronized 적용
  • ** 읽기 **: 대부분 lock-free (volatile read)
JAVA
// Java 8 ConcurrentHashMap의 putVal 핵심 로직 (간략화)
if (tab[i] == null) {
    // CAS로 새 노드 삽입 — lock-free
    casTabAt(tab, i, null, new Node<>(hash, key, value));
} else {
    synchronized (f) { // f = 버킷의 첫 번째 노드
        // 체이닝 또는 트리 노드에 추가
    }
}

버킷 단위로 synchronized를 걸기 때문에, 서로 다른 버킷에 접근하는 스레드끼리는 전혀 간섭이 없습니다. 세그먼트 방식보다 세밀한 락 단위라 성능이 더 좋아요.

ConcurrentHashMap의 진화: Java 7의 세그먼트 락(16개 고정 파티션) → Java 8의 CAS + Node 단위 synchronized(버킷 단위 세밀한 락). 락의 단위가 작아질수록 동시성이 올라간다.

AtomicInteger, AtomicReference — CAS 기반 lock-free

synchronized 없이도 원자적 연산이 가능합니다. java.util.concurrent.atomic 패키지의 클래스들이 CAS를 기반으로 동작해요.

CAS (Compare-And-Swap)

PLAINTEXT
"현재 값이 내가 예상한 값(expected)과 같으면, 새 값(new)으로 바꿔라.
다르면 실패하고, 다시 시도해라."

하드웨어 수준(CPU 명령어)에서 원자적으로 실행되기 때문에 락이 필요 없습니다.

JAVA
AtomicInteger counter = new AtomicInteger(0);

// 내부적으로 CAS 루프를 돌며 원자적 증가
counter.incrementAndGet(); // 1
counter.addAndGet(5);      // 6

// compareAndSet도 직접 쓸 수 있다
boolean success = counter.compareAndSet(6, 10); // true, 6 → 10

AtomicReference

객체 참조도 CAS로 원자적 교체가 가능합니다.

JAVA
AtomicReference<String> ref = new AtomicReference<>("hello");

ref.compareAndSet("hello", "world"); // true
System.out.println(ref.get());       // "world"

**ABA 문제 **: CAS에는 유명한 함정이 있습니다. 값이 A → B → A로 변경됐는데, CAS는 "여전히 A니까 변경된 적 없다"고 판단해요. 이걸 방지하려면 AtomicStampedReference로 버전(stamp)을 함께 관리하면 됩니다.

JAVA
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(1, 0);
int stamp = ref.getStamp();
ref.compareAndSet(1, 2, stamp, stamp + 1);

CompletableFuture — 비동기 프로그래밍

Future는 결과를 가져올 때 get()으로 블로킹해야 합니다. 콜백도 못 달고, 여러 비동기 작업을 조합하기도 어려워요. CompletableFuture는 이 모든 걸 해결합니다.

기본 사용

JAVA
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
    // ForkJoinPool.commonPool()에서 비동기 실행
    return fetchDataFromDB();
});

// 블로킹 없이 콜백 등록
cf.thenAccept(data -> System.out.println("결과: " + data));

변환과 조합

JAVA
CompletableFuture<String> result = CompletableFuture
    .supplyAsync(() -> getUserId())        // 비동기로 userId 조회
    .thenApply(id -> fetchUser(id))        // userId로 User 조회 (동기 변환)
    .thenApply(user -> user.getName());    // User에서 이름 추출

thenApply vs thenCompose — 반환 타입이 다릅니다.

JAVA
// thenApply: 값 → 값 (map과 유사)
CompletableFuture<CompletableFuture<User>> nested =
    cf.thenApply(id -> fetchUserAsync(id)); // 이러면 중첩됨!

// thenCompose: 값 → CompletableFuture (flatMap과 유사)
CompletableFuture<User> flat =
    cf.thenCompose(id -> fetchUserAsync(id)); // 평탄화됨

thenApply는 Stream의 map(값을 동기 변환), thenComposeflatMap(CompletableFuture를 반환하는 비동기 변환을 평탄화)이다.

여러 작업 조합

JAVA
CompletableFuture<String> userCf = CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<String> orderCf = CompletableFuture.supplyAsync(() -> fetchOrder());

// 둘 다 끝나면 결합
CompletableFuture<String> combined = userCf.thenCombine(orderCf,
    (user, order) -> user + " / " + order);

// 여러 개 동시에 기다리기
CompletableFuture<Void> all = CompletableFuture.allOf(userCf, orderCf);
all.thenRun(() -> System.out.println("둘 다 완료"));

// 하나라도 끝나면
CompletableFuture<Object> any = CompletableFuture.anyOf(userCf, orderCf);

예외 처리

JAVA
CompletableFuture<String> cf = CompletableFuture
    .supplyAsync(() -> {
        if (true) throw new RuntimeException("에러 발생");
        return "ok";
    })
    .exceptionally(ex -> {
        System.out.println("예외: " + ex.getMessage());
        return "fallback";
    });

// 또는 handle로 성공/실패 모두 처리
cf.handle((result, ex) -> {
    if (ex != null) return "에러 처리됨";
    return result;
});

스레드 풀 지정

기본적으로 ForkJoinPool.commonPool()에서 실행되는데, 이 풀은 CPU 코어 수 - 1개의 스레드만 가지고 있습니다. I/O 작업이 많으면 별도 풀을 지정하는 게 좋아요.

JAVA
ExecutorService ioPool = Executors.newFixedThreadPool(20);

CompletableFuture.supplyAsync(() -> fetchFromNetwork(), ioPool)
    .thenApplyAsync(data -> process(data), ioPool);

함께 알아야 할 개념들

동시성을 다루다 보면 자연스럽게 파생되는 주제들이에요.

ThreadLocal

스레드마다 독립적인 변수를 저장하는 저장소입니다. 다른 스레드와 공유되지 않아요.

JAVA
ThreadLocal<String> context = new ThreadLocal<>();

// Thread A
context.set("user-123");
context.get(); // "user-123"

// Thread B
context.get(); // null (Thread A의 값은 안 보임)

ThreadPool에서 ThreadLocal을 쓸 때 반드시 remove()를 호출해야 합니다. 풀의 스레드는 재사용되기 때문에, 이전 작업의 데이터가 남아서 다음 작업에 노출될 수 있어요.

JAVA
try {
    context.set("request-data");
    // 작업 수행
} finally {
    context.remove(); // 필수!
}

Spring Security의 SecurityContextHolder가 내부적으로 ThreadLocal을 사용합니다. 서블릿 요청마다 인증 정보를 ThreadLocal에 담아두고, 컨트롤러나 서비스 어디서든 꺼내 쓸 수 있는 구조예요.

가상 스레드 (Project Loom)

Java 21에서 정식 도입된 경량 스레드입니다. OS 스레드와 1:1로 매핑되는 플랫폼 스레드와 달리, JVM이 관리하는 가상 스레드는 수십만 개를 만들어도 문제없어요.

JAVA
// 가상 스레드 생성
Thread.startVirtualThread(() -> {
    System.out.println("가상 스레드: " + Thread.currentThread());
});

// ExecutorService로 사용
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            Thread.sleep(1000);
            return "done";
        });
    }
}

가상 스레드가 I/O에서 블로킹되면 캐리어 스레드(실제 OS 스레드)에서 자동으로 분리(unmount)되고, I/O가 끝나면 다시 캐리어 스레드에 올라탑니다(mount). 그래서 블로킹 코드를 그대로 쓰면서도 논블로킹 수준의 처리량을 낼 수 있어요.

단, synchronized 블록 안에서 블로킹하면 캐리어 스레드가 고정(pinning)돼서 가상 스레드의 장점이 사라집니다. 이 경우 ReentrantLock으로 바꿔야 해요.

Fork/Join 프레임워크

큰 작업을 재귀적으로 쪼개서(fork) 병렬 처리하고, 결과를 합치는(join) 프레임워크입니다. ForkJoinPool이 work-stealing 알고리즘으로 유휴 스레드에 작업을 분배해요.

RecursiveTask를 상속받아 compute()에서 분할 로직을 구현합니다.

JAVA
class SumTask extends RecursiveTask<Long> {
    private final long[] arr;
    private final int start, end;
    // 생성자 생략
    @Override
    protected Long compute() {
        if (end - start <= 1000) {
            long sum = 0;
            for (int i = start; i < end; i++) sum += arr[i];
            return sum;
        }
        int mid = (start + end) / 2;
        SumTask left = new SumTask(arr, start, mid);
        SumTask right = new SumTask(arr, mid, end);
        left.fork();                        // 비동기 실행
        long rightResult = right.compute(); // 현재 스레드에서 실행
        return left.join() + rightResult;   // fork한 결과 합산
    }
}

parallelStream()은 내부적으로 ForkJoinPool.commonPool()을 씁니다. I/O 작업에 parallelStream을 쓰면 다른 스트림에 영향을 줄 수 있으므로 주의해야 해요.

데드락 디버깅 — jstack

데드락이 의심될 때 가장 빠른 진단 도구가 jstack입니다.

BASH
# 프로세스 ID 확인
jps

# 스레드 덤프
jstack <pid>

데드락이 있으면 jstack 출력 맨 아래에 이런 내용이 보여요:

PLAINTEXT
Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x00007f8a3c003f08 (object 0x00000007aab50320, a java.lang.Object),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x00007f8a3c006358 (object 0x00000007aab50330, a java.lang.Object),
  which is held by "Thread-1"

프로그래밍적으로 감지하려면 ThreadMXBean도 쓸 수 있습니다.

JAVA
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = bean.findDeadlockedThreads();
if (deadlockedThreads != null) {
    ThreadInfo[] infos = bean.getThreadInfo(deadlockedThreads, true, true);
    for (ThreadInfo info : infos) {
        System.out.println(info);
    }
}

TIP: 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.

주의할 점

Executors 팩토리로 ThreadPool을 만드는 실수

newFixedThreadPoolLinkedBlockingQueue(무한 큐)를 쓰기 때문에 요청 폭증 시 OOM이 발생할 수 있습니다. newCachedThreadPool은 최대 스레드 수가 Integer.MAX_VALUE라 역시 OOM 위험이 있어요. ThreadPoolExecutor를 직접 생성해서 유한 큐와 적절한 스레드 수를 설정해야 합니다.

CompletableFuture의 기본 스레드 풀

supplyAsync()를 스레드 풀 지정 없이 호출하면 ForkJoinPool.commonPool()에서 실행됩니다. 이 풀은 CPU 코어 수 - 1개의 스레드만 가지고 있어서, I/O 작업이 많으면 병목이 돼요. I/O 작업에는 별도 ExecutorService를 지정하는 것이 안전합니다.

ThreadLocal을 remove() 없이 쓰는 실수

스레드 풀의 스레드는 재사용되기 때문에, remove() 없이 ThreadLocal을 쓰면 이전 작업의 데이터가 다음 작업에 노출됩니다. Spring Security의 SecurityContextHolder도 내부적으로 ThreadLocal을 사용하므로 같은 원리가 적용돼요.

정리

도구해결하는 문제핵심 특징
synchronized상호 배제 + 가시성모니터 락 기반, 타임아웃 불가
ReentrantLocksynchronized의 한계tryLock, Condition, 공정성
volatile가시성원자성 미보장, 플래그용
ExecutorService스레드 생성 비용스레드 재사용, 작업 큐
ConcurrentHashMapthread-safe MapJava 8: CAS + Node 단위 synchronized
AtomicInteger락 없는 원자적 연산CAS 기반
CompletableFuture비동기 조합콜백 체이닝, 예외 처리
ThreadLocal스레드별 독립 저장풀 사용 시 반드시 remove()
댓글 로딩 중...