본문 바로가기

Language/Java

[JAVA] 자바의 동시성 구현 방법

들어가며

이전 포스트에서 스레드 안전성의 다섯 가지 수준과 락 최적화 기법을 살펴보았습니다. 이번 포스트에서는 한 걸음 뒤로 물러나, 자바에서 동시성(Concurrency)을 구현하는 네 가지 메커니즘을 정리합니다.

 

자바의 동시성 모델은 버전이 올라가면서 점점 추상화 수준이 높아져 왔습니다. 스레드를 직접 생성하던 시대에서 시작해, 스레드 풀에 작업을 제출하고, 작업을 분할·병합하며, 최근에는 JVM이 경량 스레드를 직접 스케줄링하는 단계까지 발전했습니다.

 

 

 


1. Thread / Runnable — 직접 스레드 관리

자바에서 동시성을 구현하는 가장 기본적인 방법입니다. Java 1.0부터 존재했으며, 두 가지 방식으로 사용할 수 있습니다.

1-1. Thread 상속

public class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("스레드 실행: " + getName());
    }
}

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

1-2. Runnable 구현

Runnable task = () -> System.out.println("작업 실행: " + Thread.currentThread().getName());
Thread t = new Thread(task);
t.start();

Runnable 방식이 더 선호되는데, 자바는 단일 상속만 지원하므로 Thread를 상속하면 다른 클래스를 상속할 수 없기 때문입니다. 또한 Runnable은 작업(task)과 실행 메커니즘(thread)을 분리하여 설계의 유연성을 높여줍니다.

 

1-3. 한계

이 방식은 개발자가 스레드의 생성, 시작, 종료를 모두 직접 관리해야 합니다. 몇 가지 심각한 문제가 있습니다.

 

첫째, 스레드 생성 비용이 큽니다. 스레드를 생성할 때마다 OS 커널 스레드가 함께 생성되고, 스택 메모리가 할당됩니다. 요청마다 새 스레드를 만드는 방식은 고부하 환경에서 성능 병목이 됩니다.

 

둘째, 스레드 수를 제어할 수 없습니다. 수천 개의 요청이 동시에 들어오면 수천 개의 스레드가 생성되어 메모리 부족(OutOfMemoryError)이나 컨텍스트 스위칭 오버헤드가 발생할 수 있습니다.

 

셋째, 반환 값이 없습니다. Runnable.run()void를 반환하므로, 작업의 결과를 받아오려면 공유 변수를 사용해야 하고, 이는 동기화 문제로 이어집니다.

 

이러한 한계를 해결하기 위해 Java 5에서 Executor 프레임워크가 도입되었습니다.

 


2. Executor / ThreadPool — 스레드 풀 기반 작업 제출

2-1. Executor 프레임워크

Java 5에서 도입된 java.util.concurrentExecutor 프레임워크는 작업의 제출(submission)실행(execution)을 분리합니다. 개발자는 "무엇을 실행할지"만 정의하고, "어떻게 실행할지"는 프레임워크에 위임합니다.

ExecutorService executor = Executors.newFixedThreadPool(4);

executor.submit(() -> {
    System.out.println("작업 실행: " + Thread.currentThread().getName());
});

executor.shutdown();

 

핵심 인터페이스 계층은 다음과 같습니다.

  • Executor: execute(Runnable) 하나만 정의한 최상위 인터페이스
  • ExecutorService: Executor를 확장하여 submit(), shutdown(), invokeAll() 등 생명주기 관리 기능 추가
  • ScheduledExecutorService: 지연 실행 및 주기적 실행 지원

 

2-2. ThreadPoolExecutor

ExecutorService의 핵심 구현체인 ThreadPoolExecutor는 스레드 풀의 동작을 세밀하게 제어할 수 있습니다.

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    4,                // corePoolSize: 기본 유지 스레드 수
    8,                // maximumPoolSize: 최대 스레드 수
    60L,              // keepAliveTime: 초과 스레드 유휴 대기 시간
    TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(100)  // 작업 대기 큐
);

작업이 제출되면 다음과 같은 순서로 처리됩니다.

 

  1. 현재 스레드 수가 corePoolSize보다 적으면 → 새 스레드를 생성하여 작업 실행
  2. corePoolSize에 도달했으면 → 작업을 대기 큐에 넣음
  3. 대기 큐가 가득 찼고 스레드 수가 maximumPoolSize보다 적으면 → 새 스레드 생성
  4. maximumPoolSize에도 도달했으면 → 거부 정책(RejectionPolicy) 실행

 

