CodeGym /행동 /JAVA 25 SELF /Scoped Values 및 새로운 스레드 메커니즘 (Java 21+)

Scoped Values 및 새로운 스레드 메커니즘 (Java 21+)

JAVA 25 SELF
레벨 57 , 레슨 4
사용 가능

1. 왜 ThreadLocal이 덜 적합해지는가

ThreadLocal은 왜 필요한가?

전통적인 멀티스레딩(예: 서버)처럼 스레드가 오래 살아있는 환경에서는, 스레드마다 서로 섞이면 안 되는 고유 데이터를 저장해야 할 때가 있습니다. 예를 들어 사용자 이름, 요청 ID, 임시 버퍼 등이 그렇습니다.

이를 위해 Java에는 ThreadLocal<T>이 도입되었습니다. 스레드의 ‘개인 공간’에 데이터를 저장해 이웃 스레드와 간섭하지 않도록 합니다:

ThreadLocal<String> user = new ThreadLocal<>();

user.set("Alice"); // 이 값은 현재 스레드에만 저장됩니다
String name = user.get(); // 여기서는 "Alice"를 반환하고, 다른 스레드에서는 null입니다

왜 ThreadLocal은 가상 스레드와 잘 맞지 않는가

가상 스레드는 기존의 ‘무거운’ 스레드와는 전혀 다르게 살아갑니다. 수천 개가 생겼다가 사라지며 — 때로는 밀리초의 일부만 존재하기도 합니다. 그런데 ThreadLocal은 데이터를 특정 스레드에 묶어, 그 스레드가 마치 영원히 살 것처럼 가정합니다.

가상 스레드가 작업을 끝내면, ThreadLocal의 데이터가 메모리에 남아 있을 수 있습니다 — 스레드는 이미 사라졌는데도요. 이는 누수로 이어질 수 있으며, JVM이 그 값이 더 이상 필요 없다는 사실을 항상 알 수는 없습니다.

또한 스레드가 재사용되면(예: 풀) 더 골치 아픈 문제가 생길 수 있습니다. ‘남의’ 컨텍스트가 새 요청으로 우연히 넘어갈 수 있기 때문입니다. 예를 들어 사용자 Petya가 Vasya의 데이터를 받는다면 — 버그와 취약점으로 이어집니다.

ThreadLocal은 스레드 수가 적고 오래 살아있는 환경에서는 훌륭합니다. 하지만 가상 스레드와 함께라면 — 매초 사라지는 옷장에 물건을 보관하려는 것과 같습니다.

2. Scoped Values: 컨텍스트를 전달하는 새로운 방법

Scoped Values는 Java 21의 신기능으로, 오래된 ThreadLocal 문제를 더 우아하게 해결합니다. ThreadLocal처럼 데이터를 스레드 내부에 저장하는 대신, 실행 영역(특정 코드 구간)에 ‘부착’합니다. 값은 그 구간이 실행되는 동안에만 존재하고, 끝나면 자동으로 사라져 메모리에 흔적을 남기지 않습니다.

import java.lang.ScopedValue;

ScopedValue<String> USER = ScopedValue.newInstance();

ScopedValue.where(USER, "Alice").run(() -> {
    System.out.println("Hello, " + USER.get()); // 출력: Hello, Alice
});

코드가 run 블록을 벗어나면 값은 더 이상 접근할 수 없습니다 — 접근을 시도하면 예외가 발생합니다. 수동으로 정리할 필요가 없습니다.

Scoped Values는 메모리를 어지럽히지 않고, 스레드 간 컨텍스트가 섞이지 않으며, 내부 값이 외부 값을 일시적으로 덮어쓰는 중첩 영역을 만들 수 있습니다. 특히 가상 스레드 환경에서 컨텍스트를 전달하기 위한 깔끔하고 예측 가능하며 안전한 방법입니다.

3. Scoped Values 사용 예시

예시 1: 사용자 컨텍스트 전달

여러 사용자의 요청을 처리하는 서버가 있다고 가정해 봅시다. 각 요청마다 누가 요청했는지 알고 싶습니다.

import java.lang.ScopedValue;

public class ServerExample {
    static final ScopedValue<String> USER = ScopedValue.newInstance();

    public static void main(String[] args) {
        processRequest("Alice");
        processRequest("Bob");
    }

    static void processRequest(String userName) {
        ScopedValue.where(USER, userName).run(() -> {
            handleBusinessLogic();
        });
    }

