한 줄 정의

핵심 메시지

동시성은 더 이상 선택이 아니라 전제입니다. CPU 클럭은 약 2005년부터 정체되었고, 성능 향상은 코어 수 증가로 옮겨 갔습니다. 자바 가상 머신은 멀티코어 환경에서 이점을 가지지만, 공유 상태(shared state)를 다루는 순간 자바 메모리 모델(JMM)이 무엇을 보장하고 무엇을 보장하지 않는지 알아야 합니다.

핵심은 두 가지입니다. 첫째, 올바름(correctness)은 테스트로 증명되지 않는다는 것 — 동시성 버그는 운에 따라 숨어 있다가 어느 날 터집니다. 둘째, 성능은 무분별한 synchronized로는 오히려 떨어지며, 메서드 핸들·변수 핸들·비교와 교환(CAS)·반복 잠금(spinlock)처럼 저수준 도구가 받쳐주는 java.util.concurrent 라이브러리를 신뢰해야 한다는 것입니다.

쉽게 말하면

예전에는 새 CPU를 사면 코드를 그대로 두고도 빨라졌습니다. 이게 허브 서터가 말한 “공짜 점심”입니다. 그런데 약 3GHz 근처에서 클럭 속도가 멈추면서 점심이 끊겼고, 그때부터는 여러 코어에 일을 나눠 줘야 빨라집니다.

자바는 1.0부터 스레드를 언어 수준에서 지원한 흔치 않은 환경이었습니다. 그 결과 자바 개발자는 다른 언어 개발자보다 일찍, 자주, 그리고 깊이 동시성의 함정에 빠졌습니다. 카운터를 증가시키는 한 줄짜리 코드조차 멀티스레드에서는 깨질 수 있다는 사실을 처음 마주하는 곳이 보통 자바입니다.

이 장은 그 함정 — 업데이트 손실, 가시성 문제, 명령어 재정렬 — 이 왜 생기는지, 자바 메모리 모델이 어떤 보장을 제공하는지, 그리고 synchronized보다 더 정교한 도구들(메서드 핸들, AtomicInteger, 변수 핸들의 compareAndSet, 반복 잠금)이 어떻게 그 보장 위에서 만들어지는지를 다룹니다.

왜 중요한가?

멀티코어는 옵션이 아니라 기본값입니다

오늘날 데스크톱, 서버, 심지어 모바일까지 거의 모든 기기가 멀티코어입니다. 잘 작성된 현대 애플리케이션은 처리를 여러 코어에 분산해 성능을 극대화해야 합니다. 자바 가상 머신은 JIT 컴파일과 GC 같은 작업을 항상 여러 코어로 분산하기 때문에, 단일 스레드만 쓰는 애플리케이션조차 멀티코어 하드웨어의 혜택을 일부 받습니다. 다만 진짜 이득을 보려면 애플리케이션 코드가 명시적으로 병렬화되어야 합니다.

”정상 동작”이 곧 “정확함”은 아닙니다

동시성 코드는 대부분의 경우 정상 동작합니다. 그런데 그게 곧 정확함을 뜻하지 않습니다. 단 한 번의 실패 사례만 찾아도 그 프로그램이 올바르지 않음을 입증할 수 있지만, 100만 번 잘 돌았다고 해서 올바름을 증명할 수는 없습니다. 게다가 동시성 버그는 재현이 매우 어렵다는 본질적 약점이 있습니다.

『Java Concurrency in Practice』 — 브라이언 괴츠 등

올바른 프로그램을 작성하는 것은 어렵습니다. 올바른 동시성 프로그램을 작성하는 것은 더 어렵습니다. 동시성 프로그램에서는 순차적 프로그램보다 잘못될 수 있는 요소가 훨씬 많기 때문입니다.

무분별한 동기화는 오히려 느립니다

동기화는 신중한 설계와 사전 계획이 필요합니다. 준비 없이 synchronized를 곳곳에 뿌리면 오히려 처리량이 떨어집니다. 동시성을 도입하는 본래 목적과 정반대 결과가 나오는 것입니다. 따라서 모든 병렬화 시도는 추가된 복잡성의 이점이 성능 테스트로 증명되어야 합니다.

핵심 내용

13.1 병렬 처리 소개

무어의 법칙과 공짜 점심의 종말

원래 무어의 법칙은 칩의 트랜지스터 수가 매년 두 배가 된다고 예측했고, 이후 18개월마다 두 배로 수정되었습니다. 약 50년간 유효했지만 최근 한계를 드러내고 있으며, 단일 코어 클럭은 약 2005년부터 약 3GHz에서 정체되기 시작했습니다. 이 변화를 가장 명료하게 짚은 글이 허브 서터의 「The Free Lunch Is Over」(2005)입니다.

graph LR
    A[1970~2005<br/>클럭 속도 증가] --> B[하드웨어가 알아서<br/>빠르게 만들어 줌]
    A --> C[순차 코드만으로<br/>충분]
    D[2005~<br/>클럭 정체, 코어 수 증가] --> E[코드가 명시적으로<br/>병렬화되어야]
    D --> F[메모리 모델·동기화·<br/>락 프리 도구 필요]
데이터 병렬성 vs 작업 병렬성
구분정의자바에서의 표현예시
데이터 병렬성 (data parallelism)큰 데이터 풀의 단일 대규모 작업을 더 작은 단위로 세분화데이터를 여러 프로세서에 분배급여 계산 시 각 CPU 코어에 직원 블록 할당
작업 병렬성 (task parallelism)서로 다른 작업을 여러 프로세서에 나눠 실행스레드, Executor 객체REST 서버에서 각 스레드가 개별 사용자 요청 처리

두 방식은 자주 혼합됩니다. 예를 들어 REST 서버는 요청 수준에서 작업 병렬성을, 한 요청 내부의 집계 연산에서는 데이터 병렬성을 사용할 수 있습니다.

13.1.1 암달의 법칙

공식

직렬 실행이 필요한 부분을 S, 작업에 필요한 전체 시간을 T, 프로세서 수를 N이라 하면:

T(N) = S + (1 / N) * (T - S)

총 소요 시간은 프로세서 수와 무관하게 직렬 시간 S보다 짧아질 수 없습니다. 직렬 오버헤드가 5%면 코어를 아무리 늘려도 최대 20배 향상이 한계입니다.

graph TD
    A[전체 작업 T] --> B[직렬 부분 S<br/>나눌 수 없음]
    A --> C[병렬 부분 T-S<br/>N개로 분할 가능]
    C --> D[N이 커져도<br/>S만큼은 항상 직렬]
함의: 단일 코어 성능이 다시 중요해진다

단일 스레드 성능을 향상시키는 방법(더 빠른 코어)은 S 값을 줄일 수 있는 유일한 방법입니다. 하지만 CPU 클럭이 더 이상 빨라지지 않으므로, 암달의 법칙은 소프트웨어 확장성의 실질적 한계가 되는 경우가 많습니다.

쉬운 병렬 처리

병렬 작업 간 통신이나 직렬 처리가 전혀 없다면 이론적으로는 무한한 성능 향상이 가능합니다. 이런 부하를 쉬운 병렬 처리(embarrassingly parallel)라고 부르며, 동시 처리를 비교적 쉽게 구현할 수 있습니다.

일반적인 접근은 공유 데이터 없이 워크 스레드로 분할하는 것입니다. 그러나 한 번이라도 공유 상태나 데이터를 도입하는 순간, 작업 복잡성이 증가하고 일부 순차 처리와 통신 오버헤드가 다시 발생합니다.

13.1.2 기본적인 자바 동시성

단일 연산이 아닌 i++

동시성을 배우는 첫 깨달음은 i = i + 1 같은 카운터 증가가 단일 연산이 아니라는 사실입니다.

public class Counter {
    private int i = 0;
    public int increment() {
        return i = i + 1;
    }
}

increment()의 바이트코드는 다음과 같이 여러 단계로 나뉩니다.

0: aload_0
1: aload_0
2: getfield    #2  // 필드 i:I
5: iconst_1
6: iadd
7: dup_x1
8: putfield    #2  // 필드 i:I
11: ireturn

값을 로드 → 증가 → 저장하는 일련의 명령어가 생성됩니다. 두 스레드 A, B가 동시에 호출하면 운영 체제 스케줄러가 임의 시점에 문맥 전환을 일으키므로, 다음처럼 두 스레드의 명령어가 교차 실행될 수 있습니다.

A0: aload_0
A1: aload_0
A2: getfield #2     ← A가 i 읽음 (7)
A5: iconst_1
A6: iadd            ← A가 8 계산
A7: dup_x1
B0: aload_0
B1: aload_0
B2: getfield #2     ← B가 i 읽음 (아직 7)
B5: iconst_1
B6: iadd            ← B도 8 계산
B7: dup_x1
A8: putfield #2     ← A가 8 저장
A11: ireturn
B8: putfield #2     ← B가 8 저장 (덮어씀)
B11: ireturn

각 스레드는 메서드에 진입할 때마다 개별 평가 스택을 가지므로 실제로 충돌하는 부분은 필드에 대한 연산뿐입니다(객체 필드는 힙에 저장되고, 힙은 모든 스레드가 공유하기 때문). 결과적으로 increment()가 두 번 호출되었음에도 최종 값은 7→8 한 번만 올라갑니다. 이게 업데이트 손실(lost update)입니다.

운영 체제 스케줄링이 원인입니다

이 문제는 운영 체제 스케줄링에 의해 발생하며, 복잡한 하드웨어 메커니즘이 필요하지 않습니다. 최신 기능이 없는 오래된 CPU에서도 동일하게 발생합니다.

volatile의 흔한 오해

volatile 키워드를 붙이면 안전해질 것이라는 생각은 잘못된 오해입니다. volatile은 값이 항상 메모리에서 다시 읽히도록 강제하므로 가시성(visibility)은 보장하지만, 업데이트 손실은 해결하지 못합니다. 증가 연산이 로드·계산·저장이라는 복합 연산(composite operation)이기 때문입니다.

다음 예제처럼 두 스레드가 동일한 카운터 참조를 공유하면 실행할 때마다 결과가 달라집니다.

public class CounterExample implements Runnable {
    private final Counter counter;
 
    public CounterExample(Counter counter) {
        this.counter = counter;
    }
 
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName()
                + " Value: " + counter.increment());
        }
    }
}

운이 좋으면 카운터가 정상 증가합니다. 다음처럼 같은 값이 반복되어 나오면 업데이트가 손실된 것입니다.

Thread-1 Value: 1
Thread-1 Value: 2
Thread-1 Value: 3
Thread-0 Value: 1     ← 손실!
Thread-1 Value: 4
Thread-1 Value: 6
Thread-0 Value: 5     ← 순서 뒤엉킴
동시성 코드는 “테스트가 부재를 증명하지 못한다”

다익스트라의 격언 “테스트는 버그의 존재를 보여줄 수는 있지만, 부재를 증명할 수는 없다”는 단일 스레드보다 동시성 코드에 훨씬 더 강하게 적용됩니다. 정상 실행을 100만 번 본다고 해서 다음 실행이 정상이라는 보장은 없습니다.

물론 synchronized로 단순 값 업데이트를 제어할 수 있습니다(자바 5 이전에는 동기화의 유일한 선택이었습니다). 동기화 블록 추가 비용은 경쟁이 없는 경우 이전 자바 가상 머신보다 훨씬 저렴해졌지만, 여전히 필요하지 않다면 자제하는 것이 좋습니다.

