비동기 코드에서 반복해서 보는 사고들
블로킹 호출이 섞이는 경우. 단일 루프 모델에서 동기 파일 읽기나 동기 HTTP 클라이언트를 호출하면 그 순간 모든 작업이 함께 멈춥니다. 부하가 낮을 때는 전혀 티가 안 나다가 동시 요청이 늘면 응답 시간이 계단식으로 무너집니다. 라이브러리를 고를 때 비동기 대응 여부를 먼저 확인해야 하는 이유입니다.
순차 await로 만든 가짜 동시성. 독립적인 요청 세 개를 await로 하나씩 기다리면 시간은 그대로 더해집니다. 동시에 보내려면 먼저 작업들을 시작해 놓고 한꺼번에 기다려야 합니다. 각 언어가 이를 위한 도구를 갖고 있습니다. JavaScript의 Promise.all, Python의 asyncio.gather, Kotlin의 async 후 await, Go의 sync.WaitGroup, Java의 allOf, C#의 Task.WhenAll이 같은 자리를 채웁니다.
시작 시점의 차이. JavaScript의 프로미스와 C#의 Task는 만들어지는 순간 이미 실행이 시작됩니다. 반면 Rust의 future는 게으르기 때문에 await하거나 실행기에 넘기기 전까지 아무 일도 하지 않습니다. Rust에서 "코드가 그냥 실행이 안 된다"는 당황스러운 경험 대부분이 여기서 옵니다. Kotlin의 async도 기본은 즉시 시작이지만 시작 옵션으로 지연시킬 수 있습니다.
버려지는 예외. 결과를 받지 않는 fire-and-forget 작업에서 예외가 나면 조용히 사라지기 쉽습니다. JavaScript는 처리되지 않은 프로미스 거부로 경고를 내고, Go는 고루틴 안의 panic이 프로세스 전체를 죽이며, Java는 미래를 아무도 조회하지 않으면 예외가 묻힙니다. 백그라운드 작업에는 예외를 반드시 로깅하는 경계를 직접 만들어 두어야 합니다.
취소와 시간 제한은 나중에 붙이기 가장 어려운 기능입니다. Go는 context.Context를 함수 체인 전체에 첫 인자로 흘려보내는 관례가 있고, Kotlin은 코루틴 스코프가 자식 작업을 함께 취소하며, C#은 CancellationToken을 넘깁니다. Python은 태스크 취소가 CancelledError로 전달됩니다. 공통점은 취소 신호가 호출 체인 끝까지 전달되어야 의미가 있다는 것입니다. 중간에 한 군데라도 신호를 삼키면 상위에서 타임아웃을 걸어도 실제 작업은 계속 돕니다.
동시성 코드에서는 짧은 코드가 특히 위험합니다. await를 붙이는 것만으로 확장성이 생기지 않고, 고루틴을 무제한으로 띄우면 연결 수 제한에 먼저 걸립니다. 어떤 언어를 쓰든 동시 실행 개수를 세마포어나 워커 풀로 묶는 작업은 직접 해야 합니다. 상세한 예제는 Go 고루틴, Kotlin 코루틴, Python asyncio, Java 비동기 문서에 있습니다.
디버깅이 어려워지는 지점
비동기 코드는 스택 트레이스가 잘 남지 않습니다. 작업이 시작된 위치와 예외가 발생한 위치가 서로 다른 스택에 있기 때문에, 트레이스만 봐서는 어느 요청에서 시작된 일인지 알 수 없습니다. 각 언어가 나름의 보완책을 갖고 있습니다. Kotlin은 코루틴 이름과 디버그 모드에서의 스택 병합을, .NET과 최근의 JavaScript 런타임은 비동기 경계를 넘는 스택 복원을 제공합니다. 그럼에도 가장 확실한 방법은 요청 단위 식별자를 만들어 모든 로그에 함께 남기는 것입니다.
테스트도 다른 종류의 어려움을 만듭니다. 실제 시간에 의존하는 대기를 테스트에 넣으면 느리고 불안정해집니다. 가상 시간을 지원하는 테스트 도구를 쓰거나, 타이머를 주입 가능한 의존성으로 빼 두면 재현 가능한 테스트를 쓸 수 있습니다. 동시성 버그는 재현이 어렵기 때문에, 재현 가능한 환경을 만드는 데 드는 초기 비용이 대체로 회수됩니다.