    static void handleBusinessLogic() {
        System.out.println("사용자에 대해 처리 중: " + USER.get());
    }
}

무슨 일이 일어나는가:

  • 각 요청마다 자체 scope가 생성되고, 그 안에서 USER는 “Alice” 또는 “Bob”이 됩니다.
  • handleBusinessLogic() 내부에서는 항상 올바른 사용자 이름을 얻습니다.
  • 요청 처리가 끝나면 값은 사라집니다.

예시 2: 컨텍스트가 포함된 로깅

로그에 요청 식별자를 자동으로 삽입하고 싶다고 가정해 봅시다:

import java.lang.ScopedValue;

public class LoggingExample {
    static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

    public static void main(String[] args) {
        for (int i = 1; i <= 3; i++) {
            String reqId = "REQ-" + i;
            ScopedValue.where(REQUEST_ID, reqId).run(() -> {
                log("처리 시작");
                doWork();
                log("처리 종료");
            });
        }
    }

    static void log(String message) {
        System.out.printf("[%s] %s%n", REQUEST_ID.get(), message);
    }

    static void doWork() {
        log("작업 중...");
    }
}

결과(예):

[REQ-1] 처리 시작
[REQ-1] 작업 중...
[REQ-1] 처리 종료
[REQ-2] 처리 시작
[REQ-2] 작업 중...
[REQ-2] 처리 종료
[REQ-3] 처리 시작
[REQ-3] 작업 중...
[REQ-3] 처리 종료

각 scope는 자기 요청 식별자를 보유하며, 스레드 간 혼동이 발생하지 않습니다.

4. Scoped Values와 가상 스레드: 완벽한 조합

가상 스레드에서 Scoped Values가 특히 유용한 이유

가상 스레드는 수명이 짧습니다 — 수천 개가 생성·소멸하며 때로는 1초의 일부만 존재합니다. 따라서 데이터가 스레드 자체에 ‘단단히’ 묶이는 ThreadLocal 방식은 잘 맞지 않습니다. 스레드가 너무 빨리 사라지고, 컨텍스트가 우연히 누수되거나 뒤섞일 수 있기 때문입니다.

반면 ScopedValue는 데이터를 작업 자체, 즉 그 실행 영역에 묶습니다. 이는 컨텍스트(예: 사용자 이름이나 요청 ID)가 스레드가 아니라 코드 흐름을 따라간다는 뜻입니다. 작업이 끝나면 값은 자동으로 사라집니다. 가상 스레드에 이상적인 해법입니다 — 안전하고 깔끔하며 예상 가능하니까요.

예시: 가상 스레드로 대량 작업 처리

import java.lang.ScopedValue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadScopedValueDemo {
    static final ScopedValue<Integer> TASK_ID = ScopedValue.newInstance();

    public static void main(String[] args) {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

        for (int i = 1; i <= 10_000; i++) {
            int taskId = i;
            executor.submit(() -> ScopedValue.where(TASK_ID, taskId).run(() -> {
                processTask();
            }));
        }

        executor.shutdown();
    }

    static void processTask() {
        // 각 작업마다 고유한 TASK_ID
        System.out.println("작업 처리 중 #" + TASK_ID.get());
    }
}

핵심 포인트:

  • 각 작업마다 TASK_ID 값의 자체 scope가 생성됩니다.
  • 작업이 병렬 실행되더라도 값이 스레드 간에 뒤섞이지 않습니다.
  • 메모리 누수 없음: scope는 작업과 함께 ‘소멸’합니다.

5. 비교: ThreadLocal vs ScopedValue

기준 ThreadLocal ScopedValue
바인딩 대상 스레드 코드 영역(scope)
수명 주기 스레드가 살아있는 동안 scope가 실행되는 동안
안전성 누수·혼동 위험 누수 없음, 혼동 없음
가상 스레드 비효율적, 위험 이상적
사용 패턴
set/get
where(...).run(...), get
중첩 override 미지원 값을 덮어쓸 수 있음

6. 중첩 영역(scopes): 값 덮어쓰기

ScopedValue<String> INFO = ScopedValue.newInstance();

ScopedValue.where(INFO, "외부").run(() -> {
    System.out.println(INFO.get()); // "외부"
    ScopedValue.where(INFO, "내부").run(() -> {
        System.out.println(INFO.get()); // "내부"
    });
    System.out.println(INFO.get()); // "외부"
});

결과:

외부
내부
외부