거부 정책은 네 가지가 기본 제공됩니다: AbortPolicy(예외 발생, 기본값), CallerRunsPolicy(호출 스레드에서 실행), DiscardPolicy(무시), DiscardOldestPolicy(가장 오래된 작업 제거 후 재시도).

 

Executors 유틸리티 클래스는 자주 사용하는 구성을 편의 메서드로 제공합니다.

팩토리 메서드 내부 구성 특징
newFixedThreadPool(n) core = max = n, 무제한 큐 고정 크기 풀
newCachedThreadPool() core = 0, max = Integer.MAX_VALUE 유휴 스레드 재사용, 60초 후 제거
newSingleThreadExecutor() core = max = 1 단일 스레드, 순서 보장
newScheduledThreadPool(n) ScheduledThreadPoolExecutor 지연/주기적 실행

 

다만 실무에서는 Executors의 편의 메서드보다 ThreadPoolExecutor를 직접 생성하는 것이 권장됩니다.

 

newFixedThreadPoolnewSingleThreadExecutor는 무제한 큐(LinkedBlockingQueue)를 사용하여 작업이 계속 쌓이면 메모리 부족이 발생할 수 있고, newCachedThreadPool은 최대 스레드 수가 Integer.MAX_VALUE여서 스레드가 무한히 생성될 수 있기 때문입니다.

 

 

2-3. 비동기 결과 처리가 필요하다면?

ExecutorService에 작업을 제출하면 결과를 Future로 받을 수 있습니다. 그런데 Future만으로는 논블로킹 콜백, 작업 조합 같은 진짜 비동기 처리가 어렵습니다.

Callable, Future, CompletableFuture로 이어지는 비동기 결과 처리의 진화는 다음 포스트에서 자세히 다룹니다.

👉 [다음 포스트] Java 비동기 결과 처리의 진화: Callable → Future → CompletableFuture


3. Fork/Join Framework — 분할 정복 병렬 처리

3-1. 개요

Java 7에서 도입된 Fork/Join 프레임워크는 분할 정복(Divide and Conquer) 패턴에 특화된 병렬 처리 프레임워크입니다. 큰 작업을 작은 단위로 재귀적으로 분할(fork)하고, 각각의 결과를 합치는(join) 방식으로 동작합니다.

 

3-2. 핵심 구성 요소

ForkJoinPool: Fork/Join 작업을 실행하는 특수한 스레드 풀입니다. 일반 ThreadPoolExecutor와 달리 작업 훔치기(Work-Stealing) 알고리즘을 사용합니다. 각 워커 스레드가 자신만의 양방향 큐(deque)를 가지며, 자신의 큐가 비면 다른 스레드의 큐에서 작업을 가져옵니다.

ForkJoinTask: Fork/Join 작업의 기본 타입이며, 두 가지 주요 하위 클래스가 있습니다.

  • RecursiveTask<V>: 결과를 반환하는 작업
  • RecursiveAction: 결과를 반환하지 않는 작업

 

3-3. 사용 예시

배열의 합계를 병렬로 계산하는 예시입니다.

public class SumTask extends RecursiveTask<Long> {
    private static final int THRESHOLD = 10_000;
    private final long[] array;
    private final int start, end;

