에이전트가 침묵시킨 데이터 레이스(Data Race)를 잡아내는 테스트
요약
AI 에이전트가 `@unchecked Sendable`을 사용하여 데이터 레이스(Data Race)를 은폐했을 때, 이를 탐지하기 위한 테스트 전략을 다룹니다. 단순한 해피 패스 테스트가 아닌, 동시성 상황을 강제로 만들어 레이스를 유발하는 테스트 설계의 중요성을 설명합니다.
핵심 포인트
- 에이전트의 잘못된 수정은 빌드와 테스트를 통과시키면서도 데이터 레이스를 남길 수 있음
- 단순 값 검증 테스트는 동시성 버그를 잡아내지 못하는 '침묵된 레이스'를 유발함
- 레이스를 탐지하려면 여러 스레드가 동시에 값을 수정하는 환경을 테스트에 구현해야 함
- 동시성 프로그래밍에서 진정한 검증은 레이스가 발생하는 상황을 정확히 재현하는 것임
지난주 저는 에이전트가 단 한 단어로 Swift 6의 데이터 레이스(Data Race)를 사라지게 만드는 모습을 보여드렸습니다. 에이전트는 struct에 @unchecked Sendable을 표시했고, 빌드는 통과(green)되었으며, 모든 테스트 역시 여전히 통과되었습니다. 하지만 레이스는 이전과 똑같이 존재하고 있었습니다. 그 포스트는 제가 명확한 답을 내놓지 못했던 질문으로 끝났습니다. 빌드와 테스트가 모두 침묵된 레이스(silenced race)에 대해 통과(green) 신호를 보낼 때, 루프 내의 무엇이 그것을 잡아내야 하는가 하는 질문이었습니다.
빨간색 빌드(red build)는 신뢰할 수 있는 신호입니다. 반면 침묵된 레이스는 신뢰할 수 없는 초록색 빌드를 제공하며, 배포를 막는 유일한 장애물은 diff를 읽고 있는 당신 자신뿐입니다. 저는 이번 주에 다시 빨간색 신호를 띄울 수 있는 테스트를 만드는 데 시간을 보냈습니다. 여기 작동하는 테스트와 그 한계점에 대한 솔직한 내용이 있습니다.
지난번 설정: 동시성 코드(concurrent code)로 넘어가는 값 타입(value type)이며, 에이전트가 더 이상 컴파일러가 불평하지 않기를 바랐던 가변 클래스(mutable class)를 포함하고 있습니다.
public final class AuditPen {
public var ink: Int
public init(ink: Int) { self.ink = ink }
...
다음은 동시성에 대해 전혀 생각하지 않고 사람이 작성하는 테스트입니다:
@Test func happyPath() {
let t = Transfer(amount: 100, memo: "rent", pen: AuditPen(ink: 0))
#expect(t.amount == 100)
...
빌드됩니다. 통과합니다. 침묵된 버전에서도 영원히 통과할 것입니다. 왜냐하면 단 한 번도 두 가지 일을 동시에 실행하지 않기 때문입니다. 단언(assertion)은 금액(amount)에 관한 것이고, 버그는 pen에 관한 것입니다. 둘은 결코 만나지 않습니다. 이것이 침묵된 레이스 옆에 놓인 거의 모든 테스트의 형태입니다. 해피 패스(happy path)에서 값을 확인하지만, 레이스가 발생하려면 무언가 잘못되기 전에 보드 위에 두 번째 작성자(writer)가 필요합니다.
따라서 테스트 설정 자체에 두 번째 작성자를 배치하고, 레이스가 망가뜨리는 요소를 단언하십시오. 테스트의 역할은 Sendable 약속이 다루고자 했던 바로 그 상황, 즉 동시에 여러 태스크(task)가 pen을 작성하는 상황을 정확히 만들어내는 것입니다.
@Test func concurrentBumpsMustNotLoseUpdates() {
let n = 1000
let t = Transfer(amount: 100, memo: "rent", pen: AuditPen(ink: 0))
...
concurrentPerform은 여러 스레드에 걸쳐 블록을 실행합니다. 각 스레드는 ink를 읽고, 1을 더한 뒤, 다시 기록하지만, 이들을 조정하는 장치는 아무것도 없습니다. 두 스레드가 동일한 시작 값에 도달하면, 업데이트 중 하나가 유실됩니다. 침묵된 @unchecked Sendable 버전에서는 제가 실행할 때마다 매번 이 테스트가 실패합니다. 카운트가 부족하게 나오는데, 때로는 몇 개가, 때로는 수백 개가 부족하며, 목표 값과 정확히 일치하는 경우는 결코 없습니다. 유실되는 양은 실행할 때마다 요동칩니다. 하지만 실패 자체는 요동치지 않습니다.
이것이 단순하게 동시성 코드(concurrent code)를 나쁘게 보이게 만드는 방법이 아니라, 왜 진정한 검증(check)이 되는지를 보여주는 지점입니다. 타입을 제대로 수정하면 동일한 테스트가 통과(green)되고, 계속해서 통과 상태를 유지합니다.
public final class AuditPen: @unchecked Sendable {
private let lock = NSLock()
private var _ink: Int
...
이제 @unchecked Sendable은 동기화(synchronization)가 실제로 존재하는 곳으로 내려갔으며, 마침내 의미를 갖게 되었습니다. 약속 뒤에 락(lock)이 존재합니다. Transfer는 다시 일반적인 Sendable로 돌아갈 수 있습니다. 동시성 테스트는 100번 중 100번 모두 통과하는데, 이는 업데이트가 더 이상 서로를 침범할 수 없기 때문입니다. 이것이 제가 테스트로부터 원하는 속성입니다. 타입이 안전하다고 주장만 할 때는 빨간색(red), 실제로 안전할 때는 초록색(green)이 되는 것입니다. 에이전트는 좋은 Swift 코드를 작성합니다. 하지만 에이전트가 할 수 없는 것은 진짜 Sendable 준수(conformance)와 껍데기뿐인 준수를 구별하는 것이며, 그 간극이 바로 이 테스트가 포착해내는 지점입니다.
이 분야를 잘 아신다면 제가 ThreadSanitizer(TSan)를 언급하기를 기다리셨을 것입니다. 그것은 교과서적인 정답이며, 이론적으로는 더 나은 방법입니다. TSan은 런타임(runtime)에 메모리를 감시하고 레이스(race)를 직접 보고합니다. 유실된 업데이트가 서로 상쇄되어 운 좋게 카운트가 맞게 나오는 드문 실행 상황에서도 TSan은 잡아냅니다. 행동 기반 테스트(behavioral test)는 그 실행을 놓칠 수 있지만, TSan은 놓치지 않습니다.
저는 이 포스트의 중심을 TSan으로 만들려고 노력했습니다. 하지만 현재 제가 사용하는 툴체인(toolchain)에서는 이를 실행할 수 없었습니다. swift test --sanitize=thread를 실행하면 빌드는 되지만 단 하나의 테스트가 실행되기도 전에 종료되는데, 이는 새니타이저(sanitizer) 자체의 런타임 라이브러리(runtime library) 로드를 거부하기 때문입니다. 즉, 툴체인에 포함된 dylib에 대한 코드 서명(code-signing) 및 플랫폼 정책 거부 문제입니다. -sanitize=thread로 빌드된 독립 실행 파일(standalone executable)도 동일한 방식으로 실패합니다. 이는 TSan에 대한 판결이 아니라 이 Xcode에 국한된 설정 문제이지만, 제가 일반적인 테스트를 먼저 제시한 이유이기도 합니다. 행동 기반(behavioral) 버전은 이미 가지고 있는 테스트 러너(test runner) 외에는 아무것도 필요로 하지 않습니다. TSan이 제대로 연결되면 이는 진정한 업그레이드이며, 동시성(concurrency) 비중이 높은 모든 작업의 CI(지속적 통합)에 포함되어야 합니다. 이를 연결하는 작업은 그 자체로 반나절이 걸리는 일이며, 사용 중인 Xcode 버전이 협조해주느냐에 달려 있습니다.
이 중 그 어떤 것도 일반적인 문제에 대한 해결책은 아니며, 그 이유에 대해 정확히 말씀드리고 싶습니다. 테스트는 당신이 작성하려고 의도했던 적대적 상황(adversary)만을 잡아낼 뿐입니다. 만약 아무도 동시 쓰기(concurrent-writer) 케이스를 작성하지 않는다면, 추적할 '레드 플래그(red)'는 존재하지 않으며 당신은 다시 수동으로 디프(diff)를 읽는 상태로 돌아가게 됩니다. 어설션(assertion)은 그것이 만들어내는 경합(contention)만큼만 날카롭습니다. 디버그 빌드(debug build)에서는 손실이 작을 수 있으며, 동시성을 충분히 강하게 밀어붙이지 않으면 조용한 실행 상태에서는 레이스(race)가 여전히 빠져나갈 수 있습니다. 또한 다른 실패 모드(failure modes)에 대해서는 아무런 도움이 되지 않습니다. 잘못된 기능을 깔끔하게 구축하는 에이전트(agent)는 당신이 가진 모든 적대적 동시성 테스트를 무사히 통과할 것입니다.
이것이 제공하는 가치는 좁지만 실질적입니다. 빌드와 기존 테스트가 모두 당신에게 거짓을 말하는 단 하나의 실패 모드에 대해서, 이 방식은 확신에 찬 문단(paragraph)이 논리로 빠져나갈 수 없는, 루프(loop)가 실패할 수 있는 지점을 제공합니다. 에이전트는 @unchecked Sendable을 작성하고 그것이 왜 합리적인 결정이었는지 설명할 수는 있습니다. 하지만 실제로 동기화(synchronization)를 수행하지 않고서는 천 번의 동시 증가(concurrent increments)가 정확히 천이 되도록 만들 수는 없습니다.
그래서 제가 도달한 지점은 여기이며, 여전히 부족한 부분은 다음과 같습니다. 동시성(concurrency)에 민감한 타입의 경우, 이러한 적대적 상황을 포함하는 테스트(adversary-carrying tests)를 직접 작성하시나요, 아니면 선언된 모든 Sendable에 대해 "경합 상황에서의 실행(exercise it under contention)" 케이스를 생성하는 방법을 찾으셨나요? 그리고 만약 CI에서 TSan을 실행한다면, 툴체인(toolchain)이 바로 작동했나요, 아니면 통과(green)시키기 위해 씨름해야 했나요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기