13.2 자바 메모리 모델의 이해

JMM이 답하는 질문

자바 메모리 모델(JMM)은 1.0부터 존재했고, JSR 133으로 대대적으로 수정되어 자바 5에 포함되었습니다. JMM은 다음 같은 질문에 답합니다.

  • 두 개의 코어가 동일한 데이터를 접근할 때 어떤 일이 발생할까?
  • 언제 두 코어가 동일한 값을 볼 수 있다고 보장할까?
  • 메모리 캐시는 이러한 답변에 어떤 영향을 미칠까?
강한 메모리 모델 vs 약한 메모리 모델
구분강한 메모리 모델약한 메모리 모델
보장모든 코어가 언제나 동일한 값을 본다코어마다 다른 값을 볼 수 있다
개발자 부담거의 없음가시성을 명시적으로 제어해야 함
하드웨어 부담캐시 무효화 트래픽이 메모리 버스를 가득 채움적은 동기화 트래픽으로 빠르게 동작
이식성강한 모델 미지원 하드웨어로 이식 시 큰 비용다양한 하드웨어에 자연스럽게 매핑

자바 메모리 모델은 의도적으로 매우 약한 메모리 모델을 채택합니다. 이유는 세 가지입니다. 첫째, 매니코어(many-core) 환경에서 강한 모델은 캐시 무효화 알림이 메모리 버스를 가득 채워 데이터 전송 속도가 급격히 떨어집니다. 둘째, 자바는 아키텍처 독립적 환경을 목표로 했기 때문에 강한 모델을 약한 하드웨어 위에 구현하려면 큰 소프트웨어 작업이 필요합니다. 셋째, 약한 모델은 실제 CPU 아키텍처의 MESI 흐름과 잘 맞아떨어집니다.

JMM의 네 가지 기본 개념

JMM의 보장은 다음 개념들 위에 세워집니다.

  • 선행 발생 (happens-before): 한 이벤트가 다른 이벤트보다 확실히 먼저 발생합니다.
  • 동기화 관계 (synchronizes-with): 해당 이벤트가 객체의 메모리 보기를 주 메모리와 동기화하도록 만듭니다.
  • 순차 실행 (as-if-serial): 실행 중인 스레드 외부에서는 명령어가 순차적으로 실행되는 것처럼 보입니다.
  • 선 해제 후 획득 (release before acquire): 한 스레드가 잠금을 해제하면, 다른 스레드가 이를 획득하기 전에 반드시 해제가 완료됩니다.
synchronized의 의미와 한계

이 관점에서 synchronized모니터를 가진 스레드의 로컬 뷰가 메인 메모리와 동기화됨을 의미합니다. 동기화 메서드·블록은 스레드가 동기화를 수행해야 하는 접점(touchpoint)을 정의합니다.

하지만 기존 자바 synchronized 잠금에는 몇 가지 한계가 있습니다.

  • 잠긴 객체에 대한 모든 synchronized 연산은 동일하게 처리됩니다. 우선순위 전략이나 읽기/쓰기 접근 구분이 불가능합니다.
  • 잠금 획득과 해제는 메서드 수준 또는 메서드 내 synchronized 블록 내에서만 가능합니다.
  • 잠금을 획득하거나 스레드가 차단되는 두 가지 선택지뿐입니다. 시도 후 다른 작업을 계속할 방법이 없습니다.

매우 흔한 실수는 잠긴 데이터의 읽기와 쓰기를 모두 동일하게 처리해야 한다는 사실을 잊는 것입니다. 읽기만 동기화하지 않으면 가시성이 깨지고, 그 결과 업데이트 손실로 이어집니다.

협력 메커니즘

자바의 스레드 간 동기화는 협력 메커니즘이며, 참여하는 스레드 중 하나라도 규칙을 따르지 않으면 올바르게 작동하지 않습니다.

JMM과 현실의 간극

JMM은 만들어진 이후 하드웨어와 동시성 기술이 지속적으로 발전했기 때문에, 설명만으로 현대 시스템을 충분히 표현하지 못하는 한계가 있습니다. 자바 9에서는 C++11과의 호환성을 핵심에 두고 JMM을 확장하여, 하드웨어를 직접 고려하는 저수준 코드가 C++11과 일관되게 상호 운용되도록 개선했습니다.

JMM을 처음 접하는 사람에게는 JSR-133 컴파일러 쓰기 쿡북(Cookbook for Compiler Writers)이 좋은 입문 자료이며, 더 깊이 알고 싶다면 알렉세이 쉬필료프(Aleksey Shipilëv)의 블로그가 매우 상세한 기술 정보를 제공합니다.

13.3 동시성 라이브러리 구축

왜 내장 잠금을 넘어서야 하는가

자바 메모리 모델은 성공적이었지만, 내부 잠금(synchronized)의 유연성 부족이라는 문제가 남았습니다. 그래서 자바 5 이후로는 자바 클래스 라이브러리의 일환으로 고품질 동시성 라이브러리를 표준화하는 방향이 강해졌고, 내장 언어 수준 지원에서 점차 벗어나고 있습니다.

java.util.concurrent는 멀티스레드 애플리케이션 작성을 훨씬 쉽게 만들어 줍니다. 잘 설계된 추상화를 골라 쓰면, 추가 노력 없이도 스레드 핫(thread hot) 성능을 얻을 수 있습니다. 여기서 “스레드 핫”이란 스레드가 대부분의 시간을 실행에 사용하고 동일 구조 내 다른 스레드와 경쟁하지 않는 동시성 프로파일을 뜻합니다.

핵심 구축 범위
범주예시
잠금과 세마포어 (semaphore)ReentrantLock, Semaphore
원자적 연산 (atomic)AtomicInteger, AtomicReference
차단 큐 (queue)BlockingQueue, LinkedBlockingQueue
래치 (latch)CountDownLatch
실행자 (executor)ExecutorService, ScheduledExecutorService
flowchart LR
    HTTP[HTTP 요청] --> Q[차단 큐]
    Q --> TP[스레드 풀]
    TP --> CDL[카운트다운 래치]
    CDL --> AI[원자적 연산<br/>정수형]
    AI --> SER[데이터 직렬화 관리자]
    SER --> DB[(데이터베이스 업데이트)]

13.3.1 메서드와 변수 핸들

왜 메서드 핸들인가

invokedynamic은 호출 지점에서 실행할 메서드를 컴파일 타임이 아닌 런타임에 결정합니다. 인터프리터가 해당 호출 지점에 도달하면 부트스트랩 메서드(BSM)가 호출되고, BSM은 실제 실행 대상 메서드를 가리키는 메서드 핸들(method handle)을 반환합니다. 이는 호출 대상(call target)이라고 부르며, 호출 지점에 “가미”됩니다.

메서드 핸들은 현대적인 리플렉션

메서드 핸들을 현대적인 리플렉션으로 보는 것은 과한 해석이 아닙니다. 자바 21부터는 실제로 리플렉션 기능이 메서드 핸들 위에서 구현되었습니다.

리플렉션 호출의 한계

기존 리플렉션 호출은 다음과 같습니다.

Method m = ...
Object receiver = ...
Object o = m.invoke(receiver, new Object(), new Object());

생성되는 바이트코드는 다음처럼 가변 인수 배열을 만들어 넘기는 일괄 호출 방식입니다.

17: iconst_0
18: new        #2  // 클래스 java/lang/Object
21: dup
22: invokespecial #1  // 메서드 java/lang/Object."<init>":()V
25: aastore
26: dup
27: iconst_1
28: new        #2  // 클래스 java/lang/Object
31: dup
32: invokespecial #1  // 메서드 java/lang/Object."<init>":()V
35: aastore
36: invokevirtual #3  // 메서드 java/lang/reflect/Method.invoke
                        // :(Ljava/lang/Object;[Ljava/lang/Object;)
                        //  Ljava/lang/Object;

