34. 스레드풀 워커의 ThreadLocal 컨텍스트 잔존과 요청 간 오염 전파
난이도 최상해설 — 스레드풀 워커의 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복원 방식은 코드 변경이 적고 즉시 적용 가능하지만, 여전히 "실수로 정리를 빼먹을 수 있는" 패턴 자체는 남아있다 — 장기적으로는 명시적 전달이나 스코프 기반 설계가 이런 클래스의 버그를 원천 차단한다.
면접 포인트
ThreadLocal<T>(또는 C++thread_local)의 수명은 "스레드"에 묶이지, "논리적 작업 단위(요청)"에 묶이지 않는다 — 스레드풀처럼 스레드가 재사용되는 환경에서는 반드시 작업 종료 시 명시적으로 정리해야 한다.- "설정만 하고 해제는 안 하는" 패턴은 정상 경로에서는 문제가 안 보이다가, 스레드 재사용·예외·조기 반환 등 비정상 경로에서 상태가 다음 작업으로 전파되는 전형적인 잠복 버그를 만든다 —
try/finally로 정리를 강제해야 한다. - 비동기 코드(
await)가 섞이면ThreadLocal자체가 부적절할 수 있다 — 실행이 다른 스레드에서 이어질 수 있으므로AsyncLocal<T>등 논리적 실행 흐름을 따라가는 메커니즘을 고려해야 한다.
해설 — 스레드풀 워커의 thread_local 컨텍스트 잔존과 요청 간 오염 전파 (C++)
난이도: 최상
요약
handleRequest 는 (A) 에서 힙에 PlayerContext 를 할당해 thread_local 포인터 CurrentContext::value 에 설정하지만, (B) 처리 종료 시점에 정리(포인터를 원상 복구하고 메모리를 해제)하지 않는다. thread_local 변수의 수명은 "스레드" 단위이지 "요청" 단위가 아니므로, 스레드풀 워커가 재사용되면 이전 요청이 남긴 포인터가 그대로 슬롯에 남는다. C++ 이라 이 문제는 C# 버전보다 한 겹 더 심각하다 — 정리하지 않은 컨텍스트는 다음 요청으로 상태가 오염 전파될 뿐 아니라, 매 요청마다 new 로 할당된 이전 PlayerContext 가 한 번도 delete 되지 않아 스레드가 재사용될 때마다 메모리 누수가 누적된다.
문제점
(A)(B) 요청 종료 시 thread_local 정리 누락 — 다음 요청으로 컨텍스트 오염 전파
- 증상: 워커 W 가 플레이어 A 의 요청을 처리하며
CurrentContext::value가 A 를 가리키는 힙 객체를 가리키게 된다. 처리가 끝나도 포인터가 정리되지 않아, 같은 워커 W 가 재사용되어 플레이어 B 의 요청을 맡았을 때handleRequest가 (A) 에서 새 값을 설정하기 전에 실행되는 어떤 코드 경로가CurrentContext::value를 읽으면 B 의 작업인데 A 의 컨텍스트로 권한 검사/로깅이 수행된다. - 재현 조건: 스레드풀 워커 재사용 + 요청 종료 시 컨텍스트 미정리 + 다음 요청 설정 전에 컨텍스트를 참조하는 코드 경로 존재.
- 근본 원인:
thread_local의 수명 단위는 스레드이지 논리적 요청이 아니다. "설정만 하고 해제는 안 하는" 패턴이 스레드 재사용 순간 이전 상태를 다음 요청에 그대로 전달한다.
(A) 매 요청 new 할당, 정리 누락 시 메모리 누수 누적
- 증상: 요청마다
new PlayerContext{...}로 힙 객체를 만들지만 이전 요청이 만든 객체를delete하는 코드가 어디에도 없다. 워커가 재사용되어 요청을 처리할 때마다 이전PlayerContext를 가리키던 포인터 값은 덮어써지고, 그 객체는 더 이상 어디서도 참조되지 않는 채 힙에 남아 누수가 요청 수만큼 누적된다. - 재현 조건: 같은 워커 스레드에서 다수의 요청을 연속 처리.
- 근본 원인: 힙 객체의 소유권을 raw 포인터로만 관리하고 해제 시점(누가, 언제
delete하는지)을 정의하지 않았다.
예외 경로에서도 정리가 없음
- 증상:
doWork가 예외를 던지면catch (...)는 재던짐만 하고 컨텍스트도, 힙 메모리도 정리하지 않은 채handleRequest를 벗어난다. - 재현 조건: 요청 처리 중 예외 발생 + 워커 재사용.
- 근본 원인: 정리 로직이 RAII 로 보장되지 않고 애초에 존재하지 않는다.
수정안 (정확한 코드)
RAII 가드로 "이전 값 저장 → 새 값 설정 → 스코프 종료 시(정상/예외 모두) 이전 값 복원 + 메모리 해제"를 보장한다.
#include <string>
#include <stdexcept>
#include <memory>
struct PlayerContext {
int playerId;
std::string permissionLevel;
};
struct CurrentContext {
static thread_local PlayerContext* value;
};
thread_local PlayerContext* CurrentContext::value = nullptr;
// 스코프 진입 시 새 컨텍스트를 설정하고, 소멸 시(정상/예외 모두) 반드시
// 이전 값으로 복원하며 자신이 만든 컨텍스트를 해제하는 RAII 가드.
class ScopedContext {
PlayerContext* previous_;
std::unique_ptr<PlayerContext> owned_;
public:
ScopedContext(int playerId, const std::string& permissionLevel)
: previous_(CurrentContext::value)
, owned_(std::make_unique<PlayerContext>(PlayerContext{playerId, permissionLevel}))
{
CurrentContext::value = owned_.get();
}
~ScopedContext() {
// 정상 종료든 예외로 인한 스택 되감기든 소멸자는 반드시 호출된다.
CurrentContext::value = previous_;
// owned_ 는 unique_ptr 이므로 여기서 자동으로 delete 됨 — 누수 없음.
}
ScopedContext(const ScopedContext&) = delete;
ScopedContext& operator=(const ScopedContext&) = delete;
};
class RequestProcessor {
void authorizeAction(const std::string& action) {
PlayerContext* ctx = CurrentContext::value;
if (!ctx) throw std::runtime_error("컨텍스트 없음");
(void)action;
}
void logAudit(const std::string& message) {
PlayerContext* ctx = CurrentContext::value;
(void)message;
(void)ctx;
}
void doWork(int playerId, const std::string& action) {
authorizeAction(action);
(void)playerId;
logAudit(action + " 처리됨");
}
public:
void handleRequest(int playerId, const std::string& permissionLevel, const std::string& action) {
ScopedContext guard(playerId, permissionLevel); // 생성자에서 설정
doWork(playerId, action);
// guard 소멸자가 함수 종료(정상/예외 모두)에 자동 실행되어 정리 보장
}
};
핵심: unique_ptr 소유의 RAII 가드가 스택에서 소멸될 때(정상 반환이든 예외로 인한 스택 되감기든) 반드시 CurrentContext::value 를 이전 값으로 복원하고 자신이 할당한 메모리를 해제한다. try/finally 가 없는 C++ 에서는 이 패턴이 정리 누락을 원천 차단하는 관용적인 방법이다.
더 나은 설계 (+ 트레이드오프)
- 명시적 파라미터 전달:
PlayerContext를 thread_local 에 숨기지 않고 호출 체인을 따라 참조로 전달하면 이 클래스의 결함이 성립하지 않는다. 함수 시그니처가 길어지지만 수명이 코드 흐름으로 명확해진다. - 스택 값 객체 + 참조: 힙 할당(
new/unique_ptr) 대신PlayerContext를handleRequest스택에 값으로 두고thread_local에는 그 주소만 저장하면, 함수 종료 시 스택 언와인딩이 자동으로 객체를 파괴해 별도delete관리가 필요 없다(단, 포인터 유효 범위가 함수 스코프를 벗어나지 않아야 한다). - 비동기/코루틴 코드가 섞이는 경우: 처리 도중 다른 스레드로 이어지는 비동기 작업이 있다면
thread_local자체가 부적절하다 — 실행 컨텍스트를 명시적으로 전달하거나 태스크 로컬(예: 코루틴 프레임에 컨텍스트를 담는 방식)을 고려해야 한다. - 트레이드오프: RAII 가드는 코드 변경이 적고 예외 안전성을 보장하지만, "가드를 스코프 시작에 만드는 것을 잊는" 실수는 여전히 가능하다 — 장기적으로는 명시적 전달이 이런 클래스의 버그를 원천 차단한다.
면접 포인트
thread_local의 수명은 스레드에 묶이지 논리적 작업 단위(요청)에 묶이지 않는다 — 스레드풀처럼 스레드가 재사용되는 환경에서는 작업 종료 시 명시적 정리가 필수다.- C++ 에서 예외 안전한 정리는
try/finally가 없으므로 RAII(소멸자)로 구현해야 한다 — "설정 후 정리 없음" 패턴은 정상 경로에서는 안 보이다가 예외/재사용 시 잠복 버그로 드러난다. - raw 포인터로 힙 객체 수명을 스레드로컬에 저장하면 정리 누락이 오염 전파뿐 아니라 메모리 누수로도 이어진다 —
unique_ptr등으로 소유권을 명확히 해야 한다.