Event Driven Programming
자바스크립트나 파이썬, Redis 등 싱글 스레드 기반으로 동작하는 프로그래밍 도구들을 보면 모두 내부적으로 이벤트 기반 프로그래밍(Event Driven Programming) 을 활용한다는 걸 알 수 있다. 다중 스레드 기반으로 운영되긴 하지만 네트워크 프레임워크 Netty 역시 이벤트 기반 프로그래밍 방식으로 구현되어 있어서 이벤트 루프가 핵심이라고 한다. 이벤트 기반 프로그래밍은 프로그램의 동작이 이벤트에 의해 트리거되는 방식으로 설계되는 프로그래밍 패러다임이다. 쉽게 말하면, 이벤트들을 처리하기 위해서 작업을 큐에 넣고 이벤트가 발생하면 꺼내서 처리하는 방식인 셈이다. 멀티 스레딩의 복잡성을 버리고, 프로그램의 구조를 단순화하고 비동기 작업을 보다 용이하게 하기 위한 방식이기도 하다.
이벤트 기반 프로그래밍의 가장 큰 장점은 역시 비동기 작업을 처리할 수 있다는 점인데, 하나의 스레드로도 사용자 인터페이스, 네트워크 통신, 파일 입출력 등 다양한 작업을 비동기적으로 처리할 수 있도록 해준다. 하지만 반대로 이벤트의 관리와 처리에 있어 복잡성을 증가시킬 수도 있다. 프로그램이 다수의 이벤트를 동시에 처리해야 할 경우, 이벤트의 우선순위 관리나 이벤트 핸들러의 설계가 중요해진다. 또 디버깅이 어려울 수 있고, 이벤트 흐름을 정확히 파악하지 못하면 예기치 않은 버그를 발생시킬 수도 있다.
오늘은 이벤트 기반 프로그래밍을 학습하는데 있어 가장 손쉬운 예시인 자바스크립트의 이벤트 루프(Event Loop) 의 동작 방식을 살펴보려고 한다. 그렇다면 어떤 연유로 자바와 다르게 싱글 스레드를 사용하는 언어가 되었는지 먼저 알아보고, 본격적으로 싱글 스레드로 어떻게 비동기 작업을 수행할 수 있었는지 알아보도록 하자.
자바스크립트가 싱글 스레드인 이유
자바스크립트가 태동하던 시기를 살펴보면, 자바스크립트는 1995년에 넷스케이프에서 자신들이 개발한 웹브라우저에서 동적인 웹 페이지를 만들기 위해 개발된 스크립트 언어였다. 애초에 웹 페이지의 보조적인 기능을 수행하기 위해 만들어진 경량 프로그래밍 언어이기도 했다. 당시에는 멀티 코어 프로세서가 보편화되지 않았고, 브라우저에서 간단한 스크립트 동작을 수행하는 데 자바스크립트가 주로 사용되었기 때문에 복잡한 병렬 처리까지 필요하지 않았다. 그래서 메모리 사용량이 적고, 동시성 문제를 피할 수 있는 싱글 스레드로 충분했다.
하지만 웹 애플리케이션이 발전함에 따라서 자바스크립트의 역할은 점점 더 다양하고 중요해졌다. AJAX의 등장으로 서버와의 동적인 데이터 통신도 활발해졌다. 그런 상황 속에서 싱글 스레드는 한 작업이 오랜 시간동안 실행되는 동안 다른 작업들이 대기해야 하기때문에 응답성이 떨어진다는 한계가 있다. 또 CPU 코어를 여러 개 사용할 수 없어 성능도 제한된다. 이러한 문제들을 해결하기 위해 언어 자체의 설계를 바꾸는 것 보다는 브라우저의 멀티 스레드를 활용해서 자바스크립트의 비동기 프로그래밍을 지원하는 방향으로 발전했다. 그리고 이 비동기 프로그래밍의 핵심 개념이 오늘 메인 주제인 Event Loop이다.
자바스크립트도 멀티 스레드가 가능하다
HTML5가 나오면서 웹 워커 나 서비스 워커라는 멀티 스레드를 지원하는 API도 등장했지만, 기본적으로 자바스크립트는 싱글 스레드 기반의 언어이다. 웹 워커란 웹 브라우저(혹은 노드)에서 워커 쓰레드를 활용할 수 있는 웹 API 이다. 웹 워커는 복잡한 연산에는 사용할 수 있지만, Window나 Document같은 Dom 객체에 접근할 수 없어서 UI접근이 불가능하다. 즉 자바스크립트의 단일 스레드는 UI 스레드라고 보면 되고, UI 스레드가 처리하기 어려운 무거운 연산(AI, games, image encoding, etc)은 웹 워커를 이용해 CPU가 처리한다고 보면 된다. 멀티 스레드가 가능해지면서 스레드 간 자원을 공유할 수 있는 SharedArrayBuffer라는 공유 객체도 ES2017부터 생겼다.
웹 워커(Web worker)는 스크립트 연산을 웹 어플리케이션의 주 실행 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술입니다. 웹 워커를 통해 무거운 작업을 분리된 스레드에서 처리하면 주 스레드(보통 UI 스레드)가 멈추거나 느려지지 않고 동작할 수 있습니다.
Web Workers API - Web API | MDN
Web Workers API - Web API | MDN
웹 워커(Web worker)는 스크립트 연산을 웹 어플리케이션의 주 실행 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술입니다. 웹 워커를 통해 무거운 작업을 분리된 스레드에서
developer.mozilla.org
웹 브라우저 비동기 동작 구성요소
자바스크립트를 실행하는 대표적인 소프트웨어가 웹브라우저와 런타임인 Node.js가 있는데, 그 중 대표적으로 웹 브라우저(chrome)의 자바스크립트 비동기 작업을 담당하는 구성요소를 살펴보자.(Node.js도 크게 다르지 않다.)
Call Stack
- 자바스크립트 엔진이 코드 실행을 위해 사용하는 메모리 구조
- 자바스크립트 엔진은 싱글 스레드 방식으로 실행되기 때문에 하나의 콜 스택을 갖는다. 이는 함수를 실행 할 수 있는 창구가 단 하나이며, 동시에 2개 이상의 함수를 동시에 실행시킬 수 없다는 의미이다. 따라서 작업 수행 시 블로킹이 발생한다.
Heap
- 동적으로 생성된 객체가 저장되는 메모리 공간
Web APIs
- 브라우저에서 자체 제공하는 API 모음
- 비동기적으로 실행되는 작업들을 전담하여 처리한다.
- Web API는 브라우저에서 멀티 스레드로 구현되어 있다.
- Web APIs의 대표적인 종류 (* 모든 Web API들이 비동기로 동작되는 것은 아님)
- DOM : HTML 문서의 구조와 내용을 표현하고 조작할 수 있는 객체
- XMLHttpRequest: 서버와 비동기적으로 데이터를 교환할 수 있는 객체. AJAX기술의 핵심
- Timer API: 일정한 시간 간격으로 함수를 실행하거나 지연시키는 메소드들을 제공
- Console API : 개발자 도구에서 콘솔 기능을 제공
- Canvas API: <canvas> 요소를 통해 그래픽을 그리거나 애니메이션을 만들 수 있는 메소드들을 제공
- Geolocation API: 웹 브라우저에서 사용자의 현재 위치 정보를 얻을 수 있는 메소드들을 제공
- Web APIs의 대표적인 종류 (* 모든 Web API들이 비동기로 동작되는 것은 아님)
Callback Queue
- 비동기적 작업이 완료되면 실행되는 함수들이 일시적으로 대기하는 공간이다.
- 콜백 큐에는 (Macro)Task Queue, Microtask Queue 두 가지 큐로 이루어져 있다.
- Task Queue : setTimeout, setInterval, fetch, addEventListener 와 같이 비동기로 처리되는 함수들의 콜백 함수가 들어가는 큐
- Microtask Queue : promise.then, process.nextTick, MutationObserver 와 같이 우선적으로 비동기로 처리되는 함수들의 콜백 함수가 들어가는 큐 (처리 우선순위가 높음)
- 참고로 브라우저의 큐는 콜백 큐 뿐만 아니라 브라우저 애니메이션 작업에 대한 처리를 담당하는 AnimationFrame Queue도 있다.
- Microtask Queue → Animation Frames → Task Queue 순으로 실행된다.(크롬기준. 웹 브라우저마다 탐색 순서가 다를 수 있다.)
Event Loop
- 비동기 함수들을 적절한 시점에 실행시키는 관리자
- 이벤트루프는 메인 스레드 겸 싱글 스레드로서 비즈니스 로직을 수행한다. 역할은 콜 스택과 콜백 큐(Tasks Queue) 대기열의 상태를 지속적으로 모니터링하는 것이다. 콜 스택이 비어 있으면 콜백 큐에서 대기 중인 콜백 함수를 가져와 콜 스택에 배치하여 실행을 예약한다. 참고로 이 한 번의 작업을 틱(tick)이라고 한다.
- 자바스크립트 같은 경우 비동기로 작업을 요청하면 브라우저에 내장된 멀티 스레드로 이루어진 Web API에 작업이 인가되어 메인 콜 스택과 작업이 동시에 처리되게 된다. 대표적인 비동기 작업으로 타이머 API, 애니메이션 실행, 네트워크 통신, 마우스 키보드 입력, 타이머 등 많다. 다만 자바스크립트 코드 실행 자체는 Web API가 아닌 콜 스택에서 실행된다.
이벤트 루프 동작 과정
- 동기 작업 실행: 콜 스택에 쌓인 동기 작업을 순차적으로 실행한다.
- 비동기 작업 처리: 비동기 함수 호출 시, 해당 작업은 웹 API에서 처리되고, 완료된 콜백 함수는 콜백 큐에 적재된다.
- 이벤트 루프 작동: 이벤트 루프는 콜 스택이 비어 있는지 확인한 후, 비어 있다면 콜백 큐에서 대기 중인 콜백 함수를 콜 스택으로 옮겨 실행한다.
- microtask queue 우선 처리: 이벤트 루프는 (매크로)태스크 큐보다 마이크로태스크 큐를 우선적으로 처리한다. 마이크로태스크 큐가 비워진 후에 태스크 큐를 처리한다.
글로만 보면 이해가 잘 안될 것이다. 직접 내부 동작이 어떤 식으로 이뤄지는지 잘 설명해 놓은 발표 영상이 있어서 아래 첨부했으니 참고.
- Javascript Event Loop 동작 방식을 시각적으로 참고할 수 있는 발표 영상
https://www.youtube.com/watch?feature=shared&t=766&v=8aGhZQkoFbQ
- 위 영상 발표자가 만든 Javascript Event Loop를 시각적으로 확인할 수 있는 웹페이지
http://latentflip.com/loupe/?code=JC5vbignYnV0dG9uJywgJ2NsaWNrJywgZnVuY3Rpb24gb25DbGljaygpIHsKICAgIHNldFRpbWVvdXQoZnVuY3Rpb24gdGltZXIoKSB7CiAgICAgICAgY29uc29sZS5sb2coJ1lvdSBjbGlja2VkIHRoZSBidXR0b24hJyk7ICAgIAogICAgfSwgMjAwMCk7Cn0pOwoKY29uc29sZS5sb2coIkhpISIpOwoKc2V0VGltZW91dChmdW5jdGlvbiB0aW1lb3V0KCkgewogICAgY29uc29sZS5sb2coIkNsaWNrIHRoZSBidXR0b24hIik7Cn0sIDUwMDApOwoKY29uc29sZS5sb2coIldlbGNvbWUgdG8gbG91cGUuIik7%21%21%21PGJ1dHRvbj5DbGljayBtZSE8L2J1dHRvbj4%3D
latentflip.com
위 영상을 봤다면 아래 예시 코드들이 어떻게 동작할지 충분히 예상할 수 있을 것이다.
예시1) setTimeout
function bar() {
setTimeout(() => {
console.log("Second")
}, 500);
}
function foo() {
console.log("First");
}
function baz() {
console.log("Third");
}
bar();
foo();
baz();
예시2) setTimeout + Promise
console.log('Start!');
setTimeout(() => {
console.log('Timeout!');
}, 0);
Promise.resolve('Promise!').then(res => console.log(res));
console.log('End!');
1. 콜 스택에 로그 콘솔 로그 함수가 쌓인 뒤 실행되어 콘솔창에 Start! 를 출력한다.
2. setTimeout 함수가 콜 스택에 쌓인 뒤 실행되면 내부 콜백 함수가 이벤트 루프에 의해 Web API로 옮겨지고 지정한 타이머(0초)가 실행된다.
3. 0초동안 대기 후 setTimeout의 콜백 함수는 이벤트 루프에 의해 macrotask queue에 쌓인다. 그리고 곧이어 Promise 함수가 콜 스택에 쌓여 실행되고 then 핸들러의 콜백 함수가 이벤트 루프에 의해 microtask queue에 쌓인다.
4. 콘솔 로그 함수가 콜 스택에 쌓여 실행되고 콘솔창에 End! 가 출력된다.
5. 메인 스레드의 모든 코드가 실행되어 콜 스택이 비게 되면 이벤트 핸들러가 이를 감지하여 콜백 큐에 남아 있는 콜백 함수들을 불러와 콜 스택에 쌓는다. 이때 우선순위에 의해 microtask queue에 남아 있는 콜백 함수가 먼저 콜 스택에 쌓여 처리된다.
6. microtask queue의 콜백 함수가 모두 실행된 뒤, 마지막으로 이벤트 루프는 macrotask queue에 있는 콜백 함수를 콜 스택에 쌓아 실행되게 한다.
문제) setTimeout + Promise
console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
Promise.resolve().then(() => setTimeout(() => console.log(4)));
Promise.resolve().then(() => console.log(5));
setTimeout(() => console.log(6));
console.log(7);
1 7 3 5 2 6 4
- [참고] 자바스크립트 온라인 컴파일러
- https://playcode.io/javascript
예시3) async
const one = () => Promise.resolve('One!');
async function myFunc(){
console.log('In function!');
const res = await one();
console.log(res);
}
console.log('Before Function!');
myFunc();
console.log('After Function!');
1. 콘솔 로그를 콜 스택에 쌓은 뒤 실행한다. 콘솔창에 'Before Function!' 이 출력된다.
2. async 함수인 myFunc 함수가 호출된다. async 함수 안에 있는 콘솔 함수가 실행되어 콘솔에 'In Function!' 이 출력된다.
3. Promise 객체를 반환하는 one 비동기 함수를 호출한다. 이때 await 키워드로 인해, myFunc 함수의 내부 실행은 잠시 중단되고 콜 스택에서 빠져나와 나머지 부분은 microtask queue에 쌓인다. 이는 자바스크립트 엔진이 await 키워드를 인식하면 async 함수의 실행이 지연되는 것으로 처리하기 때문이다.
4. 메인 스레드의 마지막 콘솔 로그를 콜 스택에 쌓은 뒤 실행한다. 콘솔창에 ‘After function!’이 출력된다.
5. 모든 메인 스레드의 코드가 실행되어 더이상 콜 스택에 실행할 스택이 남지 않게 되면 이벤트 핸들러가 이를 감지하여, microtask queue에 남아있는 async 함수를 가져와 콜 스택에 쌓는다. Promise 객체의 결과물인 ‘One!’ 문자열을 변수 res 에 담아 이를 콘솔창에 출력한다.
참고용
Netty 기반의 간단한 TCP 서버 샘플 코드
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import java.nio.charset.StandardCharsets;
public class NettyServer {
public static void main(String[] args) throws InterruptedException {
int port = 8080;
// 1. 이벤트 루프 그룹 생성
EventLoopGroup bossGroup = new NioEventLoopGroup(); // 클라이언트 연결 수락
EventLoopGroup workerGroup = new NioEventLoopGroup(); // 데이터 처리
try {
// 2. 서버 부트스트랩 설정
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class) // NIO 소켓 채널 사용
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new SimpleChannelHandler());
}
});
// 3. 서버 바인딩 및 시작
ChannelFuture future = bootstrap.bind(port).sync();
System.out.println("Server started on port " + port);
// 4. 서버 종료 대기
future.channel().closeFuture().sync();
} finally {
// 5. 이벤트 루프 종료
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
// 데이터 처리를 위한 핸들러
static class SimpleChannelHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf byteBuf = (ByteBuf) msg;
String message = byteBuf.toString(StandardCharsets.UTF_8);
System.out.println("Received: " + message);
ctx.writeAndFlush(msg); // 받은 데이터를 그대로 클라이언트에게 반환
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
}
자바스크립트와 파이썬의 Event Loop 차이점
- JavaScript의 이벤트 루프는 단일 스레드로 동작하며, Node.js와 브라우저 환경에서 주로 사용된다. 이벤트 루프는 콜백 큐에 있는 작업들을 순차적으로 처리한다.
- Python은 멀티스레드를 지원하지만, asyncio 이벤트 루프는 단일 스레드에서 동작한다. 이벤트 루프는 asyncio 모듈을 통해 제공되며, 비동기 I/O 작업을 관리한다. 비동기 작업을 통해 스레드 풀이나 프로세스 풀을 사용할 수 있다. async와 await 키워드를 사용하여 비동기 코드를 작성할 수 있다.
특징 JavaScript Python (asyncio)
기본 실행 모델 | 단일 스레드, 이벤트 루프 기반 | 단일 스레드, 이벤트 루프 기반 |
비동기 작업 관리 | 콜백, Promise, async/await | async/await, 코루틴, 태스크 |
멀티스레드 지원 | 기본적으로 단일 스레드 | 멀티스레드와 멀티프로세싱 지원 |
이벤트 루프 시작 방법 | 자동 (브라우저 또는 Node.js가 관리) | 명시적으로 이벤트 루프 시작 (asyncio.run()) |
우선순위 큐 | 마이크로태스크 큐, 태스크 큐 | 태스크만 존재, 명시적 우선순위 없음 |
콜백 큐 | setTimeout, setInterval, Promise 등 | asyncio.sleep, 비동기 I/O, 사용자 정의 비동기 작업 |
03-0001. javascript와 python의 이벤트 루프 처리 방식의 차이점은?
[python] JavaScript와 Python의 이벤트 루프 처리 방식은 비동기 프로그래밍 모델의 구현에 있어서 중요한 차이점을 보입니다. 두 언어 모두 이벤트 루프를 사용하…
wikidocs.net
본문에 사용된 예시 참고
정리가 잘 된 글
🔄 자바스크립트 이벤트 루프 동작 구조 & 원리 끝판왕
자바스크립트 비동기와 이벤트 루프 브라우저의 멀티 스레드로 작업을 동시에 Javascript는 싱글 스레드 언어라고 들어본 적이 있을 것이다. '싱글' 스레드라 한 번에 하나의 작업만 수행이 가능하
inpa.tistory.com
'개발 > Javascript' 카테고리의 다른 글
javascript에서 onclick과 addEventListener 차이점(+이벤트 캡처링 vs. 버블링) (0) | 2023.03.29 |
---|