리눅스에서 파일을 찾는 방법

이 글은 리눅스 환경에서 파일을 찾는 방법을 정리한 글이다. 리눅스 핵심 레퍼런스를 참고하여 작성하였다.

기준 환경:

Ubuntu 20.04.1 LTS (WSL2 by Windows 10 x64)


파일을 찾는 명령의 종류

Command Description
find 파일, 디렉토리 검색 중 가장 많은 옵션을 제공한다. 상대적으로 느리다.
locate (mlocate) 파일, 디렉토리 Index를 만들고, 해당 Index 로 검색을 수행한다.
which 셸의 탐색 경로 내에서 명령어의 위치를 찾는다.
type Bash 셸 내장 프로그램으로 which와 같은 기능이지만 더 빠르다.
whereis 매개변수로 주어진 디렉토리 목록에 대해 탐색한다.

find

find의 경우 Man Page가 굉장히 어려워 35 Practical Examples of Linux Find Command를 참고했다. 해당 페이지 제목에서도 알 수 있듯, 한 명령어 설명에 35개의 예제가 들어간다는 것부터 범상치 않음을 짐작할 수 있다. find는 정말 그 기능이 너무나 많다. 정말 강력하다고 할 수 있으나 인덱싱이 지원되지 않아 탐색 대상이 늘어나면 꽤 느려져서 전체 디렉토리 대상으로 수행할 거라면 Interactive한 일회성 작업에 적절할 것 같다.

아래의 모든 옵션은 직접 테스트 후 작성하였다.


1. 공통 기능

Option Description Example Output
! 옵션 앞에 !를 붙이면 NOT 조건이 된다. find ! -name "*.txt" “*.txt”에 매칭되지 않는 파일, 디렉토리만 출력

2. 이름 기반 검색

Option Example Ouput
(x) find 하위 디렉토리 전부 재귀적으로 출력 (grep 하려면 전체 방문을 기다려야 함)
-name find -name input.txt 하위 디렉토리에서 input.txt를 찾아 출력
-name “패턴” find -name "*.txt" *.txt에 매칭되는 파일/디렉토리 검색 (*만 사용 가능한지 모르겠음)
(폴더 명시) find /bin (options...) /bin 아래에서 검색을 수행
-type `find -type (f d)`

3. 권한 기반 검색

권한 비트 앞에 0, 1, 2 등 한 바이트가 더 표현할 수 있는데 SGID, Sticky Bit 등의 내용에 대해 잘 이해하고 있지 못해서 생략했다. 추후 보강하겠다.

Options Example Output
-perm 권한 find -perm 777 권한이 777인 파일, 디렉토리를 출력
-perm /권한 find -perm /u=r (이 부분은 스펙과 출력이 달라서 추후 갱신할 예정입니다.) user 권한이 r인 파일(Read-only), 디렉토리를 출력 (대신 첫 글자만 해석함. rw 등 안 됨)

4. 출력에 대해 다른 명령어 수행

-exec 옵션이 있는데, -exec COMMAND {} \;와 같이 표시하는 형태이다. {} 가 각 라인이 들어갈 placeholder인 듯하다. 다만 이는 Pipelining으로도 충분히 가능해보여서 (-exec은 스트리밍을 지원하는건가?) 설명은 생략한다.


5. 소유자 기반 검색

Option Example Output
-user 소유자명 find -user root root 소유의 파일, 디렉토리 출력
-group 그룹명 find -group sb sb 소유의 파일, 디렉토리 출력 (sb는 제 유저명입니다)

참고로 그룹의 목록은 /etc/group 파일에 텍스트로 저장돼있다 :)


6. 시간 속성 기반 검색

Option Example Output
-mtime N find ~ -mtime 1 1일 내에 변경된 ~ 내의 파일 출력
-atime 접근 시각 기준
-mtime +N -mtime -M find ~ -mtime +50 -mtime -100 N일보다 더 지나고 N일 보다 덜 지난 날짜 사이에서 변경된 ~ 내의 파일 출력
-cmin/mmin N find -cmin/mmin 60 현재 디렉토리 아래에 변경된 지 60분 이내의 파일 출력
-amin N find -amin 60 현재 디렉토리 아래에 접근한 지 60분 이내의 파일 출력

7. 파일 크기 기반 검색

Option Example Output
-size N find -size 50M 50M인 파일 출력
-size +N -M find -size +50M -100M 50M ~ 100M인 파일 출력 (초과, 미만)

locate

파일, 디렉토리 Index를 만들고, 해당 Index 로 검색을 수행한다.

기본 Index 파일이 생성되는 경로는 /var/lib/mlocate/mlocate.db 이다.

Index에서 검색하므로 가장 빠르다.

Options Example Output
-b \NAME (–basename) locate \input.txt input.txt와 일치하는 이름을 가진 모든 파일/디렉토리를 출력

which, type

셸의 탐색 경로 내에서 명령어의 위치를 찾는다.

Options Output
-a 명령어에 대한 모든 일치하는 바이너리 위치를 출력

whereis

매개변수로 주어진 디렉토리 목록에 대해 탐색한다.

whereis -bm ls tr -m gcc : ls, tr 검색어에 대해 Binary, ManPage 검색 + gcc 검색어에 대해 ManPage 검색

Options Description Output
-b (binary) 검색어에 대응되는 바이너리 검색을 한다
-B,M,S [directories…] -b/m/s 옵션으로 검색 시 탐색할 디렉토리
-m (manual) 검색어에 대응되는 매뉴얼 검색을 한다 (Man Page)
-s (sources) 검색에어 대응되는 소스파일 검색을 한다
-f -B, -M, -S 옵션 사용시 반드시 사용해야 함. 찾을 검색어와 옵션의 구분자 역할을 한다

TODO:

  • find 이외에는 문서가 자세하고 정확한 경우는 잘 없어서 조사가 더 필요할 것 같다.

Stream 생태계 정리

이 글에서는 Node.js Stream API 생태계를 정리한다.



TODO:

  1. 스트림을 제대로 써봐야 제대로 이해할 수 있을 것 같다.
  2. 스트림 생태계가 좀 엉망인데 직접 사용해보고 정리하는 기회가 필요할 것 같다.
  3. 이 글도 예제를 제대로 추가해 영양가 있는 글로 만들어야 한다.

5장 Stream API 디자인 패턴 - Pipe, Fork, Merge, Mux/Demux

이 글은 Stream에서의 Pipe, Fork, Merge, Mux/Demux 패턴에 대해 소개하고 Mux/Demux는 예를 제공한다.

참고 자료:


1. Pipe 패턴

여기서 말하는 Pipe 패턴이란 스트림의 조합으로 이루어진 하나의 파이프라인을 모듈화하고 재사용하는 방법을 말한다.

Pipe 패턴 구현 시 주의할 점

  • 첫 Stream에 Write하고, 마지막 Stream에서 Read해야 한다.
  • 내부의 모든 Stream에서 발생하는 오류를 포착할 수 있어야 한다. Error Listener 하나로 Pipeline에서 발생하는 모든 오류를 구독할 수 있도록 한다.

Combined-Stream 패키지를 이용한다. (사용량은 압도적이나 Stream v1 - Flowing 모드만 지원한다.)

(Pumpify가 더 좋은 것 같은데 사용법을 잘 모르겠다.)


2. Fork 패턴

서로 다른 대상에 동일한 데이터를 보내는 경우, 즉 하나의 Readable에 2개 이상의 스트림을 연결하는 패턴이다.

Fork 패턴 구현 시 주의할 점

  • .pipe 사용 시 {end: false} 옵션이 필수가 된다. 한 쪽의 작업이 끝나는 경우 다른 쪽도 닫히기 때문
  • 백 프레셔 때문에 제일 느린 스트림에 속도가 맞춰지게 된다.
  • 같은 프로세스 내에 두 스트림이 있는 경우 chunk가 공유되므로 한 쪽의 스트림에서 해당 chunk의 내용을 직접 수정하게 되면 다른 스트림도 그 영향을 받게 된다.

3. Merge 패턴

일련의 Readable을 하나의 스트림으로 연결하는 패턴이다. .pipe({end: false})로 연결해야 한다. Auto End 옵션은 하나의 Redable만 종료되더라도 연결된 스트림까지 종료시키기 때문이다.

