비동기 및 멀티스레드 백엔드에서의 컨텍스트 전파
Emily Parker
Product Engineer · Leapcell

현대 백엔드 시스템의 복잡한 세계, 마이크로서비스가 통신하고, 비동기 작업이 풍부하며, 멀티스레딩이 성능의 기본이 되는 환경에서 사용자 요청 여정의 일관된 이해를 유지하는 것은 마치 눈을 가리고 미로를 탐색하는 것과 같을 수 있습니다. 단일 사용자 요청이 데이터베이스와 상호 작용하고, 다른 마이크로서비스를 호출하며, 데이터를 동시에 처리하는 일련의 이벤트를 촉발한다고 상상해 보세요. 이 전체 흐름을 추적할 수 있는 안정적인 메커니즘 없이는 디버깅이 악몽이 되고, 성능 병목 현상이 숨겨진 채로 남아 있으며, 시스템 동작에 대한 정확한 이해가 어려워집니다. 바로 이곳에서 요청 컨텍스트 전파의 개념이 빛을 발합니다. 고유한 추적 ID와 같은 필수 정보가 모든 홉, 스레드 전환 및 비동기 경계를 통과하도록 보장하는 것은 관찰 가능성, 문제 해결 및 보안을 위해 매우 중요합니다. 이 글에서는 이러한 복잡한 환경에서 요청 컨텍스트를 안전하고 안정적으로 전파하기 위한 과제와 다양한 전략을 탐구합니다.
핵심 개념
"어떻게"로 들어가기 전에, 논의의 기초를 형성하는 주요 용어에 대한 공통된 이해를 확립해 보겠습니다.
- 요청 컨텍스트: 특정 들어오는 요청과 관련된 데이터 모음을 나타냅니다. 여기에는 추적 ID(서비스 전체의 전체 요청에 대한 고유 식별자), 스팬 ID(요청 내 단일 작업에 대한 고유 식별자), 사용자 인증 세부 정보, 테넌트 ID, 언어 기본 설정 또는 해당 특정 요청을 처리하는 데 관련된 기타 데이터가 포함될 수 있습니다.
- 추적 ID: 어떤 서비스나 스레드가 관련되든 관계없이 단일 종단 간 사용자 요청에 속하는 모든 작업(스팬)을 연결하는 전역적으로 고유한 식별자입니다. 분산 추적에 필수적입니다.
- 비동기 환경: 작업 완료를 기다리지 않고 시작할 수 있는 시스템을 나타냅니다. 종종 콜백, 프로미스, 퓨처 또는 메시지 큐를 포함하여 비차단 실행 및 향상된 리소스 활용을 가능하게 합니다.
- 멀티스레드 환경: 단일 프로세스 내에서 여러 실행 스레드가 동시에 실행되는 시스템을 나타냅니다. 스레드는 메모리를 공유하지만, 경쟁 조건을 방지하고 데이터 무결성을 보장하려면 적절한 동기화 및 데이터 격리가 필수적입니다.
- 컨텍스트 전파: 특히 비동기 경계 또는 스레드 전환을 넘어, 한 실행 단위(예: 스레드, 함수 호출, 서비스)에서 다른 실행 단위로 요청 컨텍스트를 이전하거나 사용할 수 있도록 하는 행위입니다.
- 스레드 로컬 스토리지(TLS): 각 스레드가 변수의 고유 인스턴스를 갖도록 하는 메커니즘입니다. 간단한 스레드별 데이터에 유용하지만, 주의 깊은 관리 없이는 복잡한 컨텍스트 전파를 async/await 또는 스레드 풀을 넘어 확장하는 데 효과가 제한될 수 있습니다.
- 구조화된 동시성: 동시 작업의 수명 주기를 관리하는 구문을 제공하여 실행 흐름을 더 쉽게 이해하고 컨텍스트가 올바르게 전파되도록 보장하는 프로그래밍 패러다임입니다. 예로는 Java의
StructuredTaskScope또는 Go의context패키지가 있습니다.
컨텍스트 전파의 과제
전통적인 데이터 전달 방법(함수 인수)이 실행이 스레드 간에 점프하거나 비동기적으로 연기될 때 실패하기 때문에 기본적인 과제가 발생합니다. 요청이 시스템에 들어오면 일반적으로 한 스레드에서 시작됩니다. 후속 작업이 스레드 풀에 오프로드되거나 async/await 패턴이 사용되는 경우, 명시적으로 전달되지 않으면 원래 스레드의 로컬 컨텍스트가 손실됩니다.
간단한 시나리오를 생각해 보세요. 웹 서버가 요청을 받습니다. HTTP 헤더에서 Trace-ID를 추출합니다. 그런 다음 두 개의 데이터베이스 쿼리를 병렬로 수행해야 합니다. 각 쿼리는 스레드 풀의 별도 스레드에서 실행됩니다. 쿼리가 완료되면 결과가 집계되고 응답이 다시 전송됩니다. Trace-ID가 데이터베이스 쿼리 스레드로 명시적으로 전달되지 않으면, 해당 쿼리에서 생성된 로그에는 중요한 식별자가 누락되어 원래 요청에 연결하는 것이 불가능해집니다.
컨텍스트 전파 전략
몇 가지 전략이 이 과제를 해결하며, 각각 고유한 절충점이 있습니다.
1. 명시적 매개변수 전달
가장 직접적이지만 종종 장황한 방법은 컨텍스트 객체를 필요한 모든 함수 또는 메서드의 인수로 명시적으로 전달하는 것입니다.
원칙: 컨텍스트 객체가 함수 서명의 명시적인 부분이 됩니다.
예시 (Python - 단순화됨):
import uuid def process_request(trace_id, user_data): print(f"[{trace_id}] Starting request processing for {user_data}") db_result_1 = perform_db_query(trace_id, "query_A") db_result_2 = perform_external_call(trace_id, "service_B") return f"[{trace_id}] Processed: {db_result_1}, {db_result_2}" def perform_db_query(trace_id, query_string): print(f"[{trace_id}] Executing DB query: {query_string}") # Simulate DB operation return f"DB_Result_for_{query_string}" def perform_external_call(trace_id, service_name): print(f"[{trace_id}] Calling external service: {service_name}") # Simulate external API call return f"External_Result_from_{service_name}" # Incoming request incoming_trace_id = str(uuid.uuid4()) response = process_request(incoming_trace_id, {"username": "Alice"}) print(response)
장점:
- 매우 명시적이며 이해하기 쉽습니다.
- 숨겨진 마법이 없으며 데이터 흐름이 명확합니다.
단점:
- 특히 깊은 호출 스택에서 함수 서명의 "컨텍스트 오염"으로 이어질 수 있습니다.
- 컨텍스트 전달을 잊기 쉬워 버그가 발생할 수 있습니다.
- 컨텍스트 매개변수를 예상하지 않는 라이브러리 호출을 자동으로 처리하지 않습니다.
2. 스레드 로컬 스토리지(TLS)
TLS는 각 스레드가 변수의 복사본을 갖도록 허용합니다. 이는 "현재" 스레드에 특정된 컨텍스트를 저장하는 데 사용할 수 있습니다.
원칙: 컨텍스트는 스레드 로컬 변수에 저장되며 해당 스레드에서 실행되는 코드에서 필요할 때 검색됩니다.
예시 (Java):
import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Callable; public class ThreadLocalContext { private static final ThreadLocal<String> currentTraceId = new ThreadLocal<>(); public static void setTraceId(String traceId) { currentTraceId.set(traceId); } public static String getTraceId() { return currentTraceId.get(); } public static void clearTraceId() { currentTraceId.remove(); } public static void businessLogicA() { System.out.printf("[%s] Executing businessLogicA%n", getTraceId()); // ... } public static void main(String[] args) throws InterruptedException { String mainTraceId = UUID.randomUUID().toString(); setTraceId(mainTraceId); System.out.printf("[%s] Main thread started%n", getTraceId()); ExecutorService executor = Executors.newFixedThreadPool(2); // This task will run on a new thread from the pool executor.submit(() -> { // Here, getTraceId() would return null by default on a new thread // unless explicitly set or inherited. System.out.printf("[%s] Async task 1 started (expected null if not propagated)%n", getTraceId()); // How do we get mainTraceId here? // manual propagation needed: String current = getTraceId(); // null setTraceId("ASYNC-" + mainTraceId.substring(0, 8)); // New trace ID for async context System.out.printf("[%s] Async task 1 trace ID set%n", getTraceId()); clearTraceId(); // Clean up }); // Another direct thread use (similar issue) Thread t2 = new Thread(() -> { System.out.printf("[%s] Thread 2 started (expected null)%n", getTraceId()); setTraceId("THREAD2-" + mainTraceId.substring(0, 8)); System.out.printf("[%s] Thread 2 trace ID set%n", getTraceId()); clearTraceId(); }); t2.start(); t2.join(); // After async tasks, main thread's context should still be there System.out.printf("[%s] Main thread finished async calls%n", getTraceId()); clearTraceId(); executor.shutdown(); } }
장점:
- 매개변수 오염을 방지합니다. 코드는 암시적으로 컨텍스트에 액세스할 수 있습니다.
- 요청당 동기, 단일 스레드 실행의 경우 비교적 간단합니다.
단점:
- 비동기/멀티스레딩의 주요 단점: TLS 변수는 본질적으로 현재 스레드에 묶여 있습니다. 작업이 스레드를 전환할 때(예: 스레드 풀, async/await, 반응형 프로그래밍), 원래 스레드의 TLS에 저장된 컨텍스트는 새 스레드로 자동으로 전달되지 않습니다. 이는 명시적인 "상속" 메커니즘을 사용하지 않으면 컨텍스트 손실로 이어집니다.
- 스레드가 재사용되는 경우 메모리 누수 또는 컨텍스트 누출을 방지하기 위해 주의 깊은 정리(
remove()또는clear())가 필요합니다.
3. 컨텍스트 데이터 구조 (예: Go의 context.Context)
Go와 같은 언어는 전용 유형을 통해 컨텍스트 전파에 대한 1급 지원을 제공합니다. 이 패턴은 컨텍스트를 전달하는 명시적이지만 덜 장황한 방법을 권장합니다.
원칙: Context 객체가 호출 체인을 따라 전달됩니다. 불변이며 임의의 키-값 쌍을 전달할 수 있습니다. 기존 컨텍스트에서 새 컨텍스트를 파생하여 값을 상속하고 새 값을 추가할 수 있습니다.
예시 (Go):
package main import ( "context" "fmt" "time" ) // 충돌을 방지하기 위한 컨텍스트 키의 사용자 지정 유형 type traceIDKey string const keyTraceID traceIDKey = "traceID" func generateTraceID() string { return fmt.Sprintf("trace-%d", time.Now().UnixNano()) } func logWithContext(ctx context.Context, message string) { if traceID := ctx.Value(keyTraceID); traceID != nil { fmt.Printf("[%s] %s\n", traceID, message) } else { fmt.Printf("[No-Trace] %s\n", message) } } func dbOperation(ctx context.Context, query string) { logWithContext(ctx, fmt.Sprintf("Executing DB query: %s", query)) // Simulate async DB call time.Sleep(50 * time.Millisecond) logWithContext(ctx, fmt.Sprintf("DB query %s finished", query)) } func externalServiceCall(ctx context.Context, service string) { logWithContext(ctx, fmt.Sprintf("Calling external service: %s", service)) // Simulate async external call time.Sleep(100 * time.Millisecond) logWithContext(ctx, fmt.Sprintf("External service %s call finished", service)) } func processRequest(parentCtx context.Context, userData string) { ctx := context.WithValue(parentCtx, keyTraceID, generateTraceID()) // Add a new trace ID to context logWithContext(ctx, fmt.Sprintf("Starting request processing for %s", userData)) // Simulate concurrent operations using goroutines done := make(chan struct{}) go func() { defer func() { done <- struct{}{} }() dbOperation(ctx, "SELECT * FROM users") // Pass context to goroutine }() go func() { defer func() { done <- struct{}{} }() externalServiceCall(ctx, "UserService") // Pass context to goroutine }() // Wait for concurrent operations to complete <-done <-done logWithContext(ctx, "Request processing finished") } func main() { // Create a background context for the application's lifetime appCtx := context.Background() processRequest(appCtx, "Alice") time.Sleep(200 * time.Millisecond) // Give time for goroutines to finish fmt.Println("---") processRequest(appCtx, "Bob") }
장점:
WithValue패턴 덕분에 순수 명시적 전달보다 명확하고 읽기 쉽게 유지됩니다.- 동시성을 자연스럽게 처리합니다. 컨텍스트는 고루틴/함수의 인수입니다.
- 취소 및 데드라인을 지원하여 복잡한 흐름을 관리하는 데 강력합니다.
- Go에서 관례적으로 강제되며 관용적으로 사용됩니다.
단점:
- 언어 수준 지원 또는 라이브러리 채택이 필요합니다.
- 컨텍스트 객체를 전달해야 하지만, 많은 개별 매개변수보다 덜 침해적입니다.
- 주의하지 않으면 사용이 잘못되어 주의 깊게 사용하지 않을 경우 중첩된
context.WithValue호출로 이어질 수 있습니다.
4. 비동기 컨텍스트 라이브러리 / 구조화된 동시성 (예: Java의 StructuredTaskScope, Project Loom ScopedValue, Kotlin CoroutineContext, Python contextvars)
이러한 접근 방식은 실행 경계를 가로질러 컨텍스트를 자동으로 전달하여 비동기 및 멀티스레드 환경에 대한 TLS 문제를 해결하는 것을 목표로 합니다.
원칙: 이러한 라이브러리 또는 언어 기능은 현재 실행 컨텍스트를 캡처하고 이를 하위 작업, 스레드 풀의 스레드 또는 비동기 작업의 연속으로 자동으로 전파하는 메커니즘을 제공합니다.
예시 (Python contextvars):
import asyncio import contextvars import uuid # Define a ContextVar for our trace ID current_trace_id = contextvars.ContextVar('trace_id', default='no_trace_id') async def db_operation_async(query_string): trace_id = current_trace_id.get() # Get context automatically print(f"[{trace_id}] Async DB query: {query_string}") await asyncio.sleep(0.05) # Simulate async DB call print(f"[{trace_id}] Async DB query {query_string} finished") async def external_call_async(service_name): trace_id = current_trace_id.get() # Get context automatically print(f"[{trace_id}] Async external service: {service_name}") await asyncio.sleep(0.1) # Simulate async external call print(f"[{trace_id}] Async external service {service_name} call finished") async def process_request_async(user_data): # Set the trace ID for the current async task execution token = current_trace_id.set(str(uuid.uuid4())) trace_id = current_trace_id.get() print(f"[{trace_id}] Starting async request processing for {user_data}") # These async calls will automatically inherit the current_trace_id await asyncio.gather( db_operation_async("SELECT * FROM users_async"), external_call_async("AsyncUserService") ) print(f"[{trace_id}] Async request processing finished") current_trace_id.reset(token) # Clean up context async def main(): await process_request_async("Alice") print("---") await process_request_async("Bob") if __name__ == '__main__': asyncio.run(main())
예시 (Java - Project Loom ScopedValue - 개념적 이해를 위한 단순화):
import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import jdk.incubator.concurrent.ScopedValue; // Requires Java 21+ with Loom public class ScopedValueContext { // Define a ScopedValue private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance(); public static void dbOperation() { System.out.printf("[%s] Executing DB operation%n", TRACE_ID.get()); // Simulate DB call } public static void externalServiceCall() { System.out.printf("[%s] Calling external service%n", TRACE_ID.get()); // Simulate external API call } public static void main(String[] args) throws InterruptedException { ExecutorService executor = Executors.newFixedThreadPool(2); // Process request 1 String traceId1 = UUID.randomUUID().toString(); // Bind the ScopedValue for this task and any sub-tasks launched within it ScopedValue.where(TRACE_ID, traceId1).run(() -> { System.out.printf("[%s] Starting request 1%n", TRACE_ID.get()); executor.submit(() -> { // This callable will *automatically* inherit the context if Loom is used correctly dbOperation(); }); executor.submit(() -> { externalServiceCall(); }); // In a real Loom application, Virtual Threads would naturally inherit the context. // With traditional thread pools, careful wrapping/manual propagation might still be needed // if the executor doesn't integrate directly with ScopedValue. // structured concurrency (StructuredTaskScope) makes this much cleaner for pooled threads too. }); Thread.sleep(200); // Give tasks time to run // Process request 2 String traceId2 = UUID.randomUUID().toString(); ScopedValue.where(TRACE_ID, traceId2).run(() -> { System.out.printf("[%s] Starting request 2%n", TRACE_ID.get()); executor.submit(() -> dbOperation()); }); executor.shutdown(); executor.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS); } }
장점:
- 자동 전파: 가장 중요한 장점은 컨텍스트가 async/await 경계 또는 구조화된 동시성 범위(예: Java의
StructuredTaskScope, Python의contextvars) 내에서 시작된 새 스레드를 통해 자동으로 전파된다는 것입니다. 이는 수동 전달 또는 명시적 상속의 필요성을 크게 제거합니다. - 깔끔한 코드: 상용구 코드를 줄이고 가독성을 향상시킵니다.
- 오류 발생 가능성 적음: 컨텍스트 전달 누락 가능성을 줄입니다.
단점:
- 언어/런타임 지원 또는 성숙한 라이브러리 생태계가 필요합니다.
- 다양한 동시성 기본 요소와 상호 작용하는 방법을 이해하려면 학습 곡선이 있을 수 있습니다.
- 컨텍스트 전환 및 관리로 인해 직접 전달보다 오버헤드가 약간 높을 수 있지만, 대부분의 애플리케이션에서는 사소한 경우가 많습니다.
모범 사례 및 권장 사항
-
언어/프레임워크에 맞는 올바른 도구 선택:
- Go:
context.Context를 항상 사용하세요. 관용적이고 강력합니다. - Python: 비동기 코드를 위해
contextvars를 활용하세요. 멀티스레드 코드의 경우contextvars와 스레드 풀이 상호 작용하는 방식에 주의하세요.opentelemetry-python과 같은 라이브러리는 이를 잘 처리합니다. - Java: 기존 멀티스레딩의 경우 컨텍스트를 명시적으로 복사하는 사용자 지정 실행자 래퍼와 함께
ThreadLocal을 향상시키세요. 최신 Java(21+)의 경우 구조화된 동시성 및 자동 컨텍스트 전파를 위해StructuredTaskScope를 사용하는ScopedValue가 권장되는 경로입니다. 반응형 프레임워크는 종종 자체 컨텍스트 메커니즘을 가지고 있습니다(예: Reactor의Context).
- Go:
-
컨텍스트 정리: TLS 또는 명시적인 설정/해체가 필요한 메커니즘을 사용할 때는 항상 요청 또는 작업이 끝날 때 컨텍스트가 정리되도록 하세요. 이렇게 하면 재사용 스레드 간의 컨텍스트 누수를 방지하고 메모리 누수가 발생하지 않습니다.
-
가장자리에서 컨텍스트 시작: 가능한 한 빨리 요청 라이프사이클에서(일반적으로 API 게이트웨이 또는 서비스 진입점) 컨텍스트(특히
Trace ID)를 도입하세요. -
**컨텍스트 점진적으로 보강:
정보(예: 사용자 세부 정보, 테넌트 ID)를 컨텍스트에 추가하세요.처리 중에 사용 가능해짐에 따라.
-
컨텍스트 키 표준화: 일반 컨텍스트 맵을 사용하는 경우 코드베이스 및 서비스 전반에 걸쳐 일관성을 보장하기 위해 표준 키를 정의하고 문서화하세요.
-
관찰 가능성 도구와 통합: 컨텍스트 전파 전략이 분산 추적 시스템(예: OpenTelemetry, Zipkin, Jaeger)과 일치하는지 확인하세요. 이러한 시스템은 종종 설명된 컨텍스트 전파 메커니즘과 원활하게 통합되는 라이브러리를 제공합니다.
결론
비동기 및 멀티스레드 백엔드 환경에서 요청 컨텍스트를 안전하고 안정적으로 전달하는 것은 단순히 좋은 관행이 아니라 관찰 가능하고 유지 관리 가능한 디버깅 가능한 시스템을 구축하는 기본 요구 사항입니다. 명시적 매개변수 전달은 단순성을 제공하지만, Go의 context.Context 또는 Python의 contextvars와 같은 구조화된 동시성 및 비동기 컨텍스트 관리를 전담하는 현대 프로그래밍 패러다임과 라이브러리는 훨씬 더 강력하고 덜 침해적인 솔루션을 제공합니다. 기술 스택에 적합한 컨텍스트 전파 전략을 채택함으로써 백엔드 서비스가 각 요청의 전체 여정을 이해하도록 지원하여 복잡한 시스템 상호 작용을 투명하고 추적 가능한 작업으로 전환할 수 있습니다. 컨텍스트 전파를 올바르게 수행하는 것은 분산 시스템의 미로를 요청을 위한 명확하게 매핑된 여정으로 바꾸는 것을 의미합니다.

