하드웨어 기술의 발전이 물리적 한계에 부딪히며 무어의 법칙이 동력을 잃어가고 있습니다. 반도체 공정이 나노미터 단위로 미세화되면서 양자 역학적 문제로 인해 CPU의 클럭 속도를 높이는 것이 매우 어려워졌습니다. 하지만 현대의 IT 환경은 모바일 기기와 수많은 IoT 기기의 등장으로 과거와 비교할 수 없을 만큼 거대한 데이터 요청을 처리해야 합니다. 하드웨어의 성장이 정체된 상황에서 소프트웨어 엔지니어들은 시스템 최적화를 통해 성능을 극대화해야 하는 과제에 직면했습니다.
컴퓨터 시스템 최적화의 핵심은 하드웨어 간의 속도 차이를 극복하는 것입니다. CPU의 연산 속도와 비교했을 때 메모리, 디스크, 네트워크의 속도는 상상을 초월할 정도로 느립니다. Jeff Dean이 제시한 수치를 바탕으로 체감 속도를 비교해 보겠습니다.
CPU가 명령 하나를 처리하는 시간을 1초라고 가정한다면, 메모리에 접근하는 시간은 약 1분 40초가 소요됩니다. SSD에서 데이터를 읽는 것은 약 4시간 30분이 걸리며, 물리적 디스크(HDD)에서 데이터를 읽는 작업은 무려 23일이 필요합니다. 대서양을 횡단하는 네트워크 요청은 약 4.8년이라는 시간이 걸립니다. CPU 입장에서 I/O(입출력) 작업은 영겁의 시간과 같습니다.
과거에는 이러한 대기 시간을 효율적으로 활용하기 위해 멀티스레딩(Multi-threading) 방식을 주로 사용했습니다. 운영체제가 스레드를 최소 단위로 스케줄링하여, 특정 스레드가 I/O 작업을 기다릴 때 CPU가 다른 스레드의 작업을 처리하도록 하는 방식입니다. 하지만 웹 서비스와 같은 I/O 밀집형(I/O Intensive) 환경에서는 스레드 모델의 한계가 명확합니다.
- 메모리 오버헤드: 각 스레드는 독립적인 스택 공간(통상 8MB)을 가집니다. 수천 개의 동시 요청을 처리하려면 막대한 메모리가 소요됩니다.
- 컨텍스트 스위칭 비용: 스레드를 전환할 때마다 운영체제는 레지스터 정보를 저장하고 복구하는 오버헤드를 발생시킵니다.
비동기(Asynchronous) 프로그래밍은 단일 작업의 속도를 높이는 것이 아니라, 컴퓨터가 같은 시간 내에 더 많은 작업을 처리하도록(Throughput 향상) 설계되었습니다.
비동기 작업의 효율성 이해
파일을 읽어 내용을 출력하는 단순한 동기 코드를 살펴봅시다.
let data = file_read("source.txt");
print(data + " processing");
이 코드는 file_read가 끝날 때까지 CPU가 아무것도 하지 못하고 기다려야 합니다. 이를 비동기로 단순히 바꾸면 어떻게 될까요?
let task = file_read_async("source.txt");
while (true) {
if (task.is_ready()) {
let content = task.get_result();
print(content + " processing");
break;
}
}
이 방식은 오히려 최악입니다. CPU는 여전히 결과가 나올 때까지 반복문을 돌며 바쁘게(Busy-waiting) 움직이지만, 정작 실질적인 작업은 하지 못합니다. 비동기의 진정한 가치는 병렬성에 있습니다.
let task_a = request_http_async("api.service_a.com");
let task_b = request_http_async("api.service_b.com");
// 두 요청은 동시에 네트워크로 전달됨
let result_a = wait_until_ready(task_a);
let result_b = wait_until_ready(task_b);
print(result_a + result_b);
만약 두 요청이 각각 100ms가 걸린다면, 동기 방식은 200ms가 걸리지만 비동기 방식은 약 100ms 면 충분합니다. 논리적으로 병행 가능한 작업을 동시에 처리함으로써 전체 소요 시간을 단축하는 것입니다.
이벤트 루프와 OS의 지원
위 코드의 wait_until_ready 함수가 효율적으로 작동하려면 운영체제의 지원이 필수적입니다. 리눅스의 epoll, 맥의 kqueue, 윈도우의 IOCP와 같은 시스템 호출은 특정 I/O 작업이 완료될 때까지 스레드를 잠재우고(Sleep), 준비가 되었을 때만 깨워주는 기능을 제공합니다.
수많은 비동기 작업을 관리하기 위해 개발자들은 이벤트 루프(Event Loop)와 콜백(Callback) 개념을 도입했습니다. 작업이 완료되었을 때 실행할 함수를 미리 등록해 두는 방식입니다.
function async_handler(url, callback) {
let operation = start_io(url);
operation.on_complete = callback;
register_to_event_loop(operation);
}
async_handler("site_a.com", (res) => {
save_file("a.html", res);
});
async_handler("site_b.com", (res) => {
save_file("b.html", res);
});
// 런타임이 내부적으로 실행하는 루프
while (has_active_tasks()) {
let ready_tasks = wait_for_os_notification();
for (let task of ready_tasks) {
task.on_complete(task.result);
}
}
이것이 JavaScript의 V8 엔진이나 Node.js의 libuv가 작동하는 기본적인 메커니즘입니다. 개발자는 루프를 직접 작성할 필요 없이 비동기 함수와 콜백만 정의하면 됩니다.
콜백 지옥과 프로미스(Promise)의 등장
비동기 처리가 많아지면 콜백 함수 내부에 또 다른 콜백이 중첩되는 '콜백 지옥(Callback Hell)' 문제가 발생합니다. 이를 해결하기 위해 등장한 개념이 프로미스입니다. 프로미스는 비동기 작업을 객체화하여 상태를 관리하고, 체이닝(Chaining)을 통해 코드의 흐름을 선형적으로 표현합니다.
function fetch_data(target) {
let job = create_async_job(target);
let handler = {
success_callbacks: [],
then: function(cb) {
this.success_callbacks.push(cb);
return this;
},
resolve: function(val) {
let current_val = val;
for (let cb of this.success_callbacks) {
let next = cb(current_val);
if (next instanceof TaskHandler) {
// 다음 비동기 작업이 있다면 남은 콜백을 이양
next.success_callbacks = this.success_callbacks;
return;
}
current_val = next;
}
}
};
job.on_done = (data) => handler.resolve(data);
return handler;
}
// 가독성 높은 체이닝 구조
fetch_data("/api/user")
.then(user => fetch_data("/api/orders?id=" + user.id))
.then(orders => process_orders(orders))
.then(result => console.log("Success:", result));
프로미스는 내부적으로 비동기 작업의 상태를 추적하고, 다음 단계로 결과를 전달하는 메커니즘을 캡슐화합니다. 특히 then 메서드가 새로운 비동기 객체를 반환할 때 남은 콜백들을 해당 객체로 넘겨주는 방식은 복잡한 비동기 흐름을 동기적인 코드 흐름처럼 보이게 만드는 핵심 기술입니다.
이러한 비동기 프로그래밍 패러다임은 한정된 자원으로 높은 동시성을 확보해야 하는 현대 서버 아키텍처의 표준이 되었습니다. 비동기 시스템은 하드웨어의 속도 차이를 소프트웨어의 논리적 구조로 해결하려는 엔지니어링의 정수라 할 수 있습니다.