Merge-Stream 패키지를 사용한다.

  • multistream 패키지보다 훨씬 사용량이 많다.

4. Mux/Demux 패턴

(직접 구현한다.) 여러 스트림에서 들어오는 데이터를 한 스트림(이 예에서는 net 패키지의 도움을 받아 TCP Socket을 사용한다.)으로 내보내고, 같은 방식으로 데이터를 받아들인 후 여러 스트림으로 다시 분류하는 멀티플렉싱/디멀티플렉싱을 스트림 수준에서 구현한다.

아키텍처 요약

긴 설명은 하지 않고, 코드에 주석을 달아 놓았으니 흐름을 따라가면 쉽게 이해할 수 있을 것이다.

generateData.js

표준 출력, 오류 스트림에서 데이터를 생성하기 위한 코드이다. Client에서 실행하게 된다.

1
2
3
4
5
console.log("out1");
console.log("out2");
console.error("err1");
console.log("out3");
console.error("err2");

Client.js

generateData로 생성된 데이터가 표준 출력, 오류 스트림으로 들어오게 되고, 아래 코드에서 헤더로 포장한 후 Socket으로 Server에 전송한다. (참고로 Client 코드가 이 case에서 가장 어렵다. 이 코드만 이해하면 다 했다고 볼 수 있다.)

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
const child_process = require("child_process");
const net = require("net");

function multiplexChannels(sources, destination) {
let totalChannels = sources.length;

for (let i = 0; i < sources.length; i++) {
sources[i]
.on("readable", function () {
let chunk;
while ((chunk = this.read()) !== null) {
// 5+chunk byte (Node.js는 바이트 스트림(Octet Stream)만 지원하는건지, Buffer도 최소 단위가 bit이 아니라 byte이다.)
const outBuff = Buffer.alloc(1 + 4 + chunk.length); // Buffer.alloc(size); === new Buffer(size);
outBuff.writeUInt8(i, 0); // write(data, idx) - 이 경우에는 idx=0
outBuff.writeUInt32BE(chunk.length, 1); // write (data, idx) 이 경우에는 idx=1 (앞 데이터는 8bit 이므로, 한 칸만 사용)
chunk.copy(outBuff, 5); // 앞에서 40bit를 사용해서 다음 데이터의 offset=5
// chunk가 무슨 타입인지 모르겠지만 Readable이 제공하는 chunk는 copy 메소드가 있는 듯.
console.log("Sending packet to channel: " + i);
destination.write(outBuff); // 대상 스트림으로 쓰기
}
})
.on("end", () => {
// 모든 Readable이 닫힌 후 대상 스트림 종료
if (--totalChannels === 0) {
destination.end();
}
});
}
}

// net.connect: (port, host?, callback)
const socket = net.connect(3000, () => {
//현재 프로세스는 소켓을 열고 끝. net.connect는 Non-blocking call 이다.
const child = child_process.fork(
// child_process.fork로 새 프로세스에서 JS 파일을 실행한다. (이 경우 generateData.js)
process.argv[2],
process.argv.slice(3), // fork로 실행할 JS파일
{ silent: true } // silent 옵션: Child 프로세스가 독립적인 표준 스트림을 갖도록 (상속받지 않도록)
);

multiplexChannels([child.stdout, child.stderr], socket); // 대상 스트림으로 Socket 생성해 전달
});

Server.js

클라이언트로부터 데이터를 파싱한 후 각 스트림에 대응되는 파일에 내용을 쓴다. 헤더 격인 앞 1바이트를 읽어 채널을 구분한다.

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 net = require("net");
const fs = require("fs");

// 소켓마다 한 번 호출됨 (상태 관리 필요)
// 디멀티플렉싱 수행
function demultiplexChannel(source, destinations) {
// 상태 관리 필드
let currentChannel = null;
let currentLength = null;
source
.on("readable", () => {
// 1. 표준 입력/오류 구분
let chunk;
if (currentChannel === null) {
chunk = source.read(1);
currentChannel = chunk && chunk.readUInt8(0);
}

// 2. 데이터 길이 파싱
if (currentLength === null) {
chunk = source.read(4);
currentLength = chunk && chunk.readUInt32BE(0);
if (currentLength === null) {
return;
}
}

// 3. 데이터 길이만큼 읽기
chunk = source.read(currentLength);
if (chunk === null) {
return;
}

// 4. 읽은 데이터(chunk)를 대상 스트림에 작성
console.log("Received packet from: " + currentChannel);
destinations[currentChannel].write(chunk);

// chunk 순서대로 호출되므로 여기서 다시 null을 할당하면 됨 :)
currentChannel = null;
currentLength = null;
})
.on("end", () => {
// 소켓에서 받은 데이터가 끝난 경우 대상 스트림 모두 종료
destinations.forEach((destination) => destination.end());
console.log("Source channel closed");
});
}

// 소켓 3000번으로 서버 열기
net
.createServer((socket) => {
// 연결 수립 시 수행할 Callback
const stdoutStream = fs.createWriteStream("stdout.log");
const stderrStream = fs.createWriteStream("stderr.log");
// Source: 소켓을 통해 전달된 octet-stream, 대상 스트림 2개: 표준 출력, 표준 오류
demultiplexChannel(socket, [stdoutStream, stderrStream]);
})
.listen(3000, () => console.log("Server started"));

TODO:

  1. 스트림을 제대로 써봐야 제대로 이해할 수 있을 것 같다.
  2. 스트림 생태계가 좀 엉망인데 직접 사용해보고 정리하는 기회가 필요할 것 같다.
  3. 이 글도 예제를 제대로 추가해 영양가 있는 글로 만들어야 한다.

리눅스 셸(bash) 기본 기능 소개

이 글에서 소개할 내용은 굉장히 유용한 기능이라고 생각하며, 셸의 역할인 코드 실행 시의 입/출력에 대한 Proxy, Middleware 역할을 잘 활용하는 기능들이라고 생각한다. 이 글은 리눅스 핵심 레퍼런스의 일부를 참고해 작성하였다.

기준 환경:

Ubuntu 20.04.1 LTS (GCP Compute Engine)


셸은 단순한 호출보다 훨씬 많은 것을 할 수 있다.


1. wildcard: 정규표현식과 유사한 검색을 수행한 결과를 명령의 입력으로 사용


1. 가장 기본이 되는 예제

이름에 wildcard 검색을 수행해 결과를 명령의 입력으로 사용할 수 있다. 이름 일치 기준은 디렉토리이다. 즉 a*를 셸 명령어에 입력하게 되면, ./a로 시작하는 파일/디렉토리를 반환한다. 특정 상위 폴더나 하위 폴더를 대상으로 검색하고 싶은 경우 그에 맞는 상대 경로를 입력하면 된다.


(ex 1) ls a* == ls aardvark adamantium apple

(ex 2) ls githubblog/.* == ls githubblog/.git githubblog/.deploy_git, ...

기준 디렉토리 목록

위 사진은 기준 디렉토리이다. 여기서 g* 인 파일/디렉토리를 ls -alF의 매개변수로 주려고 한다.

g*에 해당하는 목록을 wildcard를 통해 입력으로 사용

위 사진은 명령의 결과물로, 실제로 잘 수행됨을 확인할 수 있다.


2.추가 옵션

[문자들...] : 문자들 중 하나와 일치하는 경우.

  • [aieou] : 모음 중 하나. 단 이렇게 찾으려면 파일/디렉토리 이름이 a, i, e, o, u 중 하나여야 한다. (즉 한 글자)

[^문자들...], [!문자들...] : 명시된 문자 이외의 any 문자

  • [^aieou] : 자음 중 하나. 단 이렇게 찾으려면 파일/디렉토리 이름이 b, c, d, f, g, … 중 하나여야 한다. (즉 한 글자)

? : 임의의 한 문자. character 하나의 placeholder라고 생각하면 편리하다.

  • [githubblo?] : githubblog가 있다면 일치한다.

* : asterisk의 일반적인 의미처럼 아무거나. empty를 포함한 모든 string을 의미. 보통 조합할 때 필수적으로 사용된다.

  • *[aioeu] : 모음으로 끝나는 경우
  • *[aioeu]* : 모음이 포함된 경우
  • [aioeu]* : 모음으로 시작하는 경우

