"정확하긴 했어. 근데 크기와 상관없이 초당 16MiB쯤밖에 안 나왔지. 응답이 전부 몇 KB짜리 JSON이던 동안엔 아무도 몰랐고."
일시 중단을 기대하는 작은 풀
Swift 동시성은 태스크를 대략 CPU 코어 수만 한 협력 스레드 풀에서 돌려. "협력"이 곧 계약이야. 태스크는 기다릴 때 await에서 멈추고 스레드를 돌려줘야 해. 반대로 막아버리는 코드(큰 디렉터리 트리를 동기로 훑기, Thread.sleep, 세마포어 대기, 동기 네트워크 호출)는 풀 스레드를 인질로 잡아. 몇 개만 막혀도 앱 곳곳의 상관없는 태스크들까지 멈춰버리고, 로그엔 이유가 하나도 안 남아.
@concurrent나 Task.detached를 써도 달라지는 건 없어. 둘은 어느 실행자가 코드를 돌릴지 고를 뿐이고, 그 실행자도 결국 같은 풀이야. 막히는 일엔 막혀도 되는 스레드가 필요해. 디스패치 큐나 전용 스레드에 두고, continuation으로 다시 이어줘.
문이 두 개인 서버
Mac에서 도는 엔진들이 쓰는 가족 Swift HTTP 리스너는 일부러 핸들러 문을 두 개 열어둬. async 핸들러는 detached 태스크에서 돌아. 기다리는 일에 알맞지. blockingHandler는 동시 디스패치 큐에서 돌아. 동기 파일 시스템 호출로 데이터 루트를 훑는 동기화 엔진에 맞아. 이건 취향 문제가 아니야. 스캔을 async 문 뒤에 두면 스캔 내내 협력 풀 스레드 하나를 차지하고 앉아 있게 돼.
원소마다 드는 비용은 무시 못 해
킷의 보안 강화 HTTP 전송은 응답 본문을 이렇게 읽었어. for try await byte in session.bytes(for: request)로 돌면서 크기 한도를 지키고 UInt8을 하나씩 붙이는 식이었지. 결과는 정확했어. 근데 로컬 파일로 재보니 크기와 상관없이 초당 16MiB쯤이었어. 원소마다 async next() 비용이 붙으니까. data(for:)는 같은 바이트를 초당 1,000MiB 넘게 옮겼지만 받는 도중 한도 검사를 포기해야 해. 고친 방법은 URLSessionDataDelegate야. didReceive data가 덩어리째 들어오니까 덩어리마다 한 번 붙이고, 누적 크기가 한도를 넘는 순간 태스크를 취소해. 루프백 소켓으로 실제 전송을 걸어 재보니 32MiB가 초당 1,237MiB쯤으로 오갔어. 느린 루프를 2주 동안 아무도 몰랐던 건, 앞선 사용처가 전부 작은 JSON만 받았기 때문이야. 이미지를 받은 첫 사용처가 찾아냈지.
메인 액터 테스트 본문이 자기 콜백을 막아
@MainActor를 붙인 Swift Testing 테스트가 메인 큐로 오는 콜백을 확인하려다 매번 타임아웃이 났어. 테스트 본문 자체가 메인 큐 작업이라서 콜백이 그 뒤에 줄 서 있었어. 테스트 안에서 런 루프를 돌려도 디스패치 큐는 비워지지 않았어. 고친 건 제품 코드가 아니라 테스트 구조였어. 테스트 본문은 격리 없이 두고, 준비하거나 값을 읽을 때만 메인 액터로 넘어가. 확인과 확인 사이엔 Task.sleep으로 메인 큐에 틈을 줘.