기다리는 일 하나마다 사람을 한 명씩 붙여 두는 게 꼭 필요할까요? 직원 한 명이 여러 곳에 전화를 걸어 두고 벨이 울리는 것만 받으면, 자리 하나로도 여러 통화를 동시에 붙잡을 수 있습니다.
기초에서 먼저 보기이벤트루프는 언제 유리하고 언제 불리할까?발상을 뒤집어 봅시다. 기다림을 스레드가 붙잡고 있어야 할 이유가 있을까요? "이 작업이 끝나면 이 함수를 실행해 줘"라고 적어두고 스레드는 즉시 다음 일을 하러 가면 됩니다. 그러면 기다림은 잠든 스레드가 아니라 목록에 적힌 한 줄이 됩니다. 이것이 이벤트루프입니다.
아래 코드에서 db.query()는 결과를 기다리지 않고 곧바로 반환합니다. 대신 "결과가 오면 실행할 함수"를 함께 넘깁니다. 이렇게 나중에 불릴 목적으로 건네는 함수를 콜백이라고 합니다. 핸들러 함수 자체는 순식간에 끝나고, 스레드는 즉시 다음 요청을 처리하러 갑니다.
두 번째 코드 상자가 이벤트루프의 전부입니다. 무한 루프를 돌면서 완료된 이벤트를 큐에서 꺼내고, 거기 매달린 콜백을 실행합니다. 새 요청이 도착한 것도 이벤트, 디비 응답이 도착한 것도 이벤트입니다. 마법 같은 이름과 달리 구조는 놀랄 만큼 단순합니다.
연결이 1만 개일 때 "이 연결에 데이터 왔니?"를 하나씩 물어보면 그것만으로도 CPU가 바닥납니다. 그래서 운영체제가 "이 1만 개 중에 준비된 것만 알려줘"라고 한 번에 물어보는 방법을 제공합니다(리눅스의 epoll, macOS의 kqueue). 이벤트루프가 실용적이 된 결정적 이유가 이것입니다.
같은 요청량을 스레드 하나로 처리합니다. 화면 아래 숫자를 10단원과 비교해 보세요. 완료 속도는 비슷하거나 오히려 낫지만, 스레드 메모리는 4.0에서 1.x로 떨어지고 갈아타기 횟수는 0에 가깝습니다. 이 모형은 루프 안에서 요청을 바꾸는 일을 OS 스레드 교대로 세지 않습니다. 실제 루프 스레드도 OS에 선점되거나 이벤트 대기 중 잠들 수 있습니다.
루프가 매번 모든 연결을 확인한다면 연결 수에 비례해 느려집니다. 예전의 select가 그랬습니다. epoll과 kqueue는 관심 있는 소켓을 커널에 미리 등록해 두고, 준비된 것만 목록으로 돌려줍니다. 그래서 연결이 만 개여도 실제로 준비된 열 개만 받아옵니다. 이벤트루프가 현실적인 선택이 된 것은 이 커널 기능이 나온 다음입니다.
루프는 요청을 아주 빨리 받아들일 수 있습니다. 문제는 받아들인 만큼 처리할 수 있느냐입니다. 처리 속도보다 수용 속도가 빠르면 대기 중인 콜백과 버퍼가 메모리에 쌓이다가 결국 터집니다. 그래서 리액티브 스택은 소비자가 감당할 수 있는 만큼만 요청하게 하는 장치를 둡니다. 이것을 백프레셔라고 합니다.
1handle(req) {2 id = parse(req)① CPU 일함3 db.query(id, (user) => {② "끝나면 불러줘" 등록만4 res.send(toJson(user))③ 나중에 루프가 실행5 })6} // 기다리지 않고 바로 끝!
1while (true) {2 ev = queue.take() // 완료된 IO, 새 요청...3 ev.callback() // 해당 콜백 실행4}
시뮬레이터에서 해보기
이 단원의 용어
왜 이렇게 발전했나
엔진엑스가 아파치를 성능으로 앞선 이유, Node.js와 Redis가 스레드 하나로 수만 개의 연결을 감당하는 이유가 모두 이 구조입니다. 스레드 수를 늘려 동시성을 사는 대신, 기다림을 자료구조로 바꿔 메모리를 아낀 것입니다.
이벤트루프 = 기다림을 스레드 밖(목록)으로 빼기.
남은 문제
공짜는 아닙니다. 코드가 콜백으로 쪼개져 읽기 어려워집니다. 먼저 그 문제부터 해결해 봅시다 →