2. 중괄호 확장: 단순히 가능한 모든 경우의 수를 입력으로 사용

문자열 중간에서 사용되며 가능한 모든 경우의 수로 치환된 후 입력으로 사용된다.

echo a{b,c,d}e{f,g,h} 결과

(ex) echo a{b,c,d}e{f,g,h} == echo abef abeg abeh acef aceg aceh adef adeg adeh


3. 변수: String 타입의 환경 변수

bash 프로파일 관리에 대해선 좀 더 나중에 다루려고 한다.

Windows의 환경 변수와 cmd 환경 변수와 같은 2가지 변수가 있다. 모두 환경 변수이지만 그 범위가 다른데, cmd 환경 변수에 대응되는 Linux에서의 개념이 셸 변수이다. 단 아래의 방법으로 하면 해당 세션(셸)에서만 사용할 수 있으므로 일회성 변수로 생각하면 좋다.

쓰기 : MYVAR=string_value

읽기 : $MYVAR

만약 환경 변수로 저장하고 싶다면, export MYVAR=string_value와 같이 사용하면 된다. 환경 변수는 printenv 혹은 env 명령으로 확인할 수 있다.


기본으로 제공되는 환경 변수

PATH : 바이너리 검색 경로의 목록. 콜론으로 구분.

PWD : 현재 디렉토리 ( OLDPWD : 마지막으로 방문한 디렉토리 )

HOME : 홈 디렉토리 ( ex : /home/sb )

USER : 로그인명 ( sb )


4. alias

단순한 String 치환이다.

지정 : alias ll = "ls -lG"를 입력하면, 이후 셸에서 ll을 입력하면 ls -lG가 입력된다.

목록 확인 : alias만 입력하면 된다.

alias 입력 시의 결과


5. 입출력 redirection

아직 입출력을 파일을 통해 수행해본 적이 없어서 추후 적절한 예시를 추가하려고 한다. 이번 글에서는 개념적으로 그 사용법만 다룬다.

표준 입출력의 지점을 임의의 파일에 수행하게 한다.

입력을 파일의 내용으로 : command < input_file

출력을 파일로 :

  1. 새로운 파일로 작성 : command > output_file

  2. 기존 파일에 이어 쓰기 : command >> output_file

  3. 오류의 경우 : command 2> error_file

  4. 출력, 오류 모두 : command >& output_file 혹은 command &> output_file

  5. 출력, 오류 각각 : command > output_file 2> error_file

리눅스에서 표준 스트림은 3가지이며 자세한 내용은 위키 백과 (표준 스트림) 참고


6. Pipe

  1. 각 프로그램이 하나의 일을 잘 할 수 있게 만들 것. 새로운 일을 하려면, 새로운 기능들을 추가하기 위해 오래된 프로그램을 복잡하게 만들지 말고 새로 만들 것.
  2. 모든 프로그램 출력이 아직 잘 알려지지 않은 프로그램이라고 할지라도 다른 프로그램에 대한 입력이 될 수 있게 할 것. 무관한 정보로 출력을 채우지 말 것. 까다롭게 세로로 구분되거나 바이너리로 된 입력 형식은 피할 것. 대화식 입력을 고집하지 말 것.
  3. 소프트웨어를, 심지어는 운영 체제일지라도 이른 시기에 수주에 걸쳐 이상적으로 시도해가며 설계하고 만들 것. 어설픈 부분을 버리고 다시 만드는 것을 주저하지 말 것.
  4. 프로그래밍 작업을 가볍게 하기 위해, 심지어 우회하는 방법으로 도구를 만들고 바로 버릴지라도 어설픈 도움 보다는 도구 사용을 선호할 것.

출처: 위키 백과 (유닉스 철학)

Pipe 연산자는 유닉스 철학을 구현하는 도구 중 하나로, 이 중 2번 규칙을 지키는 도구로 사용된다.

(ex) who | sort | awk '{print $1}' | less

pipe 연산자의 효과를 제대로 소개하는 예제를 만들기엔 아직 아는 명령어가 극히 적어서 추후 제대로 소개하고자 한다. 해당 소개 글이 작성될 경우 이 글에서 링크를 제공하도록 하겠다.


7. 평가식

평가식이란 그 내용이 코드로 해석되는 영역을 말한다. 셸에서의 평가식은 해당 평가식을 셸에서 따로 실행시켰을 때의 결과를 반환하는 형태를 갖는다. 이 평가식의 문법은 크게 두 가지가 있는데,

  1. backtick : echo This year is ``date +%Y\``
    • This year is 2021
  2. $() : echo Next year is $(expr $(date +%Y) + 1)
    • Next year is 2022