    public SumTask(long[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        // 작업이 충분히 작으면 직접 계산
        if (end - start <= THRESHOLD) {
            long sum = 0;
            for (int i = start; i < end; i++) {
                sum += array[i];
            }
            return sum;
        }

        // 작업 분할
        int mid = (start + end) / 2;
        SumTask left = new SumTask(array, start, mid);
        SumTask right = new SumTask(array, mid, end);

        left.fork();    // 왼쪽 작업을 비동기 실행
        long rightResult = right.compute(); // 오른쪽 작업은 현재 스레드에서 실행
        long leftResult = left.join();      // 왼쪽 작업의 결과 대기

        return leftResult + rightResult;
    }
}

ForkJoinPool pool = new ForkJoinPool();
long result = pool.invoke(new SumTask(array, 0, array.length));

 

3-4. Work-Stealing 알고리즘

Fork/Join의 핵심인 Work-Stealing은 다음과 같이 동작합니다.

각 워커 스레드는 자신의 작업을 deque의 앞쪽(head)에서 꺼내서 처리합니다. 자신의 deque가 비면, 다른 스레드의 deque 뒤쪽(tail)에서 작업을 훔쳐옵니다. 이렇게 하면 작업이 고르게 분배되어, 특정 스레드만 과부하에 걸리는 상황을 방지할 수 있습니다.

 

3-5. Parallel Stream과의 관계

Java 8의 parallelStream()은 내부적으로 ForkJoinPool.commonPool()을 사용합니다.

long sum = LongStream.rangeClosed(1, 1_000_000)
                     .parallel()
                     .sum();

 

편리하지만 주의할 점이 있습니다. commonPool은 JVM 전체에서 공유되므로, 하나의 parallelStream이 무거운 작업을 수행하면 다른 병렬 스트림에도 영향을 줍니다. 이를 피하려면 커스텀 ForkJoinPool에서 실행해야 합니다.

ForkJoinPool customPool = new ForkJoinPool(8);
long sum = customPool.submit(() ->
    LongStream.rangeClosed(1, 1_000_000)
              .parallel()
              .sum()
).get();

 


4. Virtual Thread — JVM 경량 스레드

4-1. 기존 스레드 모델의 한계

Java 21 이전의 스레드는 모두 플랫폼 스레드(Platform Thread)로, OS 커널 스레드와 1:1로 매핑됩니다. 이 모델에는 근본적인 한계가 있습니다.

 

스레드 하나당 약 1MB의 스택 메모리가 필요하므로, 수천 개 이상의 스레드를 생성하기 어렵습니다. 웹 서버처럼 동시 요청이 많고 I/O 대기 시간이 긴 애플리케이션에서는 스레드가 대부분의 시간을 블로킹 상태로 낭비합니다. 이를 해결하기 위해 비동기/리액티브 프로그래밍(WebFlux, RxJava 등)이 등장했지만, 코드의 복잡성이 크게 증가했습니다.

 

4-2. Virtual Thread란?

Java 21에서 정식 도입된 가상 스레드(Virtual Thread)는 JVM이 직접 관리하는 경량 스레드입니다. 플랫폼 스레드와의 핵심 차이는 M:N 스케줄링에 있습니다. 수많은 가상 스레드(M)가 소수의 플랫폼 스레드(N, 캐리어 스레드)에 매핑되며, JVM이 스케줄링을 담당합니다. 스레드 모델(1:1, M:N 등)에 대한 자세한 내용은 Java 스레드의 모든 것 — 스레드 모델부터 Virtual Thread까지를 참고하시기 바랍니다.

 

[Java] Java 스레드의 모든 것 — 스레드 모델부터 Virtual Thread까지

1. 스레드 구현 모델스레드는 사용자 스레드(User Thread)와 커널 스레드(Kernel Thread) 간의 매핑 방식에 따라 세 가지 모델로 나뉜다. 1-1. M:1 모델 (사용자 레벨 스레드)사용자 스레드 M개가 커널 스레

dmoritle.tistory.com

 

가상 스레드의 스택은 힙 메모리에 저장되며, 필요에 따라 크기가 동적으로 조절됩니다. 초기 스택 크기가 수백 바이트 수준이므로, 수십만~수백만 개의 가상 스레드를 생성할 수 있습니다.

 

4-3. 사용 방법

// 방법 1: Thread.startVirtualThread
Thread vt = Thread.startVirtualThread(() -> {
    System.out.println("가상 스레드에서 실행");
});

// 방법 2: Thread.ofVirtual()
Thread vt2 = Thread.ofVirtual()
    .name("my-virtual-thread")
    .start(() -> System.out.println("이름 지정된 가상 스레드"));

// 방법 3: ExecutorService와 함께 (가장 실무적인 방식)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));  // 블로킹 I/O 시뮬레이션
            return "완료";
        });
    }
}

newVirtualThreadPerTaskExecutor()는 작업마다 새로운 가상 스레드를 생성합니다. 플랫폼 스레드라면 10만 개의 스레드를 생성하는 것이 비현실적이지만, 가상 스레드에서는 문제가 되지 않습니다.

 

4-4. 동작 원리: 마운트와 언마운트

