← 문제로

34. 스레드풀 워커의 ThreadLocal 컨텍스트 잔존과 요청 간 오염 전파

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — 스레드풀 워커의 ThreadLocal 컨텍스트 잔존과 요청 간 오염 전파

난이도: 최상

요약

HandleRequest 는 (A) 에서 CurrentContext.Value.Value 에 이번 요청의 PlayerContext 를 설정하지만, 처리가 끝난 뒤(B) 그것을 정리(clear)하지 않는다. ThreadLocal<T> 는 "스레드마다 독립적인 슬롯"이지 "요청마다 독립적인 슬롯"이 아니다. 스레드풀 워커 스레드는 요청 하나를 처리한 뒤 스레드 자체는 그대로 유지한 채 다음 요청을 처리하도록 재사용되므로, 이전 요청이 설정한 PlayerContext다음 요청의 처리 시작 시점까지 슬롯에 남아있다. 만약 다음 요청이 (A) 를 실행하기 전에 어떤 코드 경로(예: 비동기 콜백, 조기 반환, 예외로 인한 우회, 혹은 애초에 컨텍스트를 설정하지 않는 다른 종류의 작업)가 CurrentContext.Value.Value 를 읽는다면, 이전 요청(다른 플레이어)의 컨텍스트를 그대로 자신의 것으로 오인하게 된다. 이는 잘못된 플레이어 권한으로 검사가 통과되거나, 감사 로그에 엉뚱한 플레이어 ID 가 기록되는 등 상태 오염이 요청 경계를 넘어 전파되는 심각한 결함이다.

문제점

(A)(B) 요청 종료 시 ThreadLocal 정리 누락 — 다음 요청으로 컨텍스트 오염 전파

  • 증상: 워커 W 가 플레이어 A 의 요청을 처리하며 CurrentContext.Value.Value = A. 처리가 끝나도 정리되지 않아 슬롯에는 여전히 A 가 남아있다. 이후 같은 워커 W 가 재사용되어 플레이어 B 의 요청을 맡았을 때, HandleRequest 가 (A) 에서 새로 설정하기 전에 실행되는 어떤 코드(예: 스레드풀이 작업을 큐에서 꺼내는 사이 실행되는 사전 훅, 혹은 컨텍스트를 설정하지 않는 백그라운드 유지보수 작업이 같은 워커에서 실행되는 경우)가 CurrentContext.Value.Value 를 읽으면 B 의 작업인데 A 의 컨텍스트로 권한 검사/로깅이 수행된다.
  • 재현 조건: 스레드풀 워커 재사용(.NET ThreadPool/Task 스케줄러는 워커 스레드를 재사용하는 것이 기본 동작) + 요청 종료 시 컨텍스트 미정리 + 다음 요청 설정 전에 컨텍스트를 참조하는 코드 경로 존재.
  • 근본 원인: ThreadLocal<T> 의 수명 단위는 "스레드"이지 "논리적 요청"이 아니다. 요청 처리기가 이 차이를 인지하지 못하고 "설정만 하고 해제는 안 하는" 패턴을 쓰면, 스레드가 재사용되는 순간 이전 요청의 상태가 다음 요청으로 그대로 넘어가는 것이 구조적으로 예정되어 있다.

예외 경로에서도 정리가 없음 (근본 원인의 연장)

  • 증상: DoWork 가 예외를 던지면 catch 블록은 재던짐만 하고 CurrentContext 를 정리하지 않은 채 HandleRequest 를 벗어난다. 정상 종료든 예외든 정리 코드 자체가 없으므로 결과는 같다 — 다음 요청까지 오염이 이어진다.
  • 재현 조건: 요청 처리 중 예외 발생 + 워커 재사용.
  • 근본 원인: 정리 로직이 try/finally 로 보장되지 않고 애초에 존재하지 않는다.

수정안 (정확한 코드)

요청 시작 시 이전 값을 저장해두고, finally 에서 반드시 컨텍스트를 정리(이전 값으로 복원 또는 null 설정)한다. 이렇게 하면 정상 종료와 예외 종료 모두 다음 요청이 시작되기 전에 슬롯이 비워진다.

using System;
using System.Threading;