평가식으로 (5)에서 실패했던 echo < sample.txt를 평가식으로는 실행할 수 있다: echo $(cat sample.txt) (`을 사용해도 된다.)


8. 작업 제어

셸에서 수행되는 프로그램은 대개 포그라운드로 실행된다. 즉 사용자와의 인터렉션이 블로킹되는데 셸에서 프로그램을 실행할 때 백그라운드로도 실행시킬 수 있다. 또한 포그라운드와 백그라운드를 넘나들 수 있으며 작업을 정지하고 다시 실행할 수도 있으며 셸 마저 정지할 수도 있다.

백그라운드로 작업 실행 : command ... & (&가 핵심이다.)

포그라운드 작업 정지 : Ctrl + Z

백그라운드에서 작업 재개 : `bg {id}``

포그라운드로 작업을 가져와 실행 : fg {id}

현재 수행 중인 작업의 목록 조회 : jobs

현재 셸 정지 : suspend (현재 실행 중인 셸이 2개 이상이어야 호출 가능하다.)


9. 여러 셸 동시 사용

screen은 내장 기능이어서 dependency가 추가로 필요하지 않아 사용에 제약이 없지만 그 기능이 적고 불편하다. tmux가 많이 사용되며 기본으로 설치되는 경우도 있으나(WSL Ubuntu에는 기본으로 설치돼있다.) 둘 모두 사용해본 적이 없어 추후에 다루도록 한다.


TODO :

입출력 Redirection 적극적으로 활용해보기 (특히 알고리즘 테스트 케이스 수행 시)

screen/tmux 모두 사용해보고 비교하기

리눅스 명령어 더 공부하고, pipe로 효과적인 예 만들기

리눅스 주요 디렉토리의 이름과 의미 정리 (계속 업데이트 예정)

이 글은 리눅스를 처음 시작하는 경우 다른 OS의 디렉토리 구성과의 큰 차이로 인한 불편함을 줄이기 위한 큰 지도이다.


1. Home 디렉토리

크게 2가지 Home 디렉토리가 있다. 이는 사용자에 따라 구분되는데,

  • 일반 사용자: /home/{USER_NAME}
  • 루트 사용자: /root

형태로 사용한다.


2. 시스템 디렉토리

시스템 디렉토리란 사용자 디렉토리를 제외한 (거의) 모든 디렉토리를 말한다. 윈도우에서 Program Files 폴더가 시스템 디렉토리에 포함된다고 생각하면 이 정의에 동의할 수 있을 것이고, Windows, AppData 등의 폴더만 시스템 디렉토리라고 생각한다면 이 정의에 동의하긴 어려울 듯 하다.


일반적인 경로 구성

시스템 디렉토리의 경로 구성


(ex) /usr/local/share/emacs

  • /usr/local : 스코프
  • /share : 카테고리
  • /emacs : 응용 프로그램

경로 구성 요소 - 스코프

스코프는 가장 상위 디렉토리로서 하위 디렉토리인 카테고리나 응용 프로그램이 실행되는 범위를 나타낸다. 다만 구분에 사용되는 명확한 기준은 없고 단지 //usr보다 좀 더 운영 체제에 가까운 근본적인 수준이라는 느낌이라고 한다(출처: 리눅스 핵심 레퍼런스).

스코프의 종류:

  1. / : 리눅스 시스템 파일

  2. /usr : 또 다른 리눅스 시스템 파일

  3. /usr/local : 개인 컴퓨터에서 지역적으로 생성되는 시스템 파일

    (ex) /usr/local/bin : 기본 프로그램이 아닌 경우 주로 여기에 설치된다.


경로 구성 요소 - 카테고리

카테고리는 하위 디렉토리인 응용 프로그램의 목적을 나타낸다.

  1. 실행 파일:

    • bin : 바이너리
    • sbin : 바이너리 (root 사용자 용이어서 root 권한이 필요하다.)
    • lib : 바이너리에서 사용되는 라이브러리
    • Ubuntu 20.04의 경우 / 스코프의 bin, sbin, lib/usr 스코프로의 심볼릭 링크로 돼 있음 (어떤 배포판들이 또 이렇게 돼있는지는 확인 x)
  2. 설정 파일:

    • etc : 시스템 설정 파일
    • init.d, rc.d : 부팅 설정 파일
  3. 문서: doc, info, man, share

  4. HW 관련:

  5. Runtime:

  6. 운영체제 관련:

    • boot ( /boot/vmlinuz ): 커널, 부팅 관련 파일
    • lost+found : 손상된 파일 등 추후 복구를 위한 임시 저장 경로
    • proc : 현재 실행 중인 프로세스 정보. 저용량이며 실시간으로 반영한다.
      • cat /proc/ioports : I/O HW 목록 표시
      • cat /proc/cpuinfo : 프로세서 정보. 코어 단위로 상세 정보를 출력
      • cat /proc/version : OS 버전 정보 표시 (ex: Linux version 4.19.128-microsoft-standard)
      • cat /proc/uptime : ms 단위로 uptime 출력. (uptime 명령어가 더 낫다)
      • ll /proc/{PID} : PID에 대응되는 프로세스의 정보. (많은데 잘 모르겠다)
      • ll /proc/self : 현재 실행중인 프로세스로의 심볼릭 링크 (ex) 1261

이 글은 꾸준히 계속 업데이트될 예정입니다!

5장 Stream API (3/3) - Stream을 사용할 때에 순차 실행, 병렬 실행, 제한된 병렬 실행 구현하기

이 글은 Stream을 사용할 때에 순차 실행, 병렬 실행, 제한된 병렬 실행에 대해 다룬다. 또한 독자가 Node.js Stream에 대한 기초 지식이 있음을 전제로 작성되었음을 밝힌다.

참고 자료:


1. 여러 파일을 하나의 파일로 순차적으로 병합하는 방법

스트림은 당연하게도 비동기로 작동한다. 여러 개의 Redable Stream이 있고 하나의 Writable Stream이 있을 때, 각 작업들을 순차적으로 수행하는 방법이 있을까? 가능하다. 여러 개의 Readable 을 각각 Writable로 연결하고, Redable에 순서를 지정하면 된다. 아래 코드에 대한 설명은 주석으로 나타나 있으니 주석을 따라가기 바란다.

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
const fromArray = require('from2-array');
const through = require('through2');
const fs = require('fs');

function concatFiles(destination, files, callback) {
const destStream = fs.createWriteStream(destination);

// fromArray.obj: readableStream of param array.
fromArray.obj(files)
// readable 끼리 pipe 수행
// through.obj(fn) == through({ objectMode: true }, fn) => Transform 스트림 반환
// 현재는 through를 많이 사용하지 않아도 괜찮음.
.pipe(through.obj((file, enc, done) => {
const src = fs.createReadStream(file);
// 파일명을 file로 입력 받음
// src1 => dest 로 pipe 연결 (pipe 사용 시 자동으로 백 프래셔 수행. src에서 데이터 생산만 하면 됨.)
// src1.end
// src2 => dest 로 pipe 연결
// src2.end
// ...
// dest.end
// [끝]
// ---
// 연결을 요청함. 이벤트 핸들러 등록과 같은 느낌. 실제 스트림 간의 통신은 비동기로 수행됨.
src.pipe(destStream, {end: false});
// 이후 src에서 dest로 연결하려면, dest는 종료되지 않아야 함
// ---
// 이 파일에 대한 Read Stream이 끝나면, through.obj로 생성하는 Trasnform 스트림의
// callback인 'done' 함수를 호출하게 함. (단순히 params 이름만 바꾼 것임.)
src.on('end', done);
}))
.on('finish', () => {
// WritableStream을 종료함.
destStream.end();
// concatFiles 호출자에게 종료를 알림.
callback();
});
}

2. 순서에 상관 없이 결과를 비동기로, 병렬적으로 한 파일에 출력하는 방법

http://thiswillbedownforsure.com is down
https://www.naver.com is up
https://www.google.com is up

위와 같이 특정 사이트 목록들에 대해 health check를 하고 그 결과를 파일로 출력하는 프로그램을 만든다고 하자. 굳이 Stream으로 만들 필요는 없겠지만 그렇게 해본다면 다음과 같은 코드를 생각해볼 수 있다.

일단 Transform 기반의 스트림을 하나 정의한다. 이 스트림은 request의 콜백으로 스트림의 기능을 빌려주는 형태로 작동한다.

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
// Transform 스트림을 하나 정의한다.
class ParallelStream extends stream.Transform {
constructor(userTransform) {
super({objectMode: true});
this.userTransform = userTransform;
// const userTransform = (chunk, enc, done, pushFn) => { ... }
this.running = 0;
this.terminateCallback = null;
}

_transform(chunk, enc, done) {
this.running++;
this.userTransform(chunk, enc, this._onComplete.bind(this), this.push.bind(this));
done();
}

// flush는 스트림 종료 직전에 호출되며 즉 done() 의 호출 여부를 결정할 수 있다.
_flush(done) {
// 작업이 모두 종료되기 전에 스트림이 종료되려고 하는 경우 done()을 호출하지 않는다.
// 그 대신 onComplete에서 곧바로 종료할 수 있도록 done 함수를
if(this.running > 0) {
this.terminateCallback = done;
} else {
done();
}
}

// userTransform에서 done이라는 이름으로 호출되는 함수. 이 때의 done은 각 단위 작업의 완료를 의미한다.
_onComplete(err) {
this.running--;
if(err) {
return this.emit('error', err);
}
// 실행 중인 작업이 모두 종료되었고 스트림 종류가 한 번 이상 보류된 경우 직접 스트림을 종료한다.
if(this.running === 0) {
this.terminateCallback && this.terminateCallback();
}
}
};

위에서 정의한 스트림을 사용해 구현한다.

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
/*
(ex)
1. process.argv[2]: urls.txt

2. urls.txt:
http://thiswillbedownforsure.com
https://www.naver.com
https://www.google.com
*/
fs.createReadStream(process.argv[2]) //[1] 파일로 readable 스트림 생성
.pipe(split()) //[2] 파일의 라인 단위로 chunk를 잡아 출력하는 Transform 스트림 생성 (파일 내용은 url 단위로 줄바꿈 돼있음)
.pipe(
//[3] pipe로 전달되는 데이터(각 URL) 마다 Transform Stream의 _transform 함수에서 아래의 콜백 함수가 호출된다.
// 생성자로 이 콜백(userTransform이라고 불리는)을 등록한다.
new ParallelStream((url, enc, done, push) => {
if (!url) return done(); // 더 이상 데이터가 없는 경우 (null인 경우) 스트림 종료하도록 (this.running == 0)
request.head(url, (err, response) => {
push(url + " is " + (err ? "down" : "up") + "\n");
done();
});
})
)
.pipe(fs.createWriteStream("results.txt"))
.on("finish", () => console.log("All urls were checked"));
/*
result:
http://thiswillbedownforsure.com is down
https://www.naver.com is up
https://www.google.com is up
*/

3. (2)의 동시 실행 수를 제한하는 방법

비동기 요청 여러 개를 처리하는 일은 Node.js에선 매우 간단하다. Run to Completion이기 때문에 변수 하나로 비동기 작업의 개수를 정확히 세고 이 값에 기반해 의사 결정을 할 수 있다.

따라서 this.running의 개수가 동시 실행 제한 개수에 도달한 경우 처리하지 않으면 된다. 좀 더 정확하게는, _transform 함수에서 해당 chunk의 처리가 완료됐음을 알리는 콜백을 호출하지 않고 보류하면 된다.

이 경우 해당 chunk를 처리한 결과는 다음 스트림으로 넘어가지 않으며 현재 chunk가 처리되지 않았기 때문에 추가적인 chunk가 스트림으로 전달되지도 않는다(스트림 내부 버퍼에 쌓인다).

만약 ReadableStream이 chunk를 생성하고 내보내는 속도가 우리의 스트림의 처리 속도보다 빠르다면 처리되지 않는 chunk는 Transform의 버퍼에 쌓이며 이내 백 프레셔가 발동되고 알아서 처리될 것이다. - pipe로 연결하면 Node.js에서 자동으로 처리한다. 백 프래셔에 대해선 5장 Stream API (2/3) - Node.js의 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
constructor(concurrency, userTransform) {
// ... 아래 두 멤버 필드만 추가된다.
this.concurrency = concurrency;
this.continueCallback = null;
}

// 입력은 직접 제한하지 않고 계속 받는다.
// 완료를 의미하는 콜백을 호출해서 다음 chunk를 처리하지 않으면, 스트림 내부의 버퍼에 쌓이게 된다.
// 그러면 Node.js 런타임이 자동으로 백 프레셔를 수행한다.
_transform(chunk, enc, done) {
this.running++;
this.userTransform(chunk, enc, this.push.bind(this), this._onComplete.bind(this));
if (this.running < this.concurrency) {
done();
} else {
// 만약 현재 running의 최대치에 도달한 경우 완료 콜백을 수행하지 않는다. 이는 자연스럽게 백 프래셔 발동으로 이어진다.
this.continueCallback = done;
}
}

_onComplete(err) {
this.running--;
if (err) {
return this.emit("error", err);
}

// continueCallback이 할당되어 있으면 호출한다.
// 이 시점에서 앞 chunk들은 모두 처리됐음이 보장된다.
// 왜냐하면 입력이 출력보다 충분히 빨라 버퍼링이 되는 시점에서는 항상 continueCallback으로 done()이 호출되게 된다.
// 항상 this.running == this.concurrency여서 꽉 차 있는 상태이기 때문이다.
// (설명이 부드럽지 못한데 실행 흐름을 보고 설명을 다시 읽어보면 이해가 될 것이다.)
const tmpCallback = this.continueCallback;
this.continueCallback = null;
tmpCallback && tmpCallback();

if (this.running === 0) {
this.terminateCallback && this.terminateCallback();
}
}

참고 자료 (이번 글만 특별히 도움이 됐는지와는 별개로 읽은 몇 개의 글을 링크한다.):

What’s the proper way to handle back-pressure in a node.js Transform stream?

Awesome Nodejs#Streams (Github Repo)


TODO:

  1. Stream 관련해서 자세한 자료보단 내부 구조를 코드 수준에서 확인하는 게 가장 좋을 것 같다.
  2. Back Pressure의 효과를 제대로 확인하기 위해선 디버거를 키고 스트림 객체를 살펴봐야 할 것 같다.
  3. Stream의 추상하된 구현체들을 가져다 쓸 수록 더욱 더 이해하기 어려워지는 것 같다.
  4. Stream을 3부작으로 나누어 작성하려고 했는데 한 10부작 까지는 나올 수도 있을 것 같다. 그만큼 부족하고, 글 쓰는 데도 매우 오래 걸린다.

5장 Stream API (2/3) - Node.js의 4가지 스트림 소개와 사용법

이 글은 Node.js 디자인 패턴 CH 05 스트림 코딩의 일부를 참고해서 작성하였으며, Node.js에서 코어 모듈로 제공하는 Stream 4종류를 다룬다. Node.js에서의 스트림 자체에 대해서는 5장 Stream API (1/3) - 스트림 개요 및 Readable Stream를 참고하라.


Node.js 스트림 객체

Node.js에서는 4가지의 추상 스트림 클래스를 제공하여 쉽게 스트림을 구현할 수 있게 한다. 이 클래스들은 core 모듈에서 제공하므로 추가 의존성이 필요하지 않다.

Name 목적 dataSource 가능
stream.Readable 외부 데이터 읽기 (dataSource에서 꺼내는 형태) True
stream.Writable 내부 데이터 외부로 전송하기 (dataSource로 써주는 형태) False
stream.Duplex Readable + Writable 스트림. True
stream.Transform 외부 데이터 읽기 => 데이터 변조하기 => 외부로 전송하기 True

Node.js의 두 버전의 스트림 API

Node.js에는 두 가지의 Stream API가 있다.

API Version Name Event Name Description
Stream v1 Flowing Mode on('data') 무조건 해당 데이터를 처리해야 함. 버퍼 크기 등의 문제로 처리하지 못 하는 경우 해당 데이터를 되살릴 방법이 없음.
Stream v2 Non-Flowing Mode on('readable') 곧바로 데이터를 처리하지 않아도 됨. 백 프래셔를 지원함.

Back Pressure: Event 송신자의 처리량이 Event를 수신하는 측의 처리량을 넘기는 경우 송신자의 전송 속도를 줄여야 하는 경우가 생기는 데 이를 해결하는 메커니즘을 Back Pressure라고 한다.

송신자-수신자 처리량 차이 발생 오류 백프래셔 필요
송신자 전송량 < 수신자 처리량 없음 False
송신자 전송량 > 수신자 처리량 처리하지 못하는 데이터에 대한 정의되지 않은 동작 등 손실 발생 가능 True

Stream v2의 백 프래셔:
Node.js의 버퍼가 알아서 버퍼링을 해주며, 버퍼 한계치를 넘으면 OS에서 패킷을 drop시켜 sender 입장에서 전송 속도를 늦추게 함. 이 기능을 자동으로 지원. (v1도 가능하다고 함. 다만 더 어렵다고 함.)

출처: What are the differences between readable and data event of process.stdin stream?

추가 참고:

[RxJava2]Flowable에서의 Backpressure

[RxJava2]Backpressure와 함께 Flowable를 만들어 보자

1. Readable 스트림

Readable 스트림은 데이터를 읽어들이는 게 목적이다.

1-1. 사용 예시:

stream.read() 함수를 사용하면 chunk를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
const RandomStream = require("./randomStream");
const randomStream = new RandomStream();

// readable 스트림:
// stream.read() 로 내용을 읽는 것을 의미한다.
randomStream.on("readable", () => {
let chunk;
while ((chunk = randomStream.read()) !== null) {
console.log(`Chunk received: ${chunk.toString()}`);
}
});

1-2. Readable 구현 코드

Readable Stream은 _read 함수를 구현하면 된다.

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
const stream = require('stream'); // 코어 모듈 (stream)
const chance = new require('chance')(); // 랜덤 (외부 의존성)

// [1] Readable 구현체
class RandomStream extends stream.Readable {
constructor(options) {
super(options);
}

// Readable에서 구현해야 하는 함수는 _read 하나임
//
_read(size) {
const chunk = chance.string(); //[1] 랜덤값 생성
console.log(`Pushing chunk of size: ${chunk.length}`);
this.push(chunk, 'utf8'); //[2] Encoding을 설정하면 String으로 읽음
if(chance.bool({likelihood: 5})) { //[3] null을 보내면 종료하기로 약속함
this.push(null);
}
}
}

// [2] 스트림 사용
const randomStream = new RandomStream();

randomStream.on('readable', () => {
let chunk;
while((chunk = randomStream.read()) !== null) { // [1] 약속한대로 null 이면 읽기 종료
console.log(`Chunk received: ${chunk.toString()}`);
}
});

2. Writable 스트림

Writable 스트림은 데이터를 생성하는 게 목적이다. (ex) HTTP response 생성

2-1. 사용 예시

stream.write 함수를 사용하면 스트림에 내용을 쓸 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// res가 Writable Stream 이다 :)
// "Writable Stream은 데이터의 목적지를 나타낸다"는 뜻은
// stream.write(내용) 을 쓰면 해당 stream으로 전달된다는 뜻이다.
// 전송하는 입장에선 writable이지만, 받는 입장에선 readable로 취급하면 쓰기, 읽기가 각각 되는 것이다.
require("http")
.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
while (chance.bool({ likelihood: 95 })) { // 5% 확률로 루프 빠져나오는 코드.
res.write(chance.string() + "\n");
}
res.end("\nThe end...\n");
res.on("finish", () => console.log("All data was sent")); // 스트림에 finish 이벤트 리스너 등록 후 종료
})
.listen(8080, () => console.log("Listening on http://localhost:8080"));

