2011년 5월 26일 목요일

[node.js] 비동기처리의 환상

node.js 소개글에 따라 다니는 이야기는 비동기(asynchronous) 처리를 위한 이벤트 위주(event driven) 언어라는 점이다. 그리고 한 가지 더 따라다니는 소개는 '빠르고(performance) 확장(scalable)에 강하다 라는 점이다.

소개로는 뭔 말을 못 하랴. 실제로 node.js의 API 구조는 개발하기도 편하고 빠르기도 하다.

하지만 비동기 처리구조에서 오는 단점을 제대로 파악하지 못 하면 node.js의 장점은 그저 환상에 불가능하다.

비동기 처리 구조의 단점을 생각한 글을 그냥 한번 풀어 써 본다. (이 글에서는 직접적인 node.js의 테크닉이나 API는 안나온다.)

동기와 비동기


두 음식점이 있다고 가정하자.

  • A 음식점: 이 음식점은 손님 앞에서 직접 요리를 해 주는 특별한 컨셉을 가지고 있다. 요리사를 겸하는 웨이터를 10명 보유하고 있다.
  • B 음식점: 일반적인 음식점으로 웨이터 1명이 서빙을 하고 주방에는 요리사 1명이 요리를 한다.

A 음식점은 10명의 요리사 겸 웨이터 덕분에 주문을 받고 바로 그 자리에서 요리를 해 준다. 덕분에 한 웨이터는 손님의 테이블에 항상 붙어있고 그래서 많은 웨이터를 보유하고 있다.

B 음식점은 웨이터가 1명 뿐이지만 그들이 요리를 하지는 않는다. 주문을 받고 요리를 내 준다. 대신 요리사가 1명 뿐이라 음식이 언제 나올지는 모른다.

두 음식점 모두 손님이 없으면 할 일 없이 기다리는 건 똑같다.

A 음식점은 손님이 오면 요리를 바로 눈 앞에서 끝까지 만들어 준다. B 음식점은 손님이 오면 주문을 받고 음식을 가져다 준다. 손님이 많으면 둘 다 결과적으로 요리를 늦게 받게 되겠지만, A 음식점은 아예 주문을 늦게 받을 수도 있지만 B 음식점은 주문 자체는 빨리 할 수도 있다.

아마도 동기처리와 비동기처리를 이렇게 설명 할 수도 있을 것 같다.

A 음식점의 요리사 겸 웨이터는 쓰레드(thread)다. 한 쓰레드 내부는 일을 순서대로 처리하기 때문에 다른 일을 할 수는 없다. 대신 멀티쓰레드(multi-thread) 구조를 이용해 다수(10개의 thread)의 일을 병렬로 처리할 수 있다.

B 음식점의 웨이터는 이벤트를 받아(Event Listener)들인다. 이벤트가 들어오면 요리를 하고 그 결과물(callback)을 손님에게 내 준다.

일반적으로 봤을 때 A 음식점은 상대적으로 많은 직원을 놀려먹는 것일 수도 있다. B 음식점은 상대적으로 직원이 적다 보니 놀려도 별 손해를 보지 않는다. 퍼포먼스의 차이는 이런 구조에서 나오는 것일테지.

비동기 처리 시 발생할 수 있는 문제점


B 음식점에서 주문을 받은 한 요리가 조리시간이 아주 오래 걸리는 요리라면 어떻게 될까. 가스레인지 같은 도구는 한정되어 있다보니 그 요리를 완성하기 전 까지 다른 요리를 만들지 못 한다. 주문은 잔뜩 밀려 있을테고 많은 손님들이 오래 기다려야 되는 피해를 보게 된다.

대신 A 음식점은 한 손님이 오래걸리는 요리를 주문해도 다른 웨이터들은 각자 알아서 자기들이 할 요리를 한다. 그래서 대부분의 손님들은 크게 피해가 없을 것 같다.

비동기 처리의 문제는 여기서 처럼, 특정 한 작업이 오래 걸리게 될 경우 다른 이벤트 처리가 늦어지는 문제를 가지고 있다. 다르게 이야기 해서, 비동기 처리를 하는 코드 내부에 동기 처리를 하는 코드가 들어가게 되면 문제를 일으킨다는 의미다.
TASK_A(function(result) {
    sleep(10000);
    put(result);
});
TASK_B(function(result) {
    put(result);
});
pseudo 코드로 표현하긴 했지만 node.js 스러운 코드다.

TASK_A가 호출되면 sleep(10000)이라는 코드로 인해 일정 시간 대기를 한다. 사실 sleep 대신에 뭔가를 계산하는 등의 오래 걸리는 코드가 있어야 하겠지만 어쨌든 예로 sleep을 사용했다.

TASK_A 이벤트가 먼저 오고 그 다음에 TASK_B의 이벤트가 바로 왔다면 어떻게 될까.

이 경우 sleep이 끝나기 전 까진 TASK_A의 작업이 끝나지 않게 되고 따라서 TASK_B는 TASK_A의 작업이 끝나야 자신의 차례가 돌아온다. TASK_B의 응답이 너무 늦어지게 된다.

