context.Context는 계층적 트리를 통해 취소를 전파하며, 각 파생 노드는 내장된 cancelCtx 또는 valueCtx 구조체를 통해 부모에 대한 참조를 유지합니다. 이 트리 구조는 양방향 추적을 가능하게 합니다: 부모는 뮤텍스 보호 맵을 통해 자식을 알며, 자식은 직접 포인터 참조를 통해 부모를 알 수 있습니다. 취소가 발생할 때, 이 설계는 전역 조정 없이 루트에서 잎사귀까지 즉각적인 탐색을 허용합니다.
**cancel()**이 부모 노드에서 호출되면, 부모는 children 맵을 보호하기 위해 뮤텍스를 획득하고, 등록된 모든 자식 컨텍스트를 순회하며 각자의 cancel 클로저를 재귀적으로 호출합니다. 각 자식의 cancel 함수는 전용 done 채널을 닫고(컨텍스트가 취소되지 않았던 경우를 최적화하기 위해 sync.Once를 통해 할당됨) 부모의 children 맵에서 자신을 제거하여 가비지 수집을 방지하는 참조를 없애줍니다. 이 메커니즘은 취소 신호가 전체 서브트리를 통해 즉각적으로 전파되도록 하며 자원 누수를 방지합니다.
타임아웃 기반의 취소를 위해, timerCtx는 정해진 시간이 만료되면 cancel 클로저를 자동으로 호출하는 time.Timer를 포함합니다. 중요한 것은, 부모가 타이머가 작동하기 전에 취소하는 경우, 자식의 cancel 함수가 **Stop()**을 통해 타이머를 명시적으로 중지하고 필요시 채널을 비워서, 이미 컨텍스트가 취소된 후에도 타이머 고루틴이 런타임에 남아 자원을 소모하지 않도록 합니다.
사용자 요청을 처리하는 고성능 Go 마이크로서비스를 생각해 보세요. 이 요청은 세 개의 다운스트림 서비스: 주요 PostgreSQL 데이터베이스, Redis 캐시 및 제3자 REST API로 분산됩니다. 각 요청은 모든 세 출처에 대해 쿼리를 실행하여 응답을 집계해야 하며, p99 지연 시간은 500 밀리초 미만으로 예산이 책정됩니다. 이 서비스는 수천 개의 동시 연결을 처리하므로 자원 관리가 안정성을 위해 매우 중요합니다.
문제 설명:
부하가 많이 걸릴 경우, 클라이언트는 요청을 제출한 후 자주 연결이 끊기는데(타임아웃 또는 연결 종료), goroutines는 여전히 데이터베이스에 대해 전체 쿼리를 처리하고 느린 외부 API를 기다리며 연결 풀과 CPU를 소모합니다. 결과가 무가치함에도 불구하고 말이죠. 수동 취소는 수십 개의 함수 호출을 통해 불리언 플래그를 전달해야 하며, 이는 취약하고 오류가 발생하기 쉽습니다. 또한, 적절한 전파가 없으면 이러한 버려진 요청을 처리하는 고루틴이 무한정 쌓일 수 있으며, 결국 호스트 서버에서 OOM(Out Of Memory) 상태나 파일 설명자 고갈로 이어질 수 있습니다.
고려된 다양한 해결책:
원자 플래그를 통한 수동 전파: 매 함수 시그니처에 atomic.Bool 포인터를 전달하고 주기적으로 루프에서 검사하는 방법을 고려했습니다. 이 접근 방식은 제로 추상화 오버헤드를 제공하고 취소 지점에 대한 명시적 제어를 줍니다. 그러나 블로킹 시스템 호출인 TCP 읽기를 중단할 수 없고, 모든 라이브러리 함수에 대한 침습적인 코드 변경이 필요하며, 타임아웃이나 마감 기한에 대한 표준화가 부족합니다.
명시적인 종료 채널을 통한 고루틴 농장: 각 다운스트림 작업을 별도의 goroutine에서 실행하고 사용자 정의 종료 채널에 대한 select 블록을 사용하여 취소 요청 시 조기 반환이 가능하게 합니다. 이 접근 방식은 비차단 취소 지점을 제공하고 각 작업에 대한 모듈화된 타임아웃 처리를 제공합니다. 그러나 요청당 **O(n)**의 고루틴을 생성하며, 여기서 n은 작업 수이고, 상당한 스케줄링 오버헤드를 발생시키며, 채널이나 취소 상태를 확인하지 않는 제3자 라이브러리 안에서 취소를 강제로 수행할 수 없습니다.
표준 컨텍스트 트리 전파: **http.Request.Context()**를 루트로 사용하고 각 다운스트림 호출에 대해 context.WithTimeout을 통해 자식 컨텍스트를 유도함으로써 표준 라이브러리에서의 기본 제공 취소 지원을 활용합니다. 이 방법은 거래 스택 전체에서 마감 기한의 자동 전파를 제공하며 작업당 고루틴 오버헤드를 발생시키지 않고 타이머 정리를 자동으로 처리합니다. 그러나 타이머 자원이 누수되는 것을 방지하기 위해 WithTimeout에 의해 반환된 취소 함수를 항상 호출하는 것과 같은 올바른 API 사용을 엄격하게 준수해야 합니다.
선택된 해결책과 결과:
우리는 표준 컨텍스트 트리 전파를 선택했으며, 각 HTTP 핸들러가 30초 타임아웃의 요청 범위 컨텍스트를 유도하고 개별 데이터베이스 쿼리가 **context.WithTimeout(reqCtx, 2*time.Second)**를 사용하여 더 엄격한 하위 마감 기한을 적용합니다. 클라이언트가 연결을 끊으면 HTTP 서버는 루트 컨텍스트를 취소하고, 이로 인해 트리를 탐색하며 sql 드라이버의 네트워크 호출을 즉시 차단 해제하여 연결을 해제합니다. 10,000개의 동시 요청 및 30% 클라이언트 드롭과 함께 부하 테스트를 수행했을 때, 연결 풀 소모 사건이 95% 감소하였고, 활성 요청에 대한 p99 지연 시간이 자원 경쟁 감소로 인해 상당히 개선되었습니다.
왜 취소된 자식 컨텍스트가 부모의 children 맵에서 명시적으로 제거되어 메모리 누수를 방지해야 하나요?
많은 사람들은 부모가 자신이 파괴될 때까지 자식을 유지한다고 가정합니다. 그러나 실제로는 **cancelCtx.cancel()**이 실행될 때(부모 전파 또는 지역 타임아웃에 관계없이), 부모의 뮤텍스를 획득하고 children 맵에서 스스로를 삭제합니다. 이 제거가 발생하지 않으면 장기간 생존하는 부모 컨텍스트(예: 백그라운드 서버 컨텍스트)가 생성된 모든 임시 요청 컨텍스트의 항목을 쌓게 되어 완료된 요청 메모리에 대한 가비지 수집을 방지하고 한계 없는 힙 성장을 초래하게 됩니다.
context.WithValue는 어떻게 O(1) 키당 공간을 달성하면서 O(k) 조회 시간을 유지합니까? 여기서 k는 트리 깊이입니다. 왜 맵을 사용하지 않나요?
후보자들은 종종 각 WithValue 호출 때마다 맵을 복사하는 것을 제안합니다(이는 맵 크기에 대해 **O(n)**이 됩니다) 혹은 전역 동기화된 맵을 사용하는 것을 제안합니다(경쟁 문제). 실제 구현은 연결 리스트를 사용합니다: 각 valueCtx는 키, 값, 부모 포인터를 포함합니다. **Value()**는 키를 비교하며 위로 올라갑니다. 컨텍스트 트리는 깊이가 5-10 레벨을 넘지 않기 때문에(요청 → 핸들러 → 서비스 → DB → tx), 이는 사실상 상수 시간입니다. 각 컨텍스트에 맵을 사용하면 복사(비용 발생) 또는 변경 가능성(동시 읽기 위해 안전하지 않음)이 필요합니다.
context.Context 인터페이스 변수에 nil을 저장하는 것의 특정 위험은 무엇이며, 왜 context.Background()는 nil 대신 널이 아닌 빈 구조체를 반환하나요?
var c context.Context = nil은 유효하지만, 이를 취소 가능한 컨텍스트를 기대하는 함수에 전달하면 nil 인터페이스에서 메서드를 호출할 때 패닉이 발생합니다. **Background()**는 메서드 호출이 항상 성공하도록 보장하는 인터페이스를 구현하는 널이 아닌 빈 구조체 backgroundCtx{}를 반환하여 컨텍스트 트리의 안정적인 루트를 제공합니다. 이를 통해 "nil 인터페이스 vs nil 구체적" 혼동(타입이 없는 nil 포인터가 != nil 체크를 만족하지만 메서드 호출에서 패닉이 발생하는 경우)을 피하고, 컨텍스트 값이 결코 nil이 되지 않도록 보장하며, 오직 부모 포인터만 논리적으로 nil일 수 있습니다.