시작하며
이번의 TIL은 어제와 오늘의 내용을 합친 내용입니다.
이번에는 내가 아는것과 모르는것을 정확히 구분하기 위해 시작부터 해당 내용을 작성했습니다.
그 결과 아는것과 모르는것은 아래처럼 나뉘었습니다.
모르거나 잘 모르고 있는것을 알았으니 해당 내용에 대해 정리부터 한 다음 구현을 시작했습니다.
그리고 오늘은 다른 동료를 편결을 가진 시선으로 바라보지 않고 있는 그대로 바라봤습니다.
그 결과 저번주와 다르게 더 친하게 다가갈 수 있었고 앞으로도 노력 해 봐야겠습니다.
문제해결 방식의 두가지
그리고 오늘부터는 아하! 순간이란 내용에 대해 추가하려 합니다.
문제를 해결하는 방식은 두가지가 있습니다.
탑다운과 바텀업 인데요, 탑다운은 말 그대로 문제를 쪼개가며 큰 설계도를 만들어 문제를 해결하는 방법입니다. 바텀업 방식은 아무것도 모르는 상태에서 문제에 부딪혀 가며 문제를 해결하는 방식입니다.
코딩 할 때를 예로 들어보면 구현을 할 때 큰 틀을 짜고 ( 추상화 클래스 등 ) 설계를 하는게 탑 다운, 클래스 하나 만들고 다른거 하나 만들고 두개를 합쳐가며 리팩토링 해 나가는 과정을 바텀업 이라고 할 수 있을것같습니다.
얼마전 읽은 책에서는 아하! 순간이 이 두 방식이 서로 전환될 때의 순간이라고 말하고 있습니다.
전문가는 보통 한가지 방식만 고집하지 않고, 두가지 방식을 섞어 쓴다고 하는데 나는 그 때가 언제인지 정확히 알아보기 위해 해당 내용을 맨 아래에 추가로 적어보려 합니다.
알고 있는것
- 비동기 처리 문법
- es6부터 async await promise를 사용한다.
- 병렬처리와 스레드
- 스레드는 흐름의 단위이다. 병렬처리는 스레드를 여러개 만들어서 동시에 처리한다.
- Promise
- js에서 비동기 처리를 위해 사용하는 객체이다.
(잘)모르고 있는것
Event Emitter
프론트엔드의 마우스, 키보드, 움직임 등은 모두 이벤트를 통해 처리된다.
nodejs는 events 모듈을 사용해 이와 유사한 시스템의 구축이 가능하다.
import { EventEmitter } from 'events';
class You extends EventEmitter {
constructor() {
super();
this.on('callme', () => {
console.log('나 불렀니?');
});
}
}
class Me extends EventEmitter {
constructor() {
super();
}
call(item: EventEmitter) {
console.log('응답해라!');
item.emit('callme');
}
}
function main() {
const me = new Me();
const you = new You();
me.call(you);
}
main();
EventEmitter 모듈의 on으로 이벤트를 등록하고 emit으로 해당 이벤트를 실행하게된다.
해당 객체로 이벤트를 호출할 때 해당 이벤트에 붙어있는 모든 함수는 동기적으로 호출되며, 호출을 받은 리스너가 반환한 결과는 어떤 값이든 무시된다.
객체지향 설계와 데이터 흐름
- 객체지향 설계는 프로그램을 객체라는 단위로 나눈다.
이 때 상속, 다형성, 캡슐화 등의 원칙을 적용한다.
- 데이트 흐름은 프로그램 내에서 데이터가 어떻게 이동되는지를 중점으로 고려한다.
이 흐름은 입/출력, 처리 과정을 시각적으로 표현하기도 한다.
- ex) 데이터 플로우 다이어그램, 데이터 플로우 그래프
- 이 두가지 개념을 함께 적용하면 효율적이고 유지보수가 용이한 프로그램을 개발할 수 있다.
스레드 풀
- 병렬처리 작업이 많아지면 스레드 대수가 늘어나고 그에따른 스레드 생성, 스케줄링으로 cpu가 바빠져 메모리 사용량이 늘어나다. 이는 애플리케이션 성능 저하로 이어진다.
- 이를 방지하기 위해서 스레드 풀을 사용해야 한다.
- 스레드 풀은 작업 처리에 사용되는 스레드를 제한해두고 작업큐에 들어오는 작업을 하나씩 스레드가 맡아 처리한다.
- 작업 처리가 끝난 스레드는 다시 작업큐에서 새로운 작업을 가져와 처리하낟.
- 따라서 처리 요청이 폭증해도 작업 큐라는 곳에 작업이 대기하다가 여유가 있는 스레드가 그것을 처리하므로 스레드의 전체 개수는 일정하며, 애플리케이션 성능 저하도 되지 않는다.
- nodejs에서 멀티스레드 모델은 스레드 폴을 두고 요청을 처리할 때 스레드를 기반으로 처리한다.
- I/O, 네트워크 등의 작업은 os에게 넘겨주어 논블로킹 방식으로 동작한다.
- 하지만 os 에서 지원하지 않는 특정 작업은 libuv에서 처리하고 내부적으로 운영되는 스레드 풀을 이용해 논블로킹을 유지한다.
이벤트 루프(이벤트 큐)
- 이벤트 루프는 비동기 작업을 관리하기위한 구현체다. 이벤트 루프는 특정 작업을 수행하기 위한 페이즈로 구성되어있다.
- Timer Pahse
- Pending Callbacks Phase
- Idle, Prepare Phase
- Poll Phase
- Check Phase
- Close Callbacks Phase
graph LR
TimerPhase(TimerPhase)-->PendingCallbacksPhase(PendingCallbacksPhase)-->Idle,PreparePhase(Idle,PreparePhase)-->PollPhase(PollPhase)-->CheckPhase(CheckPhase)-->CloseCallbacksPhase(CloseCallbacksPhase)-->TimerPhase(TimerPhase)
- 위의 페이즈들은 무한루프를 돌게된다. 그리고 한 페이즈에서 다른 페이즈로 넘어가는걸 틱(Tick) 이라고 부른다.
- 각 페이즈는 자신만의 큐를 하나씩 갖고 있는데 이 큐에는 이벤트 루프가 실행해야 하는 작업들이 순서대로 담겨있다. nodejs가 페이즈에 진입을 하면 이 큐에서 자바스크립트 코드를 꺼내 하나씩 실행하게 된다. 만약 큐의 작업을 모두 실행하거나 시스템의 한도에 다다르면 다음 페이즈로 넘어간다.
- 큐의 작업을 수행하던중 같은큐에 또 작업이 들어오면 한개만 완료하고 다음 페이즈로 넘어가는게 아니라 해당 작업도 동일하게 처리한다.
- 근데 페이즈는 시스템의 실행 한도의 영향을 받기에 쌓인 작업을 처리하다가 포기하고 다음 페이즈로 넘기기에 한 페이즈에 영원히 갇히는 일은 발생하지 않는다.
- 하지만 nextTickQueue의 경우 시스템 실행 한도의 영향을 받지 않으므로 영원히 갇혀 다음 페이즈로 이동하지 못할수도 있다.
- 프로그램을 실행하면 우선 이벤트 루프를 실행한다. 그리고서 코드를 싱핸하는데 이 때 비동기 처리할 작업이 있을 경우에만 이벤트 루프에 진입한다.
- 이벤트 루프에 진입하면 해당 작업을 처리하기 위한 페이즈를 실행한다.
- 만약 루프가 더이상 없다면 프로그램을 종료한다.
각 페이즈의 역할
Timer Phase
- setTimeout, setInterval과 같은 함수가 만들어내는 타이머들을 다룬다.
- 정확히 말하면 큐에 콜백을 직접 담지 않는다. 대신 콜백을 언제 실행할지에 대한 정보가 담긴 타이머를 Timer Phase의 최소힙에 넣는다.
- 만약 Poll Phase에서 setTimeout을 3번 호출했다면 타이머 페이즈의 최소힙에 3개의 타이머가 저장되어있고, 실행할 준비가 되면(시간이 되면) 타이머가 가리키고 있는 콜백을 호출한다.
- 이러한 원리때문에 setTimeout(fn,1000)을 호출해도 정확히 1초후에 실행되지 않는다.
- 만약 1초후에 실행되어야 타이머가 여러개 있어도 시스템 실행 한도에 다다르면 다음 페이즈로 넘어간다. 타이머가 5개 있는데 한도가 3이라면 나머지 2개는 다음 루프에 실행되게 된다.
Pending Callbacks Phase
- 해당 페이즈의
pending_queue
큐에는 이전 이벤트 루프 반복에서 수행되지 못했던 I/O 콜백들이 담기게 된다.
- 예를들어 Timer Phase에서 시스템의 실행 한도 제한에 걸려 처리되지 못한 작업들을 Pending Callbacks Phase의 큐에 쌓아둔다.
Idle, Prepare Phase
- nodejs의 내부적인 관리를 위한 페이즈이다. 그래서 자바스크립트를 실행하지 않는다.
Poll Phase
watcher_queue
의 콜백들을 실행한다. 해당 큐에는 거의 모든 I/O에 대한 콜백이 담긴다. 즉, setTimeout, setImmediate, close 콜백등을 제외한 모든 콜백이 여기서 실행된다.
- 예를들면 db요청, http요청, 파일 읽기/쓰기 등의 요청이 있을 수 있다.
watcher_queue
큐에 담긴 콜백들은 TimerPhase와 다르게 큐에 들어온 순서대로 처리가 완료된다는 보장이 없기에 단순한 콜백 큐를 사용하지 않는다.
- 이벤트 루프가 n개의 열린 소켓이 있고, n개의 완료되지 않은 요청이 있다고 할 때, 이 n개의 소켓에 대해 소켓과 메타데이터를 가진 watcher를 관리하는 큐가
watcher_queue
다. 각 watcher는 FD(File Descriptor)를 가지고 있다.
- OS가 FD가 준비됐다고 알리면 이벤트 루프는 해당 FD를 가진 watcher를
watcher_queue
에서 찾아서 실행한다.
Poll Phase Blocking
- 다른 페이즈는 자신의 관리하는 큐만 확인하고 다음 페이즈로 넘어간다. 하지만 Poll Phase는 조금 다르게 동작하는데, 이벤트 루프를 한번 돌았을 떄 실행할 수 있는작업이 없다면 Poll Phase에서 잠시 대기할 수 있다. 이 때 대기하는 시간은 여러 조건들에 의해 결정된다.
- 이벤트 루프가 종료된경우 다음 페이즈로 넘어간다.
- Close Callbacks Phase, Pending Callbakcs Phase에 실행할 작업이 있다면 다음페이즈로 넘어간다.
- Timer Phase에서 즉시 실행할 수 있는 타이머가 있다면 다음 페이즈로 넘어간다.
- Timer Phase에서 즉시 실행할 수 있는 타이머는 없지만, n초후 실행할 수 있는 타이머가 있다면 n초후 다음 페이즈로 넘어간다.
Check Phase
- 오직 setImmediate의 콜백만을 위한 페이즈다. setImmediate가 실행되면 Check Phase의 큐에 담기고 차례대로 실행한다.
- process.nextTick은 같은 페이즈에서 호출한 즉시 실행된다.
- setImmediate는 다임 틱에서 실행된다. 정확히는 nodejs가 틱을 거쳐 Check Phase에 진입하면 실행된다.
Close Callbacks Phase
- close 콜백들을 실행한다. 이 콜백들은 socket.on('close', ...)와 같이 소켓이 닫힐 때 실행되는 콜백이다.
nextTickQueue, microTaskQueue
-
이 두 큐는 이벤트 루프의 일부가 아니다. 정확히는 libuv에 포함되지 않고, nodejs에 구현되어 있다. 따라서 이벤트 루프의 페이즈와 상관없이 동작한다.
-
nextTickQueue는 process.nextTick()의 콜백을 관리하고 microTaskQueue는 Resolve된 Promise의 콜백을 가지고 있다. 이 두 큐는 현재 페이즈와 상관없이 지금 수행하고 있는 작업이 끝나면 즉시 실행한다.
-
우선 순위는 nextTickQueue가 더 높으므로 먼저 실행된다.
Promise.resolve().then(() => console.log('resolve'));
process.nextTick(() => console.log('nexTick'));
-
이 두 큐는 시스템의 실행 한도의 영향을 받지 않는다. 따라서 큐가 비워질 떄까지 콜백들을 실행한다.
const fn = () => {
process.nextTick(fn)
}
setTimeout(() => {
console.log("Timer")
},0 )
fn()
Event Emitter
- 특정 이벤트에 리스너 함수를 달아서, 이벤트가 발생했을 때 이를 캐치할 수 있도록 만들어진 api이다. 이 동작은 일반적으로 이벤트 리스너가 원래 등록된 이벤트 핸들러보다 나중에 호출되기때문에 비동기처러 보인다.
- dom API의 addEventListener와 비슷하다.
이벤트 핸들러
- event emitter에 등록된 함수를 이벤트 핸들러라고 한다.
libuv?
- nodejs의 이벤트 루프를 구현한 라이브러리이다.
- 운영체제의 커널을 추상화한 래핑 라이브러리로 커널이 어떤 비동기 API를 지원하는지 알고있다.
- 그래서 libuv에게 비동기 작업을 요청하고, 커널이 지원하는 작업이라면 커널에게 요청했다가 그 응답을 전달해준다. 만약 작업을 커널이 지원하지 않는다면? 자신만의 워커 스레드가 담긴 스레드 풀을 사용한다.
- nodejs는 싱글 스레드로 논블로킹 비동기 작업을 지원하는데, I/O 작업을 다른 스레드에 위임함으로 이를 지원한다. 그리고 그 기반에는 이벤트 루프가 존재한다.
아하! 순간
- 처음에 nodejs의 timerPhase를 흉내낼려고 setTimeout을 커스텀으로 만들었는데 이 때 입력 문제가 생겼습니다. 그래서 다 만들고 나서야 setTimeout을 사용해야 한다는 아하 ! 순간이 왔습니다. 그런데 구조를 이벤트 루프를 흉내내서 그런지 해당 부분만 교체후 정상적으로 동작할 수 있었습니다.
- 이벤트 컨트롤러를 만들 처음에는 하나의 클래스를 상속받아서 만들면 될것같다고 생각했습니다. 하지만 해당 이벤트 컨트롤러는 한개만 있어야 하기때문에 설계가 바뀌었습니다.
- 처음에는 nodejs의 이벤트 루프를 그대로 따라할려고 했었습니다. 그런데 다른 동료의 말을 듣고 이벤트 루프는 nodejs 뿐만 아니라 모든 프로그램에서 존재하고 그 방식은 다르다는걸 듣고 고정관념에 갇혀있다고 생각했었습니다. 그래서 기존의 틀을 깨고 생각을 할 수 있게 됐습니다. 그래서 구조를 과감히 바꾸게 되었고 결과는 성공적이었습니다.
오늘을 마치며
이번엔 이틀동안 긴 호흡으로 구현을 진행했습니다.
좋았던 점은 동료들의 피드백을 듣고 혹은 동료들의 코드를 보고 내 코드를 한번 더 개선할 시간이 주어졌다는 점 이었습니다. 그런데 조금 아쉬운 점은 동료를 만나는 날이 조금 줄어들어서 아쉽다는 정도입니다.
매일매일 새로운걸 배우고 느끼는 점이 많은데 오늘은 특히 그랬습니다.
지금까지는 처음 설계가 잘못되면 조금씩 잘못된 부분을 고치기만 했는데 이번에는 구조를 많이 뒤집어 엎었습니다. 앞으로도 만약 처음의 설계가 잘못됐다고 느끼고 생각하면 과감하게 진행 방향을 바꿔야겠습니다.
참고