async I/O란 무엇인가요?
- 면접 출제
- ★
- 예상 시간
- 7분
async I/O는 파일, 네트워크, DB 같은 I/O 작업을 요청한 뒤 완료될 때까지 호출 흐름을 막지 않는 방식입니다. 작업이 완료되면 callback, Promise, event loop, completion queue 같은 방식으로 결과를 처리합니다.
I/O 대기 시간이 긴 서버에서는 thread를 요청마다 오래 붙잡지 않아도 되므로 동시성을 높일 수 있습니다. 다만 코드 흐름과 에러 처리, backpressure 관리가 중요해집니다.
I/O가 특별 취급되는 이유는 그 일을 CPU가 하지 않기 때문입니다. 디스크 읽기와 네트워크 수신은 장치가 수행하고, CPU 입장에서는 결과가 올 때까지 수 ms에서 수백 ms를 기다릴 뿐입니다. CPU 명령 하나가 ns 단위임을 생각하면 수백만 배의 시간을 기다림에 쓰는 것이라, 이 기다림을 어떻게 처리하느냐가 서버 구조를 결정합니다.
전통적인 답은 요청마다 thread를 하나씩 붙이는 것이었습니다. 기다리는 thread는 OS가 재워 두니 논리는 단순하지만, thread마다 스택 메모리(보통 수 MB 예약)가 들고 수가 많아지면 컨텍스트 스위칭 비용이 커집니다. 연결 만 개를 thread 만 개로 감당하기 어렵다는 문제가 이른바 C10K 문제이고, async I/O는 "기다림에는 thread가 필요 없다"는 관찰로 이를 푼 접근입니다.
꼬리질문
조금 더 깊게 물어본다면
답변 뒤에 이어질 수 있는 질문들을 하나씩 열어볼 수 있어요.
non-blocking과 async는 같은 말인가요?
완전히 같은 말은 아닙니다. non-blocking은 호출이 즉시 반환되는 속성이고, async는 완료 결과를 나중에 통지받는 프로그래밍 모델까지 포함하는 경우가 많습니다.
동작 차이를 구체적으로 보면, non-blocking socket에 read를 호출했는데 데이터가 없으면 "지금은 없음"이라는 에러(EAGAIN)와 함께 즉시 돌아옵니다. 즉 기다리지는 않지만 결과를 얻으려면 언젠가 다시 물어봐야 하고, 언제 다시 물을지는 호출자의 몫입니다. async는 요청을 걸어 두면 완료가 통지로 찾아오므로 다시 묻는 일 자체가 없습니다.
OS 수준에서는 이 차이가 준비 통지(readiness)와 완료 통지(completion) 모델로 나타납니다. epoll은 "이제 읽으면 blocking 없이 될 것"이라고 준비를 알려주고 읽기는 여전히 내가 합니다. io_uring이나 Windows의 IOCP는 "요청한 읽기가 끝나서 버퍼에 담아 뒀다"고 완료를 알려줍니다. 후자가 더 async라는 말의 원래 의미에 가깝습니다.
blocking/non-blocking vs sync/async 차이는?
blocking/non-blocking은 호출이 완료될 때까지 실행 흐름이 멈추는지 여부를 말합니다. sync/async는 작업 완료를 어떻게 통보받는지에 관한 개념입니다. 호출자가 직접 기다려 결과를 받으면 sync, 완료 시 callback이나 이벤트로 통보받으면 async입니다. 조합하면 네 가지 모델이 가능하며, 실제로는 non-blocking I/O를 async하게 사용하는 경우가 많습니다.
네 조합을 예시로 채워 보면 개념이 자리 잡습니다. sync + blocking은 평범한 read로, 부르고 결과가 올 때까지 멈춥니다. sync + non-blocking은 non-blocking read를 loop로 반복해서 물어보는 폴링(polling)으로, 멈추지는 않지만 결과를 얻는 책임은 여전히 호출자에게 있습니다. async + non-blocking은 epoll 기반 event loop나 io_uring처럼 요청을 걸어 두고 통지로 결과를 받는 조합입니다.
남은 조합인 async + blocking은 실용적인 의미가 거의 없습니다. 완료를 통지받기로 해 놓고 그 자리에서 기다린다면 async의 이점이 사라지기 때문입니다. 결국 실무에서 의미 있는 선택지는 "단순한 sync + blocking"과 "동시성을 위한 async + non-blocking" 둘로 수렴하고, 나머지 조합은 이 둘을 이해하기 위한 개념적 좌표에 가깝습니다.
I/O multiplexing이란?
하나의 thread가 여러 I/O 소스를 동시에 감시하는 방법입니다. select, poll, epoll(Linux), kqueue(macOS/BSD) 같은 OS 기능이 준비된 I/O를 알려주면 해당 작업을 처리합니다. event loop 구현의 핵심이며, thread 하나로 수천 개의 connection을 다룰 수 있습니다.
select에서 epoll로의 진화가 확장성 문제를 보여줍니다. select와 poll은 매 호출마다 감시할 fd(file descriptor, 파일이나 소켓을 가리키는 번호) 목록 전체를 커널에 전달하고, 커널은 전체를 스캔해 준비된 것을 표시해 돌려줍니다. 연결이 만 개면 이벤트가 하나뿐이어도 매번 만 개를 넘기고 훑어야 하므로 연결 수에 비례해 느려집니다.
epoll은 감시 목록을 커널 안에 한 번 등록해 두는 방식으로 이를 해결합니다. 이후에는 준비된 fd만 돌려받으므로, 비용이 전체 연결 수가 아니라 실제 발생한 이벤트 수에 비례합니다. 연결은 많지만 동시에 활동하는 연결은 일부인 서버의 전형적 상황에 정확히 맞는 구조이고, Node.js, Nginx, Redis가 모두 이 계열 위에서 동작합니다.
Event loop는 async I/O에서 어떤 역할인가요?
준비된 I/O 이벤트와 예약된 callback을 감시하고 실행하면서 하나의 thread에서도 여러 I/O 작업을 번갈아 처리하게 합니다.
event loop의 뼈대는 단순한 무한 loop입니다. "준비된 이벤트가 있는지 OS에 묻고(epoll 등에서 대기), 도착한 이벤트마다 등록된 callback을 실행하고, 다시 묻는다"의 반복입니다. 동시성이 있어 보이지만 실제로는 한 번에 callback 하나씩만 실행되고, 각 callback이 짧게 끝나 주기 때문에 수천 연결이 번갈아 진행되는 것입니다.
이 구조의 약점도 여기서 나옵니다. callback 하나가 CPU 계산으로 1초를 쓰면 loop가 1초간 멈추고, 그동안 모든 연결의 이벤트가 처리되지 못합니다. thread 방식에서는 한 요청의 긴 계산이 그 thread만 붙잡지만, event loop에서는 전체를 붙잡습니다. 그래서 event loop 기반 시스템에서는 무거운 계산을 별도 thread(worker)로 보내는 것이 필수 규칙이 됩니다.
JavaScript 이벤트 루프가 async I/O를 쓰는 이유는?
JavaScript는 단일 thread로 실행됩니다. blocking I/O를 사용하면 I/O 대기 중 모든 연산이 멈추므로, OS의 async I/O와 이벤트 루프를 조합해 하나의 thread로 여러 I/O 작업을 동시에 처리합니다. Node.js의 libuv가 OS별 async I/O를 추상화해 이벤트 루프에 연결합니다.
JavaScript가 단일 thread인 것은 브라우저 환경의 설계에서 온 제약입니다. 스크립트가 DOM을 조작하는데, 여러 thread가 같은 DOM을 동시에 수정할 수 있게 하면 UI의 모든 조작에 lock이 필요해지고 웹 페이지 스크립트 작성이 동시성 프로그래밍이 되어 버립니다. 단일 thread로 두면 "실행 중인 코드가 끝나기 전에는 아무도 끼어들지 않는다"는 보장이 공짜로 생깁니다.
대신 그 하나뿐인 thread가 절대 오래 멈추면 안 되므로, 모든 I/O가 async일 수밖에 없습니다. 즉 JavaScript의 async 중심 설계는 취향이 아니라 단일 thread의 필연입니다. Node.js는 이 제약으로 만들어진 async 생태계가 I/O 위주 서버에 오히려 잘 맞는다는 점을 활용해, 같은 모델을 서버로 가져온 것입니다.
비동기 작업이 싱글 스레드에서 블로킹 없이 처리될 때, 실제 작업은 어디서 실행되나요?
JS 엔진은 싱글 스레드지만 런타임 환경(브라우저의 Web API, Node.js의 libuv)은 별도 스레드에서 I/O를 처리합니다. 완료되면 콜백을 태스크 큐에 넣고 이벤트 루프가 콜 스택이 빌 때 꺼내 실행합니다.
JS 엔진 자체는 싱글 스레드입니다. 타이머, 네트워크, 파일 I/O 같은 작업은 브라우저의 Web API 또는 Node.js의 libuv가 제공하는 별도 스레드 풀에서 처리됩니다. 실제 계산은 거기서 일어나고, 결과만 태스크 큐를 통해 JS 스레드로 전달됩니다.
setTimeout(fn, 0)은 어떤 순서로 실행되나요?
콜 스택 → Web API → 태스크 큐 → 이벤트 루프 → 콜 스택 순서입니다.
setTimeout이 호출되면 타이머 관리를 Web API에 넘깁니다. 0ms가 지나면 Web API가 콜백을 태스크 큐에 추가합니다. 이벤트 루프는 콜 스택이 비어 있을 때 태스크 큐에서 콜백을 꺼내 실행합니다.
0ms로 지정해도 HTML 스펙상 최소 1ms(중첩 4단계 이상은 4ms) 지연이 발생합니다. 그래서 setTimeout(fn, 0)은 "현재 콜 스택을 비운 뒤 실행"의 의미로 씁니다.
async I/O의 단점은?
흐름이 분산되어 디버깅이 어려울 수 있고, 너무 많은 요청을 동시에 날리면 memory나 downstream 부하가 커질 수 있습니다.
디버깅이 어려워지는 구체적 이유는 호출 스택이 끊기기 때문입니다. sync 코드에서 에러가 나면 스택 트레이스에 "누가 누구를 불렀는지"가 전부 남지만, async에서는 callback이 실행되는 시점에 원래 요청을 만든 코드의 스택이 이미 사라진 뒤입니다. 에러 지점은 보이는데 거기까지 오게 한 맥락이 안 보이는 상황이 됩니다.
코드 표현의 불편은 단계적으로 개선되어 왔습니다. callback 중첩이 깊어지는 callback hell을 Promise가 체이닝으로 폈고, async/await가 sync 코드처럼 읽히게 만들었습니다. 다만 async 함수의 결과는 여전히 Promise이므로 호출하는 쪽도 await해야 하고, 이렇게 async가 호출 경로를 따라 위로 번지는 성질은 남아 있습니다. 표기가 편해진 것이지 sync가 된 것은 아니라는 점이 함정입니다.
Backpressure는 왜 필요한가요?
생산 속도가 소비 속도보다 빠르면 memory가 계속 늘거나 latency가 커집니다. backpressure는 생산 속도를 제한해 시스템을 안정적으로 유지합니다.
async라서 이 문제가 생긴다는 점이 핵심입니다. blocking I/O에서는 소비자가 느리면 생산자의 write가 자연히 멈춰 속도가 저절로 맞춰집니다. async에서는 write가 "일단 버퍼에 넣고 즉시 반환"하므로 생산자가 멈출 이유가 없고, 디스크에서 빠르게 읽어 느린 네트워크로 보내는 상황이라면 그 차이만큼 버퍼가 무한히 쌓여 메모리 부족으로 이어집니다.
그래서 async 시스템은 멈춤 신호를 명시적으로 설계해야 합니다. Node.js stream은 write가 false를 반환하면 생산을 멈추고 drain 이벤트에서 재개하는 규약을 두고, pull 기반 모델은 소비자가 요청한 만큼만 생산하게 뒤집습니다. 네트워크 계층에서는 TCP가 수신 윈도우로 같은 일을 합니다. 수신 버퍼의 남은 공간을 상대에게 알려 송신 속도를 제한하는, 프로토콜에 내장된 backpressure입니다.
부가 설명
blocking I/O는 결과가 올 때까지 현재 thread가 기다립니다. non-blocking 또는 async I/O는 기다리는 동안 다른 작업을 처리할 수 있습니다.
Node.js, asyncio, epoll/kqueue/io_uring 같은 키워드와 연결해서 이해하면 좋습니다.
한 줄 정리
async I/O는 I/O 작업이 끝날 때까지 thread를 막지 않고, 완료 이벤트나 callback으로 결과를 처리하는 방식입니다.