3장: CPS 패턴 사용 시의 Tip

Node.js 환경에서 CPS 패턴을 사용할 때 시도할 만한 Tip들을 정리했다.


1. Callback Hell을 조금 해결하는 방법

본인은 Promise 세대여서 Callback Hell을 제대로 경험해 본 적이 없고, 웬만한 개발 환경이라면 Callback Hell을 겪기 어려울 것으로 예상돼 짧게 요약했다.

들여 쓰기 때문에 가독성이 매우 떨어지게 되고, 변수 이름도 중첩되는 문제가 있다. 만약 Blocking API를 사용해 동일한 내용을 구현했다면 잘 못 이해할 가능성은 거의 없을 것이다.

Pattern:

  • 중첩 수준을 낮게 유지하기 위해, else 문을 사용하지 않는다.
  • 인라인 함수의 이름을 지정하면, 함수 이름을 통해 더 쉽게 디버깅이 가능하다.
  • 함수를 쪼갠다.

자주 하는 실수:

  • Callback을 호출한 뒤에도 함수는 계속 실행됨을 잊는다.

    1
    2
    if (err) callback(err);
    // 여기서도 함수는 계속 실행된다.
    • return callback(err) 혹은 return을 callback 호출 이후 수행하여 함수 실행을 종료한다.

2. 순차적으로 실행시키는 방법

Callback Hell을 겪지 않고 비동기 API를 순차적으로 실행하는 방법:

  • 재귀 함수로 실행한다.
  • 재밌는 점은, StackOverFlow가 날 일은 없다는 점이다. 비동기 함수여서 매 번 스택이 초기화되니까.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const length = N;
const tasks = [
/* ... */
];
const data = [
/* ... */
];
const callback = (f) => f;
const iterate = (idx) => {
if (idx === length) return callback();

const task = tasks[idx];
task(data[idx], (err) => {
if (err) return callback(err);
iterate(idx + 1);
});
};
iterate(0); // Callback이 재귀적으로 수행돼, N 만큼 수행된다.

이 방식의 한계:

  • 실행될 작업의 숫자를 알아야 한다.

3. JS 경쟁 조건 해결하기

  • Javascript는 단일 스레드로 실행된다.

  • 리소스 동기화는 필요 없지만, 비동기 API 타이밍 문제는 아직 남아있다.

  • Javascript 역시 호출 시점과 I/O 수행 시점 차이로 중복 작업 등의 예기치 않은 동작을 할 수 있다.

  • 상호 배제로 해결 가능하다.

1
2
3
4
5
6
7
8
9
10
// 실행 중인 job을 등록한다. 공유 리소스 동기화는 필요 없다.
const jobs = new Map();
const fn = (id, data, callback) => {
// 이 코드로 타이밍 문제를 해결할 수 있다.
if (jobs.has(id))
return process.nextTick(callback);

jobs.set(id, true);
// 정상 분기.
};

4. 동시에 수행되는 작업 개수 제한 하기

한 번에 너무 많은 파일을 열려고 하는 등의 경우 리소스 부족으로 뻗어버릴 수 있다. 동시에 실행하는 작업의 수를 제한해 이를 상황을 방지하는 아이디어를 소개한다.

알고리즘:

  • 처음에 동시 실행 제한 개수만큼의 작업을 실행
  • 각 작업이 끝날 때, 동시 실행 제한 개수 - 현재 실행 개수 만큼의 작업을 실행
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const tasks = [
/* ... */
];
const limit = 2; // 동시 실행 제한 개수
let running = 0,
completed = 0,
idx = 0;

const next = () => {
// 여유 작업 개수만큼 반복
while (running < limit && idx < tasks.length) {
const task = tasks[idx++];
task(() => {
// 새 작업을 할 수 없음
if (completed === tasks.length)
return finish();
completed++;
running--;
next(); // 새 작업을 할 여유가 있음
});
running++;
}
};

// 동시 실행 제한 개수를 채우며 계속 실행함.
next();

큐로 구현하는 방법:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Queue로 구현하는 방식
// 로직은 같은데 Queue를 사용하는 것만 다르다.
class TaskQueue {
constructor(limit) {
this.limit = limit;
this.running = 0;
this.queue = [];
}

// task :: callback => void; (must call callback)
// task를 tasks에서 가져오는 게 아니라, Queue에 넣은 것이 나온다.
// => 새 작업을 큐에 동적으로 추가할 수 있다.
pushTask(task) {
this.queue.push(task);
this.next();
}

next() {
while (
this.running < this.limit &&
this.queue.length
) {
const task = this.queue.shift();
task(() => {
this.running--;
this.next();
});
this.running++;
}
}
}

5. Async 라이브러리 사용

복잡한 비동기 제어 흐름을 선언적인 방식으로 처리할 수 있게 헬퍼 함수들을 제공하는 라이브러리이다.

  • 순차적인 반복
  • 제한된 동시 실행

등을 헬퍼 함수를 통해 쉽게 구현 가능하다. CPS 패턴은 주로 사용할 것 같진 않아 따로 정리하진 않았다.


TODO:

  • 여러 번 이해하여 좋은 예제를 만들어 이 글 내용 보강하기

2장 (3/3): Node.js의 Observer Pattern

이 글은 Node.js에서 자주 사용되는 Observer Pattern에 대해 소개한다. 리액터 패턴, CPS 패턴에 대한 지식을 전제로 작성했으니 참고바란다.


1. Observer Pattern의 정의

Node.js에서 이벤트는 핵심 중 하나라고 한다.

Node.js 코어 모듈과 오픈 소스를 사용하는데도 필수적인 조건이라고 한다.

Observer Pattern은 Subject와 Listener 라는 역할로 한 쪽은 등록을, 한 쪽은 통지를 하는 관계이다.

  • Subject는 이벤트를 발생시키는 주체로, 스스로 무슨 행위를 할 때, Listener에게 통지를 해야 한다.

  • Listener는 특정 Subject 객체에 본인의 참조를 등록한다. subject.addListener(this)와 같이 수행한다.

  • foreach (listener : listeners) listener.notify(); 와 같이 Listener에게 이벤트 발생을 알린다.

  • 옵서버 패턴 | Wiki 백과 참고.

Observer Pattern이 Callback 보다 나은 점이 뭘까?

기능 Observer Pattern Continuous Passing Style
다중 리스너 지원 Yes No
핸들러 사용 횟수 여러 번(or 주기적으로) 발생하는 경우 한 번 발생하는 경우
핸들러 함수 제약 없음. onError, onSuccess 로 관심사 분리하므로. 한 함수 (err, data)=> { /* ... */ }로 두 상태 모두 처리
핸들러 등록 시점 아무 때나 함수 실행 시점에 매개변수로 전달

2. Event Emitter

Node.js는 Event Emitter라는 미리 구현된 객체를 코어 모듈(events)로 포함하고 있다. 이 객체는 emit, on, once, removeListener 로 구성된 총 4개의 메소드를 갖고 있다. 아래는 각 메소드의 사용 예시이다.

CodeSandBox가 Node.js를 Beta로 지원하고 있으므로 출력이 정상적이지 않을 수 있습니다. 왼쪽의 탭을 드래그해 코드를 확인해주세요.

아래는 File을 읽는 예제이다.

3. Event Emitter 에서의 예외 처리

Event Emitter에서도 비동기 이벤트의 경우, CPS와 마찬가지로 예외가 발생하는 경우 기존 스택을 잃기 때문에 (리액터 패턴 참고) try-catch로 무조건 예외를 처리하여야 한다. 이후 error 이벤트를 발생시켜 리스너들에게 전달함이 일반적이다.

4. Event Emitter 상속하기

아래와 같이 EventEmitter를 상속하여 인스턴스에 대해 .on을 붙이는 등의 작업을 할 수도 있다. 책에서는 일반적인 패턴이라고 하지만, emit 메소드까지 의도치 않게 Public API가 되기 때문에 추천하는 방식은 아니다. 위임으로 on, once, removeListener를 따로 API로 내보내는 게 맞다고 생각한다.

1
2
3
4
5
6
class FindPattern extends EventEmitter {
//...
}