가상 스레드가 블로킹 I/O를 만나면 다음과 같이 동작합니다.

  1. 가상 스레드가 sleep()이나 Socket.read() 같은 블로킹 연산을 호출
  2. JVM이 해당 가상 스레드를 캐리어(플랫폼) 스레드에서 언마운트(unmount) — 스택을 힙에 저장
  3. 캐리어 스레드는 즉시 다른 가상 스레드를 마운트(mount)하여 실행
  4. I/O가 완료되면, 대기 중이던 가상 스레드가 다시 캐리어 스레드에 마운트되어 실행 재개

이 과정은 OS 커널의 개입 없이 JVM 사용자 모드에서 이루어지므로, 컨텍스트 스위칭 비용이 플랫폼 스레드보다 훨씬 적습니다.

 

4-5. 주의사항

가상 스레드가 만능은 아닙니다. 몇 가지 주의할 점이 있습니다.

 

Pinning(고정): synchronized 블록 안에서 블로킹 연산을 수행하면 가상 스레드가 캐리어 스레드에 고정(pinned)되어 언마운트되지 않습니다. 이 경우 캐리어 스레드가 낭비됩니다. 해결 방법은 synchronized 대신 ReentrantLock을 사용하는 것입니다. ReentrantLock은 블로킹 시 가상 스레드가 정상적으로 언마운트됩니다.

// X 가상 스레드에서 피해야 할 패턴
synchronized (lock) {
    socket.read(buffer);  // Pinning 발생!
}

// O ReentrantLock 사용
reentrantLock.lock();
try {
    socket.read(buffer);  // 정상적으로 언마운트
} finally {
    reentrantLock.unlock();
}

 

CPU 바운드 작업에는 이점이 없습니다. 가상 스레드의 장점은 블로킹 대기 시간 동안 캐리어 스레드를 재활용하는 것입니다. CPU를 계속 사용하는 작업에서는 가상 스레드를 써도 이점이 없으며, 이런 경우에는 Fork/Join이나 일반 스레드 풀이 더 적합합니다.

 

스레드 풀링이 불필요합니다. 가상 스레드는 생성 비용이 매우 낮으므로, 풀링하지 않고 필요할 때마다 새로 생성하는 것이 올바른 사용 방식입니다. newVirtualThreadPerTaskExecutor()가 권장되는 이유입니다.

 

ThreadLocal 사용에 주의해야 합니다. 수십만 개의 가상 스레드가 각각 ThreadLocal을 가지면 메모리 사용량이 급증할 수 있습니다. Java 21에서 함께 도입된 ScopedValue(Preview)가 대안입니다.

 

4-6. 플랫폼 스레드 vs 가상 스레드

항목 플랫폼 스레드 가상 스레드
매핑 OS 커널 스레드와 1:1 캐리어 스레드에 M:N
스택 메모리 ~1MB 고정 수백 바이트~, 동적
생성 비용 높음 (커널 호출) 매우 낮음 (JVM 내부)
동시 생성 가능 수 수천 개 수준 수십만~수백만 개
스케줄링 OS 커널 JVM (ForkJoinPool)
적합한 작업 CPU 바운드, 소수 스레드 I/O 바운드, 대량 동시 처리

 


5. 정리 — 네 가지 메커니즘 비교

메커니즘 도입 시기 핵심 아이디어 적합한 상황
Thread / Runnable Java 1.0 스레드 직접 관리 학습, 단순한 백그라운드 작업
Executor / ThreadPool Java 5 작업과 실행의 분리 대부분의 서버 애플리케이션
Fork/Join Java 7 분할 정복 + Work-Stealing CPU 바운드 병렬 계산
Virtual Thread Java 21 JVM 경량 스레드 (M:N) I/O 바운드 대량 동시 처리

 

추상화 수준은 Thread → Executor → Fork/Join → Virtual Thread 순으로 높아지지만, 각각이 이전 것을 완전히 대체하는 것은 아닙니다. 작업의 성격에 따라 적절한 메커니즘을 선택해야 합니다.

 

일반적인 지침은 다음과 같습니다. 대부분의 서버 애플리케이션에서는 Executor + CompletableFuture 조합이 가장 범용적입니다. I/O 바운드 작업이 많고 Java 21 이상을 사용할 수 있다면 Virtual Thread가 코드를 크게 단순화해 줍니다. CPU 집약적인 병렬 계산에는 Fork/Join 또는 Parallel Stream이 적합합니다. 그리고 Thread를 직접 생성하는 방식은 특별한 이유가 없다면 피하는 것이 좋습니다.

 


참고 자료

  • JVM 밑바닥까지 파헤치기 — 자바 메모리 모델과 스레드 장