호출 시그니처는 (Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;로 고정되어 있어 컴파일 타임에 대상에 대해 아는 것이 없고, 단일 객체 인수와 Method 객체가 맞지 않으면 런타임 오류가 발생합니다.

또한 클래스 로딩 후에는 추가 검사가 수행되지 않아 리플렉션 코드가 악용 가능한 틈을 제공하며, setAccessible()은 자바의 접근 제어 안전 기능을 완전히 무력화합니다.

메서드 핸들 호출

같은 작업을 메서드 핸들로 표현하면 다음과 같습니다.

MethodType mt = MethodType.methodType(int.class);
MethodHandles.Lookup l = MethodHandles.lookup();
MethodHandle mh = l.findVirtual(String.class, "hashCode", mt);
 
String receiver = "b";
int ret = (int) mh.invoke(receiver);
System.out.println(ret);

두 단계로 나뉩니다.

  1. 조회: MethodHandles.lookup()이 반환하는 컨텍스트 객체는 생성 시점에 접근 가능한 메서드/필드를 기록한 변경 불가능 객체입니다. 캐싱하거나 보관해 두기 좋고, 클래스가 자신의 비공개 메서드에 선택적으로 접근을 허용하는 패턴을 안전하게 구현할 수 있습니다(리플렉션의 setAccessible() 우회와 달리 강제 우회가 불가능).
  2. 호출: 변경 불가능하고 안정적인 메서드 핸들 객체를 통해 호출합니다.

조회 부분의 바이트코드는 다음과 같이 lookup()이 정적 호출되는 시점의 접근 가능 메서드를 모두 볼 수 있는 컨텍스트 객체를 생성합니다.

0: getstatic    #2  // 필드 java/lang/Integer.TYPE:Ljava/lang/Class;
3: invokestatic #3  // 메서드 java/lang/invoke/MethodType.methodType
6: astore_1
7: invokestatic #4  // 메서드 java/lang/invoke/MethodHandles.lookup
10: astore_2
11: aload_2
12: ldc          #5  // 클래스 java/lang/String
14: ldc          #6  // 문자열 해시코드
16: aload_1
17: invokevirtual #7 // 메서드 ...MethodHandles$Lookup.findVirtual
20: astore_3

findVirtual()로 보이지 않는 메서드에 접근하면 IllegalAccessException이 발생합니다. 개발자가 이 접근 검사를 우회하거나 비활성화할 방법이 없다는 점이 리플렉션과 결정적으로 다릅니다.

호출 부분의 바이트코드는 다음과 같습니다.

21: ldc          #8  // b 문자열
23: astore       4
25: aload_3
26: aload        4
28: invokevirtual #9  // 메서드 java/lang/invoke/MethodHandle.invoke
                        // :(Ljava/lang/String;)I
31: istore       5
33: getstatic    #10 // 필드 java/lang/System.out:Ljava/io/PrintStream;
36: iload        5
38: invokevirtual #11 // 메서드 java/io/PrintStream.println:(I)V

호출 시그니처는 invoke:(Ljava/lang/String;)I입니다. MethodHandle의 자바 문서에는 이 시그니처를 가진 메서드가 명시되지 않지만, javac가 호출 지점에 맞춰 시그니처를 추론하여 호출을 생성합니다. 이 약간 특이한 자바 언어 기능을 시그니처 다형성(signature polymorphism)이라 하며, 메서드 핸들에만 적용됩니다.

시그니처 다형성

메서드 핸들 호출 바이트코드는 호출 지점에 대해 리플렉션 방식보다 더 나은 정적 타입 정보를 포함합니다. 즉, 리플렉션과 비슷한 기능을 더 현대적이고 정적 타입 안전한 도구로 제공합니다.

Unsafe와의 관계

역사적으로 자바에서 저수준 기능을 구현하는 유일한 옵션은 sun.misc.Unsafe였습니다. Unsafe는 CPU·하드웨어 기능에 직접 접근하고 힙 외부 메모리(off-heap memory)를 관리하지만, JDK 내부용으로 설계되었음에도 자바 생태계 거의 모든 프레임워크에서 널리 쓰이게 되었습니다. 이제는 메서드 핸들과 변수 핸들(var handles)이 그 역할을 이어받아, 원자적 연산이나 비교와 교환 같은 동시성 라이브러리의 기반을 제공합니다.

13.3.2 원자적 연산 또는 비교와 교환

AtomicInteger의 핵심

java.util.concurrent.AtomicInteger는 더하기(add), 증가(increment), 감소(decrement) 같은 복합 연산을 get()과 결합하여 영향을 받은 결과를 반환합니다. 두 스레드에서 동시에 증가시키면 한쪽은 currentValue + 1, 다른 쪽은 currentValue + 2를 반환받습니다. 원자적 변수의 의미는 volatile의 확장이지만 더 유연하고, 스레드 기반 연산이 가시성을 보장하기 위해 동기화할 필요 없이 수행됩니다.

원자적 연산 클래스는 감싸고 있는 기본 타입을 상속하지 않으며 직접 대체할 수 없습니다. AtomicIntegerInteger를 상속하지 않습니다 — java.lang.Integer가 (올바르게도) 최종 클래스이고, Integer가 불변 객체인 반면 AtomicInteger는 명시적으로 스레드 안전한 가변 값을 나타내기 때문입니다.

비교와 교환 (Compare-and-Swap)

원자적 연산은 일부 다른 동시성 라이브러리(java.util.concurrent의 잠금 포함)와 함께 비교와 교환(CAS) 기술을 구현하기 위해 저수준 프로세서 명령어와 운영 체제 특정 기능을 활용합니다.

CAS는 기대하는 현재 값원하는 새로운 값을 사용하며 메모리 위치(포인터)와 함께 동작합니다. 원자적 연산 단위로 두 개의 연산이 이루어집니다.

  1. 기대하는 현재 값을 메모리 위치의 실제 값과 비교합니다.
  2. 만약 두 값이 일치하면 현재 값을 원하는 새로운 값으로 교체합니다.

비교와 교환은 여러 중요한 고수준 동시성 기능의 기본 빌딩 블록이며, JMM이 생성된 이후에도 성능과 하드웨어 환경이 지속적으로 변화해 왔다는 대표적 예시입니다.

변수 핸들로 Unsafe 대체하기

전통적으로 CAS는 구현 별 확장으로 다루어졌고 하드웨어 접근은 sun.misc.Unsafe를 통해 제공되었습니다. 최근 자바 버전은 Unsafe를 제거하고 메서드 핸들과 변수 핸들로 대체하려는 노력이 증가하고 있습니다.

public class AtomicIntegerWithVarHandles extends Number {
 
    private volatile int value = 0;
    private static final VarHandle V;
 
    static {
        try {
            MethodHandles.Lookup l = MethodHandles.lookup();
            V = l.findVarHandle(AtomicIntegerWithVarHandles.class, "value",
                    int.class);
        } catch (ReflectiveOperationException e) {
            throw new Error(e);
        }
    }
 
    public final int getAndSet(int newValue) {
        int v;
        do {
            v = (int) V.getVolatile(this);
        } while (!V.compareAndSet(this, v, newValue));
 
        return v;
    }
    // ....
}

샘플은 CAS를 반복적으로 재시도하는 루프의 사용법을 보여줍니다. 비교 실패로 업데이트가 수행되지 않는 상황(다른 스레드가 현재 스레드의 읽기와 쓰기 사이에 값을 업데이트한 경우)을 처리하기 위함입니다.

이 재시도 루프는 여러 번의 재시도가 필요할 경우 성능이 선형적으로 저하됩니다. 성능을 고려할 때는 경쟁 수준을 모니터링하여 처리량을 높게 유지하는 것이 중요합니다. 그래도 원자적 연산은 잠금이 필요 없으므로 교착 상태(deadlock)에 빠질 수 없다는 큰 장점이 있습니다.

직접 구현하지 마세요

원자적 연산을 효과적으로 사용하려면 표준 라이브러리가 제공하는 기능을 활용하고, 예를 들어 원자적 정수를 직접 구현하지 않는 것이 중요합니다. 표준 라이브러리는 이미 메서드 핸들과(허용되는 경우) Unsafe를 활용하고 있으므로 안심해도 됩니다.

13.3.3 잠금과 반복 잠금

내부 잠금의 비용

자바 synchronized의 기반인 내부 잠금은 사용자 코드에서 운영 체제를 호출하여 작동합니다. 운영 체제는 신호를 받을 때까지 스레드를 무기한 대기 상태로 두는 데 사용됩니다. 이는 경쟁하는 리소스가 매우 짧은 시간 동안만 사용될 경우 엄청난 오버헤드를 초래합니다.

비잠금 기법: 반복 잠금 (Spinlock)

비잠금(lock-free) 기술은 차단이 처리량에 좋지 않다는 전제에서 출발합니다. 차단된 스레드가 CPU에서 계속 활성 상태를 유지하면서 유용한 작업을 수행하지 않고 단순히 “CPU 소모” 잠금을 재시도하는 것이 훨씬 더 효율적일 수 있습니다.

이 기법을 반복 잠금(spinlock)이라 하며, 완전한 상호 배제 잠금보다 더 가볍게 설계되었습니다. 현대 시스템에서는 하드웨어 지원을 전제로 일반적으로 CAS를 사용해 반복 잠금을 구현합니다.

저수준 x86 어셈블리에서의 간단한 예시는 다음과 같습니다.

locked:
    dd      0

spin_lock:
    mov     eax, 1
    xchg    eax, [locked]
    test    eax, eax
    jnz     spin_lock
    ret

spin_unlock:
    mov     eax, 0
    xchg    eax, [locked]
    ret

핵심 규칙 두 가지는 어느 CPU에서나 동일합니다.

  • ‘테스트와 설정’ 연산(여기서는 xchg)은 반드시 원자적이어야 합니다.
  • 반복 잠금 경쟁이 발생하면 대기 중인 프로세서는 빠른 루프를 실행합니다.

CAS가 기대하는 값과 일치할 때 하나의 명령어로 안전하게 값을 업데이트할 수 있다는 점이 잠금의 기본 빌딩 블록을 형성합니다.

반복 잠금의 비용

CPU 코어를 점유하는 것은 사용률과 전력 소비 측면에서 부담이 큽니다. 기계가 유휴 상태가 아니므로 더 높은 발열을 의미하고, 결국 아무 작업도 수행하지 않는 프로세싱 코어를 냉각하는 데 더 많은 전력이 필요하게 됩니다.

이게 트레이드오프입니다 — 운영 체제 잠금은 대기 시간이 길고 컨텍스트 스위치 비용이 크지만 CPU를 놓아 줍니다. 반복 잠금은 즉시 깨어나지만 그 시간 동안 CPU·전력을 태웁니다. 짧은 임계 구역에는 반복 잠금이, 긴 임계 구역에는 내부 잠금이 적합합니다.

13.4 동시성 라이브러리 요약

자바 동시성의 출발점

자바의 동시성은 원래 장기 실행 작업을 다른 스레드가 실행할 수 있도록 인터리브(interleaved)하기 위해 설계되었습니다. 예를 들어 I/O처럼 느린 작업이 이에 해당하며, 당시 하드웨어는 단일 실행 코어만 가지고 있었습니다.

하지만 오늘날 거의 모든 기기는 멀티코어 시스템(모바일 폰 포함)이며, 가용한 CPU 리소스를 효율적으로 사용하는 것이 매우 합리적입니다.

스레드 API의 역사적 부채

동시성 개념이 자바에 도입될 당시 업계 경험이 부족했습니다. 자바는 언어 수준에서 스레딩 지원을 구축한 최초의 산업 표준 환경이었고, 결과적으로 개발자들이 동시성에 대해 배워야 할 많은 어려운 교훈들을 처음으로 자바에서 마주하게 되었습니다. 자바는 핵심 기능을 폐기하지 않는 접근 방식을 취해 왔기 때문에 스레드 API는 여전히 자바의 일부이며 앞으로도 계속 유지될 것입니다.

이 때문에 현대적인 애플리케이션 개발에서 스레드는 자바 프로그래머가 일반적으로 코드를 작성하는 추상화 수준에 비해 상당히 저수준의 개념이 되었습니다. 자바는 수동 메모리 관리를 하지 않는데 왜 저수준의 스레드 생성과 수명 주기 이벤트를 직접 다뤄야 할까요? 다행히 현대 자바는 언어와 표준 라이브러리에 내장된 추상화를 통해 상당한 성능 향상을 제공합니다.

13.4.1 java.util.concurrent의 잠금

Lock 인터페이스

자바 5는 잠금을 재구성하고 java.util.concurrent.locks.Lock에서 보다 일반적인 잠금을 위한 인터페이스를 추가했습니다. 이 인터페이스는 내부 잠금의 동작보다 더 많은 가능성을 제공합니다.

메서드동작
lock()전통적으로 잠금을 획득하며, 잠금이 사용 가능할 때까지 차단합니다
newCondition()잠금을 중심으로 조건을 생성하여 보다 유연한 사용이 가능합니다(읽기와 쓰기 분리 등)
tryLock()잠금을 시도하며(선택적으로 타임아웃 포함), 잠금이 사용 가능하지 않을 경우 스레드가 계속 진행할 수 있도록 허용합니다
unlock()잠금을 해제합니다. 이는 lock() 호출 후의 대응되는 호출입니다

기존 내부 잠금과 달리 여러 메서드에 걸쳐 잠금을 유지할 수 있고, tryLock()을 사용하면 차단 없이 잠금 시도 후 다른 일을 계속할 수 있습니다. 즉, 경쟁이 없는 경우 잠금 획득 과정이 사실상 비잠금 방식으로 수행되어 시스템 성능을 크게 향상시킵니다.

ReentrantLockLock의 주요 구현체이며, 기본적으로 compareAndSwap()int를 사용합니다.

재진입 가능 잠금 (re-entrant locking)

스레드가 동일한 잠금을 다시 획득할 수 있는 개념을 재진입 가능 잠금이라고 합니다. 이를 통해 스레드가 스스로를 차단하는 것을 방지할 수 있습니다. 대부분의 현대적인 애플리케이션 수준의 잠금 방식은 재진입 가능 형태로 설계되어 있습니다.

AbstractQueuedSynchronizer와 LockSupport

compareAndSwap() 호출과 Unsafe 사용은 AbstractQueuedSynchronizer의 확장 클래스인 정적 내부 클래스 Sync에서 찾을 수 있습니다. AbstractQueuedSynchronizerLockSupport 클래스를 활용하는데, 이 클래스는 스레드를 대기(park) 또는 재개(unpark) 할 수 있는 메서드를 제공합니다.

LockSupport는 스레드에 대해 허가(permit)를 부여하는 방식으로 동작하며, 허가가 없으면 스레드는 대기 상태로 들어가야 합니다. 이는 세마포어의 허가 발급 방식과 유사하지만, 단 하나의 허가만 존재하는 이진 세마포어(binary semaphore) 방식입니다.

이 클래스의 메서드들은 Thread.suspend()Thread.resume()처럼 오래전에 사용이 중단된 메서드들을 대체합니다.

park() 메서드 변형

park() 메서드는 세 가지 형태로 제공되며, 다음과 같은 기본적인 의사 코드 흐름에 영향을 줍니다.

while (!canProceed()) { ... LockSupport.park(this); }
메서드동작
park(Object blocker)다른 스레드가 unpark()을 호출하거나, 해당 스레드가 인터럽트되거나, 예기치 않게 깨우는 스퓨리어스 웨이크업(spurious wakeup)이 발생할 때까지 차단됩니다
parkNanos(Object blocker, long nanos)park()과 동일하게 동작하지만, 지정된 나노초(nanos) 시간이 지나면 자동으로 반환됩니다
parkUntil(Object blocker, long deadline)parkNanos()와 유사하지만, 절대적인 시간(마감시간(deadline))을 기준으로 동작합니다. 이는 기준시점(epoch)으로부터의 밀리초 단위 시간을 사용하여 특정 시점까지 대기하도록 합니다

13.4.2 읽기/쓰기 잠금

단일 잠금 전략의 한계

애플리케이션의 많은 구성 요소들은 읽기 연산과 쓰기 연산의 수 사이에 불균형이 존재할 수 있습니다. 읽기 연산은 상태를 변경하지 않지만, 쓰기 연산은 상태를 변경합니다. 기존의 synchronized 또는 ReentrantLock(조건 없이 사용)은 단일 잠금 전략(single lock strategy)을 따릅니다.

캐싱과 같은 상황에서는 다수의 리더(읽기 작업을 수행하는 스레드)와 하나의 라이터(쓰기 작업을 수행하는 스레드)가 존재할 수 있습니다. 이 경우, 기존의 단일 잠금 방식을 사용하면 하나의 읽기 연산이 진행 중일 때도 다른 읽기 스레드들이 불필요하게 차단될 수 있습니다.

ReentrantReadWriteLock

ReentrantReadWriteLock 클래스는 ReadLockWriteLock을 제공하여 이를 해결할 수 있습니다. 여러 개의 읽기 스레드가 동시에 실행될 수 있으며, 서로를 차단하지 않습니다. 유일하게 차단 될수 있는 작업은 쓰기 작업입니다. 또한 공정 모드(fair mode)로 설정 할 수 있으며, 이를 활성화하면 스레드가 도착한 순서대로 처리되지만, 성능이 저하될 수 있습니다.

다음은 AgeCache의 구현 예제로, 단일 잠금을 사용하는 기존 방식보다 더 나은 성능을 제공할 수 있습니다.

public class AgeCache {
    private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    private final Lock readLock = rwl.readLock();
    private final Lock writeLock = rwl.writeLock();
    private Map<String, Integer> ageCache = new HashMap<>();
 
    public Integer getAge(String name) {
        readLock.lock();
        try {
            return ageCache.get(name);
        } finally {
            readLock.unlock();
        }
    }
 
    public void updateAge(String name, int newAge) {
        writeLock.lock();
        try {
            ageCache.put(name, newAge);
        } finally {
            writeLock.unlock();
        }
    }
}

그러나 기본 데이터 구조를 고려하면 이를 더욱 최적화할 수 있습니다. 이 예제에서는 동시성 컬렉션을 사용하는 것이 더 적절한 추상화 방식이며, 스레드 핫 성능을 더욱 향상시킬 수 있습니다.

13.4.3 세마포어

Semaphore의 기본 개념

세마포어는 사용 가능한 리소스 개수를 제한하는 독특한 기법을 제공합니다. 예를 들어, 스레드 풀이나 데이터베이스 연결 객체(database connection object) 관리에 활용될 수 있습니다. 세마포어의 기본 개념은 ‘최대 X개의 객체만 접근할 수 있다’는 원칙에 기반하며, 허가의 개수를 설정하여 접근을 제어합니다.

// 2개의 동시 접근 허용을 가진 세마포어를 공정 모드로 생성
private Semaphore poolPermits = new Semaphore(2, true);

Semaphore::acquire()는 사용 가능한 허가 개수를 하나 줄이며, 허가가 없을 경우 차단됩니다. Semaphore::release()는 허가를 반환하며, 대기 중인 요청자가 있으면 해제합니다. 세마포어는 리소스가 차단되거나 대기열에 있는 환경에서 자주 사용되므로, 스레드 리소스 부족 문제(thread starvation)를 방지하기 위해 일반적으로 공정하게 초기화됩니다.

세마포어 vs 뮤텍스

하나의 허가를 가진 세마포어(이진 세마포어)는 뮤텍스(mutex)와 동일하지만, 중요한 차이점이 있습니다. 뮤텍스는 해당 뮤텍스를 소유한 스레드만이 해제할 수 있는 반면, 세마포어는 소유하지 않은 스레드도 해제할 수 있습니다. 이러한 기능은 교착 상태를 강제로 해결해야 하는 경우 유용할 수 있습니다.

또한 세마포어는 여러 개의 허가를 요청하고 해제할 수 있는 기능도 제공합니다. 하지만 여러 개의 허가를 사용할 경우, 공정 모드를 사용하지 않으면 스레드 기아가 발생할 가능성이 있습니다.

13.4.4 동시 컬렉션

동시 사용을 위해 설계된 컬렉션

자바 5부터 컬렉션 인터페이스의 구현은 동시 사용을 위해 특별히 설계되었습니다. 이러한 동시성 컬렉션은 시간이 지나면서 수정이나 개선되어 최상의 스레드 핫 성능을 제공하도록 발전해 왔습니다.

ConcurrentHashMap버킷 또는 세그먼트로 분할되는 방식을 사용하며, 이 구조를 활용하면 실제 성능 향상을 달성할 수 있습니다. 각 세그먼트는 자체적인 잠금 정책을 가질 수 있으며, 읽기와 쓰기 잠금을 모두 지원하기 때문에 다수의 리더(읽기 스레드)가 ConcurrentHashMap의 여러 세그먼트에서 동시에 읽을 수 있으며, 쓰기 연산이 필요한 경우에도 해당 세그먼트에만 잠금이 적용됩니다. 일반적으로 읽기 연산은 잠금을 사용하지 않으며, put()이나 remove()와 같은 연산과 안전하게 겹쳐서 실행될 수 있습니다.

반복자와 스냅샷 방식

반복자(iterator)와 분할 반복자(spliterator, 병렬 스트림에서 사용)는 일종의 스냅샷 방식으로 획득되므로, ConcurrentModificationException을 발생시키지 않습니다. 또한 읽기 스레드는 완료된 업데이트 연산에 대해 선행-후생 순서를 보장합니다.

이 테이블은 충돌(collision)이 너무 많아질 경우 동적으로 확장되며, 이는 비용이 많이 드는 연산일 수 있습니다. 따라서 HashMap과 마찬가지로 적절한 크기를 미리 지정하는 것이 좋습니다.

CopyOnWriteArrayList와 CopyOnWriteArraySet

자바 5에서는 CopyOnWriteArrayListCopyOnWriteArraySet도 도입되었으며, 특정 사용 패턴에서는 멀티스레드 성능을 향상시킬 수 있습니다. 이러한 구조에서는 데이터 구조에 대한 변경 연산(mutation operation)이 수행될 때마다 새로운 백업 배열이 생성됩니다. 기존의 반복자는 이전 배열을 계속 순회할 수 있으며, 모든 참조가 사라지면 이전 배열은 가비지 컬렉션의 대상이 됩니다.

이와 같이 스냅샷 스타일의 반복 방식을 사용하면 ConcurrentModificationException이 발생하지 않습니다.

이러한 트레이드오프는 복사 후 쓰기(copy-on-write) 데이터 구조가 변경되는 횟수보다 읽기 목적으로 훨씬 더 많이 접근되는 시스템에서 효과적으로 작동합니다. 이 접근 방식을 고려할 경우, 성능 향상을 측정할 수 있는 적절한 테스트를 수행한 후 적용하는 것이 중요합니다.

13.4.5 래치와 장벽

스레드 집합 실행 제어

래치와 장벽은 스레드 집합의 실행을 제어하는 데 유용한 기술입니다. 예를 들어, 작업 스레드가 다음과 같은 작업을 수행하는 시스템을 고려할 수 있습니다.

  1. API에서 데이터를 검색하고 파싱합니다.
  2. 결과를 데이터베이스에 기록합니다.
  3. 마지막으로, SQL 쿼리를 기반으로 결과를 계산합니다.

단순히 모든 스레드를 실행하는 방식으로는 이벤트의 순서를 보장할 수 없습니다. 원하는 효과는 모든 스레드가 작업 1을 완료한 후 작업 2를 수행하고, 그런 다음 작업 3을 시작하도록 하는 것입니다.

CountDownLatch 예제

이를 달성하는 한 가지 방법은 래치(latch)를 사용하는 것입니다.

public class LatchExample implements Runnable {
 
    private final CountDownLatch latch;
 
    public LatchExample(CountDownLatch latch) {
        this.latch = latch;
    }
 
    @Override
    public void run() {
        // API호출
        System.out.println(Thread.currentThread().getName() + " Done API Call");
        try {
            latch.countDown();
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()
            + " Continue processing");
    }
 
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch apiLatch = new CountDownLatch(5);
 
        ExecutorService pool = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 5; i++) {
            pool.submit(new LatchExample(apiLatch));
        }
        System.out.println(Thread.currentThread().getName()
            + " about to await on main..");
        apiLatch.await();
        System.out.println(Thread.currentThread().getName()
            + " done awaiting on main..");
        pool.shutdown();
        try {
            pool.awaitTermination(5, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("API Processing Complete");
    }
}

이 예제에서, 래치는 카운트 5로 설정되며, 각 스레드는 countDown()을 호출하여 숫자를 1씩 감소시킵니다. 카운트가 0에 도달하면 래치가 열리고, await() 함수에서 대기 중이던 모든 스레드가 해제되어 이후 작업을 계속 수행할 수 있습니다.

일회용 래치

이러한 유형의 래치는 한 번만 사용할 수 있다는 점을 이해하는 것이 중요합니다. 카운트가 0이 되면 래치는 다시 사용할 수 없으며, 재설정(reset) 기능이 없습니다.

래치 활용 사례

래치는 애플리케이션 시작 시 캐시를 채우는 작업이나 멀티스레드 테스트와 같은 사례에서 매우 유용합니다.

CyclicBarrier

예제에서는 두 개의 서로 다른 래치를 사용할 수도 있었습니다. 하나는 API 결과 처리가 완료될 때까지 대기하는 래치이고, 다른 하나는 데이터베이스 결과 처리가 완료될 때까지 대기하는 래치입니다.

또 다른 옵션은 CyclicBarrier를 사용하는 것입니다. CyclicBarrier는 재설정이 가능하지만, 어떤 스레드가 재설정을 제어할지 결정하는 것은 꽤 어려운 문제이며, 또 다른 형태의 동기화를 필요로 합니다. 일반적으로, 파이프라인의 각 단계마다 하나의 장벽과 래치를 사용하는 것이 가장 좋은 사례입니다.

13.5 실행기와 작업 추상화

작업 추상화의 필요성

실제로 대부분의 자바 프로그래머는 저수준의 스레딩 문제를 직접 다룰 필요가 없습니다(단, 실행 후 잊기(fire-and-forget) 방식으로 사용하는 가상 스레드와 같은 경우는 예외일 수 있습니다). 대신, java.util.concurrent 패키지의 기능을 활용하여 적절한 수준의 추상화를 통해 동시성 프로그래밍을 수행해야 합니다.

스레딩 문제를 최소화하는 추상화 수준은 동시 작업(concurrent task)으로 설명할 수 있습니다. 즉, 현재 실행 컨텍스트 내에서 동시적으로 실행해야 하는 코드 또는 작업 단위를 의미합니다. 작업 단위를 작업으로 간주하면, 동시성 프로그램을 작성하는 과정이 훨씬 단순해집니다. 이는 실제 작업을 실행하는 스레드의 수명 주기를 개발자가 직접 관리할 필요가 없기 때문입니다.

또한, 이 접근 방식은 제어된 종료(controlled shutdown)에도 도움이 됩니다. 즉, 스레드가 실행 중인 작업을 올바르게 완료한 후 종료되도록 보장할 수 있습니다.

13.5.1 비동기 실행 소개

Callable 인터페이스

자바에서 작업 추상화를 충족하는 한 가지 방법은 Callable 인터페이스를 사용하여 값을 반환하는 작업을 표현하는 것입니다. Callable<V> 인터페이스는 하나의 함수 call()을 정의하는 제네릭 인터페이스이며, 이는 V 타입의 값을 반환하고 결과를 계산할 수 없는 경우 예외를 던집니다.

겉보기에는 CallableRunnable과 매우 유사해 보입니다. 그러나 Runnable은 결과를 반환하지 않으며 예외를 던지지 않는 반면, Callable은 결과를 반환하고 예외를 던질 수 있습니다.

Runnable의 예외 처리

Runnable이 잡히지 않은 예외를 던지면, 해당 예외는 스택을 따라 전파되며, 기본적으로 실행 중인 스레드는 종료됩니다.

스레드의 수명 주기 동안 예외를 처리하는 것은 어려운 프로그래밍 문제입니다. 또한, 스레드는 운영 체제 스타일의 프로세스로 취급되므로, 일부 운영 체제에서는 생성 비용이 클 수 있다는 점도 유의해야 합니다. 실행 Runnable에서 결과를 얻는 것도 추가적인 복잡성을 초래할 수 있으며, 특히 다른 스레드와의 실행 반환을 조정하는 경우 더욱 그렇습니다.

ExecutorService와 Executors 팩토리

Callable<V> 타입은 작업 추상화를 다루는 좋은 방법을 제공하지만, 이러한 작업은 실제로 어떻게 실행될까요?

ExecutorService는 관리되는 스레드 풀에서 작업을 실행하는 메커니즘을 정의하는 인터페이스입니다. ExecutorService의 실제 구현체는 풀 내 스레드를 어떻게 관리할지 또는 몇 개의 스레드를 사용할지를 결정합니다. ExecutorServicesubmit() 메서드와 그 오버로드를 통해 Runnable 또는 Callable을 실행할 수 있습니다.

Executors 도우미 클래스에는 선택한 동작에 따라 서비스를 생성하고 백업 스레드 풀을 구성하는 여러 new* 팩토리 메서드가 포함되어 있습니다.

팩토리 메서드동작
newFixedThreadPool(int nThreads)고정 크기의 스레드 풀을 갖는 ExecutorService를 생성합니다. 풀의 스레드는 여러 작업을 실행하는 데 재사용되므로, 각 작업마다 스레드를 새로 생성하는 비용을 피할 수 있습니다. 모든 스레드가 사용 중일 경우, 새로운 작업은 큐에 저장됩니다
newCachedThreadPool()필요에 따라 새로운 스레드를 생성하고 가능한 경우 기존 스레드를 재사용하는 ExecutorService를 생성합니다. 생성된 스레드는 60초 동안 유지되며, 이후 캐시에서 제거됩니다. 작고 비동기적인 작업을 실행할 때 성능을 향상시킬 수 있습니다
newSingleThreadExecutor()단일 스레드로 실행되는 ExecutorService를 생성합니다. 새로 제출된 작업은 스레드가 사용 가능해질 때까지 큐에서 대기합니다. 동시에 실행되는 작업의 수를 제어하는 데 유용합니다
newScheduledThreadPool(int corePoolSize)미래의 특정 시점에 작업을 실행할 수 있도록 추가적인 메서드를 제공합니다. Callable지연 시간(delay)을 기반으로 작업을 예약할 수 있습니다

작업이 제출되면 비동기적으로 처리되며, 제출한 코드에서는 차단할지, 결과를 폴링할지 선택할 수 있습니다. ExecutorServicesubmit() 호출은 Future<V> 객체를 반환하며, 이를 통해 차단 get() 호출, 타임아웃을 설정한 get() 호출, 비차단(non-blocking) isDone() 호출을 사용할 수 있습니다.

13.5.2 executorService 선택

ThreadFactory 커스터마이징

적절한 ExecutorService를 선택하면 비동기 처리를 효과적으로 제어할 수 있으며, 풀 내 스레드 수를 올바르게 선택하면 상당한 성능 향상을 얻을 수 있습니다.

사용자가 커스텀 ExecutorService를 작성할 수도 있지만, 이는 자주 필요한 작업은 아닙니다. 라이브러리는 이를 지원하기 위해 사용자 정의 옵션을 제공합니다. 즉, ThreadFactory를 제공하여 스레드의 속성을 설정할 수 있도록 합니다. ThreadFactory를 사용하면 스레드의 이름, 데몬 상태, 스레드 우선순위 등의 속성을 설정할 수 있는 사용자 정의 스레드 생성기를 작성할 수 있습니다.

ExecutorService는 때때로 애플리케이션 전체 설정에서 경험적으로 튜닝해야 할 수도 있습니다. 서비스가 실행될 하드웨어의 특성과 다른 경쟁(competing) 리소스를 잘 이해하는 것이 효율적인 성능 조정을 위한 중요한 요소입니다.

코어 수와 스레드 풀 크기

일반적으로 사용하는 한 가지 지표는 코어 수와 스레드 풀 크기 간의 관계입니다. 사용 가능한 프로세서 수보다 더 많은 스레드를 동시에 실행하려고 하면 문제가 발생할 수 있으며, 경합이 일어날 수 있습니다. 운영 체제는 이러한 스레드들을 실행하도록 스케줄링해야 하며, 이 과정에서 컨텍스트 스위치가 발생합니다.

경합이 특정 임계값에 도달하면 동시성 처리를 도입함으로써 얻는 성능 이점이 상쇄될 수 있습니다. 따라서, 올바른 성능 모델을 구축하고 성능 향상(또는 저하)을 측정하는 것이 필수적입니다.

13.5.3 포크/조인과 병렬 스트림

ForkJoinPool과 ForkJoinTask

자바는 개발자가 직접 스레드를 제어하고 관리할 필요 없이 사용할 수 있는 여러 가지 동시성 처리 방식을 제공합니다. 그중 하나가 포크/조인(Fork/Join) 프레임워크로, 이는 다중 프로세서 환경에서 효율적으로 작동하도록 설계된 새로운 API를 제공합니다. 이 프레임워크는 ExecutorService의 새로운 구현체인 ForkJoinPool을 기반으로 합니다.

이 클래스는 관리되는 스레드 풀을 제공하며, 두 가지 특별한 기능을 갖습니다.

  • 세분화된 작업(subdivided task)을 효율적으로 처리할 수 있습니다.
  • 작업 훔치기(work-stealing) 알고리즘을 구현합니다.

세분화된 작업 지원은 ForkJoinTask 클래스를 통해 제공됩니다. 이 클래스는 표준 자바 스레드와 유사한 엔티티입니다. ForkJoinTask의 주요 용도는 잠재적으로 많은 수의 작업 세분화된 작업을 소수의 실제 스레드가 처리할 수 있도록 하는 것이며, 이는 ForkJoinPool 실행자에서 효율적으로 관리됩니다.

ForkJoinTask의 핵심 특징은 작업 크기가 직접 계산할 수 있을 정도로 충분히 작아질 때까지 스스로를 ‘더 작은’ 작업으로 분할할 수 있다는 것입니다. 이러한 이유로, 이 프레임워크는 순수 함수의 계산이나 병렬 처리가 쉬운 작업 같은 특정 유형의 작업에만 적합합니다.

작업 훔치기 알고리즘

포크/조인 프레임워크의 작업 훔치기 알고리즘 부분은 작업 분할과 독립적으로 사용할 수 있습니다. 예를 들어, 하나의 스레드가 할당된 모든 작업을 완료했고, 다른 스레드가 작업이 남아 있다면, 완료된 스레드는 작업이 많은 스레드의 큐에서 작업을 훔쳐 수행하게 됩니다. 이러한 멀티스레드 간 작업 재분배는 단순하지만 영리한 아이디어로, 상당한 이점을 제공합니다.

flowchart TD
    T1[스레드 1<br/>완료] -.훔치기.-> T2Q[스레드 2 큐]
    T2Q --> T2[스레드 2<br/>처리 중]
    T3Q[스레드 3 큐] --> T3[스레드 3]
    T4Q[스레드 4 큐] --> T4[스레드 4]
commonPool과 병렬성 플래그

ForkJoinPool에는 정적 메서드인 commonPool()이 있으며, 이를 호출하면 시스템 전역 풀에 대한 참조를 반환합니다. 이는 개발자가 자체적인 풀을 생성할 필요를 없애고, 공유할 수 있는 기회를 제공합니다. 공통 풀은 지연 초기화되므로, 필요한 경우에만 생성됩니다.

풀의 크기는 Runtime.getRuntime().availableProcessors() - 1로 정의됩니다. 그러나 이 메서드는 항상 예상한 결과를 반환하는 것은 아닙니다. 예를 들어, 16-4-2 머신(즉, 16개의 소켓, 각 소켓당 4개의 코어, 각 코어당 2개의 하이퍼스레드)에서 availableProcessors()가 16을 반환하는 경우가 있습니다. 직관적인 기대값은 16×4×2 = 128이지만 예상과 다릅니다.

브라이언 괴츠

가상머신은 프로세서가 무엇인지에 대해 특별한 견해를 갖고 있지 않습니다. 단순히 운영 체제에 숫자를 요청할 뿐입니다. 마찬가지로 운영 체제도 이에 대해 깊이 고민하지 않습니다. 운영 체제는 하드웨어에 질문하고, 하드웨어는 보통 ‘하드웨어 스레드’(hardware thread)의 개수를 숫자로 반환합니다. 운영 체제는 하드웨어의 응답을 그대로 믿고, 가상 머신은 운영 체제를 신뢰합니다.

다행히, 개발자가 원하는 병렬 처리 수준을 프로그래밍적으로 설정할 수 있도록 해주는 플래그가 있습니다.

-Djava.util.concurrent.ForkJoinPool.common.parallelism=128

마법 같은 플래그를 사용할 때는 주의해야 하며, parallelStream() 선택과 관련해서도, 아무런 대가 없이 얻어지는 것은 없다는 점을 기억해야 합니다.

병렬 스트림

포크/조인 프레임워크의 작업 훔치기 기법은 이제 라이브러리나 프레임워크 개발자들 사이에서 더욱 활발하게 활용되고 있으며, 심지어 작업을 세분화하지 않더라도 사용됩니다.

자바 8에서 가장 큰 변화는 람다와 스트림의 도입이었습니다. 람다와 스트림을 함께 사용하면 자바 개발자들이 함수형 프로그래밍 스타일의 일부 이점을 쉽게 활용할 수 있도록 해주는 ‘마법같은 스위치’ 역할을 합니다. 외부 반복(external iteration, for 루프)에서 내부 반복(internal iteration, 스트림 연산)으로의 변화는 데이터를 병렬화하고 복잡한 표현식을 지연 평가(lazy evaluation)할 수 있는 좋은 기회를 제공합니다.

모든 컬렉션은 이제 Collection 인터페이스에서 제공하는 stream() 메서드를 사용할 수 있습니다. 두 번째 메서드인 parallelStream()은 데이터를 병렬로 처리하고 결과를 다시 결합하는 데 사용할 수 있습니다. parallelStream()을 사용하면 Spliterator를 이용해 작업을 분할하고, 공통 포크/조인 풀에서 연산을 실행하게 됩니다. 이 방법은 병렬 처리가 쉬운 문제를 다룰 때 유용한 기술입니다. 스트림 항목들은 기본적으로 불변을 전제로 하므로, 병렬 처리 시 상태 변이 문제를 피할 수 있습니다.

parallelStream()의 비용

항상 parallelStream()을 사용하고 싶은 유혹이 들 수 있지만, 이 접근 방식에는 비용이 따릅니다. 모든 병렬 계산과 마찬가지로, 작업을 여러 스레드에 분할하고 결과를 다시 결합하는 과정이 필요하며, 이는 암달의 법칙을 직접적으로 보여주는 사례입니다. 작은 컬렉션에서는 직렬(serial) 연산이 훨씬 더 빠를 수도 있습니다. 따라서 단순히 순차 스트림을 무작정 병렬로 변환해서는 안 됩니다.

자바 8의 도입으로 포크/조인의 사용량도 크게 증가했으며, 이는 parallelStream()이 내부적으로 공통 포크/조인 풀을 사용하기 때문입니다.

13.5.4 액터 기반 기법

액터 패러다임

최근 몇 년 동안, 스레드보다 자연적으로 더 작은 단위의 작업을 표현하는 여러가지 접근 방식이 등장했습니다. 이미 ForkJoinTask 클래스에서 이 개념을 접했으며, 이후 가상 스레드에 대한 절에서도 다시 만나게 될 것입니다. 또 다른 인기 있는 접근 방식으로는 액터 패러다임(actor paradigm)이 있습니다.

액터는 작고 독립적인 처리 단위로, 자체적인 상태와 동작(behavior)을 가지며, 다른 액터들과 통신하기 위한 메일박스 시스템을 포함하고 있습니다. 액터는 가변 상태를 공유하지 않고, 오직 불변 메시지를 통해서만 서로 통신함으로써 상태 관리 문제를 해결합니다. 액터 간의 통신은 비동기적으로 이루어지며, 액터는 메시지를 수신하면 이에 반응하여 지정된 작업을 수행합니다.

액터들은 병렬 시스템 내에서 각자 특정한 작업을 수행하는 네트워크를 형성함으로써, 기본적인 동시성 모델을 완전히 추상화하는 방식을 취합니다.

액터들은 동일한 프로세스 내에서 실행될 수도 있지만, 반드시 그래야 하는 것은 아닙니다. 이것은 액터 시스템이 다중 프로세스를 지원할 수 있으며, 심지어 여러 대의 머신에 걸쳐 확장될 가능성을 열어줍니다. 여러 머신과 클러스터링을 활용하면, 일정 수준의 장애 허용(fault tolerance)이 필요한 환경에서도 액터 기반 시스템이 효과적으로 동작할 수 있습니다. 액터들이 협업 환경에서 성공적으로 동작하도록 보장하기 위해, 일반적으로 빠른 실패(fail-fast) 전략을 사용합니다.

Apache Pekko

자바 가상 머신 기반 언어에서, 아파치 페코(Apache Pekko)는 액터 기반 시스템을 개발하는 데 널리 사용되는 프레임워크입니다. 이 프레임워크는 스칼라로 작성되었지만 자바 API도 제공하기 때문에, 자바 또는 기타 자바 가상 머신 언어에서도 사용할 수 있습니다.

페코의 유래

페코는 이전 프로젝트(아카, Akka)에서 포크되었으며, 원래 프로젝트가 비오픈 소스 라이선스를 채택했을 때 분리되었습니다.

액터를 고려하는 동기

페코 문서는 기존의 잠금 기반 방식보다 페코를 고려해야 하는 세 가지 핵심 동기를 강조합니다.

  • 도메인 모델 내에서 가변 상태(mutable state)를 캡슐화하는 것은 까다로운 작업입니다. 특히, 객체의 내부 상태에 대한 참조가 의도치 않게 외부로 유출될 경우, 이를 제어하기가 어렵습니다.
  • 잠금을 사용하여 상태를 보호하는 것은 시스템의 처리량을 크게 감소시킬 수 있습니다.
  • 잠금은 교착 상태 또는 기타 유형의 활성 문제를 유발할 수 있습니다.

추가적으로, 공유 메모리를 올바르게 관리하는 것이 어렵다는 점과, 여러 CPU에서 캐시 라인을 공유하도록 강제할 경우 발생하는 성능 문제도 중요한 고려 사항입니다.

마지막으로, 전통적인 스레딩 모델과 호출 스택에서 발생하는 오류 처리 문제도 동기 부여 요소 중 하나입니다. 저수준 스레딩 API에서는 스레드 실패 또는 복구를 표준적으로 처리하는 방법이 존재하지 않습니다. 페코는 이를 표준화하여 개발자가 명확한 복구 방식을 사용할 수 있도록 지원합니다.

액터 모델의 적합성

액터 모델은 동시 프로그래밍을 하는 개발자가 사용할 수 있는 강력한 도구가 될 수 있습니다. 그러나 모든 문제에 대한 범용적인 해결책은 아닙니다. 만약 사용 사례가 액터 스타일(비동기적으로 불변 메시지를 전달하고, 공유 가변 상태 없음, 그리고 각 메시지 프로세서의 실행 시간이 제한됨)에 적합하다면, 이는 빠르고 효과적인 해결책이 될 수 있습니다.

반면, 시스템 설계가 요청-응답 방식의 동기 처리, 공유된 가변 상태, 또는 실행 시간이 제한되지 않는 구조를 포함한다면, 신중한 개발자는 다른 추상화를 선택하는 것이 더 적절할 수도 있습니다.

13.6 가상 스레드

가상 스레드의 등장 배경

자바의 가장 큰 강점 중 하나는 매우 적응력이 뛰어나다는 점입니다. 새로운 기능을 위한 아이디어를 어디에서든 받아들이지만, 이를 신중하고 계획적으로 도입합니다. 그 목적은 단순히 빠르게 추가하는 것이 아니라, 해당 기능의 ‘가장 자바다운’ 버전을 제공하는 것입니다.

수년간 자바의 동시성 프로그래밍에서 가장 중요한 혁신 중 하나인 가상 스레드(virtual thread)가 자바 21에서 도입되었습니다. 가상 스레드는 고 언어의 고루틴(goroutine)이나 얼랭(Erlang)의 협력 프로세스(cooperative process)와 유사한 개념으로 볼 수 있습니다.

13.6.1 가상 스레드 소개

그린 스레드와 1:1 매핑

초기 자바 버전에서는 자바 가상 머신의 스레드가 운영 체제(플랫폼) 스레드에 다중화(multiplex)되어 실행되었으며, 이러한 방식의 스레드를 그린 스레드(green thread)라고 불렀습니다.

하지만 이 방식은 자바 1.2/1.3 시대를 거치면서 사라졌고, 자바 21 이전의 최신 자바 버전에서는 **‘하나의 자바 스레드는 정확히 하나의 플랫폼 스레드’**라는 규칙이 적용되었습니다. 즉, Thread.start()를 호출하면 운영 체제의 스레드 생성 시스템 호출(예: 리눅스의 clone() 함수)이 실행되고, 실제로 새로운 운영 체제 스레드가 생성됩니다.

스택 세그먼트 문제

운영 체제가 스레드를 생성, 관리, 삭제하는 이 방식은 몇 가지 중요한 영향을 미칩니다. 이를 이해하려면 프로세스의 메모리 공간이 표준적인 레이아웃을 따른다는 점을 떠올려야 합니다.

flowchart TB
    subgraph 프로세스["프로세스 메모리"]
        STACK["'C 스택'<br/>(스레드별 스택 프레임)"]
        HEAP["'C 힙'<br/>(자바 힙)"]
        DATA["데이터"]
        TEXT["텍스트<br/>(JVM 이진 코드)"]
    end

이 메모리 레이아웃의 중요한 특징 중 하나는, 운영 체제 프로세스가 스택 세그먼트(stack segment)를 가진다는 점입니다. 각 스레드는 프로세스의 가상 주소 공간 내에서 고정된 크기의 메모리 블록을 스택 용도로 예약합니다. 이 스택은 스레드가 생성될 때 할당되며, 스레드가 종료될 때까지 반환되지 않습니다.

예를 들어, 리눅스 x64 환경에서는 기본적으로 사용자 공간 스택 크기가 1MB로 설정되어 있습니다. 즉, 새로운 스레드가 생성될 때마다 운영 체제가 1MB의 메모리를 예약합니다. 수학적으로 계산해보면, ‘오직’ 20,000개의 스레드만 실행하더라도 20GB의 메모리가 필요합니다. 이것이 바로 스레드 병목 문제이며, 특히 요청 별 스레드 처리 방식(thread-per-request)과 유사한 아키텍처에서 그렇습니다.

가상 스레드와 캐리어 스레드

이 문제를 해결하기 위해 등장한 것이 바로 가상 스레드입니다. 이 프로젝트는 ‘룸 프로젝트’(Project Loom)라는 코드명으로 개발되었습니다. 해결책은 다음과 같은 특성을 가진 새로운 종류의 스레드를 도입하는 것입니다.

  • 운영 체제가 아니라 자바 가상 머신이 생성하고 관리함
  • 전용 플랫폼 스레드가 없으며, 캐리어 스레드(carrier thread) 풀을 공유해야 함
  • 스레드 세그먼트의 정적 할당을 더 유연한 모델로 대체함
  • (적어도 일부) I/O를 수행하는 작업을 위해 설계됨

가상 스레드는 ‘단순히’ 실행 가능한 자바 객체이며, 실행을 위해 플랫폼 스레드가 필요하지만, 이 플랫폼 스레드들은 캐리어 스레드로 공유됩니다. 이는 자바 스레드와 운영 체제 스레드 간의 1:1 관계를 제거하고, 가상 스레드가 실행되는 동안에만 캐리어 스레드와 임시적으로 연결되도록 합니다.

또한 캐리어 스레드가 서로 다른 가상 스레드 간에 전환될 때 운영 체제가 개입하지 않기 때문에 컨텍스트 전환이 더욱 저렴해질 수 있습니다. 대신, 이 전환은 완전히 사용자 공간에서 이루어집니다.

둘째로, 가상 스레드는 가비지 컬렉션이 적용되는 힙 내의 자바 객체를 사용하여 스택 프레임을 표현합니다. 이는 훨씬 더 동적인 방식이며, 스택 세그먼트 예약으로 발생하는 정적 병목 현상을 제거합니다.

가상 스레드의 동작 원리

스레드 수명 주기를 다시 살펴보면, 이론적으로는 스레드가 완전히 사용자 모드에서 실행을 완료할 수도 있습니다. 예를 들어, AI/ML과 같은 작업으로 계산만 한다면, CPU 타임슬라이스(timeslice)를 전부 사용할 때까지 실행된 후 OS 스케줄러가 이를 교체할 것입니다. 그러나 실제로는 이런 경우가 거의 없습니다.

대신, 대부분의 스레드는 차단 호출(예: I/O 작업)에 도달하여 커널 공간으로 전환되며, 운영 체제가 해당 작업을 대신 수행합니다. 가상 스레드는 이러한 실행 지점(execution points)을 핵심 구현 기법으로 활용합니다.

자바는 차단 I/O와 비차단 I/O의 두 가지 변형을 제공합니다. 자바 17부터 자바 소켓 API는 비차단 I/O(NIO) 기반으로 다시 구현되었습니다. 이전에는 차단 I/O 기반이었으나, 이번 변경은 API 자체가 아니라 내부 구현만 변경된 것이며, 가상 스레드의 중요한 기반 요소를 제공합니다.

기본적으로, 가상 스레드가 ‘차단’ I/O 호출을 수행할 때, 실제로는 비차단 호출을 수행하고 자신의 캐리어 스레드를 양보합니다. 이 과정에서 실제 I/O 작업은 진행되지만, 가상 스레드는 일시 정지되며, 다른 가상 스레드가 해당 캐리어 스레드를 사용할 수 있습니다.

캐리어 스레드 자체는 특별한 것이 아닙니다. 이것들은 단순히 일반적인 자바 스레드 풀(ExecutorService)이며, 운영 체제에서 볼 때도 표준 플랫폼 스레드와 동일하게 동작합니다.

yield()의 사용

가상 스레드에서 yield()를 호출하여 명시적으로 캐리어 스레드를 포기하는 것이 가능하지만, 일반적으로 권장되지 않습니다. 자바 문서에서 Thread.yield()에 대해 다음과 같이 명시하고 있듯이, ‘이 메서드를 사용하는 것이 적절한 경우는 거의 없습니다.’ 이는 모든 유형의 스레드에 해당합니다.

Thread 클래스 계층

자바의 클래스 계층 구조 측면에서 보면, 가상 스레드는 스레드의 새로운 봉인된(sealed) 하위 클래스를 도입하여 추가되었습니다. 이 하위 클래스는 오직 하나의 최종(final) 하위 클래스인 VirtualThread를 가집니다.

flowchart TD
    T[스레드]
    T --> SUB[하위 클래스]
    T --> BVT["기본 가상 스레드<br/>(봉인됨)"]
    BVT --> VT["가상 스레드<br/>(최종)"]

이러한 설계는 기존 코드가 Thread를 직접 상속하는 경우(이 경우 항상 플랫폼 스레드가 생성됨)를 그대로 유지하면서도, 모든 가상 스레드가 Runnable에서 생성되도록 보장합니다.

Thread.Builder 패턴

가상 스레드를 얻기 위해 Thread에 새로운 정적 메서드와 빌더 패턴이 추가되었습니다. 이를 다음과 같이 사용할 수 있습니다.

Thread.Builder tb = Thread.ofVirtual();
tb.name("MyVirtualThread");
Thread t = tb.unstarted(() -> System.out.println("Hello World!"));
System.out.println(t);
t.start();

새로운 메서드인 .ofPlatform().ofVirtual()을 이용하여 스레드 빌더 객체를 생성할 수 있습니다. 스레드 빌더는 스레드의 이름을 설정할 수 있으며, Runnable 작업을 제공하면 시작된 스레드 또는 시작되지 않은 스레드로 생성할 수 있습니다. 추가적인 유연성을 위해 .factory() 메서드를 통해 빌더에서 스레드 팩토리를 사용할 수도 있습니다.

가상 스레드의 격리와 제한

가상 스레드는 격리되도록 설계되었습니다. 예를 들어, 가상 스레드는 현재 실행 중인 캐리어 스레드를 직접 관찰할 수 없습니다. Thread.currentThread()는 가상 스레드를 반환하며, 캐리어 스레드의 스택 프레임은 가상 스레드의 스택 추적에 나타나지 않습니다.

코틀린 코루틴과의 차이

자바 가상 머신 언어인 코틀린에는 코루틴(coroutine)이라는 기능이 있는데, 겉보기에는 가상 스레드와 유사해 보일 수 있지만, 실제로는 매우 다른 개념입니다. 코틀린 소스 컴파일러는 코루틴을 상태 기계로 변환하여 컴파일된 바이트코드에서 볼 수 있는 형태로 만듭니다. 반면, 가상 스레드는 자바 SDK와 자바 가상 머신 레벨에서 직접 지원됩니다.

가상 스레드에는 몇 가지 제한 사항이 있습니다.

  • 가상 스레드는 차단 I/O 호출이 발생할 때만 양보하며, 선점(preemption)은 없습니다.
  • 자바 네이티브 인터페이스 호출과 synchronized 키워드(단, java.util.concurrent의 잠금은 제외)는 가상 스레드를 캐리어 스레드에 고정시켜 언마운팅(unmounting)을 방지합니다. 가상 스레드가 스케줄링 될 때, 플랫폼 스레드에 마운트되거나 할당 됩니다. 보통 언마운트는 가상 스레드가 I/O를 기다리거나 코드 실행을 완료하기 위한 차단될 때 발생하며, 이 과정에서 플랫폼 스레드는 해제되어 다른 작업을 사용할 수 있게 됩니다. 하지만 이러한 방식으로 가상 스레드를 고정하면, 이러한 언마운팅이 불가능해지고, 결국 자원 문제와 예기치 않은 차단 현상을 초래 할 수 있습니다.
  • 가상 스레드는 객체 풀 패턴과 잘 호환되지 않습니다.
  • 가상 스레드는 수명이 짧도록 설계되었으며, 기본적인 캐싱 기술은 재사용할 수 없는 가비지 객체에 대한 약한 참조를 유지하는 결과를 초래할 수 있습니다.

ThreadLocal 대신 ScopedValue

가상 스레드에서 ThreadLocal의 동작 방식은 더 복잡합니다. 따라서 경우에 따라 ThreadLocal 대신 ScopedValue를 사용하는 것이 더 나을 수 있습니다.

가상 스레드에 대한 권고 사항

가상 스레드에 대한 소개를 다음의 몇 가지 해야 할 것과 하지 말아야 할 것으로 마무리합니다.

해야 할 것하지 말아야 할 것
가상 스레드에 대한 새로운 직관을 배우게 될 것이라 기대하세요가상 스레드를 마치 공짜 점심처럼 생각하지 마세요
가상 스레드가 어떤 유형의 문제를 해결하는데 도움이 되는지 학습하세요무작정 적용하지 마세요
가상 스레드를 계산 집약적인 작업에 사용하지 마세요(이들은 차단 호출이 있어야만 양보 할 수 있습니다)

사실, 가장 좋은 조언 중 하나는 다른 개발자들과 대화하는 것입니다. 고 언어를 사용하는 친구가 있다면, 그들에게 고루틴을 어떻게 활용하는지, 그리고 어떤 패턴이 가상 스레드에 적용될 수 있는지 물어봐야 합니다.

13.6.2 가상 스레드 동시성 패턴

NIO 비차단 형식의 직접 사용 회피

실무에서 가상 스레드의 가장 즉각적인 이점 중 하나는 개발자가 NIO API의 비차단 형식을 직접 사용할 필요가 없어진다는 점입니다. 대신, 프로그램은 차단 API를 사용하는 전용 가상 스레드를 생성할 수 있으며, 런타임이 이를 효율적으로 처리합니다.

이는 성능 측면에서 비차단 I/O를 사용하는 것과 사실상 동일한 효과를 내면서도, 훨씬 단순한 프로그래밍 모델을 제공합니다. 특히 비동기 전염(async-await 또는 컬러 함수(colored function))1 같은 복잡한 프로그래밍 모델을 피하는 것이 룸 프로젝트의 주요 설계 목표 중 하나였습니다.

명시적 가상화

동시에, 명시적인 ‘리액티브 접근법’을 JDK에 도입하는 것은 목표가 아니었습니다. 그 결과, 자바 21에서 볼 수 있는 가상 스레드가 탄생했습니다. 자바 21은 자동으로 스레드를 가상화하지 않습니다. 가상 스레드를 명시적으로 생성하지 않는 한, 프로그램이 생성하는 스레드는 항상 플랫폼 스레드가 됩니다.

기존 스레드를 가상 스레드로 대체

가장 직관적인 접근 방식은 프로그램에서 일부 기존 스레드를 가상 스레드로 대체하는 것입니다. 캐리어 스레드는 ForkJoinPool 실행기에서 제공되며, 대부분의 차단 연산 시 양보합니다. 즉, 일정 부분 I/O 작업을 수행하는 스레드의 경우, 가상 스레드로 전환하면 성능상의 이점을 얻을 가능성이 있습니다.

물론, 일부 스레드를 가상 스레드로 변환하는 목적이 성능 향상을 기대하는 것이라면, 반드시 실제 시스템에서 테스트하여 기대한 효과를 얻을 수 있는지 확인해야 합니다.

newVirtualThreadPerTaskExecutor

가상 스레드는 수동으로 생성할 수도 있지만, 새로운 실행기 유형도 추가되었습니다. 바로 Executors.newVirtualThreadPerTaskExecutor()입니다. 이름에서도 알 수 있듯이, 기존의 전통적인 스레드 풀에 의존하는 것이 아니라, 각 작업이 제출될 때마다 새로운 가상 스레드를 생성하는 방식을 사용합니다.

새로운 실행기 유형을 지원하기 위해 ExecutorService 인터페이스가 AutoCloseable을 구현하게 되었습니다. 덕분에, try-with-resources 블록에서 사용할 수 있도록 개선되었습니다.

기존의 플랫폼 스레드 실행기는 보통 긴 수명을 가지는 객체로 관리됩니다. 그 이유는 실행기가 시작할 때 스레드를 생성하는데, 그 과정이 비용이 많이 드는 연산이기 때문입니다. 따라서, 플랫폼 스레드 실행기를 메서드 내 로컬 객체로 생성하는 것은 비효율적이며, 대부분 (필요할 경우 static으로 선언되는) 필드로 관리되는 것이 일반적입니다.

반면, 가상 스레드는 매우 가볍습니다. 실제로 가상 스레드는 단순한 자바 객체에 불과하며, 실행기를 생성하는 비용도 낮기 때문에 메서드 내에서 로컬 객체로 생성해도 성능상의 문제가 거의 없습니다.

Volatile Shutdown 패턴

다음과 같은 블록 범위의 가상 스레드 실행기를 사용하는 웹 서버의 기본 예시 코드가 만들어집니다.

private volatile boolean isShutdown = false;
 
void handle(Socket socket) {
    // 들어오는 요청 처리
}
 
void serveVT(ServerSocket serverSocket) throws IOException,
    InterruptedException {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            try {
                while (!isShutdown) {
                    var socket = serverSocket.accept();
                    executor.submit(() -> handle(socket));
                }
            } finally {
                // 오류가 발생했거나 인터럽트되었을 경우, 요청 수신을 중단
                executor.shutdown();
            }
        }
    }
 