2-2. Writable 구현 코드

(윗 코드와는 상관 없음.) Writable Stream은 _write 함수를 구현하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ToFileStream extends stream.Writable {
constructor() {
super({objectMode: true});
}

_write (chunk, encoding, callback) {
mkdirp(path.dirname(chunk.path), err => {
if (err) {
return callback(err);
}
fs.writeFile(chunk.path, chunk.content, callback);
});
}
}

2-3. 백 프래셔 예제

백 프래셔란 Read보다 Write가 빠를 때 병목이 생기는 것을 방지하는 메커니즘이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
require("http")
.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });

function generateMore() {
while (chance.bool({ likelihood: 95 })) {
const shouldContinue = res.write(
// res.write가 Falsy를 반환하면 내부 버퍼를 다 사용한 것
// (자세한 내용은 나중에 포스팅할 예정)
chance.string({ length: 16 * 1024 - 1 })
);
if (!shouldContinue) {
console.log("Backpressure"); // 백 프래셔를 수행해야 하는 시점
return res.once("drain", generateMore); // once로 drain 이벤트 핸들러를 등록해 재시작 대기
}
}
res.end("\nThe end...\n", () => console.log("All data was sent"));
}
generateMore();
})
.listen(8080, () => console.log("Listening on http://localhost:8080"));