이 경우 문제를 해결하려면 오래 걸리는 작업 또한 비동기로 돌려야 한다는 점이다.
TASK_A(function(result) {
    LONG_TASK(function (result) {
        put(result);
    });
})
TASK_B(function(result) {
    put(result);
});
이것이 node.js 스타일의 해결법이다.

sleep에 해당하던 오래 걸리던 작업(LONG_TASK)을 비동기로 또 돌리는 것이다. 따라서 LONG_TASK의 작업이 끝나기 전에도 TASK_B가 이벤트를 받아들여서 처리가 가능해진다. 음식점 예에서는 B음식점의 요리사가 보조 요리사를 하나 채용한 셈이다.

하지만 항상 이런 API가 제공되는 것도 아니고 상황에 따라 오래걸리는 계산을 비동기로 처리하기에 곤란한 점이 있을 수 있다. 위의 예의 코드는 쉽게 쓴 것 같지만 쉬운 상황이 아닐 가능성이 더 많이 생길 것 같다.

이런 경우가 비동기 처리 방식의 가장 큰 문제가 된다.

즉, 제대로 된 비동기 처리 구조는 하나의 작업에 걸리는 시간을 최대한 작게 해야 한다. 아니면 모든 것을 비동기로 처리해야 한다. 결국 개발자가 잘 짜면 잘 돌아간다는 말이다. -_-;;;

개인적으로 생각하는 node.js로 코딩하기의 특징


1. 익숙하지 않은 환경

불행히도 많은 개발자들은 동기처리 방식에 익숙해져 있다. 잘 쓰던 코드를 비동기 처리 하려면 뭘 바꿔야 할지 혼란이 올 수도 있다.

물론 익숙해지면 해결될 문제다.

2. 코드의 가독성

동기처리구조의 코드는 직관적이다. 일을 처리할 순서를 그대로 적은 코드이다 보니 당연히 가독성도 좋을 수 밖에 없다.

비동기처리구조의 코드는 코드를 결코 순서대로 읽으면 안된다. 비동기처리의 결과를 처리하는 코드는 언제 호출될지 알 수 없기 때문이다.

결국 비동기처리를 위한 코드들은 굉장히 가독성이 떨어질 수도 있다. 하지만 가독성 문제는 개발자의 능력으로 해결이 가능하다.

node.js의 비동기API들은 다행히도 이런 가동성 문제를 해결하도록 의도적으로 디자인 된 것 같다.

3. 분산 처리?

node.js는 서버 역활을 하기 편하도록 설계되었다. 비동기 방식으로 설계한 코드의 한계(오래 걸리는 동기 작업)를 극복하기 가장 좋은 방법으로써 클러스터링(clustering, 서버분산)을 하도록 유도하는 것 같다.

비동기의 단점으로 발생하는 문제라 하더라도 클러스터(cluster)를 구성해 분배(load balancing 등)을 할 수 있도록 설계한다면 이런 문제도 어느 정도 헤쳐 나갈 수 있게 된다.

그런 점에서 node.js의 심플한 API는 칭찬해 주고 싶다.

물론 이것도 개발자의 능력이 필요한 부분이겠지만...

댓글 2개 :

익명 :

안녕하세요. 좋은글 잘봤습니다. 그런데 'B 음식점 요리사가 1명 뿐'이라는 말씀은 적절하지 않아 보입니다. 처음 식당문 열 때부터 주방에 대기하는 요리사는 여러명 입니다. 이 요리사들이 들어오는 주문들을 나눠가며 처리하고 있죠 (즉, thread pool). 이것은 http://nikhilm.github.com/uvbook/basics.html 에 언급되었습니다.

How the I/O is run in the background is not of our concern, but due to the way our computer hardware works, with the thread as the basic unit of the processor, libuv and OSes will usually run background/worker threads and/or polling to perform tasks in a non-blocking manner.

그리고 A식당의 경우도 정확한 비유를 하자면, 손님이 들어올때마다 요리사를 구해와서 붙여준다가 맞을거 같네요. 즉, node.js 가 단일 쓰레드라는것은 사용자의 스크립트를 수행하는 쓰레드가 하나라는 의미이고, 실제 비동기처리를 수행하기 위해서 내부적으로는 위처럼 thread pool 을 사용해서 멀티쓰레드를 돌리고 있습니다.

Seorenn :

B의 경우 비동기 구조를 설명하기 위해 일부러 스레드 개념을 뺐습니다. 물론 비동기 구조도 일반적으로 수 개의 스레드로 구성되고 있긴 힙니다만, 가장 단순한 비동기 구조는 이벤트를 관리 스레드와 작업 스레드 2개 만으로 구성이 가능합니다. 이 글은 node.js의 구조 설명이라기 보단 비동기 구조를 설명하려는 글이라서 가급적 단순화해서 생각했습니다.

두 번재 이야기는 맞습니다. 그런데 10명 제한을 둔 건 싱글 프로세스 기반의 비동기구조와 어느 정도 비슷한 수준으로 맞춰 주기 위함(?)이라는 게 있습니다만 약간 억지 설정이기도 하지요. ^^; threadpool을 쓰면서 이 제한을 넘어서면 새로운 스레드를 만드는 방식도 물론 유용한 멀티스레드 방식이긴 합니다.