public shutdown() {
    isShutdown = true;
}

서버 소켓은 serveVT() 메서드에 전달되며, 각 들어오는 요청을 처리할 때마다 새로운 가상 스레드를 시작합니다. 모든 요청은 서로 독립적이므로 데이터를 공유하거나 컨텍스트를 공유할 필요가 없으며, 요청은 모두 일정한 시간 내에 완료되고 네트워크 I/O가 필요합니다. 이러한 유형의 작업을 실행 후 잊기라고 부를 수 있으며, 이 패턴은 가상 스레드를 사용하여 간단한 웹 서버를 구현하는 데 매우 적합합니다.

또한, 이 코드가 Volatile Shutdown 패턴을 사용한다는 점도 중요합니다. 이 패턴에서는 isShutdown이라는 volatile 필드를 사용하여 플래그를 다시 읽도록 강제한 후 새로운 연결을 수락합니다. 이는 자바 서버 애플리케이션의 정상 종료(graceful shutdown)를 처리하는 데 있어 매우 표준적인 패턴입니다.

13.7 요약

동시성 도입 시 고려할 점

이 장에서는 멀티스레딩을 사용하여 애플리케이션 성능을 향상시키기 전에 고려해야 할 주제들의 표면만을 다루었습니다. 단일 스레드 애플리케이션을 동시성 설계로 변환할 때 다음 내용들을 고려해야 합니다.

  • 직선적인 처리(straight-line processing)의 성능을 정확하게 측정할 수 있도록 해야 합니다.
  • 변경을 적용한 후 실제로 성능이 향상되었는지 테스트해야 합니다.
  • 시스템에서 처리하는 데이터 크기가 변경될 가능성이 있는 경우, 성능 테스트를 쉽게 다시 실행할 수 있도록 해야 합니다.
