part2
제로초의 Node.js 교과서 섹션 2 요약
3.1 REPL 사용하기
REPL이라는 용어를 들어본 적이 있는가? 레플? 이라고 읽는 것 같다.
Read-Eval-Print-Loop의 사이클을 한 단어로 나타내는 용어인데, JavaScript와 Python같은 인터프리터 기반의 언어들에서는 일종의 대화형 코드 실행 인터페이스로서 제공하는 경우가 많다.
그게 가능한 이유는 그 언어들이 소스코드를 통째로 컴파일해야만 동작하는 것이 아니라 한줄 한줄 인터프리터가 읽어가면서 실행하는 방식으로 동작하기 때문이다.
Python에는 IDLE라는 인터페이스를 제공하고 있고, Node.js에서 REPL을 활용하고 싶으면 터미널을 열어서 node
라는 명령어를 입력하면 된다.
그러면 마치 브라우저의 개발자 도구에 console 탭에서 볼 수 있는 것과 같은 코드 실행기가 실행된다.
Welcome to Node.js v20.8.1.
Type ".help" for more information.
>
3.2 JS 파일 실행하기
앞서 설명한 REPL은 아주 적은 양의 코드를 하나 하나 입력해보면서 실행 결과를 확인하는, 일종의 테스트 용도로 활용하는 데 적합하다.
수백 줄로 이루어진 거대한 코드 덩어리를 실행하는 데는 적합하지 않다는 이야기이다.
이런 경우에는 해당 코드를 .js
확장자를 가진 파일에 저장하고, 해당 파일을 node
명령어로 실행하는 방식이 적합하다.
node example.js
3.3 모듈로 만들기
Node.js는 모듈 시스템을 지원하기 때문에 단일 프로그램의 덩치가 너무 커지지 않도록 해당 프로그램을 여러개의 하위 모듈로 분리할 수 있다. 물론 그 모듈 또한 하나의 프로그램일 수 있고, 상위 모듈이 동작할 때 의존 관계에 있는 모듈들과 더불어 조립되어 상위 모듈을 동작시키게 된다.
3.3.1 CommonJS 모듈
CommonJS 모듈
CommonJS 모듈은 지금의 자바스크립트 표준인 ECMAScript가 나오기 이전에 쓰였던 방식으로, 여전히 많이 쓰이 고 있다.
모듈이 될 var.js와 모듈을 활용할 func.js, 그 둘을 활용할 index.js를 선언해보자.
// var.js
const odd = "홀수입니다.";
const even = "짝수입니다.";
module.exports = {
odd,
even,
};
// func.js
const { odd, even } = require("./var");
function isOdd(num) {
return num % 2 ? odd : even;
}
module.exports = isOdd;
// index.js
const { odd, even } = require("./var");
const isOdd = require("./func");
function isStrLengthOdd(str) {
return str.length % 2 ? odd : even;
}
console.log(isOdd(10));
console.log(isStrLengthOdd("hello"));
func.js는 var.js의 변수 정보를 활용하고 있고, index.js는 그 둘의 함수 또는 변수의 정보를 불러와 활용하고 있다.
여기서 주목할 것은 모듈이 "내보낼" 정보는 module.exports
객체에 할당하고 있다는 것과, 해당 모듈에서 내보내고 있는 정보를 "불러올" 때는 require()
함수를 사용하고 있다는 것이다.
여기서 module.exports 객체는 정보를 내보낼 때 활용되고 있지만, exports 객체도 같은 역할을 수행하고 있다. 이는 exports 객체가 module.exports 객체를 참조하고 있어, 결과적으로는 두 객체 모두 같은 대상을 참조하고 있기 때문이다.
단, exports 객체를 활용할 때 주의할 점이 있다.
- exports 객체에 직접 다른 값을 대입하면 module.exports 객체와의 참조 관계가 끊어져서 정보들이 제대로 내보내지지 않는다.
- 반드시 속성명과 속성값을 대입해야 한다.
그리고 exports 객체는 module.exports와 참조 관계에 있으므로, 같은 파일 내에서 둘 모두를 활용하는 것도 exports 객체로 내보내는 코드가 동작하지 않는 결과를 낳으므로 바람직하지 않다.
추가로 다음과 같은 특징도 기억하자.
- exports객체는 모듈 최상단에서 this 호출 시 바인딩 된다.
require() 함수는 객체로서의 속성을 몇 개 가지고 있는데, 이에 대해 알아보자.
// require.js
console.log("require는 최상단에 위치하지 않아도 됩니다.");
module.exports = "저를 찾아보세요.";
require("./var");
console.log("require.cache: ");
console.log(require.cache);
console.log("require.main: ");
console.log(require.main);
console.log(require.main === module);
console.log(require.main.filename);
위 파일을 실행하면 다음과 같은 결과를 얻을 수 있다. (실행 환경마다 다를 수 있다.)
require는 최상단에 위치하지 않아도 됩니다.
require.cache:
[Object: null prototype] {
'/Users/cheshier/Desktop/Learn/Node/Section 2/require.js': Module {
id: '.',
path: '/Users/cheshier/Desktop/Learn/Node/Section 2',
exports: '저를 찾아보세요.',
filename: '/Users/cheshier/Desktop/Learn/Node/Section 2/require.js',
loaded: false,
children: [ [Module] ],
paths: [
'/Users/cheshier/Desktop/Learn/Node/Section 2/node_modules',
'/Users/cheshier/Desktop/Learn/Node/node_modules',
'/Users/cheshier/Desktop/Learn/node_modules',
'/Users/cheshier/Desktop/node_modules',
'/Users/cheshier/node_modules',
'/Users/node_modules',
'/node_modules'
]
},
'/Users/cheshier/Desktop/Learn/Node/Section 2/var.js': Module {
id: '/Users/cheshier/Desktop/Learn/Node/Section 2/var.js',
path: '/Users/cheshier/Desktop/Learn/Node/Section 2',
exports: { odd: '홀수입니다.', even: '짝수입니다.' },
filename: '/Users/cheshier/Desktop/Learn/Node/Section 2/var.js',
loaded: true,
children: [],
paths: [
'/Users/cheshier/Desktop/Learn/Node/Section 2/node_modules',
'/Users/cheshier/Desktop/Learn/Node/node_modules',
'/Users/cheshier/Desktop/Learn/node_modules',
'/Users/cheshier/Desktop/node_modules',
'/Users/cheshier/node_modules',
'/Users/node_modules',
'/node_modules'
]
}
}
require.main:
Module {
id: '.',
path: '/Users/cheshier/Desktop/Learn/Node/Section 2',
exports: '저를 찾아보세요.',
filename: '/Users/cheshier/Desktop/Learn/Node/Section 2/require.js',
loaded: false,
children: [
Module {
id: '/Users/cheshier/Desktop/Learn/Node/Section 2/var.js',
path: '/Users/cheshier/Desktop/Learn/Node/Section 2',
exports: [Object],
filename: '/Users/cheshier/Desktop/Learn/Node/Section 2/var.js',
loaded: true,
children: [],
paths: [Array]
}
],
paths: [
'/Users/cheshier/Desktop/Learn/Node/Section 2/node_modules',
'/Users/cheshier/Desktop/Learn/Node/node_modules',
'/Users/cheshier/Desktop/Learn/node_modules',
'/Users/cheshier/Desktop/node_modules',
'/Users/cheshier/node_modules',
'/Users/node_modules',
'/node_modules'
]
}
true
많은 정보를 알 수 있지만 require.cache와 require.main 속성에 주목해보자.
- require.cache : require 함수를 통해 읽어온 정보를 임시로 저장한다. 해당 모듈 내에서 require을 통해 불러온 정보는 내부에서 활용될 때 require.cache를 참조하게 된다.
- require.main : 불러온 모듈이 어디에 위치하는지 알 수 있다.
require 함수를 사용할 때에도 주의할 점이 있다. 다음과 같은 모듈들이 있다고 하자.
// dep1.js
require("./dep2.js");
// dep2.js
require("./dep1.js");
이 dep1과 dep2는 모듈의 입장에서 자신을 참조하는 모듈을 참조하고 있기 때문에, 이론적으로는 무한히 참조하는 '순환 참조' 상황을 만들고 있다.
Node는 이렇게 순환 참조 상황을 인지하면 알아서 순환 참조를 만드는 require 함수를 빈 객체로 만들어버리기 때문에 실제로 순환 참조가 일어나는 상황은 없겠지만, 애초에 이런 상황을 유발하는 코드를 작성하지 않도록 주의하자. 본인을 포함한 다른 개발자에게 혼란을 줄 수 있다.
3.3.2 ECMAScript 모듈
ECMAScript 모듈(이하 ES 모듈)은 공식 JS 모듈 형식이다. 브라우저는 이미 ES 모듈 형식을 채택하고 있으니, 이 모듈 형식에 익숙해지는 것이 장기적으로는 좋을 것이다.
ECMAScript 모듈 파일의 확장자는 기본적으로 .mjs
이지만, .js
로 설정해도 무방하다. 대신 .js 확장자의 파일을 모듈로 활용하려면, 이를 HTML에서 불러올 때는 **type="module"**을, Node에서는 package.json 파일에 type: module을 명시해야 사용할 수 있다는 점을 기억하자.
이제 앞서 CommonJS 형식으로 정의했던 모듈들을 ECMAScript 형식으로 선언해보자. 매우 간단하다.
// var.mjs
export const odd = "홀수입니다.";
export const even = "짝수입니다.";
// func.mjs
import { odd, even } from "./var.mjs";
function checkOddOrEven(num) {
if (num % 2) {
return odd;
}
return even;
}
export default checkOddOrEven;
// index.mjs
import { odd, even } from "./var.mjs";
import checkNumber from "./func.mjs";
function checkStringOddOrEven(str) {
if (str.length % 2) {
return odd;
}
return even;
}
console.log(checkNumber(10));
console.log(checkStringOddOrEven("hello"));
내보내고 싶은 정보를 export라는 키워드를 앞에 붙여 내보내고, 불러오고 싶은 정보는 import라는 키워드를 통해 불러온다.
export default는 해당 모듈에서 말 그대로 기본적으로 내보내고 싶은 정보를 명시하고 있고, 그렇기에 import해올 때 중괄호(Bracket)를 감싸지 않고도 가져올 수 있다.
또한 불러올 때 이름을 변경해도 괜찮은 것 또한 export default로 내보낸 정보의 특징이라고 할 수 있다.
그리고 CommonJS에서의 방식처럼 함수나 객체를 활용하는 것이 아니라, export, import라는 JavaScript 문법 자체를 활용하고 있는 점도 특징이다.
정리하면, CommonJS와 ECMAScript 방식의 모듈의 차이점은 다음과 같다.
3.3.3 다이내믹 임포트
다이내믹 임포트는 말 그대로 동적으로 모듈을 불러오는 기능이다. CommonJS 모듈은 이 기능을 지원하지만 ECMAScript 모듈은 지원하지 않는다.
// dynamic.js
const a = false;
if (a) {
require("./func");
}
console.log("성공!");
cjs 방식에서는 이렇게 조건부로 모듈을 불러오거나, 불러오지 않을 수 있다.
// dynamic.mjs
const a = false;
if (a) {
import "./func.js"; // SyntaxError
}
console.log("성공!");
// dynamic2.mjs
const a = true;
if (a) {
const m1 = await import("./func.mjs");
const m2 = await import("./var.mjs");
}
console.log(m1, m2); // 모듈 정보 ~~~
mjs 방식에서는 import 문이 항상 코드 최상단에 위치하고 있어야하기 때문에, cjs 방식에서 가능했던 것처럼 조건문 안에 작성하는 것이 불가능하다. 따라서 mjs에서 동적으로 모듈을 불러올 때에는 import()
함수의 호출을 통해 모듈을 동적으로 불러오는 다른 방식을 사용하게 된다. import 함수는 프로미스를 리턴하기 때문에, 활용하려면 await을 앞에 붙여줘야한다. 그래서 사실 ES 모듈에서 기본 문법으로 다이내믹 임포트를 지원하지 않는 것은 맞지만, 다이내믹 임포트 자체가 불가능한 것은 아니다.
3.3.4 __filename, __dirname
CommonJS 모듈에서는 현재 파일과 디렉토리의 경로를 알기 위해 사용할 수 있는 __filename과 __dirname이라는 키워드를 제공한다.
_(언더스코어)를 접두사처럼 두 개 붙인 filename과 dirname이라서 "더블 언더스코어 filename"처럼 읽는 줄 알았는데, 줄임말(Double Underscore -> Dunderscore)을 써서 "Dunderscore filename"이라고 읽는 방법도 있는 것 같다. (규칙은 아니니까 그냥 그런갑다 하자)
// filename.js
console.log(__filename); // 경로~~/filename.js
console.log(__dirname); // 경로~~
ES 모듈은 이 키워드를 사용할 수 없다. 단, 파일 경로에 대해 import.meta.url
을 사용할 수 있다.
// filename.mjs
console.log(import.meta.url); // 경로~~/filename.mjs
3.4 노드 내장 객체 알아보기
우리가 앞서 require, module, console 등의 객체를 따로 불러오지 않고도 활용할 수 있었던 이유는 이게 모두 노드의 내장 객체에 해당하기 때문이다.
노드에서 자주 활용되는 내장 객체들에는 어떤 것들이 있는지 알아보자.
- global : 노드의 전역 객체이다. 브라우저 상에서 window 객체와 같은 역할
- console : global 객체에 내장돼있다. 보통 디버깅을 위해 활용
- 타이머 : global 객체에 내장돼있다. setTimeout, setImmediate, setInterval 등
- process : 현재 실행되고 있는 노드 프 로세스에 대한 정보를 포함
3.4.1 global
global은 전역 객체이므로, 모든 파일에서 접근할 수 있다는 특성을 갖는다. 브라우저의 window와 같은 역할을 하며, window.open을 그냥 open으로 호출할 수 있는 것처럼 global은 생략할 수 있다. 즉, console, setTimeout을 global.console, global.setTimeout으로 호출하지 않아도 된다.
global의 내부를 노드에서 확인하려면 REPL에서 global을 통해 확인하거나, 모듈에서 console.log(globalThis)
를 통해 확인할 수 있다.
이 때 globalThis는 환경에 따라 동적으로 전역 객체에 바인딩되는 객체이며, 브라우저 환경에서 window를, 노드 환경에서는 global을 가리키게 된다.
global 객체는 모든 파일에서 접근할 수 있다는 특성을 갖고 있어 값을 대입하면 다른 파일에서도 해당 값을 쉽게 참조할 수 있는데, 그렇다고 global 객체를 남용하면 안된다. C언어를 배울 때 전역 변수를 남용하면 안된다고 배웠을텐데, 같은 맥락이다.
3.4.2 console
console은 global에 내장되어있는 객체로, 특정 시점에 변수에 값이 제대로 할당되었는지, 에러의 내용이 무엇인지 알기 위해 등 보통 디버깅을 위해 활용하게 된다.
아주 대표적인 메서드는 console.log()
이다. 하지만 다양한 상황에서 활용할 수 있는 이외의 메서드들도 많이 있다. 예제를 통해 활용해보자.
const string = "abc";
const number = 1;
const boolean = true;
const obj = {
outside: {
inside: {
key: "value",
},
},
};
console.time("전체시간");
console.log("평범한 로그입니다. 쉼표로 구분해 여러 값을 찍을 수 있습니다.");
console.log(string, number, boolean);
console.error("에러 메시지는 console.error에 담아주세요.");
console.table([
{ name: "제로", birth: 1994 },
{ name: "hero", birth: 1988 },
]);
console.dir(obj, { colors: false, depth: 2 });
console.dir(obj, { colors: true, depth: 1 });
console.time("시간 측정");
for (let i = 0; i < 100000; i++) {}
console.timeEnd("시간 측정");
function b() {
console.trace("에러 위치 추적");
}
function a() {
b();
}
a();
console.timeEnd("전체시간");
여기서 console.log()가 아닌 다른 메서드를 확인할 수 있다.
-
console.time(레이블): console.timeEnd(레이블)과 대응되고, 같은 레이블을 가진 해당 메서드의 호출 시점까지의 시간을 측정
-
console.log(내용): 내용을 콘솔에 표시. console.log(내용1, 내용2, 내용3, ...)등과 같이 여러 내용을 한번에 출력하는 것도 가능
-
console.err(에러): 에러를 콘솔에 표시.
-
console.table(배열): 배열의 요소로 객체 리터럴을 넣으면 객체의 속성들이 테이블 형식으로 표현.
-
console.dir(객체, 옵션): 객체를 콘솔에 표시할 때 사용. 옵션의 colors를 true로 설정하면 출력되는 내용에 색상이 추가되며, depth는 표시할 객체의 깊이를 설정한다. 기본값은 2로 설정되어있다.
-
console.trace(레이블): 에러가 어디에서 발생했는지 추적할 수 있다. 에러 내용 만으로는 에러가 발생한 위치를 알기 힘들 때 사용
이 예제를 실행한 결과는 다음과 같다.
3.4.3 타이머
시간과 관련된 작업을 실행하고 싶을 때 주 로 활용하는 함수 setTimeout, setInterval, setImmediate 3가지가 있다.
- setTimeout(콜백, 밀리초) : 주어진 밀리초 이후에 콜백을 실행
- setInterval(콜백, 밀리초) : 주어진 밀리초마다 콜백을 반복 실행
- setImmediate(콜백) : 콜백을 즉시 실행
타이머 함수들은 반환값에 각 함수의 ID를 포함한다. setTimeout과 setInterval은 이 ID를 이용해 작업을 취소할 수 있다.
- clearTimeout(ID) : ID에 해당하는 setTimeout을 취소
- clearInterval(ID) : ID에 해당하는 setInterval을 취소
- clearImmediate(ID) : ID에 해당하는 setImmediate를 취소
참고로 setImmediate(cb)와 setTimeout(cb, 0)은 모두 이벤트 루프를 거쳐 콜백을 실행하지만, 파일 시스템 접근, I/O 작업의 cb에서 이 둘을 호출한다면 setImmediate가 먼저 실행된다. 하지만 혼동을 피하기 위해 콜백을 즉시 실행하도록 하고 싶다면 setImmediate를 활용하도록 하자.
타이머는 콜백 기반으로 동작하는 API이지만, 대표적인 비동기 함수이기 때문에 프로미스 기반의 형태로도 존재한다.
//promiseTimer.mjs
import { setTimeout, setInterval } from "timers/promises";
await setTimeout(3000);
console.log("3초 후 실행");
for await (const startTime of setInterval(1000, Date.now())) {
console.log("1초마다 실행", new Date(startTime));
}
3초 후 실행
1초마다 실행 2023-12-10T09:10:39.242Z
1초마다 실행 2023-12-10T09:10:39.242Z
1초마다 실행 2023-12-10T09:10:39.242Z
1초마다 실행 2023-12-10T09:10:39.242Z
1초마다 실행 2023-12-10T09:10:39.242Z
1초마다 실행 2023-12-10T09:10:39.242Z
1초마다 실행 2023-12-10T09:10:39.242Z
3.4.4 process
process는 현재 실행 중인 노드 프로세스에 대한 정보를 담고 있는 객체로서, 굉장히 다양한 정보를 담고 있지만, 이 모든 정보들을 항상 활용하지는 않기 때문에 알아두면 좋을 속성들에 대해서 먼저 살펴보자.
-
process.version : 설치된 Node.js의 버전
-
process.execPath : 설치된 Node.js의 경로
-
process.arch : 프로세서의 아키텍처 버전
-
process.platform : 운영체제 플랫폼 정보
-
process.pid : 현재 프로세스의 id
-
process.uptime() : 현재 프로세스의 실행 시간
-
process.cwd() : 현재 프로세스의 실행 위치
-
process.cpuUsage() : 현재 CPU 사용량
중요하지만 위 목록에 없는 속성에 대해서는 아래에서 다룬다.
3.4.4.1 process.env
시스템의 환경 변수 정보를 담고 있다. 비밀 정보(DB 접근 비밀번호, API 키 등)를 보관하는 용도로도 사용된다.
const secretId = process.env.SECRET_ID;
const secretCode = process.env.SECRET_CODE;
하지만 노드의 실행에 영향을 주는 환경 변수들도 존재한다.
NODE_OPTIONS=--max-old-space-size=8192
UV_THREADPOOL_SIZE=8
NODE_OPTIONS
는 노드의 실행 옵션이다. 그리고 예시로 위에서 할당되고 있는 --max-old-space-size
는 노드가 사용할 수 있는 메모리 8GB로 제한하는 옵션이다.
UV_THREADPOOL_SIZE
는 스레드풀 개수를 지정한다.
3.4.4.2 process.nextTick(콜백)
process.nextTick()
은 인자로 넘긴 콜백을, 현재 실행이 예약된 다른 콜백보다 먼저 실행하고 싶을 때 활용하는 메서드이다. 아래 예제를 보자.
setImmediate(() => {
console.log("immediate");
});
process.nextTick(() => {
console.log("nextTick");
});
setTimeout(() => {
console.log("timeout");
}, 0);
Promise.resolve().then(() => console.log("promise"));
이 예제의 실행 결과는 다음과 같다.
nextTick
promise
timeout
immediate
이 예제의 실행 결과를 이해하려면 태스크 큐에 대해 알아야 한다.
태스크 큐는 마이크로 태스크를 위한 것과, 매크로 태스크를 위한 것으로 나뉘는데, 타이머 함수의 콜백은 매크로 태스크 큐로, process.nextTick과 Promise의 콜백은 마이크로 태스크 큐로 넘어간다.
그리고 이벤트 루프의 입장에서 마이크로 태스크 큐의 실행 우선 순위가 매크로 태스크 큐보다 높기 때문에, 마이크로 태스크 큐에 등록된 콜백을 모두 호출 스택으로 끌어온 뒤에 실행하고 호출 스택이 다 비게 되면 매크로 태스크 큐에 등록된 콜백을 끌어온다. 그래서 저런 결과가 나온다.
3.4.4.3 process.exit(코드)
이 메서드는 호출되면 호출한 주체인 노드 프로세스를 종료한다. 그래서 특성상 서버로서 실행되고 있는 노드의 환경에서는 잘 호출하지 않고, 독립적인 노드 프로그램에서 수동으로 이를 종료시키기 위해 사용한다.
process.exit()
은 인수로 0 또는 1의 코드를 넘겨줄 수 있는데, 코드에 따라 작동 방식이 달라진다.
-
process.exit(0) : 정상 종료
-
process.exit(1) : 비정상 종료 (ex: 에러 발생 상황)
3.4.5 기타 내장 객체
-
URL, URLSearchParams
-
AbortController, FormData, fetch, Headers, Request, Response, Event, EventTarget
-
TextDecoder, TextEncoder
-
WebAssembly
3.5 노드 내장 모듈 사용하기
노드의 내장 모듈은 노드 버전에 따라 상이할 수 있으나, 버전과 상관없이 안정적이고 유용한 기능을 제공하는 모듈들과 관련 메서드들을 한번 알아보자.
물론 이런 류의 지식은 공식 문서 기반으로 공부하는 것이 매우 매우 좋으니 한번 살펴봐야한다. Node.js v20(현재 최신 LTS) API Docs - https://nodejs.org/docs/latest-v20.x/api/index.html
3.5.1 os
os 모듈은 노드의 내장 모듈 중 하나로서, 운영체제의 정 보를 가져온다. 성능 모니터링, CPU 코어 개수에 따라 가동할 서버의 개수 파악 등에 이 os 모듈을 활용할 수 있다. 아래는 자주 활용하는 메서드 목록이다.
- 운영체제 정보 관련
os.arch()
os.platform()
os.type()
os.uptime()
os.hostname()
os.release()
- 경로 관련
os.homedir()
os.tmpdir()
- cpu 정보 관련
os.cpus()
- 메모리 정보 관련
os.freemem()
os.totalmem()
3.5.2 path
path 모듈은 폴더와 파일의 경로를 쉽게 조작할 수 있게하는 모듈이다. Windows 계열과 POSIX 계열 운영체제의 경로 구분자가 다르기 때문에 발생할 수 있는 문제에서 이 path 모듈은 큰 도움이 될 것이다. 아래는 자주 활용하는 메서드 목록이다.
- 구분자
path.sep()
path.delimiter
- 경로, 파일명
path.dirname(string)
path.extname(string)
path.basename(string)
- 포맷팅, 정규화
path.parse(string)
path.format({ dir: string, name: string, ext: string })
path.normalize(string)
- 이외
path.isAbsolute(string)
path.relative(string)
path.join()
path.resolve()
3.5.3 url
url 모듈은 인터넷 주소를 쉽게 조작할 수 있게하는 모듈이다. url 처리 방식은 WHATWG 방식과 그 이전 방식으로 나뉘는데, 요즘은 전자만 활용한다. (웹 표준이기 때문) URL 객체는 username, password, origin, searchParams 등의 속성이 존재하는데, 이 searchParams 또한 Iterator 객체로서, 클라이언트에서 쿼리스트링으로 요청을 보내면 이 객체에 데이터가 담기게 된다. 따라서 아래와 같은 메서드를 활용할 수 있다.
searchParams.getAll()
searchParams.get()
searchParams.has()
searchParams.keys()
searchParams.values()
searchParams.append()
searchParams.set()
searchParams.delete()
searchParams.toString()
3.5.4 dns
DNS(Domain Name System)를 다룰 때 사용하는 모듈이다. 주로 도메인 주소를 통해 IP 주소를 알아내거나 하는 상황에서 활용한다.
- dns.lookup(domain)
- dns.resolve(domain, recordName) // recordName : A, AAAA, CNAME, MX ...
3.5.5 crypto
암호화를 담당하는 모듈이다. 고객의 계정 ID, 비밀번호, 각종 개인정보 등 유출 시 매우 심각한 위험을 초래할 수 있는 민감한 정보는 DB에 절대절대 평문으로 저장하면 안되기에 이 모듈을 활용해서 암호화를 해야한다.
3.5.5.1 단방향 암호화
복호화가 불가능한 암호화 방식을 단방향 암호화라고 한다. 주로 비밀번호같이 복호화할 필요가 없는 데이터를 암호화할 때 해당 방식을 채택한다. 단방향 암호화는 해시 기법을 사용해서 진행하게 된다. 활용 예시를 살펴보자.
const crypto = require("crypto");
console.log(
"base64: ",
crypto.createHash("sha512").update("password").digest("base64")
);
위 예시에서는 sha512 해시 알고리즘을 활용해서 비밀번호 평문을 해싱하고 그것을 base64로 인코딩했다.
하지만 해시 알고리즘은 가끔 전혀 다른 문자열에 대해 동일한 출력값을 반환해서 충돌을 내기도 하는데, 이 경우 보안 취약점으로 이어질 수 있으므로, 해시 알고리즘은 항상 최신화하는게 좋다. 현재는 pbkdf2, bcrypt, scrypt 등의 알고리즘을 활용해 암호화를 수행한다. (Node에서는 pbkdf2, scrypt 지원)
3.5.5.2 양방향 암호화
양방향 암호화는 복호화가 가능하며 복호화에 활용되는 Key가 존재한다. 암호화할 때 사용한 Key를 동일하게 사용해야만 복호화가 가능하다. 양방향 암호화는 암호학을 추가로 공부(AES 암호화 등)해야만 완벽히 이해하고 코드를 작성할 수 있으므로, 지금은 활용할 수 있는 메서드 목록과 예시코드만 살펴보자.
const crypto = require('crypto');
const algorithm = 'aes-256-cbc';
const key = 'abcdefghijklmnopqrstuvwxyz123456';
const iv = '1234567890123456';
const cipher = crypto.createChiperiv(algorithm, key, iv);
let result = cipher.update('암호문', 'utf8', 'base64');
result += chipher.final('base64') // 암호화
const decipher = crypto.createDecipheriv(algorithm, key, iv);
let result2 = decipher.update(result. 'base64', 'utf8');
result2 += decipher.final('utf8'); // 복호화
하지만 좀 더 간단하게 암호화하고 싶으면 crypto-js 라는 라이브러리도 좋은 옵션.
3.5.6 util
각종 편의 기능을 모아놓은 모듈이다! 많은 API가 추가되기도하고, 사라지기도(deprecated) 한다. 유용한 메서드 두개만 짚고 가보자.
- util.deprecate()
- util.promisify()
const util = require("util");
const crypto = require("crypto");
const dontUseMe = util.deprecate((x, y) => {
console.log(x + y);
}, "dontUseMe 함수는 deprecated되었으니 더 쓰지 마세요!");
dontUseMe(1, 2);
const randomBytePromise = util.promisify(crypto.randomBytes);
randomBytesPromise(64)
.then((buf) => {
console.log(buf.toString("base64"));
})
.catch((error) => {
console.error(error);
});
3.5.7 worker_threads
이 모듈은 싱글 스레드 기반의 환경인 노드에서 멀티 스레드 방식으로 작업을 처리할 수 있도록 하는 모듈이다. 아래 예제 코드를 보자.
// worker_threads.js
const { Worker, isMainThread, parentPort } = require("worker_threads");
if (isMainThreads) {
// 메인 스레드
const worker = new Worker(__filename);
worker.on("message", (message) => console.log("from worker", message));
worker.on("exit", () => console.log("worker exit"));
worker.postMessage("ping");
} else {
// 워커 스레드
parentPort.on("message", (value) => {
console.log("from parent", value);
parentPort.postMessage("pong");
parentPort.close();
});
}
노드의 멀티스레드는 메인 스레드를 제외한 워커 스레드가 작업을 할당받으면, 해당 작업들의 처리 결과를 메인 스레드로 전달한 뒤, 메인 스레드가 최종적으로 작업 처리 결과를 반환하는 식으로 이루어진다. 물론 이 모든 과정은 수동으로 이루어진다.
메인 스레드에서 여러 개의 워커 스레드를 생성해서 작업을 처리하려면 어떻게 해야할까?
// worker_data.js
const {
Worker,
isMainThread,
parentPort,
workerData,
} = require("worker_threads");
if (isMainThread) {
// 부모일 때
const threads = new Set();
threads.add(
new Worker(__filename, {
workerData: { start: 1 },
})
);
threads.add(
new Worker(__filename, {
workerData: { start: 2 },
})
);
for (let worker of threads) {
worker.on("message", (message) => console.log("from worker", message));
worker.on("exit", () => {
threads.delete(worker);
if (threads.size === 0) {
console.log("job done");
}
});
}
} else {
// 워커일 때
const data = workerData;
parentPort.postMessage(data.start + 100);
}
위와 같이 여러 개의 워커를 생성한 뒤 반복문을 돌며 작업을 수행하고, 각 워커의 작업이 종료되면 해당 워커를 지워주는 식(메모리 효율상 권장)으로 코드를 작성할 수 있다.
이렇게 멀티 스레드를 활용해서 처리하면 좋을 작업은 어떤 경우일까? 당연하게도 아주 많은 연산이 필요한 작업의 경우 멀티 스레드를 활용하는게 좋다.
예를 들어 어떤 수의 소수를 대신 찾아주는 서비스를 구축한다고 할 때, 하나의 메인 스레드로 소수를 찾는 작업을 지시하면 그 수가 작은 경우엔 그닥 문제가 없겠지만 큰 수의 소수를 찾을 때는 소수를 찾는 동안 서비스가 멈춰버릴 수도 있다. 그런 경우에 메인 스레드에서 워커들에게 작업을 분배하고, 메인은 사용자들의 요청을 처리하고 워커들이 계산한 결과를 반환하는 역할만 수행하도록 할 수 있다.
하지만 원래 노드는 멀티 스레드 기반 작업에 적합한 언어가 아니기 때문에, 아주 간단한 형태의 문제가 아니라면 다른 언어를 채택해서 접근하는 것이 좋을 것이다.
3.5.8 child_process
이전 섹션과 내용을 조금 이어가보자. 다른 언어를 채택해서 접근한다는게 어떤 의미일까? 노드로 서버 만들지 말고 자바의 Spring이나 파이썬의 Flask같은 프레임워크를 활용해서 서버를 만들라는 의미일까? 아니다. 노드는 child_process 모듈을 활용하면 다른 언어로 만들어진 프로그램을 호출할 수 있다.
const exec = require("child_process").exec;
const process = exec("dir"); // 명령어. 리눅스의 맥은 ls를 넣자
process.stdout.on("data", function (data) {
console.log(data.toString());
}); // 실행 결과
process.stderr.on("data", function (data) {
console.error(data.toString());
}); // 실행 에러
위 예제에서 프로그램 실행이 성공하면 그 결과가 표준 출력에서, 실패하면 그 결과가 표준 에러에서 나타나게 된다.
이번엔 파이썬으로 작성된 프로그램을 실행해보자.
const spawn = require("child_process").spawn;
const process = spawn("python", ["test.py"]);
process.stdout.on("data", function (data) {
console.log(data.toString());
}); // 실행 결과
process.stderr.on("data", function (data) {
console.error(data.toString());
}); // 실행 에러
주의할 점은 노드 자체에서 파이썬을 실행해서 해당 프로그램의 실행 결과를 가져오는 것이 아니고, 노드가 실행되고 있는 컴퓨터에게 해당 프로그램을 실행해달라고 '요청'하는 것이기 때문에 다른 언어의 실행환경이 노드가 실행되고 있는 환경에 이미 갖춰져있어야한다는 것이다.
3.5.9 기타 모듈들
- async_hooks: 비동기 코드의 흐름을 추적할 수 있는 실험적인 모듈
- dgram: UDP와 관련된 작업을 할 때 사용
- net: HTTP보다 로우 레벨인 TCP, IPC 통신을 할 때 사용
- perf_hooks: 성능을 측정할 때
console.time
보다 더 정교하게 측정 - querystring:
URLSearchParams
가 나오기 이전에 쿼리스트링을 다루기 위해 사용했던 모듈.URLSearchParams
를 사용하자. - string_decoder: 버퍼 데이터를 문자열로 바꾸는 데 사용
- tls: TLS와 SSL에 관련된 작업을 할 때 사용
- tty: 터미널과 관련된 작업을 할 때 사용
- v8: v8 엔진에 직접 접근할 때 사용
- vm: 가상 머신에 직접 접근할 때 사용
- wasi: 웹어셈블리를 실행할 때 사용하는 실험적인 모듈
- assert: 테스트 할 때 사용
3.6 파일 시스템 접근하기
노드에서는 파일 시스템에 접근하는 fs라는 모듈이 있다. 이 모듈을 활용하면 로컬 컴퓨터에 저장된 파일을 읽거나 생성, 수정 혹은 삭제 또한 가능하게 된다. 따라서 이 fs 모듈을 활용하는 코드는 정보보안적 측면에서도 각별히 주의되어 관리될 필요가 있다. 내포된 잠재적 위험성과 별개로, fs 모듈은 매우 유용한 모듈이다. 아래의 예제를 보자.
const fs = require("fs");
fs.readFile("./readme.txt", (err, data) => {
if (err) {
throw err;
}
console.log(data);
console.log(data.toString());
});
임의의 텍스트 파일을 읽어온 결과를 바이너리 데이터와 스트링 데이터로 출력하는 예제이다. 노드의 모듈을 활용할 때 콜백을 파라미터로 넘기는 경우라면 저렇게 err
와 data
를 인자로 갖는 콜백 형태가 되는데, 콜백 헬의 여지도 있고, 프로미스에 더 익숙한 사람들은 아래와 같은 방식으 로 promisify를 수행해서 모듈을 활용할 수 있다.
const fs = require("fs").promises;
fs.readFile("./readme.txt")
.then((data) => {
console.log(data);
console.log(data.toString());
})
.catch((err) => {
console.error(err);
});
이외에도 파일을 생성할 때는 fs.writeFile()
, 파일을 삭제할 때는 fs.unlink()
, 파일의 이름을 변경할 때는 fs.rename()
등의 메서드를 활용하면 된다.
3.6.1 동기 메서드와 비동기 메서드
동기와 비동기의 차이는 뭘까? 간단하게 생각하면 실행 순서의 보장 여부
일 것이다.
동기적인 코드는 인간의 입장에서 직관적이고 이해하기 편하다. 그냥 위에서 아래로, 왼쪽에서 오른쪽으로 읽으면 코드의 동작 흐름을 파악하는 데 큰 문제가 없다.
하지만 비동기적인 코드는 그렇게 동작하지 않는다. 먼저 실행된 코드가 나중에 실행된 코드보다 늦게 끝날 수도 있는 것이다. 그래서 직관적이지 않고 이해하기도 어려운 편이다.
이 동기와 비동기의 동작 흐름은 싱글 스레드 기반인 노드의 특성과 블로킹, 논블로킹 동작 방식과의 조합에 기인한다.
어떤 일련의 작업들을 처리함에 있어 동기-블로킹 방식은 비동기-논블로킹 방식의 효율성을 따라올 수 없다. 그렇지만 앞서 서술한 바와 같이 비동기적인 코드의 동작 흐름을 파악하는 것은 매우 어렵기에, 코드가 비동기-논블로킹 방식으로 동작하면서 동기적인 흐름을 보이도록 하는 콜백, 프로미스, async / await 구문 등의 방법들이 존재한다.
그렇다면 동기-블로킹 방식의 코드는 필요가 없을까? 그건 아니다. 일련의 작업들이 실행될 때 후순위의 작업들이 선순위 작업이 처 리됨에 따라 지연되었을 때 장점보다 단점이 더 많다면 당연히 좋지 않겠지만, 딱 한번 실행되는 작업은 동기적인 코드로 작성해도 무관하다.
초기화 작업을 예로 들 수 있을 것이다. 여러번 실행될 필요 없이 딱 한번 실행되고, 개발자가 의도한 일련의 단계를 거칠 필요가 있다면 동기적인 코드로 작성하는 것이 좋다.
fs 모듈에서는 이런 상황에서 동기적인 코드를 작성할 수 있도록 Sync 버전의 메서드도 지원하고 있다.
const fs = require("fs");
console.log("시작");
let data = fs.readFileSync("./readme2.txt");
console.log("1번", data.toString());
data = fs.readFileSync("./readme2.txt");
console.log("2번", data.toString());
data = fs.readFileSync("./readme2.txt");
console.log("3번", data.toString());
console.log("끝");
이걸 비동기-논블로킹 방식의 코드로 작성하면 아래와 같아진다. 콜백 패턴의 예제 코드를 확인해보자.
const fs = require("fs");
console.log("시작");
fs.readFile("./readme2.txt", (err, data) => {
if (err) {
throw err;
}
console.log("1번", data.toString());
fs.readFile("./readme2.txt", (err, data) => {
if (err) {
throw err;
}
console.log("2번", data.toString());
fs.readFile("./readme2.txt", (err, data) => {
if (err) {
throw err;
}
console.log("3번", data.toString());
console.log("끝");
});
});
});