비동기 DB 조회로 future를 받았지만 결과를 기다리는 동안 서버가 멈춥니다. 비동기 API를 써도 스레드가 블로킹될 수 있나요?
잠깐, 답변을 보기 전에
먼저 내 말로 설명해 볼까요?
답변과 점검 포인트 펼치기답변 접기
구두 답변
정의를 외우기보다, 원리와 조건을 연결해 설명해 보세요.
네, 비동기 API를 호출했어도 결과를 기다리는 방식에 따라 스레드가 블로킹될 수 있습니다. 비동기는 작업을 요청한 시점과 결과를 받는 시점을 분리하는 방식입니다. 그런데 결과를 받는 코드에서 다시 기다리면, 요청은 비동기로 했어도 그 스레드는 멈출 수 있습니다. 그래서 저는 API 이름보다 결과가 아직 없을 때 코드가 어떻게 동작하는지를 먼저 보겠습니다.
예를 들어 DB 조회를 비동기로 보내고 나중에 받을 결과를 나타내는 future를 받았는데, 바로 get을 호출해 결과를 기다린다고 하겠습니다. 별도의 작업 스레드라면 그 스레드가 기다리는 것으로 끝날 수 있지만, 여러 연결을 담당하는 이벤트 루프라면 다른 요청 처리까지 함께 밀립니다. 특히 모든 워커가 같은 스레드 풀에 넣어 둔 하위 작업을 기다린다면, 하위 작업을 실행할 스레드가 없어 진행이 막힐 수도 있습니다. 이런 것이 스레드 풀 고갈이 교착으로 이어지는 경우입니다.
이 상황에서는 워커 수부터 늘리기보다 대기가 필요한 위치를 바꾸겠습니다. 완료 콜백이나 코루틴의 중단을 이용해 결과가 올 때까지 실행 자원을 내주고, 완료된 뒤 이어서 처리하도록 합니다. 다만 await 같은 문법만 썼다고 되는 것은 아닙니다. 그 안에서 동기 I/O를 호출한다면 여전히 스레드를 잡고 있으므로, 실제 호출 경로를 확인해야 합니다.
또 이렇게 바꿔도 DB의 처리 용량은 그대로입니다. 저는 DB 연결을 기다린 시간과 실제 쿼리 시간을 나눠 보겠습니다. 쿼리 시간은 비슷한데 연결 대기만 늘어난다면 요청을 더 병렬로 보내기보다 DB로 들어가는 작업 수를 제한하겠습니다. 대기열에도 상한을 두고, 남은 시간 안에 처리하기 어려운 요청은 오래 붙잡아 두지 않고 거절하는 편을 택하겠습니다. 이런 식으로 하위 시스템의 처리 한계를 상위에 반영하는 것이 백프레셔입니다.
결국 비동기는 기다림을 없애는 기술이 아니라, 기다리는 동안 실행 자원을 다르게 쓰는 방식이라고 보겠습니다. 스레드가 풀렸다는 것만 확인하지 않고, 그 대기가 요청 큐와 메모리로 옮겨간 것은 아닌지까지 확인해야 합니다.
비동기·논블로킹·병렬은 다른 질문입니다
이 구분을 조금 더 정확히 정리하겠습니다. 결과를 나중에 전달받는 계약을 비동기(asynchronous)라고 하고, 호출한 스레드가 결과를 기다리며 멈추지 않는 성질을 논블로킹(non-blocking)이라고 합니다. 여러 작업의 진행 구간이 겹치는 것은 동시성이고, 실제로 같은 순간 여러 코어에서 실행되는 것은 병렬성입니다. 이 예에서 비동기 API를 사용했다는 사실만으로 결과를 기다리는 호출까지 논블로킹이거나 쿼리가 병렬 실행된다고 결론 낼 수는 없습니다. Go의 채널 대기는 보통 고루틴을 주차하고 OS 스레드는 다른 고루틴을 실행할 수 있는 반면, C++의 future.get 같은 호출은 호출 스레드를 막을 수 있으므로 API와 런타임 계약을 따로 확인해야 합니다.
워커가 네 개뿐인 풀을 생각해 보겠습니다. 네 요청이 각각 하위 작업을 같은 풀에 넣고 완료를 동기적으로 기다리면, 실행 가능한 하위 작업은 큐에 있지만 그것을 실행할 워커가 없습니다. 이런 자원 의존을 스레드 풀 기아 교착(thread-pool starvation deadlock)이라고 부릅니다. 단순히 요청이 많아 잠시 밀린 것과 달리 스스로 진행할 실행 자원이 없어졌다는 점이 중요합니다. 블로킹 호출을 분리된 제한 크기의 풀로 옮기거나 완료 후 이어 실행하는 구조로 바꾸어 의존을 끊겠습니다. 다만 대기 중 다른 작업을 도와 실행하는 실행기 등은 동작이 다르므로, 이름만 보고 교착을 단정하지 않고 스택과 풀 구현을 확인해야 합니다.
결과가 도착한 뒤 실행할 나머지 코드를 후속 실행(continuation)이라고 부릅니다. 콜백은 이를 명시적인 함수로 표현하고, 코루틴은 중단 위치와 지역 상태를 보관해 문법적으로 이어 쓰게 해 줍니다. 스레드를 내줬어도 요청 객체·버퍼·인증 정보는 살아 있을 수 있습니다. 그러므로 코루틴 수가 늘수록 메모리는 여전히 늘고, 완료 통지가 한꺼번에 몰리면 재개 처리 자체가 병목이 될 수 있습니다. 후속 실행이 어느 실행기에서 동작하는지, 취소 후에도 재개할 수 있는지, 캡처한 버퍼를 언제 해제하는지를 함께 계약으로 정하겠습니다.
수정이 실제로 도움이 됐는지 확인합니다
먼저 요청 하나의 시간을 연결 풀 대기, 쿼리 실행, 결과 수신, 후속 처리로 나눕니다. 이벤트 루프의 스케줄 지연과 워커 스택을 함께 보면 블로킹이 어느 요청까지 영향을 주는지 확인할 수 있습니다. 같은 입력률에서 동기 대기를 제거한 전후의 처리량·p99·진행 중 요청 수·메모리를 비교하겠습니다. 스레드 수만 줄고 진행 중 요청이 끝없이 늘어난다면 병목을 없앤 것이 아니라 대기 장소만 옮긴 것입니다. 이 경우 다음 판단은 비동기 문법이 아니라 진입 제한과 백프레셔로 넘어가야 합니다.
득점 포인트
이해가 드러나는 지점을 확인해 보세요.
- 대기 위치와 영향 범위를 연결한다. future의 동기 대기가 전용 워커와 이벤트 루프에서 왜 다른 영향을 주는지 설명한다.
- 실행 자원을 내주는 방법을 설명한다. 콜백·코루틴을 제안하면서 실제 I/O 경로가 블로킹인지 확인한다.
- 관측 결과에 따라 행동을 선택한다. 쿼리 시간과 연결 대기 시간을 나눠 보고, 대기가 병목이면 작업 수와 대기열을 제한한다.
- 최적화 뒤의 병목 이동을 확인한다. 스레드 대기가 줄어도 요청과 메모리가 쌓일 수 있음을 설명한다.
감점 포인트
놓치기 쉬운 조건과 오해를 짚어 보세요.
- “비동기 API니까 스레드는 절대 멈추지 않는다”고 말한다. 결과를 동기적으로 기다리는 호출과 내부 동기 I/O가 있을 수 있다.
- “await를 붙이면 별도 스레드에서 실행된다”고 말한다. 중단·재개와 실행 위치는 런타임 및 대기 대상 구현에 달려 있다.
- “스레드 풀을 키우면 해결된다”고 단정한다. 동일 풀에 대한 의존 사이클과 하위 시스템 용량 제한은 남는다.
- “백프레셔를 고려한다”로 끝낸다. 어떤 대기를 보고 어디에서 작업을 제한할지 설명하지 않으면 실제 판단이 빠진다.
더 파고들 거리
하나 더 질문하며 이해의 범위를 넓혀 보세요.
- Go 채널에서 기다리는 고루틴은 OS 스레드 전체를 붙잡나요? 런타임의 park와 블로킹 호출을 구분해 보세요.
- 요청마다 처리 시간이 다릅니다. deadline 안에 끝낼 수 있는 동시 실행 수와 대기열 길이를 어떻게 정하나요?
- 코루틴이 취소되는 동안 I/O 완료가 도착하면 프레임과 버퍼는 누가 정리해야 할까요?
- 이벤트 루프 자체가 느린 것과 하위 서비스가 느린 것을 구분하려면 어떤 구간을 추적해야 할까요?
득점·감점 포인트는 학습용 자기 점검 기준이며, 특정 회사의 실제 채점표가 아닙니다.
원리부터 공부하고 싶다면
답변을 보충하는 개념·예제·슈도코드를 읽어 보세요.