const findPattern = new FindPattern(/hello \w+/g);
findPattern.on(/* ... */);

5. 동기, 비동기 이벤트 별 리스너 등록 시점

이벤트를 동기적으로 발생시키려면, 리스너 등록을 이벤트 발생 이전 시점에 완료하여야 한다.

이벤트를 비동기적으로 발생시키는 경우, 리스너를 동기적으로만 등록한다면 시점이 자유롭다. (리액터 패턴 참고.)

[1 Month Docker] 2. Dockerfile, Docker Image

Dockerfile과 Docker Image 개념을 소개하고, 핵심적인 내용을 설명한다. Docker에 대한 간단한 소개의 내용을 기본으로 가정하고 시작한다.


저번 글에선 Container와 Docker를 체험해보았다. Container는 어떠한 스택의 애플리케이션이든 배포 측면에서 일관된 경험을 제공하므로 사용하는 것이 좋지 않을까 생각한다.

Docker로 Container를 실행하려면 Docker Image가 필요한데, 이번 글에서는 최종적으로 Image를 직접 생성한다(공식적으로는 build 한다고 표현함.).

1. 기초 개념 설명

1. Dockerfile: 이미지 빌드 명령어의 입력으로 들어가는 스크립트이다. 아래와 같은 내용을 담는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Parent Image 지정
# Dockerfile은 이미지를 정의하는 파일이다.
# 새 이미지를 만들 때 다른 이미지의 내용에 기반해 덧씌우는 형태이다.
FROM diamol/node

# 환경 변수 3개 설정
# Docker와 같이 컨테이너 환경으로 앱이 배포되는 경우,
# 환경 변수를 arguments로 많이 활용한다.
ENV TARGET="blog.sixeyed.com"
ENV METHOD="HEAD"
ENV INTERVAL="3000"

# Working Directory를 /web-ping으로 지정 (폴더 생성 후 이동함. mkdir && cd)
WORKDIR /web-ping

# Host의 app.js 파일을, Working Directory(.)에 복사
COPY app.js .

# node로 다음의 js를 실행
# CMD 명령어는 컨테이너 실행 시에 1회 수행되는
# container.once('start', callback)과 같다.
CMD ["node", "/web-ping/app.js"]

아마 셸 스크립트에 익숙한 사람은 셸 스크립트와 다름 없다고 생각할 것이다. 맞다. 똑같다. 아마 셸 스크립트가 익숙하지 않으면 Dockerfile에 쉽게 친해질 순 없을텐데, 리눅스 환경 구성 기초 | T 아카데미리눅스 커맨드 라인 & 쉘 스크립트 #1 | ABCD DevOps라는 좋은 자료가 있으니 참고하자.

Dockerfile 안에서만 쓸 수 있는, Dockerfile에서 쓰일 만한, 명령어가 10개 정의돼있다. 이 명령어들이 주축이 돼서 Dockerfile의 내용을 구성하게 된다.

2. Image: 이미지는 Dockerfile에서 기술한 내용이 실행된 모습을 스냅샷 형태로 담은 파일이다.

  • 컨테이너 실행 시 이미지를 통해 Dockerfile에 정의된 내용이 그대로 재현된다.

3. Image 받아오기: 이미지를 직접 생성하지 않고, DockerHub 등의 Docker Registry (이미지 저장 서버)에서 받아올 수도 있다. 단순히 받아오기만 하는 명령어는 docker image pull 이다.

  • docker image pull diamol/ch03-web-ping 을 실행해 DockerHub에서 이미지를 받자.
  • 하나의 이미지를 받는데, 여러 Pull Complete가 표시돼있다. (나중에 설명한다.)

4. Image 빌드: docker image build 명령어를 실행하면, 이미지는 자동으로 빌드된다.

예: docker image build --tag web-ping . => web-ping이라는 이미지를 생성.

  • (Mandatory) .은 Dockerfile 및 COPY 등에서 Host의 기준 디렉토리로 사용된다.
  • (Mandatory) --tag는 이미지의 이름을 지정한다.
  • 주의: 파일을 Windows -> Linux로 복사하는 경우, 권한이 rwxrwx로 지정되는데, 이는 서로 권한 정보가 호환되지 않기 때문이다.
  • 로컬에서 직접 빌드된 이미지는 도커 엔진에 캐시돼 보관된다.
  • 새로운 버전을 빌드하려는 경우, --tag web-ping:v2와 같이 :으로 버전을 구분하여 명시하면 된다.

Docker Image Build Process Example

5. Image 실행(컨테이너로):

  • docker container run {image_name}으로 실행

6. Image Layer:

이미지에는 생성 과정에 대한 메타데이터도 포함된다. 이미지 생성 과정을 통해

  • docker image history web-ping
  • Image History Example

Docker Image는 Image Layer라는 더 작은 개념으로 구성되며, Dockerfile의 각 명령(CREATED BY) 마다 Layer가 생성된다.

  • 이미지는 각 Layer의 논리적인 집합이다.
  • Layer는 도커 엔진에 물리적인 파일의 형태로 캐시되는 단위이다.
  • 이미지 간에 Layer가 공유되므로 전체 용량 부하를 낮출 수 있다.
    • docker image ls로 논리적인 용량을 확인할 수 있지만, docker system df로 이미지가 차지하는 물리적인 용량을 확인할 수 있다.
    • docker system df

이런 Image Layer 캐시를 활용하려면 조건이 필요한데: Layer 이전의 Layer 들의 내용과 순서가 바뀌지 않아야 한다.

  • 이전 내용이 바뀌었는데, 이 명령(Layer)을 실행한 결과가 같음을 보장할 수 없다.
  • 만약 내용을 바꾸는 경우, 이 Layer에 의존하고 있던 모든 이미지에 영향을 끼친다.
  • 그러므로, 이전 Layer가 변경되는 경우, 이후 Layer는 캐시로 사용될 수 없게 되고, 새로 Layer를 생성하게 된다.

7. Layer 캐시 최적화 전략: Layer 캐시 활용을 통해 전체 용량과 이미지 빌드 시간을 줄일 수 있다.

  • 이미지에서 변하지 않는 부분을 최대한 먼저 실행해 새로 빌드할 Layer 수를 줄인다.

  • 캐시 사용 가능 여부는 Instruction의 내용과 Arguments(명령어 내용일 수도 있지만, COPY와 같은 경우 파일의 내용까지.)로 Hash 값을 만들고 비교하여 결정한다.

  • Hash가 일치하는 경우 빌드하지 않고 도커 엔진에 캐시된 Layer를 사용한다. 일치하지 않는 경우, 해당 Layer부터 최종 Layer까지 새로 빌드한다. (뒷 Layer의 해시가 같아도, 재사용할 수 없다.)

  • docker build image cache

  • app.js 파일을 수정한 후 (nano app.js) 빌드한 모습이다. COPY app.js를 수행하는 step 6가 다시 Layer를 만듦을 확인할 수 있고, 이후 Layer인 step 7은 바뀐 내용이 없지만 앞 Layer가 바뀌어서 다시 만들어짐을 확인할 수 있다.

8. Layer 캐시 최적화 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FROM diamol/node

# 시작 시 실행될 명령어를 지정하는 것이므로, 어디에 놓아도 상관 없다.
# 캐시를 위해 앞에 놓는다.
CMD ["node", "/web-ping/app.js"]

# 환경 변수 3개를 한 번에 등록해 Layer 개수를 줄였다.
# 개수를 줄인 것과 캐시 최적화는 큰 연관은 없지만...
ENV TARGET="blog.sixeyed.com" \
METHOD="HEAD" \
INTERVAL="3000"

WORKDIR /web-ping

COPY app.js .

이제 docker image build -t web-ping:v3를 실행해보자. 환경 변수 개수가 줄어들어 7단계에서 5단계로 줄었음을 확인할 수 있다.

이제부턴 app.js를 수정해도 마지막 Layer만 바뀐다.


2. 실습

1. 목표:

diamol/ch03-lab 폴더의 이미지에서 /diamol/ch03.txt 파일을 수정하고 새 Image를 생성하라. 이 때 Dockerfile을 수정해서는 안 된다.