예를 들어 하나의 작업 내부에서 컨텍스트 값을 잠시 재정의해야 할 때 유용합니다.

Scoped Values: 대표 사용 시나리오

  • 사용자 또는 요청 식별자 전달: 로깅이나 권한 확인을 위해.
  • 로깅: 컨텍스트를 로그에 자동 삽입.
  • 트레이싱: 디버그와 프로파일링을 위해.
  • 트랜잭션 파라미터: 예를 들어 격리 수준이나 동작 모드.
  • 하나의 작업(또는 그 하위 작업) 범위에서만 보이는 모든 ‘컨텍스트’.

7. 다른 새로운 메커니즘: Structured Concurrency

Structured Concurrency는 서로 관련된 작업(예: 하나의 연산에 속한 하위 프로세스들)을 하나의 단위로 관리하는 접근법입니다. 부모 작업이 끝나거나 실패하면, 모든 자식 작업이 자동으로 취소됩니다. 잊히거나 ‘매달린’ 스레드의 위험을 줄여줍니다.

예시(아주 개략적으로):

try (var scope = StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> result1 = scope.fork(() -> fetchData1());
    Future<String> result2 = scope.fork(() -> fetchData2());

    scope.join(); // 둘 다 끝날 때까지 대기
    scope.throwIfFailed(); // 하나라도 실패하면 예외를 던짐

    String combined = result1.resultNow() + result2.resultNow();
    System.out.println(combined);
}

장점:

  • 작업 수명 주기를 더 깔끔하게 관리.
  • ‘매달린’ 하위 프로세스 없음.
  • 오류 처리가 쉬움.

Structured Concurrency는 아직 preview 단계이지만 활발히 발전 중입니다.

8. 실전 팁과 제약사항

Scoped Values를 언제 사용할까?

  • 작업 간 컨텍스트를 전달해야 할 때 항상 — 특히 가상 스레드와 함께 사용할 때.
  • 예전에 ThreadLocal을 사용했다면 — ScopedValue로 전환하는 것이 더 나은지 고민해 보세요.

ThreadLocal이 아직 필요한 경우?

  • 매우 드물지만, 스레드가 아주 오래 살고 그 생애 전체에 걸쳐 컨텍스트가 ‘상수’여야 하는 경우(예: 레거시 코드와의 작업).

제약사항

  • Scoped Values는 scope 생성 후 변경할 수 없습니다 — 읽기 전용입니다.
  • Scoped Values는 scope 밖에서 사용할 수 없습니다: 영역 밖에서 값을 얻으려 하면 예외가 발생합니다.
  • 큰 객체 저장에는 사용하지 마세요 — 영역은 가볍고 빠르게 유지되어야 합니다.

9. Scoped Values 사용 시 흔한 실수

오류 №1: scope 밖에서 값을 가져오려는 시도. ScopedValue.where(...) 블록 밖에서 USER.get()을 호출하면 NoSuchElementException이 발생합니다. 접근이 반드시 영역 내부에서만 이뤄지는지 확인하세요.

오류 №2: scope 내부에서 값을 변경하려는 시도. Scoped Values는 변경 불가능한 컨테이너입니다. 값을 일시적으로 ‘재정의’해야 한다면 중첩 scope를 만드세요.

오류 №3: ThreadLocal과 ScopedValue를 함께 사용. 특별한 이유가 없다면 이러한 메커니즘을 섞지 마세요 — 컨텍스트가 혼동되고 오류로 이어질 수 있습니다.

오류 №4: run() 블록에 로직을 넣는 것을 잊음. ScopedValue.where(USER, "Alice")만 작성하고 .run(() -> { ... })을 호출하지 않으면, 어떤 scope도 생성되지 않습니다!

오류 №5: 장시간 살아있는 전역 정보를 Scoped Value로 보관하려는 시도. 이런 경우에는 일반 변수나 ThreadLocal(정당한 경우)을 사용하는 것이 더 낫습니다.

1
과제
JAVA 25 SELF, 레벨 57, 레슨 4
잠금
기업 네트워크의 데이터 기밀성 🔒
기업 네트워크의 데이터 기밀성 🔒
1
과제
JAVA 25 SELF, 레벨 57, 레슨 4
잠금
드론 배송 주문 추적 📦
드론 배송 주문 추적 📦
1
설문조사/퀴즈
Virtual Threads, 레벨 57, 레슨 4
사용 불가능
Virtual Threads
Virtual Threads
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION