클라우드 Mac에서 검증하는 Swift TaskLocal 전파와 작업 격리
하나의 요청이 다운로드, 파싱, 디스크 저장이라는 세 가지 비동기 단계를 거치는 동안 로그의 요청 식별자가 나타났다 사라지기를 반복합니다. 식별자를 전역 변수로 옮기면 이번에는 병렬 테스트에서 서로 다른 요청의 값이 뒤섞입니다. 클라우드 Mac에서 이런 문제를 조사할 때는 스레드 수부터 조정하지 마세요. 먼저 검증해야 할 것은 컨텍스트가 어떤 작업으로 전파되고, 어느 경계에서 끝나는지입니다.
이 글에서는 독립적으로 실행할 수 있는 Swift 패키지로 TaskLocal을 검증합니다. 실제 서비스에 연결하지 않으며 시뮬레이터도 필요하지 않습니다. 확인하려는 것은 작업의 의미론이지, 작업이 어느 스레드에서 실행되는지가 아닙니다. 같은 작업이 실행 스레드를 바꾸더라도 작업 로컬 값이 사라져서는 안 됩니다.
먼저 전파 규칙을 명확히 정의하기
요청 식별자는 옵셔널 문자열로 정의하고 기본값은 nil로 둡니다. 요청 컨텍스트가 없을 때 그럴듯한 식별자를 자동으로 생성하는 것보다 기본값을 유지하는 편이 전파 누락을 발견하기 쉽습니다.
규칙은 다섯 가지입니다. 바인딩 범위 안에서는 값을 읽을 수 있어야 합니다. 중첩 범위에서는 값을 일시적으로 덮어쓸 수 있어야 합니다. 범위를 벗어나면 바깥쪽 값으로 복원되어야 합니다. 일반 자식 작업과 작업 그룹의 자식 작업은 생성 시점의 컨텍스트를 상속해야 합니다. 분리 작업은 기본적으로 상속하지 않아야 합니다. 오류가 발생해도 바깥쪽 상태는 복원되어야 합니다.
TaskLocal은 스레드 로컬 저장소도, 전역 변수를 안전하게 감싼 래퍼도 아닙니다. 전파되는 것은 작업 컨텍스트이지, 특정 스레드에 마지막으로 기록된 값이 아닙니다.
값은 되도록 작고 불변이어야 합니다. 이 글에서는 문자열만 전달하며, 변경 가능한 딕셔너리나 세션 객체, 데이터베이스 연결은 전달하지 않습니다. 참조 타입 객체를 넣는다고 해서 그 객체의 동시성 안전성이 자동으로 보장되지는 않습니다.
독립 실행 가능한 테스트 패키지 만들기
클라우드 Mac의 일반 작업 디렉터리에서 다음 명령을 실행합니다. 먼저 툴체인 버전을 기록해 두면 나중에 실패 보고서를 볼 때 코드 변경과 컴파일러 변경을 구분할 수 있습니다.
mkdir TraceScopeLab
cd TraceScopeLab
swift --version
swift package init --type library --name TraceScopeLab
mkdir -p Tests/TraceScopeLabTests
테스트 디렉터리에 ContextTests.swift를 만들고 아래 코드를 모두 작성합니다. 기본으로 생성된 테스트 파일은 그대로 두어도 됩니다. 두 파일은 서로 의존하지 않습니다. 예제는 툴체인이 제공하는 테스트 프레임워크를 그대로 사용하며 별도의 의존성이 없습니다.
import XCTest
private enum TraceContext {
@TaskLocal static var requestID: String?
}
private enum ProbeError: Error {
case expected
}
final class ContextTests: XCTestCase {
func testNestedScopeRestoresAfterThrow() {
XCTAssertNil(TraceContext.requestID)
TraceContext.$requestID.withValue("outer") {
XCTAssertEqual(TraceContext.requestID, "outer")
XCTAssertThrowsError(
try TraceContext.$requestID.withValue("inner") {
XCTAssertEqual(TraceContext.requestID, "inner")
throw ProbeError.expected
}
)
XCTAssertEqual(TraceContext.requestID, "outer")
}
XCTAssertNil(TraceContext.requestID)
}
func testTaskBoundaries() async {
let observed = await TraceContext.$requestID
.withValue("request-a") {
let child = Task {
TraceContext.requestID
}
let detached = Task.detached {
TraceContext.requestID
}
let inherited = await child.value
let absent = await detached.value
let groupValue = await withTaskGroup(
of: String?.self,
returning: String?.self
) { group in
group.addTask {
TraceContext.requestID
}
var result: String?
for await value in group {
result = value
}
return result
}
return (inherited, absent, groupValue)
}
XCTAssertEqual(observed.0, "request-a")
XCTAssertNil(observed.1)
XCTAssertEqual(observed.2, "request-a")
XCTAssertNil(TraceContext.requestID)
}
}
여기서는 먼저 await로 결과를 받은 뒤 검증 함수에 전달합니다. 동기식 검증 표현식 안에 비동기 읽기를 넣지 않기 위해서입니다. 생성한 모든 작업의 완료를 기다리므로 테스트가 끝난 뒤에도 작업이 백그라운드에 남아 실행되는 일이 없습니다.
분리 작업의 컨텍스트는 명시적으로 선택하기
Task {}는 작업 로컬 값을 상속하지만 비구조적 작업이므로 수명 주기가 생성자의 범위에 자동으로 묶이지는 않습니다. 컨텍스트를 상속하는 것과 작업이 끝날 때까지 자동으로 기다리는 것은 별개의 문제입니다.
따라서 부모 범위가 끝난 뒤에도 앞서 생성한 작업이 원래 식별자를 가지고 있을 수 있습니다. 이는 복원 실패가 아닙니다. 요청에 속한 작업에는 구조적 동시성을 우선 사용하세요. 비구조적 작업이 꼭 필요하다면 핸들을 보관하고 명시적으로 완료를 기다리거나 취소해야 합니다.
독립적인 백그라운드 작업이라면 기본적으로 요청 컨텍스트를 갖지 않는 편이 오히려 명확합니다. 추적을 위해 연결이 꼭 필요하다면 전파를 허용할 필드를 먼저 캡처한 뒤 분리 작업 안에서 바인딩합니다.
let capturedID = TraceContext.requestID
let worker = Task.detached {
TraceContext.$requestID.withValue(capturedID) {
TraceContext.requestID
}
}
let observedID = await worker.value
이 코드는 앞서 만든 테스트 파일의 비동기 테스트 메서드 안에 넣어야 합니다. 캡처는 작업을 생성하는 쪽에서, 바인딩은 새 작업 안에서 수행합니다. 분리 작업에 들어간 뒤에야 이전 컨텍스트를 읽으려 해서는 안 됩니다. 주기적 정리처럼 요청과 무관한 작업은 nil을 유지해야 합니다. 그렇지 않으면 해당 작업의 로그 전체가 특정 사용자 요청에 잘못 연결될 수 있습니다.
교차 실행 상황의 격리 검증 추가하기
요청 하나가 테스트를 통과하는 것만으로는 충분하지 않습니다. 비동기 테스트 메서드를 하나 더 추가해 두 작업을 동시에 만들고, 각각 request-a와 request-b를 바인딩합니다. 각 작업은 바인딩 범위 안에서 await Task.yield()를 호출하고, 실행이 재개되면 현재 식별자를 반환하게 합니다. 마지막으로 각 결과를 하나씩 검증합니다.
두 작업이 정해진 순서로 완료되도록 요구해서는 안 됩니다. Task.yield()는 실행 기회를 양보하겠다는 힌트일 뿐, 교차 실행 순서를 보장하지 않습니다. 검증 목표는 “어느 작업이 먼저 끝나든 각자의 값이 정확하다”여야 합니다. “첫 번째 로그는 반드시 A에서 나온다”가 되어서는 안 됩니다.
두 비동기 범위가 모두 끝난 뒤에는 기본값이 여전히 nil인지도 확인합니다. 결과를 모으는 데 공유 배열을 사용하거나 순서를 맞추려고 전역 카운터를 사용하지 마세요. 테스트 자체가 경쟁 상태를 만들 수 있습니다.
원인을 찾을 수 있는 실패 보고서 만들기
대상 테스트를 먼저 실행한 뒤 병렬 실행을 켜서 다시 확인합니다. 두 번째 실행이 명시적인 격리 검증을 대신할 수는 없지만, 테스트 사이의 공유 상태로 인한 오염을 드러내는 데 도움이 됩니다.
swift test --filter ContextTests
swift test --parallel --filter ContextTests
일반 자식 작업에서 nil을 읽는다면 해당 작업이 실제로 withValue 안에서 생성되었는지 먼저 확인합니다. 분리 작업에서 식별자를 읽는다면 명시적인 바인딩이나 추가 래퍼 계층이 있는지 살펴봅니다. 중첩 범위를 빠져나온 뒤 값이 복원되지 않는다면 읽고 있는 대상이 이름이 같은 전역 프로퍼티가 아니라 실제 TaskLocal인지 확인합니다.
실패 보고서에는 툴체인 버전, 테스트 케이스 이름, 기댓값, 실제 값만 남기면 됩니다. 운영 로그에 있는 실제 요청 식별자를 테스트 픽스처에 복사할 필요는 없습니다.
마지막으로 추적과 권한 부여는 분리해야 합니다. 컨텍스트는 하나의 작업 흐름을 연결하는 데 도움을 주지만, 호출자에게 해당 동작을 수행할 권한이 있음을 증명하지는 못합니다. 작은 값은 명확한 작업 경계를 따라 전파하고, 권한은 명시적인 입력으로 받아 검증해야 병렬 테스트의 결론을 장기적으로 신뢰할 수 있습니다.
자주 묻는 질문
Task.detached도 TaskLocal 값을 상속하나요?
상속하지 않습니다. 필요한 식별자를 생성 측에서 읽은 뒤 분리 작업 내부의 withValue에 명시적으로 전달해야 합니다.
TaskLocal만으로 사용자 권한을 판단해도 되나요?
안 됩니다. 추적 정보를 전달하는 용도로 사용하고, 민감한 작업의 권한 정보는 명시적으로 전달하여 검증해야 합니다.
다음 빌드는 단독 클라우드 Mac에서
MacFTP는 기본 macOS를 실행하는 Apple Silicon 전용 물리 서버를 제공합니다. 일·주·월·분기 단위로 이용 기간을 선택하세요. 먼저 구성을 확인한 뒤 기존 도구 체인을 노드에 연결하면 됩니다. 실제 사용 가능 여부는 콘솔에 표시되는 실시간 상태를 기준으로 확인하세요.