2. 힌트:

  • -it으로 컨테이너에 키보드 I/O 가능
  • 컨테이너 파일 시스템이 Exit 상태에도 제거되지 않음을 활용
  • docker container --help로 모르는 명령어에 대해 공부할 것

3. 처음 생각한 접근 방법:

  1. Container에서 일단 파일을 수정한다.
  2. 컨테이너로 이미지를 생성해낸다. 명령어를 찾아보자.

4. 실제 수행 과정:


1. 일단 이미지를 빌드함

cd ../../lab (빌드를 위해 lab 폴더로 이동)

docker build image -t ch03-lab . (빌드 성공)


2. 이제 컨테이너를 실행해야 함

docker container run ch03-lab (실패)

docker container ls (없었음)

cat Dockerfile (CMD 등 명령어 실행이 없고, COPY 뿐이었음)


3. 컨테이너에서 수행할 명령어로 주어 실행해야 함

docker container run ch03-lab /bin/bash (실패)

docker container run ch03-lab /bin/sh (이미지에 bash가 없었음..)

vi ch03.txt (텍스트 파일 수정)

exit (sh 나옴)


4. 정지된 컨테이너를 이미지로 빌드해야 함

Docker Commit Reference를 참고해서 빌드 명령어 학습

docker container ls --all 로 종료된 컨테이너 ID 확인 (67a)

docker image commit 67a ch03-lab:v2 (무슨 해시값이 출력됨..)

docker image ls (v2로 생성됨을 확인)

docker container run ch03-lab:v2 cat ch03.txt (파일 갱신됨을 확인)

끝!


Lab 하면서 배운 점:

docker commit 명령어로 컨테이너 내용으로 이미지를 빌드할 수 있다는 점.

  • 다만 이렇게 되면 Dockerfile은 없는게 아닌가?

docker container run {IMAGE} {COMMAND}로 명령어를 실행할 수 있음

  • 다만 이는 이미지에서 수행하는 명령어가 없는 경우에 한한 것 같고, docker container exec으로 셸을 띄우는 것이 일반적인 것 같다.

TO DO:

  • 컨테이너에서 Commit으로 생성한 이미지에서 Dockerfile을 추출할 수 있을지 확인해보기

10분 만에 기반 지식 없이 Node.js를 위한 Github Actions CI 구축하기

목표

  • 10분 만에 Github Actions를 통한 CI를 구축한다.
  • master에 Merge 시에 ESLint와 테스트를 자동으로 수행하고, 결과에 따라 Merge할 수 없게 한다.

1. 기본 개념 설명

기본 개념 몇 가지를 소개하고 넘어간다.

1. CI

Continuous Integration. 각자의 코드를 병합하기 전에 검토하는 절차를 말한다.

보통 자동화된 상태를 지칭하며, master(or main) 등의 특정 브랜치에 Push(or Merge) Request가 올라오면 코드를 검토한다.

2. GitHub Actions

GitHub에서 특정 작업을 할 때 마다, 이벤트를 발생시키는데, 이를 구독해 특정 작업을 실행하는 것을 Github Actions라고 한다. (옵저버 패턴 참고)

3. Worflow File

GitHub Actions의 이벤트에 대해 무엇을 실행할 지에 대해 기록해 놓은 명령서를 workflow 파일이라고 한다.

4. Github Actions 사용 시의 CI 흐름

PR Created(EVENT!) > Build > Test > PR Merged(EVENT!) > Deploy (배포 자동화는 다음에)

  • PR을 생성할 때 CI 수행
  • Merge할 때 CD 수행

CI 과정에서 빌드가 성공했을 때만 Merge가 가능하게 설정하자.

2. Node.js App 으로 CI 구축 시작

이 챕터에서 구축을 완료하고, 결과를 확인한다.

아주 간단한 과정이어서 CI라고 하긴 부끄럽지만, 아래 과정을 수행한다.

  • npm module 설치
  • ESLint를 통한 코드 스타일 체크
  • 테스트 실행

1. 필요한 자료

  • ESLint가 설치된, 스택에 상관 없는 Node.js 샘플 앱

  • 샘플 앱을 올린 Public Repo가 필요하다.

  • (이 글에서 코드를 따로 제공하지는 않는다.)

  • 글쓴이는 토이 작업 중인 이 레포를 활용하였다.

글에서 Jest를 설치하고, ESLint와 연동할 것이다.


2. Node.js 템플릿 가져와서 사용하기

아래는 레포지토리에서 Actions 탭을 눌러, Get started with GitHub Actions 아래에 있는 Node.js 템플릿을 가져온 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
name: Node.js CI

# 구독할 이벤트
on:
push:
branches: [master]
pull_request:
branches: [master]

# jobs 단위로 개별 서버(정확히는 Docker 컨테이너 단위라고 한다.)에서 작업이 수행된다.
# 각 작업은 병렬로 실행 된다고 하는데, needs: build와 같이 표시해서 기다릴 수도 있다.
jobs:
build:
# Ubuntu, Windows, MacOS를 지원한다.
runs-on: ubuntu-latest

# 영상에서도 소개됐는데, 변수 개념으로 생각하면 된다.
# node-version 과 같이 배열로 돼있으면, 해당 원소를 순회하면서 작업이 반복해서 실행된다.
# matrix 때문인지 배열만 되는 것 같다. (TODO)
# 응용해서 runs-on에 여러 OS에서 돌릴 수도 있다.
strategy:
matrix:
node-version: [14.x] # 템플릿 기본값: [10.x, 12.x, 14.x]

# uses 개념은 다른 사람이 작성한 내용을 실행하는 개념이다.
# actions/checkout: GitHub의 마지막 커밋으로 Checkout 한다.
# actions/setup-node: Node.js를 설치한다.
# run 개념은 명령어를 실행한다. 셸 스크립트와 동일하다.
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
# npm ci는 npm install과 같은 기능을 수행한다. 자세한 내용은 아래 링크 참조.
- run: npm ci
# --if-present 옵션은 npm 스크립트가 존재할 때만 실행시키라는 의미이다.
# 만약 build 스크립트가 없는 경우, 오류 없이 지나간다.
- run: npm run build --if-present
- run: npm test

npm ci에 대한 스택 오버 플로우 설명 (속도가 2배 가량 빠르다고 한다. 캐싱에 대한 내용도 있으면 좋겠다)

–if-present 옵션에 대한 스택 오버 플로우 설명

GitHub Actions Checkout ?


프로젝트에 이 파일을 .github/workflows/ci.yml로 저장한다.

이후 Push 하면 Actions 탭을 눌렀을 때 해당 빌드 과정이 수행됨을 볼 수 있다.

단, 아직 설정이 다 끝나지 않았으므로, 이 파일의 구조만 확인하기 바란다.

2. 기본 제공 Workflow에 Lint, Test 추가

Lint와 Test 과정을 추가한다. 각 과정은 실패 없이 진행돼야 빌드가 성공한다.

Lint 과정에선 error로 설정된 Rule을 위반한 경우 빌드가 실패하게 된다.


1. Lint: node_modules 에 있는 ESLint를 수행하는 스크립트가 필요하다. 아래 내용을 추가하자.

1
2
3
4
5
//...
"scripts": {
//...
"lint": "./node_modules/.bin/eslint ."
},

ci.yaml 파일에 npm run lint를 추가하자.

1
2
3
4
5
steps:
# ...
- run: npm run build --if-present
- run: npm run lint
- run: npm test

2. Test: Jest를 설치하고, 아래 내용을 추가하자.

폴더를 /tests로 설정했는데, 굳이 그럴 필요가 없다면 생략해도 된다.

Test가 하나라도 실패하면 당연히 빌드는 실패하게 된다.

1
2
3
4
5
//...
"scripts": {
//...
"test": "./node_modules/.bin/jest --verbose ./tests"
},

Jest 폴더 설정 스택 오버플로우 설명


Jest는 글로벌로 API를 expose하기 때문에 ESLint error가 나지 않으려면 플러그인을 설치해줘야 한다.

npm i --save-dev eslint-plugin-jest 로 ESLint-Plugin-Jest를 설치한다.

eslintrc.yml 파일에 아래의 내용을 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//...
env:
//...
jest: true # Jest 글로벌
plugins:
- jest # Jest 테스트를 위해 플러그인이 필요하다.
rules:
//...
# Jest Eslint 옵션은 0,1,2 (off, warn, error) 만 옵션으로 사용 가능하다.
jest/no-disabled-tests:
- warn
jest/no-focused-tests:
- error
jest/no-identical-title:
- error
jest/prefer-to-have-length:
- warn
jest/valid-expect:
- error

ESLint 설정 스택오버플로우 설명

3. 빌드 성공 전에 Merge Button을 누를 수 없게 하기

GitHub에서 Branch Protection Rule이라는 기능을 제공한다. 레포지토리 > Settings 탭 > Branches 탭 > Branch protection rules 탭 > Add Rule 버튼 클릭 후 아래와 같이 설정하였다.

4. 끝!

  • ci.yml 파일을 Push 하자.
  • Master에 Push하거나 Pull Request를 올리자
  • CI가 동작함을 확인하자.

글쓴이는 아래처럼 잘 동작함을 확인했다.

코드 베이스가 작고, 테스트가 사실상 전무하지만, 그래도 Node 설치부터 실행까지 20초밖에 걸리지 않는다는 점은 신기하고 인상적이다. Public 레포로 작업하면 좋은 성능의 CI를 무료로 사용할 수 있어 좋은 것 같다.


TODO

  1. CD 과정도 구축하기. Docker 레지스트리 배포가 일반적인 듯하다. (쿠버네티스가 사용되는듯)

  2. Jobs에서 build 하나만으로 괜찮은 것 같긴 한데, 나누는 case는 뭐가 있을지 확인해보기

  3. npm run build 명령어로 무엇을 실행할지 고민해보기. Node.js로 프로덕션 배포를 해 본 적이 없어서 뭐가 필요한지 아직 파악하지 못 했다.

  4. GitHub Actions에 대해 이론적으로 더 공부해보고, 할 수 있는 것들 더 많이 배우기

  5. Git Hooks라는 개념도 있다고 한다. 로컬 수준에서도 프로세스를 자동화할 수 있는 것 같은데, 한 번 알아봐야겠다.


기타 내용 정리

Why is it free?

public은 무료, private은 사용량 만큼 낸다고 한다.

왜 무료일지 확인해봤는데, 출처에 따르면 Open Source 프로젝트 지원이라는 명목이다.

We want every open source project to be productive and use best practices, so Actions is free for the 40 million developers on GitHub to use with public repositories. For private repositories, Actions offers simple, pay-as-you-go pricing. (…)

Supported OS

위에서 언급했듯, Ubuntu, Windows, MacOS 이다. Docker 컨테이너로 작동한다고 하며, 매 번 Fresh한 Docker Container가 제공된다고 한다.

계기가 된 Video

참고한 유튜브 비디오. 간단하게 Github Actions이 뭔지 영상을 보기만 해도 파악이 가능하다.

https://www.youtube.com/watch?v=R8_veQiYBjI&ab_channel=TechWorldwithNana

글쓴이는 도커 기본 개념과 컨테이너 개념에 조금 익숙한 상태로 봐서 쉽다고 느꼈지만, 정말 아무것도 모른다면 조금 어려울 수도 있다. 영상에서도 언급했듯 Github Actions의 설정 파일은 Docker와 비슷하다.

[1 Month Docker] 1. Docker의 기본 컨셉과 Hello World

Docker의 기본 컨셉을 다루고, 간단한 Hello World를 실습한다.


build, share, run:

  • build: (생략)
  • share: DockerHub에서 이미지를 공유할 수 있다.
  • run: 공유된 이미지를 통해 누구나 컨테이너를 실행할 수 있다.

이미지? 일단 Docker의 재사용 단위라고 생각하자.

도커 컨테이너? 애플리케이션을 담은 박스.

  • 이 박스에는 기기명, IP 주소, 스토리지가 딸린, Docker에서 만들어낸 논리적인 가상 컴퓨터가 있다.

  • 애플리케이션은 이 컴퓨터에서 실행된다.

  • 박스 안의 애플리케이션은 박스 밖을 볼 수 없다.

  • 이 박스는 여러 개가 동시에 실행될 수도 있다.

  • 박스는 같은 실제 컴퓨터를 공유하면서 격리된 환경을 갖는다.

일관된 작업 방식: 아무리 애플리케이션이 복잡하더라도 Docker Image 단위로 Share, Run 만 하면 된다. 몇 개의, 어떤 컴포넌트, 설정 파일, 라이브러리를 사용하는지는 중요하지 않다.

Portability: Docker가 있는 컴퓨터에선 명령어 하나로 곧바로 설치가 가능하다.

효율적인 자원 활용: 도커는 VM이 그렇듯, 여러 애플리케이션을 동시에 실행하는 것으로 컴퓨터 자원을 최대한 활용할 수 있다. 다만 VM보다 나은 점을 아래 표로 정리했다.

사용 자원 Docker VM
Guest OS 사용 여부 No (커널 공유) Yes
가상화 리소스 비용 매우 낮음 (커널 공유) 독립적인 OS 수준
Gust OS Update 다운로드 Base Image 교체 수동 설치
아주 작은 앱 띄우기 Yes No
인수인계/배포 비용 A Dockerfile hours of installation

책에서는 Guest OS License 비용 문제에서도 차이가 난다고 언급했지만, Docker Image 형태로 쓴다고 해서 License 비용이 낮아지거나 사라지지는 않을 것 같다. 반대로 대수가 늘어나기 때문에 Open Source 기반으로 사용하지 않을까 생각이 든다.

네이티브 vs Docker vs KVM(VM 계열) 벤치마크 p.19 참고

주요 도커 명령어:

명령어 기능
docker container ls 실행 중인 컨테이너의 목록 표시
docker container ls –all 전체 컨테이너의 목록 표시 (종료된 것 포함)
docker container run –detach {IMG} 컨테이너를 백그라운드로 실행
docker container run –publish 8088:80 {IMG} Host의 8088 포트로 Listen하여 컨테이너의 80포트로 전달
docker container inspect {ID} 컨테이너의 상세 정보를 JSON으로 출력
docker container stats {ID} 컨테이너가 사용하는 Host 자원 출력
docker container rm (–force) {ID} 컨테이너를 완전히 제거 (실행 중인 경우 force)
docker container rm –force $(docker container ls –all – quiet) 모든 컨테이너를 강제 제거

종료된 컨테이너는 제거된 것이 아니어서 계속 용량을 차지하며, 아래 작업이 가능하다.

  • 그대로 다시 실행
  • 컨테이너 내의 App이 생성한 로그를 확인
  • 파일을 Host에서 or Host로 복사

컨테이너의 네트워크:

  • 기본적으로, 각 컨테이너는 Host 네트워크에 대해 격리된다. 컨테이너는 Host 내의 가상 사설망으로 구성된다.

  • Docker는 Host의 네트워크 트래픽을 가로채 컨테이너로 보낼 수 있다.

Docker가 컨테이너를 실행하는 방법:

Docker Engine은 Docker Backend이다. Docker API(HTTP 기반의 REST API)를 제공한다. 이미지 재사용에 관한 기능은 직접 하고, 컨테이너는 containerd에 기반해 관리한다고 한다. containerd는 CNCF에 의해 관리되는 오픈소스 프로젝트이다.

Docker CLI: Docker의 Frontend이다. Docker Engine과 소통하는 방법을 제공한다.

기타 정보:

Docker는 가장 인기가 많은 컨테이너 플랫폼이지만, 다른 기술도 있으며 컨테이너 기술로 인해 플랫폼에 락인될 걱정은 하지 않아도 된다.

Docker는 이미지를 사용해 컨테이너를 실행한다. 이 때 이미지가 로컬에 있어야 한다. docker container run을 할 때에 없으면 docker pull을 받게 된다. 한 번 다운로드한 이미지는 재사용한다.

도커 컨테이너 Id는 컨테이너의 hostname이 된다.

컨테이너를 선택할 때, 이름 앞 몇글자만 입력해도 된다. 예: f1695...일 때, docker container top f1만 해도 된다.


실습

솔루션

목표: 실행 중인 Apache 컨테이너에서 index.html을 변경하라.

힌트:

  • 컨테이너는 독립된 파일 시스템을 가지며, 컨테이너 내의 웹 서버 또한 컨테이너의 파일 시스템의 파일을 제공한다.

  • docker container 명령어를 통해 컨테이너에서 수행할 수 있는 명령어 목록을 볼 수 있다.

  • docker {command} --help를 통해 해당 명령어의 상세 설명을 확인할 수 있다.

  • diamol/ch02-hello-diamol-web 이미지는 /usr/local/apache2/htdocs 폴더 내의 파일을 정적으로 제공한다. (윈도우의 경우, C:\user\local\apache2\htdocs 폴더.)


내 풀이

풀이 과정을 서술함.

1. 제공된 컨테이너 트러블 슈팅: 일단 ch02-hello-diamol-web 의 기본 포트인 8088은 접속할 수가 없었다. 그래서 DockerHub 가서 Apache 이미지를 받아서 실행해봤다. 8080 포트로 잘 되더라. 이 때 명령어가 $ docker run -dit --name my-apache-app -p 8080:80 -v "$PWD":/usr/local/apache2/htdocs/ httpd:2.4 였는데, 배운 점:

  • -dit: --detach --interactive의 약자인데, -dit가 필요한 이유를 보면, bash 스크립트가 엔트리 포인트인 경우 -d만 하면 정지된 상태에서 아무것도 못한다고 한다. -it를 줘서 셸이 있어야 스크립트가 실행된다고 한다.
  • -p: --publish의 약자이다.
  • -v: 아직 안 배웠지만, 볼륨 개념일 것으로 추정된다.

도커 자체의 네트워크 문제가 아님을 알고, 80으로 하니까 잘 됐는데, 이유는 모르겠다.

2. 컨테이너 셸 접속: 일단 docker container exec -it --tty {id} /bin/bash 로 접속할 순 있었다. (나오는건 exit 치면 된다.)

3. 직접 파일 수정: 무슨 망할 기반 이미지를 쓰는지 vi 밖에 지원을 하지 않아서 직접 수정은 포기했다. 파일을 복사해야 하는데, 어떻게 하는지 모르겠다.

4. 파일 복사 방법: Dockerfile을 수정하는 게 가장 쉬울 것 같았지만, 제공되지 않아서 할 수 없었다. 복사를 해야 하는데, 호스트에서 컨테이너로 파일 복사하기docker cp 명령어를 배워서 수행했고, 성공했다.


매우 작은 작업이었지만 너무 오랜 기간이 걸렸다. 아무래도 기록하면서 하니까 오래 걸리고, 책의 내용을 요약했음에도 불구하고 며칠만에 다시 보는거여서 오래 걸렸다.

많이 헤맨 덕분에, docker container ls, docker container rm, docker container exec, docker container run은 정말 많이 사용해서 다행이다.


참고 자료: Docker In A Month of Lunches (Manning, 2020)


추가로 읽을 것: Docker와 VM

2장 (2/3): Node.js의 모듈 시스템

이 글은 Node.js의 모듈 시스템에 대해 소개한다.


1. 모듈 시스템의 필요성과 Javascript의 방식

모듈 시스템은 프로그램의 구성 요소들 간의 역할을 분리하고, 의존 관계와 구현 상세를 격리하는데 필수적이다. 모듈 시스템의 문법으로 보면, 소스 파일간의 import, export를 하는 것인데, 개념 상 Java의 접근 제한자 - private, protected, public - 도 모듈의 역할 중 일부를 수행 한다고 할 수 있다.

Javascript 모듈 시스템으로는 대표적으로 ESM, CommonJs 라는 두 개의 기술이 있는데, 현재의 Node.js는 ESM, CommonJs를 모두 지원한다.

종류 ESM CommonJS
제정 시기 ES6에 제정됨 ESM 이전의 대표적인 비표준
문법(Node 기준) import / export require / module.export
Node.js 지원 여부 Yes Yes
Browser 지원 여부 최신 브라우저에서 지원 CommonJs.js 로딩 필요

자세한 역사와 기타 모듈 시스템의 종류는 JavaScript 표준을 위한 움직임: CommonJS와 AMD | Naver D2를 참고.


2. Revealing Module Pattern

Javascript에는 접근 제한자가 없다. 접근을 원천적으로 제한하는 방법 중, 공개할 부분만 객체로 담아 내보내는 패턴이 있다. Private 변수는 클로저를 통해 접근할 수 있으므로, 꽤 괜찮은 방법이다.

Revealing Module 패턴을 구현하는 방법은 대표적으로 IIFE(즉시 실행 함수 표현식)가 있다. IIFE는 익명 함수를 ()로 감싼 후 즉시 실행하는 함수 호출 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
const module = (() => {
const privateFoo = () => {
/* private functionality */
};
let privateCounter = 0;

const increase = () => ++privateCounter;
const decrease = () => --privateCounter;

// 이 객체를 반환하므로, 외부에선 privateFoo, Bar에 접근할 수 없다.
return { increase, decrease };
})(); // 즉시 실행하여, { increase, decrease } 객체가 반환된다.

3. CommonJs의 require 방식에 대해

CommonJs는 const moduleA = require('./moduleA');와 같이 모듈을 로딩하는 문법을 제공한다. require동기로 작동하고, 한 번 로딩한 모듈은 캐시된다. 내보낼 때에는 각 모듈별로 제공되는 exports 객체에 필드를 할당하는 방식으로 진행한다.

모듈은 캐싱되므로 항상 동일한 객체를 반환한다.

아래는 require의 수도 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const require = (modulePath) => {
// path를 가져오고, unique한 id로 활용한다.
const id = require.resolveAbsolutePath(
modulePath,
);

// 캐시된 모듈은 캐시를 반환한다.
if (require.cache[id])
return require.cache[id].exports;

// 처음 로딩하는 경우 새 exports 객체가 필요하다.
const module = {
exports: {},
id,
};

// 객체는 캐시한다.
require.cache[id] = module;

// 이 함수가 소스 코드를 읽어 exports 객체에 export 내용들을 할당한다.
readFileAndEvaluate(id, module, require);

return module.exports;
};

require.cache = {};
require.resolveAbsolutePath = (modulePath) => {
/* implementation */
};

어느 범위까지 같은 인스턴스가 반환될까?

  1. 같은 패키지로 빌드된다면 하나의 인스턴스를 공유할 것이다.

  2. package.json별로 독립적으로 dependency를 관리하기 때문에, 각 패키지간에 제 3의 모듈의 객체를 주고 받는 경우, 해당 객체는 버전 불일치가 있을 수 있다.

A Simple Explanation | Medium (EN)


4. 비동기 모듈 초기화

비동기로 객체를 초기화할 순 없다. require 함수가 동기로 작동하기 때문인데, 아무래도 initialize와 같은 메소드를 호출하는 형태로 비동기 API를 만들어서 활용하는 수 밖에 없을 듯하다.

관련 스택 오버 플로우 참고.


5. 순환 참조가 있는 경우

Node.js 환경에서 순환 참조를 하는 경우 한 모듈이 먼저 로딩되기 때문에, 동기로 로딩하는 경우, 한 쪽에서는 null, 한 쪽에서는 정상 로딩이 될 수 밖에 없다. 아니면 명확한 순서를 지정해준다면 해결할 수도 있겠지만(A[A.B = null]->B[B.A = A]->[A.B = B]), 순서를 명시하는 API가 따로 있는지 잘 모르겠다.

  1. 한 쪽에서 느린 초기화를 진행한다. (Lazy-Init) - 순서 정하기와 사실상 동일함.

  2. 순환 참조 관계에 있는 두 객체를 제 3의 객체에 의존하도록 한다. 관련 스택 오버 플로우 - 이 부분은 잘 이해하지 못 했다.


어떻게 export 해야 좋은 모듈일까?

1. Substack 패턴

모듈의 기능을 객체가 아닌 함수 단위로 노출한다. 진입점이자 주가 되는 함수를 module.exports로 내보내는데, 따라서 const logger = require('./logger')와 같이 바로 사용할 수 있는 함수가 된다. 또한, logger.verbose(msg); 와 같이 서브 함수들도 내보내, 사용하는 입장에서 기능의 중요도를 쉽게 파악할 수 있게 한다.

1
2
3
4
module.exports = mainFn;
exports.subFn1 = subFn1;
// 2...N-1
exports.subFnN = subFnN;

(ex)

1
2
3
4
5
6
7
// 메인 함수
module.exports = (msg) =>
console.log(`${this.name} ${msg}`);

// 서브 함수 1
exports.verbose = (msg) =>
console.log(`[verbose] ${this.name} ${msg}`);

2. 생성자 내보내기

prototype 기반으로 생성자를 만들거나, ES6 Class를 활용하여 생성자를 만들어, 생성자를 내보낸다. 사용하는 입장에선 객체의 기능을 확장할 수도 있고, 쉽게 인스턴스를 생성할 수도 있고, 사용하기도 깔끔한 방법이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = class Logger {
constructor(name) {
// implementation
}

log(msg) {
console.log(`${this.name} ${msg}`);
}

verbose(msg) {
console.log(`[verbose] ${this.name} ${msg}`);
}
};

3. 인스턴스 내보내기

생성자 내보내기와 거의 같지만, 싱글톤이 자동으로 구현되는 셈이므로 쉽게 활용하기 좋다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Logger {
constructor(name) {
// implementation
}

log(msg) {
console.log(`${this.name} ${msg}`);
}

verbose(msg) {
console.log(`[verbose] ${this.name} ${msg}`);
}
}

module.exports = new Logger('App');

2장 (1/3): CPS 패턴

이 글은 CPS 패턴과 CPS가 Node.js에서 어떻게 사용되고, 어떤 점을 주의해야 하는지 다룬다.


1. CPS 패턴

Node.js는 1장에서 살펴봤듯 비동기 특성을 가지며, 따라서 Node.js 앱은 대부분의 일을 비동기로 처리할 수 밖에 없다. 비동기를 처리하는 방법 중 CPS, Continous Passing Style을 소개한다.

CPS: 비동기 API를 사용할 때, 콜백 함수를 인자로 넘기는 패턴이다.

  • 왜 사용하는가: 비동기 API는 return을 할 수 없는데, 함수의 실행이 끝나기 전에 제어권이 넘어가기 때문이다. 이를 해결하기 위해선 결과를 다른 함수에 넘기면 된다.
  • 장점: 간단하고 효과적이다.
  • 단점: 호출 깊이가 깊어지면 가독성이 감소된다. Callback Hell이라고 불린다.

2. Node.js에서의 CPS 패턴

Node.js는 CPS 패턴을 사용할 때 일관된 규칙을 따라야 한다.

  • argument 순서에 관한 규칙: (...params, callback) 과 같이, callback 함수를 마지막 인자로 넘겨야 한다.
  • callback 함수의 argument에 관한 규칙: (err, ...args) 와 같이, err가 첫 인자여야 한다.
  • err 인자의 경우, 항상 Error() 객체여야 한다. (이 부분은 잘 지켜지지 않는 듯 하다.)

3. CPS 패턴의 콜 스택

Node.js에서 비동기 API를 호출하는 경우, callback 함수는 프로그래머가 예상한 호출 순서로 구성된 스택을 갖지 않는다. 비동기 API가 완료됐을 때, 이벤트 루프에 의해 단일 함수로 Queue에 쌓인 후 다른 타이밍에 실행되기 때문에 새로운 스택에서 실행된다. 비동기 함수에서 예외를 던지면, Error를 반환하며 프로세스가 종료된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const fs = require('fs');
const readJsonThrows = (filename, cb) => {
try {
fs.readFile(filename, 'utf8', (err, data) => {
if (err) return cb(err);
cb(null, JSON.parse(data));
// cb이 없거나, data가 불량인 경우 exception 발생 가능.
// 콜백 함수 내에서 try-catch하지 않는 경우 프로세스가 죽는다.
});
} catch (err) {
// 여기서도 catch할 수 없음. 호출 스택은 fs.readFile에서 끝나고,
// cb은 별개의 새 스택에서 실행되기 때문
}
};

// 만약 JSON.parse에서 오류나는 경우, 프로세스가 종료된다.
readJsonThrows('C./test.json', (f) => f);
/*
SyntaxError: Unexcepted end of JSON input
at JSON.parse
at FSReqCallback.readFileAfterClose (internal/...)
*/

4. Node.js에서 비동기를 처리할 때 절대 하지 말아야 할 점들

1. 결괏값을 동기, 비동기 2가지 방식으로 전달하지 않는다.

  • 결괏값이 비동기일것을 기대하고 이벤트 리스너를 등록할 때, 동기로 결괏값이 제공되는 경우 이벤트 리스너가 동작하지 않는다.

  • 동기 반환값을 비동기화 한다. setTimeout, setImmediate, nextTick, Promise 등이 가능하다.

2. Callback 함수를 argument로 받는 동기 함수를 작성하지 않는다.

  • 동기 API는 바로 결괏값을 받는 형태로 코드를 작성하면 된다.

1장: Reactor 패턴 (내용 검증 필요)

1장은 Node.js에 대한 소개하는 챕터이다.


주의!
해당 글의 내용은 부정확한 내용이 아주 많을 수 있습니다. 책의 설명이 추상적이고 OS 개념이 많이 필요하므로 추후 정리가 완료되는 경우 따로 표시하겠습니다.


Node.js 철학 (Node Way)

최소 기능: 기능 개수를 최소한되므로, 개발자, 사용자 모두에게 간단함

  1. Node.js 자체 뿐만 아니라 node 기반 모듈을 설계할 때도 동일하게 적용
  2. KISS 원칙: 부족하더라도 복잡함보다 단순함이 더 낫다

Reactor 패턴과 Node.js 이벤트 루프

Reactor 패턴은 Node.js의 비동기 특성 - Node.js에서 여러 요청이 동시에 있는 경우는 항상 비동기 방식으로 작업을 처리한다 - 의 원인이자, 비동기 방식으로 작업을 처리하는 방법에 해당한다. Reactor 패턴을 배우기 전에, 동시성을 처리하는 2가지 방법에 대해서 알아보자.

Blocking I/O : 느린 I/O기다리는 방식

  1. 많은 스레드 개수: 소켓의 데이터를 매번 기다리게 되면 각 연결 별로 스레드가 적어도 하나씩 돌아야 한다. 기다리는 시간에 타 사용자가 기다리지 않게 하기 위해서이다.

  2. 비효율적인 대기 시간: I/O가 CPU에 비해 매우 느리기 때문에 블로킹 API는 스레드의 유휴 시간이 처리 시간에 비해 압도적으로 길 수 밖에 없다. 스레드가 아무 일을 하지 않은 상태로 긴 시간 존재한다.

  3. 스레드의 비용: 스레드는 그 비용이 싸지 않다. 아주 많은 스레드가 있는 경우, Context Switching만 해도 비용이 매우 클 것이고, 적은 스레드가 있는 경우 사용자를 처리하지 못하므로 비즈니스적으로 비용이 매우 클 것이다.

Non-blocking I/O: 비동기 API를 호출 시 바로 제어권을 반환(내부적으로 특정 상수를 반환)하여 CPU 유휴 시간을 최소화한다.

  1. Polling: 비효율적으로 I/O를 처리하는 방식으로, 리소스는 데이터가 없을 때 읽기 조작을 요청 받는 경우 EAGAIN을 반환하는데, 이 때문에 값이 필요한 입장에선 리소스를 계속 확인해야 한다. 이걸 BUSY_WAITING이라고 하는데, CPU를 계속 활용하므로 효율적이지 못하다.

  2. 동기 이벤트 디멀티플렉서: 논블로킹을 처리하는 효율적인 방법으로, 이벤트가 완료될 때마다 큐에 이벤트를 쌓아놓고 처리를 수행하는 객체. 이벤트가 없으면 Block 상태로 대기한다.

    1. 이벤트 통지자가 감시 대상 리소스의 자원이 읽기가 가능할 때(즉, 이벤트가 완료되었을 때) Demultiplexer에게 통지한다. (이벤트 통지자 역할로 IOCP, epoll/kqueue 등이 있는 것 같다.)

    2. Event가 발생하면 Event Demultiplexer가 깨어나 Queue에서 이벤트를 읽어들여 처리하면 됨. 이 시점에서 리소스의 I/O 작업은 (1)에서 이미 완료되어있으므로 동기식으로 처리하면 됨. 또한 처리 방식이 싱글 스레드이므로 공유 자원 문제도 존재하지 않는다.

리액터 패턴: 이벤트 디멀티플렉서 + 이벤트 루프 + 이벤트 큐 + 실행 환경(V8, 싱글 스레드!)

  1. 이벤트 디멀티플렉서는 I/O 처리가 끝나면 (완료된) 이벤트를 이벤트 큐에 넣어줌

  2. 이벤트 루프는 실행 환경 상에서 스택이 비는 경우(즉 모든 동기 코드가 실행이 끝났을 때 - 노드 환경에서 동기 코드는 얼마 없어서 최초의 동기 코드는 금방 끝나기 마련.), 이벤트 큐에서 이벤트를 꺼내어 실행 환경에 이벤트 핸들러를 올리고, 인자로 이벤트를 넘겨 수행함.

    1. 만약 async 내에 async가 있다면 해당 이벤트는 또 이 과정을 거침.

이벤트 디멀티플렉서의 구현체

libuv: 크로스 플랫폼으로(가상머신 느낌으로 각 OS에 대응되는 이벤트 통지자를 활용) 비동기 작업을 처리함. 단, libuv는 이벤트 디멀티플렉서 역할만 하는 게 아니라 이벤트 루프도 구현함.

참고: libuv에 이벤트루프가 포함돼있음:

더 정확한 이벤트 디멀티플렉서, 이벤트 루프 구현 상세에 관한 글, 영상

로우 레벨로 살펴보는 Node.js 이벤트 루프 | Evans Library

Node.js 이벤트 루프, 타이머, process.nextTick() | Node.js (놀랍게도 이 문서가 더 어려운 것 같다…)

브라우저 환경에서의 이벤트 루프(자막 있음, 자세함!):
Jake Archibald: In The Loop | JSConf.Asia

아마도 이벤트 루프에 대한 가장 유명하고, 쉬운 설명:
What the heck is the event loop anyway? | JSConf EU


함수형 패러다임의 꽃: 함수 합성(composition)

함수형 패러다임에서 최우선 설계 원칙으로 삼아진다고 하는 함수들의 합성에 대해서 설명한다.

합성

이전에 설명했던 compose 함수를 말한다.

1
2
3
4
const compose = (...fns) =>
fns.reduce((f, g) => (...args) =>
f(g(...args)),
);

합성은 왜 하는걸까?

프로그램을 간결하고 실용적으로 작성할 수 있게 한다. 합성이 되므로 함수를 부담 없이 나눌 수 있게 되어 더 작고 의미있는 단위의 함수를 더 편하게 작성할 수 있다. 이렇게 합성된 함수는 가독성이 좋다. 아무래도 객체지향 패러다임을 강하게 지원하는 언어들에선 함수 합성이 쉽지 않다. 애초에 순수 함수를 작성하기도 쉽지 않다. public static으로 도배할 순 없기 때문이다.

합성함수의 결합법칙

함수 합성은 수학에서의 합성함수와 같이 결합법칙이 성립한다. compose(f, compose(g, h)) === compose(compose(f, g), h)가 성립한다. Javascript 상에서 생성되는 함수가 동일하다는 것이 아니라, 그 실행 결과가 언제나 같다는 뜻이다.

결합법칙이 무슨 소용일까

합성한 함수들을 재귀적으로 합성한 경우, 결합법칙을 적용하면 결과 예측과 리팩토링 시에 유용하다.

그 예로, 아래 세가지 loudLastUpper 함수는 동일하다. 더 작고 더 의미있는 함수로 정의할수록 재사용성과 가독성은 높아진다.

버전 1

1
2
3
4
5
6
const loudLastUpper = compose(
exclaim,
toUpperCase,
head,
reverse,
);

버전 2 (리팩토링)

1
2
3
4
5
6
const last = compose(head, reverse);
const loudLastUpper = compose(
exclaim,
toUpperCase,
last,
);

버전 3 (리팩토링)

1
2
3
const last = compose(head, reverse);
const angry = compose(exclaim, toUpperCase);
const loudLastUpper = compose(angry, last);

쓸모있고 재미있는 디버깅 방법

합성 함수를 디버깅하는 재밌는 방법이 있다. 흔히 trace라 부르는 유명한 함수인데, 항등함수(const pass = x => x;)에 console.log만 추가한 함수이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const trace = (tag) => (x) => {
console.log(tag, x);
return x;
};

// 자동 커리
const trace = curry((tag, x) => {
console.log(tag, x);
return x;
});

// 사용 예시
const toDebug = compose(
replace,
trace('after A'),
applyA,
trace('after B'),
applyB,
trace('after last'),
last,
);

당연하게도 trace 함수는 순수하지 않다. console를 사용하기 때문이다.

간단한 함수 합성 예제

이하의 예제 코드는 아래의 cars 객체를 대상으로 한다.

1
2
3
4
5
6
7
8
9
[
{
name: 'Aston Martin One-77',
horsepower: 750,
dollar_value: 1850000,
in_stock: true,
},
//...
];

예제 1

각 함수들의 정의는 이 문서를 참고하라. 이 문서는 ramdajs documentation과도 호환된다.

1
2
3
4
5
6
7
8
9
10
const isLastInStock = (cars) => {
const lastCar = last(cars);
return prop('in_stock', lastCar);
};

// after compose:
const isLastInStock = compose(
prop('in_stock'),
last,
);

예제 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const average = (xs) =>
reduce(add, 0, xs) / xs.length;

const averageDollarValue = (cars) => {
const dollarValues = map(
(c) => c.dollar_value,
cars,
);
return average(dollarValues);
};

// after compose:
const averageDollarValue = compose(
average,
map(prop('dollar_value')),
);

예제 3

1
2
3
4
5
6
7
8
9
10
11
12
13
const fastestCar = (cars) => {
const sorted = sortBy((car) => car.horsepower);
const fastest = last(sorted);
return concat(fastest.name, ' is the fastest');
};

// after compose:
const fastestCar = compose(
append(' is the fastest'),
prop('name'),
last,
sortBy(prop('horsepower')),
);

함수 합성 예제 프로그램

스펙

  1. 검색어에 대응하는 URL을 생성한다.

  2. flicker API를 호출한다.

  3. 결과 JSON에서 이미지 링크를 추출한다.

  4. 이미지를 HTML에 표시한다.

구현 코드

예제의 스펙에서 보았듯, 2단계 API 호출과 4단계 이미지 표시는 순수하지 않다. 일단 순수하지 않은 함수를 같이 사용하면서 예제를 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 유틸 함수 선언
const prop = curry((p, obj) => obj[p]);

// 순수하지 않은 함수
// Impure 객체로 접근하도록 하여 사용자에게 주의를 준다.
const Impure = {
getJSON: curry((callback, url) =>
$.getJSON(url, callback),
),
setHtml: curry((sel, html) =>
$(sel).html(html),
),
};

/*
참고: 서버의 응답이 아래와 같은 형태로 구성됨
{
...
items: [
{
...
media: {
m: '<image-link>'
}
},
{ ... },
{ ... },
]
}
*/
const host = 'api.flicker.com';
const path = '/services/feeds/photos-public.gne';
const query = (t) =>
`?tags=${t}&format=json&jsoncallback=?`;
const url = (t) =>
`https://${host}${path}${query(t)}`;

const mediaUrl = compose(
prop('m'),
prop('media'),
);

const mediaUrls = compose(
map(mediaUrl),
prop('items'),
);

const img = (src) => `<img src="${src}" />`;

const render = compose(
Impure.setHtml('#root'),
map(img),
mediaUrls,
);

const app = compose(Impure.getJSON(render), url);

app('cat');

compose와 map 리팩토링

아주 간단한 리팩토링이다. 같은 배열에 대해 map을 여러 번 실행하기보다, 순서를 유지한 채로 매 원소에 대해 map할 함수를 합성해서 한 번에 실행하게 되면 반복 횟수를 줄일 수 있다.

1
2
3
4
// from
compose(map(img), map(mediaUrl));
// to
compose(map(compose(img, mediaUrl)));

참고

mostly-adequate-guide (EN)

순수함수와 curry 함수

이번 글은 쉽다(?). 순수함수에 대해 이론적으로 다루고, curry 함수를 소개한다. 다만 객체지향과의 비교, 테스트와 설계에 대한 내 생각을 공유하므로 지식이 없으면 어렵게 보일 수도 있다.

순수함수?

순수함수는 수학에서 정의하는 함수와 동일하다. 입력에 대한 출력이 항상 동일하고, 입력에 대한 출력이 항상 1가지이다. 이게 가능하기 위해선 DB, HTTP, 현재 시간 등에 의존하면 안 된다! 함수 외부의 것과 함수 내용이 전혀 연관이 없어야 한다.

부원인과 부작용

영어권에서 흔히 side-effects라고 얘기하는 부수효과부작용은 함수 밖의 코드의 상태에 영향을 주는 일을 말한다. 부수효과를 크게 부작용부원인으로 구분할 수 있다. 부작용은 숨겨진 출력이고, 부원인은 숨겨진 입력이라고 생각하면 된다. 왜 외부의 상태와 상호작용하면 안 될까? 궁금하면 계속 읽어야 된다.

숨겨진 입력

숨겨진 입력이라고 하면 뭐가 있을까? Javascript와 같이 객체지향 패러다임을 지원하는 언어의 경우는 this가 항상 함수에 전달된다. this도 숨겨진 입력이다. 또한 함수 내부에서 new Date() 등의 코드로 현재 시간에 의존하는 경우도 숨겨진 입력이라고 할 수 있다. 둘 모두 외부의 상태를 변경하기 때문이다.

숨겨진 출력

숨겨진 출력은 함수를 실행했을 때 바뀌는 모든 것이라고 할 수 있다. 순수함수 내에서는 어떤 외부의 상태도 변할 수 없으므로, 어떤 외부의 상태가 조금이라도 변경된다면 그 함수는 순수하다고 할 수 없다.

부수효과는 복잡성 빙산

왜 외부의 상태와 상호작용하면 안 될까?

순수함수가 아닌 함수의 Signature는 프로그래머가 읽더라도, 심지어 객체지향 언어의 설계 방식대로 설계했더라도 무슨 부수효과가 일어날지 알 수 없다.

캡슐화는 좋은 규칙이지만 그 구현 코드를 읽기 전까지 부수 효과를 정확히 알 순 없다.

부수효과가 왜 복잡성 빙산일까? 프로그래머가 예상한 그대로 동작하지 않는 경우 논리적 버그의 원인이 되기 때문이다. 부수 효과는 해당 코드 혹은 해당 코드와 간접적으로 연관이 있는 코드를 수정했을 때 바뀌기 또한 쉽고, 바뀌었을 때 작동하지 않게 될 확률도 높다. 그래서 객체지향 방식으로 설계를 하는 경우 회귀테스트를 그렇게 많이 작성해야 하나 보다. 응집성과 캡슐화를 생각해서 상태 의존적이고, 변경 시 서로의 영향을 받아서 깨지기 쉽기 때문이다.

그래서 함수형 패러다임에서는 공유 자체를 하지 않는 방향으로 설계하도록 지향한다. 그 결과가 순수함수이다.

순수함수가 아니면 테스트하기 힘들다

어떤 함수가 부수효과가 있는 경우 이미 그 함수는 다른 코드랑 최소한 1번은 엮여 있을 수 밖에 없다. 덕분에 그 함수를 테스트하기 위해서는 다른 코드까지 테스트할 수 밖에 없고, 이 과정에서 Blackbox Testing이 불가능해진다. 구현 상세에 외부 코드와의 연관이 존재하기 때문이다. 이 과정은 객체지향 언어로 작성한 경우 자주 발생하며 덕분에 Mock을 자주 사용하게 된다. 또한 테스트 자체도 구현 상세의 변경에 취약하게 된다.

부수효과를 제거하기, 제거했을 때의 장점

부수효과를 제거하려면 순수함수를 만들고 사용하면 된다.

모든 부작용, 부원인은 숨겨진것이기에 이를 Signature에 명시하면 된다. 이렇게 명시하는 것은 객체지향 언어에서는 응집성과 캡슐화를 위해 구현 상세로 분류하여 함수 안에 전부 집어넣는 등 지양하는 편이지만, 함수형 패러다임에서는 권장된다. 덕분에 덜 복잡해지고, 훨씬 테스트하기 쉬워지며, 추론이 훨씬 쉬워지기 때문이다.

부수효과를 완전히 제거할 수는 없다.

아무래도 웹 등 실세계의 애플리케이션은 함수 내의 수식을 한 번 계산하고 종료하는 게 목적이 아니라, 부수효과로 불리는 것들 대부분을 사용하여 목적을 달성할 수 밖에 없다. 함수형 패러다임은 이런 한계를 인정하고, 가능한 모든 곳에서 부수효과를 제거하고, 제거할 수 없을 땐 강력히 통제한다.

순수함수의 조합과 재사용성

순수함수는 그 자체의 명료함 덕분에 재사용성과 조합이 굉장히 쉽고, 많이 조합하더라도 쉽게 그 결과가 예측 가능하다. 특히 한 번에 풀 수 없는 크고 복잡한 문제를 쪼개서 작은 함수의 조합으로 해결할 수 있다. 앞서 만들어 놓은 산출물을 쉽게 조합하여 새로운 문제를 해결할 수 있게 되고, 생산성도 비약적으로 늘어난다.

순수함수에 대한 간단한 사실들

  1. 순수함수는 수학의 함수와 동일한 정의를 갖는다.

  2. 순수함수는 (input, output) 쌍이므로 객체로도 표현 가능하다. (key,value 쌍)

  3. 순수함수는 항상 캐시 가능하다.

  4. 순수함수는 필요한 건 다 전달받는다(dependency injection)

  5. 동시성 문제가 적거나 없다. 공유하는 메모리가 없기 때문이다.

curry 함수

2번째 글에서 currying을 이미 다루었다. 그 때의 currying은 프로그래머가 함수에 대해 직접 curry한 방식이고, 이번에는 어떤 함수에 대해 알아서 curry된 함수를 반환하는 함수를 소개한다.

curry 함수는 함수를 받아, 인자가 완전히 전달되지 않은 경우 남은 인자를 받을 함수를 반환한다. curry 함수의 구현은 function.lengthbind, apply를 사용하는 게 핵심이다.

1
2
3
4
5
6
7
8
9
10
11
function curry(f) {
const len = f.length;
return function $curry() {
if (arguments.length < len) {
// 원래 함수의 매개변수의 갯수보다 $curry에 전달된 매개변수의 갯수가 작은 경우.
return $curry.bind(null, ...arguments); // $curry에 계속 전달받은 매개변수들이 bind 된다. (arguments가 계속 쌓인다.)
} else {
return f.apply(null, arguments); // 실제 함수 호출.
}
};
}
1
2
3
4
5
6
7
8
9
10
// 예시
const add = (a, b, c) => a + b + c;
const addC = curry(add);
const add1 = addC(1);
const add1and2 = add1(2);
const add1and2and3 = add1and2(3);
console.log(addC(1, 2)); // function $curry
console.log(add1(2)); // function $curry
console.log(add1and2and3); // 6
console.log(add1and2(3) === add1and2and3); // true

참고

ES6 bind 함수 (KO)