class PlayerContext
{
    public int PlayerId;
    public string PermissionLevel;
}

static class CurrentContext
{
    public static readonly ThreadLocal<PlayerContext> Value = new ThreadLocal<PlayerContext>();
}

class RequestProcessor
{
    private void AuthorizeAction(string action)
    {
        var ctx = CurrentContext.Value.Value;
        if (ctx == null) throw new InvalidOperationException("컨텍스트 없음");
    }

    private void LogAudit(string message)
    {
        var ctx = CurrentContext.Value.Value;
    }

    private void DoWork(int playerId, string action)
    {
        AuthorizeAction(action);
        LogAudit($"{action} 처리됨");
    }

    public void HandleRequest(int playerId, string permissionLevel, string action)
    {
        // 이번 요청 진입 전 슬롯 상태를 저장(중첩 호출 대비, 보통은 null)
        var previous = CurrentContext.Value.Value;

        CurrentContext.Value.Value = new PlayerContext
        {
            PlayerId = playerId,
            PermissionLevel = permissionLevel
        };

        try
        {
            DoWork(playerId, action);
        }
        finally
        {
            // 정상 종료든 예외든 반드시 실행 — 다음 요청이 이 워커를 재사용하기 전에
            // 반드시 슬롯을 원상 복구(보통 null)한다.
            CurrentContext.Value.Value = previous;
        }
    }
}

핵심: finally모든 종료 경로(정상 반환, 예외 전파, 조기 반환)에서 정리가 실행되도록 강제한다. 이전 값을 저장했다가 복원하는 방식은 드물게 발생하는 중첩 호출(같은 스레드에서 HandleRequest 가 재진입)에도 안전하다.

더 나은 설계 (+ 트레이드오프)

  • 명시적 파라미터 전달: 애초에 PlayerContext 를 스레드로컬에 숨기지 않고 호출 체인을 따라 명시적으로 전달하면 이 클래스의 결함 자체가 성립하지 않는다. 코드가 장황해지지만 컨텍스트 수명이 코드 흐름으로 명확히 드러난다.
  • AsyncLocal<T> + 비동기 흐름 고려: 만약 DoWork 내부에 await 가 섞인다면 ThreadLocal 은 애초에 잘못된 선택이다(스레드가 바뀔 수 있으므로). AsyncLocal<T> 은 논리적 실행 흐름(콜 컨텍스트)을 따라가며, ExecutionContext 의 copy-on-write 로 각 비동기 분기가 독립적인 값을 갖는다. 다만 AsyncLocal 도 "설정 후 해제"를 관리하는 책임은 여전히 호출자에게 있다.
  • 요청 스코프 컨테이너(스코프드 DI): 요청 시작 시 스코프를 열고 종료 시 자동으로 닫는 프레임워크 차원의 요청 스코프(ASP.NET Core 의 IServiceScope 등)를 사용하면, 컨텍스트 수명 관리를 프레임워크가 보장해 개발자 실수를 줄인다.
  • 트레이드오프: finally 복원 방식은 코드 변경이 적고 즉시 적용 가능하지만, 여전히 "실수로 정리를 빼먹을 수 있는" 패턴 자체는 남아있다 — 장기적으로는 명시적 전달이나 스코프 기반 설계가 이런 클래스의 버그를 원천 차단한다.

면접 포인트

  1. ThreadLocal<T>(또는 C++ thread_local)의 수명은 "스레드"에 묶이지, "논리적 작업 단위(요청)"에 묶이지 않는다 — 스레드풀처럼 스레드가 재사용되는 환경에서는 반드시 작업 종료 시 명시적으로 정리해야 한다.
  2. "설정만 하고 해제는 안 하는" 패턴은 정상 경로에서는 문제가 안 보이다가, 스레드 재사용·예외·조기 반환 등 비정상 경로에서 상태가 다음 작업으로 전파되는 전형적인 잠복 버그를 만든다 — try/finally 로 정리를 강제해야 한다.
  3. 비동기 코드(await)가 섞이면 ThreadLocal 자체가 부적절할 수 있다 — 실행이 다른 스레드에서 이어질 수 있으므로 AsyncLocal<T> 등 논리적 실행 흐름을 따라가는 메커니즘을 고려해야 한다.