OTCA — 오픈텔레메트리 인증 어소시에이트 · 컨텍스트 경계: 옆 주문의 태그 찾기 · 이론
같은 함수인데 옆 주문의 태그가 따라왔다
한 줄 요약
컨텍스트 문제는 값이 있느냐뿐 아니라 언제 복사했고 어디에서 복구했느냐의 문제입니다.
Task, 지연된 코루틴, 일반 스레드 풀을 같은 실행 경계로 취급하지 않으면, 요청이
섞이는 원인을 코드 몇 줄의 관측으로 좁힐 수 있습니다.
왜 이게 필요했나
주문 두 건을 동시에 처리하는 서비스가 있습니다. 주문 A를 받으면 request=order-a를
설정하고 재고 확인을 예약합니다. 곧이어 호출자의 값이 다른 요청으로 바뀝니다.
그런데 재고 확인 로그에는 A가 아니라 나중 요청의 태그가 찍힙니다. 함수 인자도
정상이고 프로세스도 하나라서 처음에는 로깅 서버의 정렬 오류처럼 보입니다.
이번 실습은 이 상황을 외부 서버 없이 재현합니다. Python 3.12와 OpenTelemetry
SDK 1.44.0이 설치된 환경에서 실제 asyncio와 ThreadPoolExecutor를 실행합니다.
가짜 로그 파일에 정답 문장을 써 넣는 방식이 아닙니다. 학생 함수가 콜백을 실행한
순간의 baggage와 함수가 돌아온 뒤 호출자의 baggage를 각각 읽어 비교합니다.
앞 모듈에서 “스팬이 수신됐는가”를 물었다면 여기서는 “그 작업이 누구의 문맥으로
실행됐는가”를 묻습니다. 관측 데이터가 많이 도착해도 서로 다른 요청을 잘못 연결하면
장애 원인 분석은 틀립니다. 수집량을 늘리기 전에 연결의 의미를 확인해야 하는 이유입니다.
어떻게 동작하나
값을 만드는 것과 현재 값으로 붙이는 것
OpenTelemetry의 baggage.set_baggage는 값을 담은 Context를 반환합니다. 현재 실행에
그 값을 적용하는 attach와 같은 동작이 아닙니다. attach가 반환한 token으로 detach하면
들어오기 전 문맥으로 돌아갑니다. [Python Context API](https://opentelemetry-python.readthedocs.io/en/latest/api/context.html)와
[Baggage API](https://opentelemetry-python.readthedocs.io/en/latest/api/baggage.html)의 계약을
구별해서 읽으세요. Context를 만들었다는 사실만으로 콜백이 그 값을 읽는다고 추측하지 않습니다.
실습 첫 단계는 콜백에 값을 보여 주면서 원래 호출자를 보존하는 작은 범위입니다.
호출자의 값은 caller, 안쪽 값은 order-a입니다. 콜백이 정상 반환한 경우와 ValueError를
던진 경우를 따로 실행합니다. 안쪽 값이 맞아도 바깥이 order-a로 남으면 절반만 맞은
코드입니다. 반환값을 버리거나 예외를 새 것으로 바꿔도 업무 코드의 계약을 깨뜨립니다.
복구를 성공 경로 맨 끝에만 놓으면 중간 예외가 그 줄을 건너뜁니다. 따라서 실습에서는
정상 경로·예외 경로 모두에서 원래 값이 관측되어야 합니다. 한 요청만 실행하고
프로세스를 종료하는 시험으로는 이 결함을 찾기 어렵습니다. 값이 남은 뒤에 무엇이
실행되는지까지 보는 시험이 필요합니다.
세 개의 시점을 적어 보자
실습의 사전 실험은 먼저 alpha를 붙이고 일을 만들었습니다. 다음으로 호출자를 beta로
바꾼 뒤 그 일을 기다렸습니다. 실행 순서는 같아 보여도 어떤 API로 일을 만들었는지에
따라 워커가 읽은 값은 달랐습니다.
| alpha에서 한 일 | beta로 바꾼 뒤 기다렸을 때의 실측 |
| --- | --- |
| create_task(coro) | Task가 alpha를 읽음 |
| to_thread(function) 코루틴만 만듦 | 스레드가 beta를 읽음 |
| create_task(to_thread(function)) | 스레드가 alpha를 읽음 |
이 표는 이 모듈의 Python 3.12 실험 결과입니다. 세 줄 모두 “나중에 실행되는 일”이지만
컨텍스트를 잡는 시점은 다릅니다. 두 번째 줄은 코루틴 객체를 만들었을 뿐 그 본문을
아직 실행하지 않았다는 점이 중요합니다. 세 번째 줄은 그 코루틴을 현재 문맥의 Task로
예약하여 호출자 변경과 분리했습니다.
Python 문서의 create_task는 기본적으로 현재 Context의 복사본을 사용하며, 코루틴을
호출하는 것만으로 실행이 예약되지는 않는다고 설명합니다. 취소 정리도 try/finally와
연결해 설명합니다. [Python 3.12 asyncio](https://docs.python.org/3.12/library/asyncio-task.html).
이 계약을 위의 실제 값과 대조하면 “async면 자동으로 된다”나 “스레드면 무조건 사라진다”
같은 구호보다 정확하게 문제를 설명할 수 있습니다.
실습의 start_task와 start_thread는 예약 당시의 요청을 보존하는 함수입니다.
워커가 올바른 값을 보도록 호출자의 현재 값을 영구히 되돌려 놓는 것은 정답이 아닙니다.
호출자는 계속 later-caller여야 합니다. 양쪽을 함께 관찰해야 전파와 누출을 구별합니다.
예약한 Task는 반환하여 호출자가 기다리고 취소할 수 있게 합니다. 이 연습을 결과도
참조도 버리는 백그라운드 작업 패턴으로 확대하지 마세요.
일반 executor는 별도의 실험이다
사전 실험의 일반 ThreadPoolExecutor.submit은 alpha를 자동으로 전달하지 않았습니다.
제출하는 쪽에서 contextvars.copy_context로 복사하고 그 복사본의 run 안에서 함수를
실행하자 alpha가 관측됐습니다. 이어서 같은 워커에 평범한 함수를 제출했을 때는 값이
없었습니다. “한 번 전달됐다”와 “다음 작업에 남지 않았다”를 따로 확인한 것입니다.
복사를 워커 함수 안에서 하면 이미 경계를 넘은 뒤의 문맥을 복사하게 됩니다.
학생 코드에는 copy_context라는 이름이 있어도 실행 순서가 틀리면 실패합니다.
채점기는 단어가 있는지 찾지 않고 실제 반환값을 비교합니다. 제출마다 별도 복사본을
만들어 재사용 워커의 상태를 보존하는 것이 이 단계의 과제입니다. 새로운 스레드를
매번 만들면 재사용 시의 누출 문제를 가릴 수 있어 같은 워커로 두 주문을 검사합니다.
취소도 요청의 끝이다
고객이 연결을 끊거나 상위 작업이 취소되면 콜백은 정상 반환하지 않을 수 있습니다.
5단계에서는 두 요청을 Event로 같은 지점에 모았다가 계속 실행하여, 서로 겹친 구간의
값을 확인합니다. 이어서 예외와 취소를 발생시키고 복구를 확인합니다. 마지막에는
실제로 대기 중인 Task에 cancel을 호출합니다. CancelledError를 직접 던진 경우만
통과했다고 해서 외부 취소 경로도 검증했다고 말하지 않기 위해서입니다.
취소를 잡고 평범한 성공처럼 돌려주면 안쪽 값은 복구됐어도 호출자는 취소 사실을
모르게 됩니다. 이 실습의 계약은 값을 복구하면서 예외와 취소를 호출자에게 전달하는
것입니다. 정리와 오류 은폐는 같은 일이 아닙니다.
현장에서 만나는 모습
다음은 이 실습에서 연습할 조사 순서입니다. 먼저 요청을 둘로 나누고 서로 다른 합성
식별자를 씁니다. 다음으로 호출 직전·예약 직후·콜백 내부·반환 뒤 값을 기록합니다.
정상 경로가 맞으면 예외와 취소를 넣습니다. 스레드 풀이 있다면 같은 워커를 다시
사용합니다. 결과에 실행 환경과 API 이름도 남겨 다음 사람이 다른 런타임의 동작으로
오해하지 않게 합니다.
이 시험은 실제 개인정보나 운영 토큰을 필요로 하지 않습니다. order-a와 order-b만으로
전파 시점과 누출을 구별할 수 있습니다. 값이 다르다는 것만 필요할 때 굳이 실제 고객
식별자를 사용하면 조사 자료 자체가 새 관리 대상이 됩니다.
다음 실습에서 할 것
1~5단계에서 작은 범위 복구, Task 예약, 스레드 예약, executor 제출, 겹친 요청의
취소 정리를 차례로 고칩니다. 각 실행의 observations와 checks를 보고 어떤 경계가
틀렸는지 설명해 보세요. 다음 이론에서는 값이 정확히 전달됐다는 사실과 그 값을
신뢰해도 된다는 사실을 분리합니다. 전달 성공은 인증 성공이 아닙니다.