3. Duplex Stream

Duplex Stream은 Readable + Writable 그 이상 그 이하도 아니며 따라서 설명을 생략한다.


4. Transform Stream

Transform 스트림은 읽어들인 데이터를 변조해 내보내는 스트림이다. 스트림이니만큼 chunk 단위로 데이터가 오므로 변환에 유의해야 한다.

4-1. 사용 예시

1
2
3
4
5
6
7
8
9
const ReplaceStream = require("./replaceStream");

const rs = new ReplaceStream("World", "Node.js");
rs.on("data", (chunk) => console.log(chunk.toString()));

rs.write("Hello W");
rs.write("orld!");
rs.end();
// Hello Node.js

4-2. Transform 구현 코드

스트림 상에서 문자열 일부를 치환하는 코드이다. (어렵다.)

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
class ReplaceStream extends stream.Transform {
constructor(searchString, replaceString) {
super();
this.searchString = searchString;
this.replaceString = replaceString;
this.tailPiece = '';
}

_transform(chunk, encoding, callback) {
const pieces = (this.tailPiece + chunk)
.split(this.searchString);
const lastPiece = pieces[pieces.length - 1];
const tailPieceLen = this.searchString.length - 1;

this.tailPiece = lastPiece.slice(-tailPieceLen);
pieces[pieces.length - 1] = lastPiece.slice(0,-tailPieceLen);

this.push(pieces.join(this.replaceString));
// 여기서의 callback은 각 chunk의 처리가 완료됐음을 알리는 함수이다.
callback();
}

// 여기서의 callback은 스트림을 종료시키는 함수이다.
_flush(callback) {
// flush라는 이름 답게 출력되지 않은 데이터의 출력을 수행한다.
this.push(this.tailPiece);
// 스트림을 종료한다.
callback();
}
}

TODO: (전부 다 책에서 나온 내용)

스트림 간의 Pipelining(조합) 소개

스트림 기반 비동기 제어 소개 (순차/비순차/제한된 비순차)

스트림 fork, merge

스트림 멀티플렉싱, 디멀티플렉싱


소스 코드 출처: Node.js 디자인 패턴

스트림 파트는 내가 스트림에 대한 경험도 거의 없고 책에서 설명하는 내용이 어려워서 내 생각을 넣어 포스팅하기가 매우 어려웠다. 내용을 간략히 정리하는 선에서 마쳐야 할 것 같아 아쉽다.

7장 의존성 주입 (2/2) - 간단한 Javascript DI 컨테이너 구현체

이 글은 7장 의존성 주입 (1/2)에서 설명한 DI 컨테이너의 간단한 구현체를 제시한다. Javascript이기 때문에 타입 정보를 얻을 수 없어 String으로 의존성을 판단하는 부분을 참고하기 바란다.


이 글의 코드는 출처에서 배포된 코드를 가져왔음을 밝힌다.


1. DI 컨테이너 구현

diContainer.js

아쉽게도 패키지 전체를 미리 스캔하여 자동으로 의존 관계를 파악하고 의존성 주입을 수행하지는 않는다. 기능은 크게 get, factory, register가 있다. 자세한 설명은 주석을 참고하라.

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
"use strict";

// fnArgs는 함수의 인자 목록을 String 배열로 반환한다.
const fnArgs = require("parse-fn-args");

module.exports = () => {
const dependencies = {};
const factories = {};
const diContainer = {};

// factory, register 둘 다 단순 등록 기능이다.
// factory 메소드의 경우 의존성 주입이 필요한 객체인 경우 사용한다.
diContainer.factory = (name, factory) => {
factories[name] = factory;
};

// register 메소드의 경우 의존성 주입이 필요 없는 객체(상수 등)를 등록할 때 사용한다.
diContainer.register = (name, dep) => {
dependencies[name] = dep;
};

/*
1. get은 dependencies에 없는 경우 factory로 간주하고 가져옴
2. 만약 가져오려 했던 객체가 존재하면 해당 객체로 inject를 호출함 (inject를 통해 재귀적으로 의존성을 resolve.)
3. (2)의 결과를 dependencies에 저장
4. 만약 그래도 dependencies에 없는 경우 모듈을 찾을 수 없는 것.
*/
diContainer.get = (name) => {
if (!dependencies[name]) {
const factory = factories[name];
dependencies[name] = factory && diContainer.inject(factory);
if (!dependencies[name]) {
throw new Error("Cannot find module: " + name);
}
}
return dependencies[name];
};

/*
1. factory로 등록된 객체를 전달받음
2. fnArgs는 함수(factory의 경우, 의존성을 명시한 함수를 export 함.)의 인자를 가져옴
3. 인자에 대해 map으로 get을 수행한 배열을 args 변수에 저장함
4. factory(생성자)를 resolved 된 dependencies로 호출함
*/
diContainer.inject = (factory) => {
const args = fnArgs(factory).map(function (dependency) {
return diContainer.get(dependency);
});
return factory.apply(null, args);
};

return diContainer;
};


2. 컨테이너 사용

1. app.js

DI 컨테이너에 각 객체를 등록하는 과정을 이 파일을 진입점 삼아 수행하였다. 좀 더 좋은 DI 컨테이너라면 Reflection 등을 이용해 자동으로 mark된 객체를 등록하고 의존성 주입을 진행할 것이다.

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
"use strict";

//...

const diContainer = require("./lib/diContainer")();

// register는 추가적으로 의존성 주입이 필요 없는 객체를 등록한다. (상수 등)
diContainer.register("dbName", "example-db");
diContainer.register("tokenSecret", "SHHH!");

// factory는 의존성 주입이 필요한 객체를 등록한다.
diContainer.factory("db", require("./lib/db"));
// Service 객체 등록 (의존성 주입 필요한 상태)
diContainer.factory("authService", require("./lib/authService"));
// Controller 객체 등록 (의존성 주입 필요한 상태)
diContainer.factory("authController", require("./lib/authController"));

// get은 의존성을 반환한다. (재귀적으로 의존성 주입이 된 채로 반환된다.)
const authController = diContainer.get("authController");

// Express에 Controller 등록
app.post("/login", authController.login);
app.get("/checkToken", authController.checkToken);

//...

2. authController.js

의존성 주입이 적용되는 객체 1이다. 주석 참고.

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
"use strict";

// Express에서 일반적으로 사용되는 Controller 예제이다.
// 모듈 차원에서 함수로 내보내며(DI 컨테이너 작동 방식에 맞춤), 인자에 이름으로 의존성을 명시한다.
module.exports = (authService) => { // DI 컨테이너에 의해 authService 의존성을 주입 받게 된다.
const authController = {};

authController.login = (req, res, next) => {
authService.login(req.body.username, req.body.password,
(err, result) => {
if (err) {
return res.status(401).send({
ok: false,
error: 'Invalid username/password'
});
}
res.status(200).send({ok: true, token: result});
}
);
};

authController.checkToken = (req, res, next) => {
authService.checkToken(req.query.token,
(err, result) => {
if (err) {
return res.status(401).send({
ok: false,
error: 'Token is invalid or expired'
});
}
res.status(200).send({ok: 'true', user: result});
}
);
};

return authController;
};

3. appService.js

의존성 주입이 적용되는 객체 2이다. 주석 참고.

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
"use strict";

const jwt = require('jwt-simple');
const bcrypt = require('bcrypt');

// 역시 모듈을 함수로 내보내며 의존성을 명시했다.
// 이 예제에서는 db 객체에 대해선 생략하였다. (DI 설명에서 의미 없는 구성 요소)
module.exports = (db, tokenSecret) => {
const users = db.(...);
const authService = {};

authService.login = (username, password, callback) => {
users.get(username, (err, user) => {
if (err) return callback(err);

bcrypt.compare(password, user.hash, (err, res) => {
if (err) return callback(err);
if (!res) return callback(new Error('Invalid password'));

const token = jwt.encode({
username: username,
expire: Date.now() + (1000 * 60 * 60) //1 hour
}, tokenSecret);

callback(null, token);
});
});
};

authService.checkToken = (token, callback) => {
let userData;
try {
userData = jwt.decode(token, tokenSecret);
if (userData.expire <= Date.now()) {
throw new Error('Token expired');
}
} catch(err) {
return process.nextTick(callback.bind(null, err));
}

users.get(userData.username, (err, user) => {
if (err) return callback(err);
callback(null, {username: userData.username});
});
};

return authService;
};

TODO:

Node.js 스트림 이어서 포스팅하기

7장 의존성 주입 (1/2) - Node.js/Javascript 환경에서의 한 패키지 내의 의존성 관리

이 글은 Node.js/Javascript 환경에서의 한 패키지(App) 내의 모듈 간의 의존성을 관리하는 방법에 대해 다룬다. 명시하지 않은 경우 Javascript 환경임을 미리 밝힌다.

Typescript는 지금까지 많이 활용돼왔고 생태계가 성숙한 상태이므로, OOP 방식으로 문제 해결을 하려는 경우 Typescript가 적정 기술이라고 생각한다.


흔한 하드코딩 의존성(구현체 직접 import)의 예


1. 유독 언급이 적은 Node.js에서의 의존성 관리, 왜?

백엔드와 같이 쉽고 빠르게 규모가 커지고 기능 변경이 잦은 코드 베이스인 경우 설계가 중요한 경우가 많을 것이다. 설계는 의존성 관리가 기본이며 Node.js 백엔드 또한 그 예외는 아닐 것인데 말이다.

Q. 왜 Node.js에서는 하드 코딩된 의존 관계를 구축하는 코드를 찾기가 매우 쉬울까?

A. 가설: 인터페이스와 상관 없이 임의의 객체를 집어 넣어 테스트를 할 수 있기 때문에 굳이 Interface가 필요하지 않다. 동적 타입 언어이니까.


2. “동적 타입 언어”라는 특징

장점

만약 Java 였다면 Interface를 아예 사용하지 않는 것은 설계에 큰 문제가 있음을 시사하는 것이겠지만 Javascript는 동적 타입 언어이다. 기능을 실행하는 객체의 타입이 중요하지 않은 언어이다. 인터페이스가 없는 만큼 규칙도 없지만 그만큼 유연해진 셈이다.

단점

다만 동적 타입을 활용해 테스트가 가능하다고 해도 자연스럽게 생기는 강한 결합이 사라지는 것은 아니다. 구현체에 직접 의존하면 강한 결합이 발생한다. 의존하는 객체의 구현 상세에 대한 아무런 격리 장치가 없으며 구현체에서 변경이 생겼을 때 해당 의존성을 사용하는 모든 객체에 그 여파가 전달되므로 다시 검증(테스트), 빌드해야만 한다.


3. OOP의 문제 해결 방식

OOP 에서는 인터페이스를 미리 정의하고 해당 인터페이스를 최대한 변경하지 않음(Open Close Principle)을 통해 문제를 해결한다. 인터페이스에 의존함을 통해 구현 상세와 사용 객체를 진정으로 격리시킬 수 있으며 이는 의존성 관리에 매우 큰 역할을 한다.

의존성에 의한 강한 결합을 막는 수단은 현재로썬 서비스 로케이터 패턴과 의존성 주입이 있다.

이제부터 이 글은 Javascript/Node.js 에서의 의존성 주입에 대해 다룬다.


4. 서비스 로케이터 패턴

서비스 로케이터 패턴이란 “의존성이 있는 각 객체가 서비스 로케이터 객체만을 직접 의존하고, 각 객체는 서비스 로케이터에 의존성을 명시해 구현체를 받아오는 것“을 말한다. (서비스 로케이터 패턴에 대해 더 자세히 알고 싶다면 이 글을 참고하라.)

아래 예는 AuthController가 AuthService에 의존하는 코드이다.

1
2
3
4
5
6
7
8
9
// AuthController.js - AuthService에 의존한다.
// AuthController는 ServiceLocator에만 '직접' 의존한다.
module.exports = (serviceLocator) => {
const authService = serviceLocator.get('authService'); // TS 등 정적 타입 언어에서는 타입으로 받아온다.
// Javascript는 딱히 타입이 없으므로 String으로 의존성(객체)을 식별한다.
// require()와 사용 방식이 매우 닮아있다. 차이가 있다면, require는 전체 경로를 명시한다는 점이다.
const authController = {};
//...
}

서비스 로케이터 패턴의 장점

의존성의 구현체에 의존하지 않게 해준다. 이는 의존성 주입과 동일한 장점이며 아주 좋은 장점이다.

서비스 로케이터 패턴의 단점

객체의 구현 코드를 보지 않으면 곧바로 의존 관게를 파악할 수 없다. 생성자 등으로 명시하지 않기 때문에 - 생성자의 파라미터로 명시한다면 필수값이라는 문서화의 역할을 수행하게 되는데 비해 - 모든 객체에 대해 문서화가 필요하다.


5. 의존성 주입

의존 관계를 가장 잘 다루는 방법은 아마도 DI일 것이다. Javascript 진영에선 Angular가 최초로 의존성 주입을 도입한 것으로 안다(Typescript도 없던 시절이었는데!).

의존성 주입이란 “모듈의 의존성을 외부 개체에 의해 입력으로 전달 받는 것“을 말한다. 의존성 주입의 개념 자체는 매우 간단하다. DI를 지원하기 위한 컨테이너와 지원 방식을 구현하는 게 어려울 뿐이다.

(ex) AuthController가 AuthService에 의존하는 경우의 예시를 확인하자.

Before DI: 구현체를 직접 가져오는 모듈

1
2
3
4
5
6
7
// 직접 가져온다.
const authService = require('./authService');

exports.login = (req, res, next) => {
authService.login(...);
//...
};

After DI: 의존성을 받아오는 모듈

1
2
3
4
5
6
7
8
9
10
// authService를 전달 받아서 사용한다. authService의 출처와 구현체에 대해 아는 것은 더 이상 이 객체의 책임이 아니다. 그냥 사용만 하면 된다.
module.exports = (authService) => {
const authController = {};

authController.login = (req, res, next) => {
authService.login(req.body.username, req.body.password, ...);
//...
}
return authController
};

Service Locator / DI Container의 간략한 구현도 포함하려고 했으나 2편에서 다루도록 하겠다.


6. Node.js의 DI 컨테이너 생태계

약간의 짬을 내어 찾아보니 크게 4개의 오픈소스 컨테이너들이 있었다: InversifyJs, tsyringe, typedi, awilix (점유율 순).

tsyringe는 Microsoft에서 만들었다. 재밌는 점은 MS에서 inversifyjs를 사용한다고 나와있는 것이다. NestJs는 DI를 Core에 내장하여 차트에 포함시켰다.

dif

각 라이브러리의 자세한 비교는 기회가 된다면 추후 진행하려 한다.


TODO:

  1. Clean Architecture를 다시 읽는다. SOLID 원칙 조차 희미해진 듯하다.
  2. DI와 DIP의 관계에 대해 다시 공부해야겠다.
  3. 양파 껍질 Architecture에 대해 제대로 이해해야겠다.
  4. require과 서비스 로케이터 패턴의 관계에 대해 이해해야겠다.

CS에서 가장 자신있던 객체지향을 이렇게 모르게 됐다는 게 새삼 충격적이다 :(

Express를 사용해야 할 이유 (2) - Microframework

이 글은 microframework라는 개념과 express의 용도를 연관지어 생각해본다. 이 글은 기술적으로 사실이라고 검증되지 않은 내용이 포함되며 데이터에 근거한 결론보다 생각 위주로 작성됨을 미리 알린다.


대략 2주 전 Express를 사용해야 할 이유 (1)을 쓰면서 왜 Express를 많이 쓰는걸까? 생각을 많이 해봤지만 결론을 내리지 못 했다. 아래는 당시 글에 작성했던 intro이다.

잠시나마 사용해본 Express는 내게 React 같았다. 무엇이든 할 수 있어 보였으나 직접 하기에는 매우 불편하고, 그러다보니 REST API를 작성할 때 이런 것까지 해야 돼? 혹은 이런 기능이 없어서 불편하네 등이 많았는데…

오늘이 되어서야 왜 Express가 기능이 적은지 알게 되었는데, 그 마법의 키워드는 바로 Microframework이다. (진지하게 이 키워드에 대해 오늘 이전에 들어본 적이 단 한 번도 없었다.)

Microframework란?

1. 정의: 최소 기능을 갖는 웹 애플리케이션 프레임워크 <-> full-fledged framework (필요한 기능은 대부분 갖춘 프레임워크를 의미)

2. 기능: Microframework는 서비스 개발 시 “주로“, “일반적으로“ 사용되는 공통적인 기능들을 제공하지 않는다. 제공하지 않는다는 그 기능들이란 대체로 아래와 같다.

  • 인증, 인가
  • ORM 혹은 DB 관련 기능
  • 입력값 검증 / 보안 (Validation, Sanitation)
  • 템플릿 엔진

3. 목적: Microframework는 작은 API 서버를 제작하는게 목적이다.

정리하자면 Microframework는 기능의 다양성이나 설계의 편리성보다 기능의 단순성이 더 우선된 프레임워크이다.

따라서 규모 있게 Monolith로 제작하는 경우 full-fledged framework를 사용하는 게 맞다고 본다. 실제로 Walamrt는 Node.js 기반으로 백엔드를 구성했지만 Commerce 기업이라 Hapi.js(full-fledged framework)를 직접 만들어서 사용하기도 하고 말이다.


Microframework의 종류

아래는 주요 언어의 Microframework의 목록의 일부다.

(더 많은 목록은 위키 백과 Microframework 문서 (EN)를 참고 바람.)

예상한 대로 Express.js도 microframework로 등재돼있다. Express 소개문에 “Fast, unopinionated, minimalist web framework for Node.js“ 라고 괜히 되어 있는 것이 아니다.

Netflix에서는 Restify라는 프레임워크를 예전부터 사용 중인데, Restify도 Microframework이다. Restify는 Semantically correct RESTful 을 지향한다. 온전히 REST API를 위한 기능만 제공하므로 Express 등에서 제공하는 템플릿 엔진 조차 없는데 기능의 단순함 측면에서 더 매력적이라고 할 수 있다. Restify는 또한 Connect 미들웨어를 지원하므로 Express 미들웨어와 호환된다.

아래는 Restify 공식 홈페이지에 나와있는 소개이다.

Meet Restify - A Node.js web service framework optimized for building semantically correct RESTful web services ready for production use at scale. restify optimizes for introspection and performance, and is used in some of the largest Node.js deployments on Earth.


Microframework가 Microservices에 좋을까?

아직 MSA에 대해 공부한 적 없기도 하고 이런 지식들이 쉬운 편이 아니라 좀 배워야 키워드 검색이 가능하기 때문에 Youtube와 여러 글들을 읽어보면서 느낀 점을 적으려 한다.

왜 restify를 사용하는지 추측해보자면 굉장히 많은 컨테이너에서 돌아가는 MSA를 구축할 때 작은 서비스가 유리하기 때문이 아닐까? Netflix는 (다른 기업들이 응당 그러하듯) MSA로 개발할 때 기능 단위로 분리하는데, Monolithic Service에 들어갈 많은 기능들이 기본 제공되지 않는 프레임워크가 가벼워지는데 유리한 것이다. (Netflix는 의사 결정 시 성능에 우선순위를 두는 듯 하다. 13년도에 Java에서 Node.js로의 이주를 시작한 것만 봐도.)


Node.js를 쓰는 대부분의 use case가 microframework가 필요해서일까?

이전 글 내용 중 다운로드 수를 비교한 자료가 있었는데 Express가 압도적이었다. 같은 microframework이며 벤치마크(hello world)도 더 우수한 Koa가 그렇게 많이 사용되지 않는 점(약 21배 차이)은 이전 글에서도 다뤘듯 async/await 문법의 지원과 커뮤니티의 차이 때문임으로 보인다.

다운로드 수에 대한 요즘 생각:

  1. 러닝 커브. 많은 사람들이 Javascript가 배우기 쉽다고 말하며 그렇게 진입하는 사람이 적지 않다. 그저 React.js와 Express.js를 기초적인 수준에서 사용하는데 머무는 사람들이 정말 많다고 생각한다.

  2. Netflix처럼 애초에 Managed로 환경 구성이 잘 된 경우라면 npm에서 다운로드 수 집계가 제대로 되지 않을 것 같다. (Proxy를 통한 캐싱 등) 즉 npm 다운로드가 현업 개발 시의 실제 사용 빈도를 정확히 나타내는 것도 아닐 수 있을 것이다.

따라서 단순히 npm 다운로드 수로 비교하는 것보단 실제로 돈을 벌고 많은 트래픽을 처리하는 기업에서 무슨 스택을 사용하는지가 더 중요할 것으로 보인다.

Hapi Koa Nest Express
img img img img

(참고로 restify는 12만, fastify는 19만 정도 된다. - fastify의 경우 Nest.js에서 사용되는 면이 있으니 참고)


다운로드 수를 유일한 척도로 삼고 맹신해서는 안 될 것 같다.

Express의 점유율이 매우 높다는 것을 microframework를 사용하는 숫자가 Node.js 백엔드 개발자 중에서 대다수라고 받아들이면 안 될 듯하다. Netflix의 경우는 MSA를 도입했기 때문이지만 트래픽이 많지 않은 기업들의 경우 아직 Monolithic이거나 작은 규모의 분산 시스템이면 충분할 거라고 생각하기 때문이다.

Node.js의 경우 …

  • Javascript를 프론트엔드와의 공용어로 사용할 수 있다는 점
  • SSR 시 코드 재사용이 가능하다는 점
  • 백엔드 언어와 Javascript 간의 Context Switching이 사라진다는 점
  • Interpreter 언어여서 Startup이 매우 빠르다는 점
  • 모듈 생태계가 크다는 점이 장점

이런 장점 속에서 굳이 프레임워크에서 가치를 찾지 않으려는 경우도 많지 않을까?

다운로드 수가 많다고 해서 Express를 사용할 이유는 없다. 1편에서도 밝혔듯이 Javascript와 Node.js의 장점 자체도 이미 많으며 Express에서 조금 고생하면서 Monolithic 서비스 개발하는 것은 점진적으로 러닝 커브가 올라가는 형태라고 생각하고, 딱 그 정도 수준이 필요한 기업도 많을 거라고 생각한다.

아직 결론은 내릴 수 없을 것 같다.

아직 Node.js의 정수를 다 배우지 못 했기 때문에, 아직 MSA를 배우지 못 했기 때문에 정확한 판단을 내릴 수가 없다. 이것들을 어느 정도 습득하고 나서 정말 왜 Express가, Express만이 이렇게 잘 팔리는 이유를 분석할 수 있으면 좋겠다.


What I Learned

  1. Netflix는 기술 블로그와 컨퍼런스를 통해 정말 많은 기술적인 내용들을 공유한다는 걸 오늘 리서치하면서 배웠다. Youtube의 경우 여러 채널에 Video가 산재돼있는데 이 Playlist가 좋은 것 같다.

  2. 아직도 Node.js 생태계에 대해 제대로 이해하지 못 하고 있다는 점을 또 알게됐다. 아직 해결되지 못한 질문들에 대해 데이터를 찾아서 반드시 답을 내리고 싶다.