ThreadLocal: 올바른 관용구, 잘못된 시대
Java EE 서블릿 컨테이너, 1999년경: 요청당 하나의 스레드. 스레드는 요청을 시작부터 끝까지 정확히 하나만 처리한 뒤 종료됩니다. ThreadLocal은 현재 스레드를 키로 값을 저장합니다. one-thread-per-request 모델에서는 ThreadLocal에 저장된 값이 정확히 하나의 요청에 속합니다. 관용구: 올바름.
스레드 풀이 계약을 바꿨습니다. 스레드가 요청 A를 처리하고, ThreadLocal에 principal A를 저장한 뒤 요청 A를 마치고 풀로 돌아갑니다. 스레드 풀은 스레드 상태를 초기화하지 않습니다. ThreadLocal.remove()가 정리를 수행하지만, 이를 호출하려면 명시적인 규율이 필요합니다. 규율이 실패하면 요청 B가 동일한 스레드에서 실행되며 ThreadLocal에서 principal A를 읽습니다.
5단계 누출:
1. 요청 A가 도착합니다. 서버가 Thread-7을 할당합니다.
2. Thread-7이 요청 시작 시 ThreadLocal.set(principal_A)를 설정합니다.
3. 요청 A가 완료됩니다. Thread-7이 풀에 반환됩니다. ThreadLocal.remove()가 호출되지 않았습니다.
4. 요청 B가 도착합니다. 서버가 Thread-7을 할당합니다 (풀 재사용).
5. Thread-7이 ThreadLocal.get()을 읽습니다: principal_A를 반환합니다. 요청 B가 잘못된 신원으로 실행됩니다.
테스트가 이를 놓치는 이유
단위 테스트는 격리된 환경에서 실행됩니다: 스레드 풀 없음, 재사용 없음. 통합 테스트는 테스트 간에 새 스레드를 사용하거나 상태를 초기화합니다. 부하 테스트는 올바른 사용자와 낮은 동시성으로 워밍업합니다. 이 결함은 스레드 풀 재사용과 중복 요청이 발생할 때만 나타나며, 이는 테스트 구성에서는 발생하지 않고 정상적인 트래픽이 있는 프로덕션 환경에서만 나타납니다.
보안 결과
사용자 A의 principal이 사용자 B의 요청으로 유출됩니다. 크래시도 아니고 예외도 아닙니다. 조용한 보안 경계 위반입니다: 사용자 B가 사용자 A로 동작을 수행하고, 사용자 A의 데이터를 읽거나, 사용자 B의 권한을 우회합니다. 시스템은 오류를 생성하지 않습니다. 로그에는 요청 B가 승인된 것으로 표시됩니다. 모든 것이 올바르게 보입니다.
The Five Steps
ThreadLocal 누수의 다섯 단계는 정확히 중요합니다: 결함은 잘못된 코드가 실행되는 순간에 발생하지 않습니다. 정리 단계가 없는 상황에서 더 일찍 발생합니다.
스코프에 연결된 값
ThreadLocal은 값을 스레드에 연결합니다. 스레드는 요청보다 오래 지속됩니다. 불일치가 발생합니다.
Scope-attached 값은 값을 작업 단위에 연결합니다. 작업 단위가 끝나면 값도 함께 종료됩니다. 명시적인 정리 작업이 필요 없으며, remove()를 호출할 필요도 없습니다.
Java 21: ScopedValue
// ThreadLocal (결함 전달자)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // 요청 시작 시 설정
// ... 요청 처리 ...
PRINCIPAL.remove(); // 반드시 호출해야 함; 자주 잊어버림
// ScopedValue (올바른 캐리어)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... 요청 처리 ...
// run()이 반환되면 값이 자동으로 사라짐
});
Go: context.Context
// context.Context는 값을 명시적으로 전달합니다. 범위 = 함수 호출 체인
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx가 명시적으로 전달됩니다. 함수가 반환되면 사라집니다.
Python asyncio: contextvars.ContextVar
# 각 async task에 스코프되는 ContextVar
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # 이 task에만 설정
# ... task 처리 ...
PRINCIPAL.reset(token) # 또는: task와 함께 스코프 종료
이들이 공유하는 속성: 수명이 작업 단위와 일치합니다. 요청이 끝나면 (run()이 반환되거나, 함수가 반환되거나, task가 완료되면) 값도 끝납니다. 잊어야 할 정리 작업도 없고, 손상될 풀도 없습니다.
식별 및 교체
Java EE 애플리케이션이 요청 시작 시 ThreadLocal에 테넌트 ID를 저장합니다. 높은 부하 상황에서 테넌트 A의 ID가 테넌트 B의 요청에 나타납니다. 테넌트 B의 쿼리가 테넌트 A의 데이터를 반환합니다. 예외는 발생하지 않습니다. 이 결함은 프로덕션 부하 테스트에서만 나타납니다.