피해야 할 유혹
  • 모든 곳에서 병렬 스트림을 사용하는 것
  • 수동 잠금(manual locking)을 사용하여 복잡한 데이터 구조를 만드는 것
  • java.util.concurrent에서 이미 제공하는 구조를 재구현하는 것
목표로 해야 할 것
  • 동시성 컬렉션을 사용하여 스레드 핫 성능을 향상시키는 것
  • 기본 데이터 구조를 효과적으로 활용하는 접근 설계(access design)를 사용하는 것
  • 애플리케이션 전반에서 잠금을 줄이는 것
  • 적절한 작업/비동기 추상화(task/asynchronous abstraction)를 제공하여 직접 스레드를 다루지 않도록 하는 것
동시성의 미래

한 걸음 물러서서 보면, 동시성은 고성능 코드의 미래에서 핵심 요소입니다.

  • 공유된 가변 상태는 어렵습니다.
  • 잠금은 올바르게 사용하기 어려우며 하드웨어 자원 측면에서 비용이 많이 듭니다.
  • 동기화된 상태 공유 모델과 비동기 상태 공유 모델이 모두 필요합니다.
  • 자바 메모리 모델은 저수준의 유연한 모델입니다.
  • 스레드 추상화는 매우 저수준의 개념입니다.

현대적인 동시성의 추세는 더 높은 수준의 동시성 모델로 이동하는 것이며, 스레드 자체에서 점점 멀어지는 경향을 보이고 있습니다. 스레드는 ‘동시성의 어셈블리 언어’처럼 보이기 시작했습니다. 최근의 자바 버전들은 프로그래머가 사용할 수 있는 고수준 클래스와 라이브러리를 증가시켜 왔습니다. 전반적으로, 업계는 안전한 동시성 추상화의 책임을 런타임과 라이브러리가 더 많이 담당하는 방향으로 이동하고 있는 것으로 보입니다.

비교 / 트레이드오프

잠금 메커니즘 비교
도구사용 시점장점단점
synchronized짧은 임계 구역, 단순한 보호언어 내장, 자동 해제우선순위/읽기-쓰기 구분 불가, tryLock 불가
ReentrantLock여러 메서드 걸친 잠금, 조건 분리 필요tryLock, newCondition, 공정 모드명시적 unlock 필수 (finally 블록)
ReentrantReadWriteLock읽기 >> 쓰기 비율다수의 읽기 동시 허용쓰기 경쟁 시 오버헤드
Semaphore리소스 풀 크기 제한비소유 스레드 해제 가능, 다중 허가공정 모드 미사용 시 기아 가능
원자적 연산 + CAS단일 변수 갱신, 락프리교착 상태 없음, 짧은 임계 구역에 최적경쟁 심하면 재시도 폭증, 성능 선형 저하
반복 잠금 (spinlock)매우 짧은 대기컨텍스트 스위치 없음CPU·전력 소모, 긴 임계 구역에 부적합
동시성 컬렉션 vs 외부 잠금
방식처리량복잡도적합 상황
HashMap + synchronized낮음 (전체 잠금)낮음작은 맵, 경쟁 거의 없음
ConcurrentHashMap높음 (세그먼트 잠금)낮음 (내장)일반적인 멀티스레드 캐시
CopyOnWriteArrayList읽기 매우 빠름, 쓰기 비쌈낮음읽기 >> 쓰기, 작은 컬렉션
ReentrantReadWriteLock + 일반 컬렉션중간중간세분화된 제어 필요
작업 추상화 수준 비교
flowchart TD
    A["Thread<br/>(저수준)"] --> B["ExecutorService<br/>(작업 추상화)"]
    B --> C["ForkJoinPool<br/>(분할-정복, 작업 훔치기)"]
    C --> D["parallelStream<br/>(데이터 병렬, 람다)"]
    A --> E["Actor 모델<br/>(메시지 패싱)"]
    A --> F["Virtual Thread<br/>(가상화, 룸 프로젝트)"]

수준이 올라갈수록 개발자 부담은 줄지만 제어권은 줄어듭니다. 보통 ExecutorService 수준이 실무에서 가장 균형이 좋고, 데이터 병렬성이 명확하면 parallelStream, I/O 집약적이면 가상 스레드, 분산·격리가 핵심이면 액터를 선택합니다.

가상 스레드 vs 플랫폼 스레드
플랫폼 스레드가상 스레드
생성 비용비쌈 (OS clone)저렴 (자바 객체)
스택 메모리고정 1MB (리눅스 x64)힙 내 동적 할당
컨텍스트 스위치커널 모드 (비쌈)사용자 모드 (저렴)
적합 작업CPU 집약적 계산I/O 차단 작업
양보 시점OS 스케줄러 (선점)차단 호출 시에만 (협력적)
synchronized 영향일반 동작캐리어에 고정(pinning)

가상 스레드는 요청 처리(thread-per-request) 모델의 메모리 병목을 해결하는 도구이지, 모든 스레드의 대체재가 아닙니다. CPU 집약적인 작업은 여전히 플랫폼 스레드(특히 ForkJoinPool)가 적합합니다.

내 생각

”공짜 점심 끝났다”가 백엔드에 주는 의미

이 장의 출발점인 허브 서터의 통찰은 백엔드 엔지니어에게 매우 직접적입니다. 단일 코어 성능 향상이 멈춘 이후로 백엔드 처리량 향상은 항상 동시성 설계의 문제가 되었습니다. JVM 기반 백엔드(Spring, Netty 등)는 이미 요청 처리를 풀에 위임해 추상화하고 있지만, 그 안에서 락 경합과 컨텍스트 스위치가 발생하는 순간 처리량이 무너집니다. 이게 13.5의 “스레드 추상화는 너무 저수준이다”라는 주장의 본질입니다.

가상 스레드는 톰캣/네티 패러다임을 바꾼다

가장 흥미로운 부분은 가상 스레드입니다. 전통적인 톰캣은 요청당 플랫폼 스레드를 할당해 메모리 병목에 부딪혔고, 그래서 Netty 같은 이벤트 루프 기반 비차단 모델이 등장했습니다. 그런데 Netty 스타일은 비동기 전염(콜백 지옥, 컬러 함수)이라는 큰 복잡도 비용을 가져왔습니다.

가상 스레드는 “차단 API의 단순함 + 비차단 I/O의 효율성”을 동시에 제공합니다. 즉, Spring Boot 6 / Java 21 환경에서 Executors.newVirtualThreadPerTaskExecutor()로 전환하는 것만으로 동기 코드 스타일을 유지하면서도 수만 개 동시 연결을 처리할 수 있게 됩니다. 이는 WebFlux의 존재 의의를 일부 약화시키는 변화입니다 — I/O 차단이 주된 병목인 평범한 CRUD 서버라면 WebFlux보다 가상 스레드 + 동기 코드가 더 단순하고 빠를 가능성이 큽니다.

다만 synchronized 사용 코드(JDBC 드라이버 일부, 레거시 라이브러리)는 가상 스레드를 캐리어에 고정시켜 이점을 무력화할 수 있다는 점이 실전 함정입니다. 가상 스레드 도입 전 의존성을 전수 조사해야 합니다.

parallelStream은 함부로 쓰지 말 것

parallelStream()을 본 신규 개발자가 가장 먼저 빠지는 함정은 “그냥 붙이면 빨라진다”는 생각입니다. 실제로는 공통 ForkJoinPool을 사용하므로 한 곳에서 무겁게 쓰면 다른 모든 병렬 스트림이 영향을 받습니다. 웹 요청 처리 스레드 안에서 parallelStream()을 쓰면 서로 다른 요청들이 같은 풀을 두고 경쟁합니다.

실무 룰: 컬렉션이 충분히 크고(수만 건 이상), 항목당 처리 비용이 비싸고, 다른 요청과 풀을 공유해도 괜찮을 때만 사용. 그 외에는 명시적인 ExecutorService를 만들어 격리하는 것이 안전합니다.

액터 모델은 도메인이 맞을 때만

페코/아카는 분산 시스템과 fault-tolerance가 필요한 환경(채팅, 게임, IoT 등)에서 강력하지만, 일반적인 RESTful CRUD 백엔드에서는 오버킬입니다. “요청-응답 + 공유 가변 상태(DB)“가 주된 패턴인 시스템에 액터 모델을 강제 적용하면, 메일박스가 새로운 병목이 되고 디버깅이 매우 어려워집니다.

마지막 정리

이 장의 진짜 메시지는 “동시성은 라이브러리에 위임하라” 입니다. synchronized를 직접 쓰는 코드, wait/notify를 다루는 코드, 카운터를 수동으로 락하는 코드는 거의 항상 java.util.concurrent의 기존 도구로 더 잘 표현할 수 있습니다. 직접 잠금 라이브러리를 만드는 일은 표준 라이브러리 기여자가 아니라면 거의 잘못된 선택입니다.

관련 개념

출처

  • 『자바 최적화 2판』 13장 동시성 성능 기법