리셋 되지 말자

[nodejs 교과서] 3장-노드 기능 본문

NodeJS

[nodejs 교과서] 3장-노드 기능

kyeongjun-dev 2020. 10. 16. 17:47

3.1 REPL 사용하기

자바스크립트는 스크립트 언어이므로 미리 컴파일을 안하고 바로 코드를 실행할 수 있다. 브라우저 콘솔 탭에서 자바스크립트 코드를 실행할 수 있는 것처럼 노드도 비슷한 콘솔을 제공한다.

입력한 코드를 읽고(Read), 해석하고(Eval), 결과를 반환하고(Print), 종료할 때까지 반복(Loop)한다고 해서 REPL(Read Eval Print Loop)이라고 부른다.

REPL

 

3.2 JS 파일 실행하기

생략

 

3.3 모듈로 만들기

  • main.js
const { old, even } = require('./var');
console.log(old, even);

 

  • var.js
const old = '홀수';
const even = '짝수';

module.exports ={
    old,
    even
};

 

  • main.js 실행 결과

 

module과 export는 노드의 내장 객체 이므로 바로 사용할 수 있다.

 

3.4 노드 내장 객체 알아보기

노드에서는 기본적인 내장 객체와 내장 모듈(3.5절)을 제공한다. 따로 설치하지 않아도 사용 가능하며 브라우저의 window 객체와 비슷하다고 보면 된다. 노드 프로그래밍에서 자주 사용하는 내장 객체를 알아본다.

3.4.1 global

브라우저의 window와 같은 전역 객체이다. 전역 객체이므로 모든 파일에서 접근이 가능하다. global은 생략이 가능한데, require함수도 global.require에서 생략된 것이다. (REPL에서 global.require로 사용 가능)

global 객체 안에는 수십 가지의 속성이 담겨 있다. 자주 사용하는 속성들만 알아보도록 한다.

  • 데이터 공유를 위한 사용
//globalA.js
//module.exports = () => global.message;
module.exports = function(){
    return global.message;
}


// main.js
const A = require('./globalA');

global.message='안녕하세요';
console.log(A());

전역이라고 남발하면 안된다. 다른 파일의 값을 사용하고 싶다면 모듈 형식으로 만들어서 명시적으로 값을 불러와서 사용하는게 좋다.

 

3.4.2 console

REPL을 사용하기 위해 사용했던 console도 노드에서 global 객체 안에 들어있다.

  • console.js
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:'person', birth: 1996}, {name:'x man', birth: 2000}]);

console.dir(obj, {colors: false, depth: 2});
console.dir(obj, { colors: true, depth: 1});

console.time('시간 측정');
for(let i=0; i<10000; i++){}
console.timeEnd('시간 측정');

function b(){
    console.trace('에러 위치 추적');
}

function a(){
    b();
}

a();

console.timeEnd('전체 시간');
  • 실행 결과
$ node console.js 
평범한 로그입니다 쉼표로 구분해 여러 값 출력 가능
abc 1 true
에러 메시지는 console.error에 담는다
┌─────────┬──────────┬───────┐
│ (index) │   name   │ birth │
├─────────┼──────────┼───────┤
│    0    │ 'person' │ 1996  │
│    1    │ 'x man'  │ 2000  │
└─────────┴──────────┴───────┘
{ outside: { inside: { key: 'value' } } }
{ outside: { inside: [Object] } }
시간 측정: 0.217ms
Trace: 에러 위치 추적
    at b (C:\VSCodeFiles\JS\practice\console.js:26:13)
    at a (C:\VSCodeFiles\JS\practice\console.js:30:5)
    at Object.<anonymous> (C:\VSCodeFiles\JS\practice\console.js:33:1)
    at Module._compile (internal/modules/cjs/loader.js:1137:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1157:10)
    at Module.load (internal/modules/cjs/loader.js:985:32)
    at Function.Module._load (internal/modules/cjs/loader.js:878:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)
    at internal/main/run_main_module.js:17:47
전체 시간: 23.909ms

 

  • console.time(레이블): console.timeEnd(레이블)과 대응되어, 같은 레이블을 가진 time과 timeEnd 사이의 시간을 측정한다.
  • console.log(내용): 평범한 로그를 콘솔에 표시한다. 쉼표로 구분하여 여러개 출력 가능
  • console.error(에러 내용): 에러를 콘솔에 표시한다.
  • console.table(배열): 배열의 요소로 객체 리터럴을 넣으면, 객체의 속성들이 테이블 형식으로 표현된다.
  • console.dir(객체, 옵션): 객체를 콘솔에 표시할 때 사용. 첫 번째 인수로 표시할 객체를 넣고, 두 번째 인수로는 옵션을 넣는다. 옵션의 colors를 true로 하면 콘솔에 색이 추가된다. depth는 객체 안의 객체를 몇 단계까지 보여줄지를 결정한다. 기본값은 2 이다.
  • console.trace(레이블): 에러가 어디서 발생했는지 추적할 수 있게 한다. 일반적으로 에러 발생 시 에러 위치를 알려주므로 자주 사용하지는 않지만, 위치가 나오지 않는다면 사용할 만하다.

 

3.4.3 타이머

타이머 기능을 제공하는 함수인 setTimeout, setInterval, setImmediate는 노드에서 global 객체 안에 들어있다.

  • setTimeout(콜백 함수, 밀리초): 주어진 밀리초(1,000분의 1초) 이후에 콜백 함수를 실행한다.
  • setInterval(콜백 함수, 밀리초): 주어진 밀리초마다 콜백 함수를 반복 실행한다.
  • setImmediate(콜백 함수): 콜백 함수를 즉시 실행한다.

위의 타이머 함수들은 모두 아이디를 반환한다. 아이디를 사용하여 타이머를 취소할 수 있다.

  • clearTimeout(아이디): setTimeout을 취소한다.
  • clearIntervar(아이디): setInterval을 취소한다.
  • clearImmediate(아이디): setImmediate를 취소한다.

 

책의 타이머 예제

  • timer.js
const timeout = setTimeout(()=>{
    console.log('1.5초 후 실행');
}, 1500);

const interval = setInterval(()=>{
    console.log('1초마다 실행');
}, 1000);

const timeout2 = setTimeout(()=> {
    console.log('실행되지 않습니다');
}, 3000);

setTimeout(()=>{
    clearTimeout(timeout2);
    clearInterval(interval);
}, 2500);

const immediate = setImmedate(()=>{
    console.log('즉시 실행');
});

const immediate2 = setImmedate(()=>{
    console.log('실행되지 않습니다');
});

clearImmediate(immediate2);

내 예상 : 

즉시 실행

1초마다 실행

1.5초 후 실행

1초마다 실행

 

실행 결과:

setImmediate(콜백)과 setTimeout(콜백, 0) : 두 개 모두 이벤트 루프를 거친 뒤에 즉시 실행된다.
둘의 차이점은, 특수한 경우에 setImmediate가 setTimeout보다 먼저 실행된다는 것이다. 하지만 항상 그런건 또 아니다.
햇갈리지 않게 setTimeout(콜백, 0)은 사용하지 말자.

 

 

 

3.4.4 __filename, __dirname

노드에서는 현재 파일의 경로나 파일명을 알 수 있도록 키워드로 정보를 제공한다.

  • main.js
console.log(__filename);
console.log(__dirname);

운영체제 별로 경로 구분이 '\'(윈도우) 이기도 하고, '/'(linux, mac)이기도 하는 등 호환에 여러 문제가 있어서 path 모듈(3.5.2)과 함께 사용한다.

 

3.4.5 module, exports, require

  • var.js(1)
const old = '홀수';
const even = '짝수';

module.exports ={
    old,
    even
};
  • var.js(2)
exports.odd = '홀수입니다';
exports.even = '짝수입니다';
  • main.js
const varJs = require('./var');
console.log(varJs.odd);
console.log(varJs.even);

위의 두 var.js는 main.js에서 동일한 결과를 출력한다.

동일하게 동작하는 이유는 module.exports와 exports가 같은 객체를 참조하기 때문이다. var.js 안에console.log(module.exports === exports);를 입력하면 true가 나온다.

module.exports에는 어떤 값이든 대입이 가능하지만, exports에는 반드시 객체처럼 '속성명'과 '속성값'을 대입해야 한다. exports에 다른 값을 대입하면 객체의 참조 관계가 끊겨 더 이상 모듈로 기능하지 않는다.
exports와 module.exports에는 참조 관계가 있으므로 한 모듈에 exports 객체와 module.exports를 동시에 사용하지 않는 것이 좋다.

 

NOTE. node에서의 this

  • this.js
console.log(this);
console.log(this===module.exports);
console.log(this===exports);
function whatIsThis(){
    console.log('function', this===exports, this===global);
}
whatIsThis();

최상위 scope의 this는 module.export 또는 exports 객체를 가리킨다. 또한, 함수 선언문 내부의 this는 global 객체를 가리킨다.

  • 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 === module);
console.log(require.main.filename);

위의 코드로 여러가지를 알 수 있다.

1. require와 module.exports가 반드시 파일 최상단에 없어도 된다.

2. require.cache 객체에 require.js나 var.js 같은 파일 이름이 속성명으로 들어있다. 속성 값으로는 각 파일의 모듈 객체가 들어있다. 한 번 require한 파일은 require.cache에 저장되므로 다음 번에 require할 때는 새로 불러오지 않고 require.cache에 있는 것이 재사용된다. 새롭게 require하고 싶다면 require.cache의 속성을 제거하면 되는데, 권장되지는 않는 방식이다.

3. require.main은 노드 실행 시 첫 모듈을 가리킨다. node require.js 명령어로 실행했기에 require.js가 require.main이 된다. 

  • 두 모듈이 서 로를 require할 경우(순환 참조)
dep1.js
const dep2 = require('./dep2');
console.log('require dep2', dep2);
module.exports=function(){
    console.log('dep2', dep2);
};
dep2.js
const dep1 = require('./dep1');
console.log('require dep1', dep1);
module.exports=function(){
    console.log('dep1', dep1);
};
dep-run.js
const dep1 = require('./dep1');
const dep2 = require('./dep2');
dep1();
dep2();

dep1의 module.exports가 함수가 아닌 빈 객체로 표시되는걸 확인할 수 있다. 이런 현상을 순환 참조라고 부르며, 순환 참조가 있는 경우 참조되는 대상(dep1.js)을 빈 객체로 만든다. 에러는 발생하지 않지만 예기치 못한 동작이 발생할 수 있으므로 순환 참조를 잘 피해서 모듈 구조를 구성하는 것이 중요하다.

 

 

3.4.6 process

process 객체는 현재 실행되고 있는 노드 프로세스에 대한 정보를 담고 있다. REPL을 이용해 process 객체 안에 있는 속성들을 확인해본다.

version : 설치된 노드의 버전

arch : 프로세서 아키텍쳐 정보

platform : 운영체제 플랫폼 정보

pid : 현재 프로세스의 id

uptime() : 프로세스가 시작된 후의 경과 시간 (초 단위)

execPath : 노드의 경로

cwd() : 현재 프로세스가 실행되는 위치

cpuUsage() : cpu 사용량

 

3.4.6.1 process.env

REPL에 process.env를 입력하면 시스템의 환경 변수들을 출력해준다. 시스템 환경 변수는 노드에 직접 영향을 미치기도 한다. 대표적으로 UV_THREADPOOL_SIZE, NODE_OPTIONS가 있다. 다양한 NODE_OPTIONS들은 3.9절에서 링크로 제공.

UV_THREADPOOL_SIZE는 노드에서 기본적으로 사용하는 스레드풀의 스레드 개수를 조절할 수 있게 한다.(3.6.4절)

시스템 환경 변수 이외에도 사용자가 임의로 환경 변수를 저장할 수 있다. process.env는 서비스의 중요한 키를 저장하는 공간으로도 사용된다. 서버나 데이터베이스의 비밀번호와 각종 API 키를 코드에 직접 입력하는 것이 위험하므로, 따로 파일로 저장하는 방법등도 고려해야 한다.

const secretId = process.env.SECRET_ID;
const secretCode = process.env.SECRET_CODE;

위처럼 사용하면 되는데, 운영체제별로 process.env에 값을 추가하는 방법이 차이가 있을 수 있다. 모든 운영체제에 동일하게 넣을 수 있는 방법은 6.2절의 dotenv에서 설명한다.

3.4.6.2 process.nextTick(콜백)

이벤트 루프가 다른 콜백 함수들보다 nextTick의 콜백 함수를 우선으로 처리하도록 만든다.

  • nextTick.js
setImmediate(function(){
    console.log('immediate');
});
process.nextTick(function(){
    console.log('nextTick');
});
setTimeout(function(){
    console.log('timeout');
}, 0);
Promise.resolve().then(function(){
    console.log('promise');
})

proces.nextTick은 setImmediate나 etTimeout보다 먼저 실행된다. resole된 Promise도 다른 콜백들보다 우선시 되는데, 여기서는 nextTick이 더 빠른것을 확인할 수 있다. 그래서 process.nextTick과 Promise를 마이크로 태스크(microtask)라고 따로 구분지어 부른다.

3.4.6.3 process.exit(코드)

실행중인 노드 프로세스를 종료한다. 실제 서버에서 사용하면 서버가 멈추므로 사용하지 않는다.

  • exit.js
let i = 1;
setInterval(function(){
    if(i==5){
        console.log('종료');
        process.exit();
    }
    console.log(i);
    i++;
}, 1000);

setInterval로 1초마다 함수가 실행되게 하고, i가 5가 되면 종료된다. process.exit()에서 인수로 코드 번호를 줄 수 있는데, 인수를 주지 않거나 정상적인 종료일 경우에는 0, 1을 주면 비정상 종료를 뜻한다.

 

 

3.5 노드 내장 모듈 사용하기

3.5.1 os

웹 브라우저에서의 javascript는 운영체제의 정보를 가져올 수 없지만, 노드는 os 모듈을 통해 정보를 가져올 수 있다.

  • os.js
const os = require('os');
console.log('==운영체제 정보==');
console.log('os.arch():', os.arch());                   // process.arch와 동일
console.log('os.platform():', os.platform());           // process.platform과 동일
console.log('os.type():', os.type());                   // 운영체제의 종류
console.log('os.uptime():', os.uptime());               // 운영체제 부팅 이후 흐른 시간(초)
console.log('os.hostname():', os.hostname());           // 컴퓨터의 이름
console.log('os.release()', os.release());              // 운영체제의 버전

console.log('==경로==');
console.log('os.homedir():', os.homedir());             // 홈 디렉토리 경로
console.log('os.tmpdir():', os.tmpdir());               // 임시 파일 저장 경로

console.log('==cpu 정보==');
console.log('os.cpus():', os.cpus());                   // 컴퓨터의 코어 정보
console.log('os.cpus().length:', os.cpus().length);     // 코어의 개수

console.log('==메모리 정보==');
console.log('os.freemem()', os.freemem());              // 사용가능한 메모리(RAM)
console.log('os.totalmem():', os.totalmem());           // 전체 메모리(RAM)

os.cpus().length를 하면 코어의 개수가 숫자로 나오는데  노드에서 싱글 스레드 프로그래밍을 하면 코어가 몇 개이든 대부분의 경우 하나의 코어를 사용한다. 4.5절의 cluster 모듈을 사용하는 경우에는 코어 개수에 맞춰서 프로세스를 늘릴 수 있다. 이때 다시 cpus() 메서드를 사용한다.

이 외에도 os.constants라는 객체가 존재한다. 이 객체 안에는 각종 에러와 신호에 대한 정보가 담겨 있다. 에러가 발생했을 때, EADDRINUSE나 ECONNRESET 같은 에러 코드를 함께 보여준다. 이러한 코드들이 os.constats안에 들어있다. 에러 코드가 너무 많아서 외울 수 없으므로 발생할 때마다 검색해보는 것이 좋다.

os 모듈은 일반적인 웹 서비스를 제작할 때는 사용 빈도가 높지 않지만, 운영 체제별로 다른 서비스를 제공한다거나 컴퓨터 내부 자원에 빈번하게 접근하는 경우에 유용하게 사용할 수 있다.

 

3.5.2 path

폴더와 파일의 경로를 쉽게 조작하도록 도와주는 모듈이다. 이 모듈이 필요한 이유는 운영체제별로 경로 구분자가 다르기 때문이다. 윈도우의 경우 '\', POSIX 타입의 경우(유닉스 기반의 운영체제)는 '/'이 구분자다.

  • path.js
const path = require('path');
const string = __filename;

console.log('path.sep:', path.sep);     // 경로의 구분자. 윈도우는 \, POSIX는 /
console.log('path.delimiter:', path.delimiter);     // 환경 변수의 구분자. process.env.PATH를 입력하면 여러 개의 경로가 이 구분자로 되어있음. 윈도우는 ;, POSIX는 :
console.log('--------------');
console.log('path.dirname():', path.dirname(string));       // 파일이 위치한 폴더 경로
console.log('path.extname():', path.extname(string));       // 파일의 확장자
console.log('path.basename():', path.basename(string));     // 파일의 이름(확장자 포함) 표시
console.log('path.basename - extname:', path.basename(string, path.extname(string)));   // 확장자를 제외한 파일의 이름 표시
console.log('--------------');
console.log('path.parse()', path.parse(string));    // 파일의 경로를 root, dir, base, ext, name으로 분리
console.log('path.format():', path.format({
    dir:`C:\\users\\person`,
    name:`path`,
    ext:`.js`
}));    // path.parse()한 객체를 파일 경로로 합침

console.log('path.normalize():', path.normalize('C://users\\\\person\\\\\\var.js'));    // /나 \를 실수로 입력했을 때 정상적인 경로로 변환
console.log('--------------');
console.log('path.isAbsolute(C:\\):', path.isAbsolute('C:\\'));     // 파일의 경로가 절대 경로인지 상대 경로인지를 true나 false로 표시
console.log('path.isAbsolute(./home):', path.isAbsolute('./home'));
console.log('--------------');

console.log('path.relative():', path.relative('C:\\users\\var.js', 'C:\\'));                // 경로를 두 개 넣으면 첫 번째 경로에서 두 번째 경로로 가는 방법을 표시
console.log('path.join():', path.join(__dirname, '..','..','/users','.','/person'));        // 여러 인수를 넣으면 하나의 경로로 합침. 상대 경로인 ..과 . 도 알아서 처리 함
console.log('path.resolve():', path.resolve(__dirname, '..', 'users', '.', '/person'));     // join과 비슷하게 동작

path.join은 /를 만나면 상대 경로로 처리하지만, path.resolve는 /를 만나면 절대 경로로 인식해서 이 전의 경로들을 무시한다.

console.log(path.join('/a', '/b', 'c'));    // /a/b/c
console.log(path.resolve('/a', '/b', 'c')); // /b/c

윈도우에서 POSIX 스타일 경로를 사용할 때 또는 그 반대일 경우, 윈도우에서는 path.posix.sep이나 path.posix.join()과 같이 사용하고 POSIX에서는 path.win32.sep이나 path.win32.join()과 같이 사용한다.

노드는 require.main 파일을 기준으로 상대 경로를 인식한다. 즉 require.main과는 다른 디렉토리의 파일이 상대 경로를 갖고 있다면 문제가 생길 수 있는데, 이 문제는 path 모듈을 통해 해결할 수 있다.

 


3.5.3 url

인터넷 주소를 쉽게 조작하도록 도와주는 모듈이다. 크게 두 가지의 처리 방식이 있으며, WHATWG(웹 표준을 정하는 단체의 이름) 방식의 url과 예전부터 노드에서 사용하던 방식의 url이 있다.

아래: WHATWG, 위: node

큰 차이로는 auth와 username, password 부분이 있겠다. 그리고 query 부분도 차이가 있다.

  • url.js
const url = require('url');
const myURL = new URL('http://www.myurl.co.kr/board/studyList?page=3&id=313#bookChip');
console.log('new URL():', myURL);
console.log('url.format():', url.format(myURL));
console.log('--------------------');
const parsedUrl = url.parse('http://www.myurl.co.kr/board/studyList?page=3&id=313#bookChip');
console.log('url.parse()', parsedUrl);
console.log('url.format():', url.format(parsedUrl));
  • 실행 결과
$ node url.js
new URL(): URL {
  href: 'http://www.myurl.co.kr/board/studyList?page=3&id=313#bookChip',
  origin: 'http://www.myurl.co.kr',
  protocol: 'http:',
  username: '',
  password: '',
  host: 'www.myurl.co.kr',
  hostname: 'www.myurl.co.kr',
  port: '',
  pathname: '/board/studyList',
  search: '?page=3&id=313',
  searchParams: URLSearchParams { 'page' => '3', 'id' => '313' },
  hash: '#bookChip'
}
url.format(): http://www.myurl.co.kr/board/studyList?page=3&id=313#bookChip
--------------------
url.parse() Url {
  protocol: 'http:',
  slashes: true,
  auth: null,
  host: 'www.myurl.co.kr',
  port: null,
  hostname: 'www.myurl.co.kr',
  hash: '#bookChip',
  search: '?page=3&id=313',
  query: 'page=3&id=313',
  pathname: '/board/studyList',
  path: '/board/studyList?page=3&id=313',
  href: 'http://www.myurl.co.kr/board/studyList?page=3&id=313#bookChip'
}
url.format(): http://www.myurl.co.kr/board/studyList?page=3&id=313#bookChip

주요한 함수는 url.parse()와 url.format() 인것 같다. 결과를 살펴보면 다른 항목들이 존재한다.

host 부분 없이 pathname만 오는 주소인 경우(/board/studyList)에는 WHATWG 방식이 처리할 수 없다.

$ node url.js
internal/url.js:256
  throw new ERR_INVALID_URL(input);
  ^

TypeError [ERR_INVALID_URL]: Invalid URL: /board/studyList
?[90m    at onParseError (internal/url.js:256:9)?[39m
?[90m    at new URL (internal/url.js:332:5)?[39m
    at Object.<anonymous> (H:\VSCodeFiles\JS\practice\url.js:4:15)
?[90m    at Module._compile (internal/modules/cjs/loader.js:1137:30)?[39m
?[90m    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1157:10)?[39m
?[90m    at Module.load (internal/modules/cjs/loader.js:985:32)?[39m
?[90m    at Function.Module._load (internal/modules/cjs/loader.js:878:14)?[39m
?[90m    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)?[39m
?[90m    at internal/main/run_main_module.js:17:47?[39m {
  input: ?[32m'/board/studyList'?[39m,
  code: ?[32m'ERR_INVALID_URL'?[39m
}

WHATWG의 방식에는 searchParams 객체가 있는데 다양한 메서드를 지원한다.

getAll(키) : 키에 해당하는 모든 값들을 가져온다

get(키) : 키에 해당하는 첫 번째 값만 가져온다

has(키) : 해당 키가 있는지 없는지 검사한다

keys() : searchParams의 모든 키를 반복자(iterator) 객체로 가져온다

values() : searchParans의 모든 값을 반복자(iterator) 객체로 가져온다

append(키, 값) : 해당 키를 추가한다. 이미 해당 키가 있다면 유지하고 하나 더 추가

set(키, 값) : 같은 키의 값이 있으면 모두 지우고 새로 추가

delete(키) : 해당 키를 제거

toString() : searchParams 객체를 문자열로 변환

WHATWG방식을 사용하지 않으면 'querystring'모듈을 사용해서 노드의 url 방식도 url의 쿼리를 처리할 수 있다.


3.5.4 querystring

WHATWG 방식의 url 대신 기존 노드의 url을 사용할 때, search 부분을 사용하기 쉽게 객체로 만드는 모듈

  • querystring.js
const url = require('url');
const querystring = require('querystring');
const myUrl = 'http://www.myurl.co.kr/board/studyList?page=3&id=313#bookChip';
const parseUrl = url.parse(myUrl);
const queryData = querystring.parse(parseUrl.query);
console.log(queryData);
console.log(querystring.stringify(queryData));
  • 결과
$ node querystring.js
[Object: null prototype] { page: '3', id: '313' }
page=3&id=313

url의 query 부분이 객체 형태로 변환된 것을 확인할 수 있다. stringify로 문자열로 변환 가능하다.


3.5.5 crypto

암호화(고객의 패스워드를 암호화)를 도와주는 모듈

3.5.5.1 단방향 암호화

복호화할 수 없는 암호화 방식을 뜻한다. 주로 해시 기법을 사용한다. 해시란 어떠한 문자열을 고정된 길의 다른 문자열로 바꾸는 방식이다.

  • hash.js
const crypto = require('crypto');

console.log('base64:', crypto.createHash('sha512').update('비밀번호').digest('base64'));
console.log('hex:', crypto.createHash('sha512').update('비밀번호').digest('hex'));
console.log('base64:', crypto.createHash('sha512').update('다른 비밀번호').digest('base64'));

//결과
$ node hash.js
base64: dvfV6nyLRRt3NxKSlTHOkkEGgqW2HRtfu19Ou/psUXvwlebbXCboxIPmDYOFRIpqav2eUTBFuHaZri5x+usy1g==
hex: 76f7d5ea7c8b451b773712929531ce92410682a5b61d1b5fbb5f4ebbfa6c517bf095e6db5c26e8c483e60d8385448a6a6afd9e513045b87699ae2e71faeb32d6
base64: cx49cjC8ctKtMzwJGBY853itZeb6qxzXGvuUJkbWTGn5VXAFbAwXGEOxU2Qksoj+aM2GWPhc1O7mmkyohXMsQw==

노드에서의 해시 함수는 위와 같이 사용한다.

createHash(알고리즘) : 사용할 해시 알고리즘을 넣는다.

update(문자열) : 변환할 문자열을 넣는다.

disgest(인코딩) : 인코딩할 알고리즘을 넣는다. base64, hex, latin1이 주로 사용되는데, base64가 결과 문자열이 가장 짧아 애용된다고 한다.

 

주로 노드에서 지원하는 pbkdf2나 bcrypt, scrypt라는 알고리즘으로 비밀번호를 암호화한다. pbkdf2는 기존 문자열에 salt라고 불리는 문자열을 붙인 후 해시 알고리즘을 반복해서 적용하는 것이다.

  • pbkdf2.js
const crypto = require('crypto');

crypto.randomBytes(64, (err,buf)=>{
    const salt = buf.toString('base64');
    console.log('salt:', salt);
    crypto.pbkdf2('비밀번호', salt, 100000, 64, 'sha512', (err, key)=>{
        console.log('password:', key.toString('base64'));
    })
})

randomBytes() 메서드로 64바이트 길이의 문자열을 만든다. 이게 salt가 된다. pbkdf2() 메서드에는 순서대로 비밀번호, salt, 반복 횟수, 출력 바이트, 해시 알고리즘을 인수로 넣는다.

  • bcript
const bcrypt = require('bcrypt');
const saltRounds = 10;

bcrypt.hash('암호화할 문자열', saltRounds, function(err, hash) {
                console.log(hash);
                }

 

3.5.5.2 양방향 암호화

말그대로 복호활 할 수 있는 암호화 방식이다. 복호화를 위해 가 사용된다.

  • cipher.js
const crypto = require('crypto');

const algorthm = 'aes-256-cbc';
const key = 'abcdefghijklmnopqrstuvwxyz123456';
const iv ='1234567890123456';
const cipher = crypto.createCipheriv(algorthm, key, iv);
let result = cipher.update('암호화할 문장이다', 'utf8', 'base64');
result += cipher.final('base64');
console.log('암호화:', result);

const decipher = crypto.createDecipheriv(algorthm, key, iv);
let result2 = decipher.update(result, 'base64', 'utf8');
result2 += decipher.final('utf8');
console.log('복호화:', result2);

// 결과
$ node cipher.js
암호화: iiopeG2GsYlk6ccoBoFvEG+nrg/dJ6nikbaKQJFVnCM=
복호화: 암호화할 문장이다

aes-256-cbc 알고리즘의 경우 키는 32바이트, iv는 16바이트여야 한다.

사용 가능한 알고리즘 목록은 crypto.getCiphers()를 호출하면 볼 수 있다.


3.5.6 util

각종 편의 기능을 모아둔 모듈이다. 계속 API가 추가되고 있고, deprecated되어 사라지는 경우도 있다.

  • util.js
const util = require('util');
const crypto = require('crypto');

const dontUseMe = util.deprecate(function(x, y){
    console.log(x, y);
}, 'dontUseMe 함수는 deprecated 되었으니 더 이상 사용하지 마세요');
dontUseMe(1,2);

const randomBytesPromise = util.promisify(crypto.randomBytes);
randomBytesPromise(64)
    .then((buf)=>{
        console.log(buf.toString('base64'));
    })
    .catch((error)=>{
        console.error(error);
    });


// 결과
$ node util.js
1 2
(node:19164) DeprecationWarning: dontUseMe 함수는 deprecated 되었으니 더 이상 사용하지 마세요
D+U6150/rrMC/KOmFrD28mfkdKtrlPq1kXSWV83nmIjcsg9H1yZYurRX8m9BcvS8idV6MQN8doX64VXxljlPCg==

util.deprecate : 함수가 deprecated 처리되었음을 알린다. 첫 번째 인수로 넣은 함수를 사용했을 때 경고 메시지가 출력된다. 두 번째 인수로 경고 메시지 내용을 넣는다.

util.promisify : 콜백 패턴을 프로미스 패턴으로 바꾼다. 바꿀 함수를 인수로 제공하면 된다. 이렇게 변경하면 async/await 패턴을 사용할 수 있다.(3.5.5.1절의 randomBytes와 비교)

프로미스를 콜백으로 변경하는 util.callbackify도 있지만 잘 사용되지는 않는다.


3.5.7 worker_threads

노드에서 멀티 스레드 방식으로 작업은 worker_threads 모듈로 가능하다.

  • worker_threads.js
const {
    Worker, isMainThread, parentPort
} = require('worker_threads');

if(isMainThread){ // 부모일 때
    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();
    });
}

// 결과
$ node worker_threads.js
from parent ping
from worker pong
worker exit

isMainThread를 통해 현재 코드가 메인 스레드(기존에 동작하던 싱글 스레드를 메인 스레드, 또는 부모 스레드라고 한다.)에서 실행되는지, 아니면 직접 생성한 워커 스레드에서 실행되는지 구분한다.

메인스레드에서 new Worker를 통해 현재 파일(__filename)을 워커 스레드에서 실행시킨다. 워커 스레드에서는 isMainThread가 아니므로 else 부분만 실행된다.

부모에서 워커 생성 후 worker.postMessage로 워커에 데이터를 보낼 수 있다. 워커는 parentPort.on('message') 이벤트 리스너로 부모로부터 메시지를 받고, parentPort.postMessage로 부모에게 메시지를 보낸다. 부모는 worker.on('message')로 메시지를 받는다. 메시지를 한 번만 받고 싶다면 once('message')를 사용하면 된다.

  • 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);
}

// 결과
$ node worker_data.js
from worker 101
from worker 102
job done

new Worker를 호출할 때 두 번째 인수의 workerData 속성으로 원하는 데이터를 보낼 수 있다. 워커에서는 workerData로 부모로부터 데이터를 받는다. 위 코드는 두 개의 워커가 실행되고, 각각 부모로부터 숫자를 받아서 100을 더하여 돌려준다. 돌려 주는 순간 워커가 종료되어 worker.on('exit')가 실행된다. 두 개가 모두 종료되면 job done이 로깅된다.

 

  • prime.js
const min = 2;
const max = 10000000;
const primes = [];
function generatePrimes(start, range){
    let isPrime = true;
    const end = start + range;
    for(let i = start; i<end; i++){
        for(let j=min; j<Math.sqrt(end); j++){
            if(i !== j && i%j ===0){
                isPrime = false;
                break;
            }
        }
        if(isPrime){
            primes.push(i);
        }
        isPrime = true;
    }
}

console.time('Prime');
generatePrimes(min, max);
console.timeEnd('Prime');
console.log(primes.length);

// 결과
$ node prime.js
Prime: 13374.061ms
664579

2부터 1,000만 까지의 소수를 찾는 코드다. 13초정도 걸렸다.

 

  • prime-worker.js
const {
    Worker, isMainThread, parentPort, workerData
} = require('worker_threads');

const min = 2;
let primes = [];

function findPrimes(start, range){
    let isPrime = true;
    let end = start + range;
    for(let i = start; i < end; i++){
        for(let j = min; j < Math.sqrt(end); j++){
            if(i !== j && i % j === 0 ){
                isPrime = false;
                break;
            }
        }
        if(isPrime){
            primes.push(i);
        }
        isPrime = true;
    }
}

if(isMainThread){
    const max = 10000000;
    const threadCount = 8;
    const threads = new Set();
    const range = Math.ceil((max-min)/threadCount);
    let start = min;
    console.time('Prime');
    for(let i = 0; i < threadCount - 1; i++){
        const wStart = start;
        threads.add(new Worker(__filename, {workerData: {start: wStart, range}}));
        start += range;
    }
    threads.add(new Worker(__filename, {workerData: {start, range: range + ((max - min + 1) % threadCount)}}));
    for(let worker of threads){
        worker.on('error', err=>{
            throw err;
        });
        worker.on('exit', ()=>{
            threads.delete(worker);
            if(threads.size===0){
                console.timeEnd('Prime');
                console.log(primes.length);
            }
        });
        worker.on('message', msg => {
            primes = primes.concat(msg);
        });
    }
}else{
    findPrimes(workerData.start, workerData.range);
    parentPort.postMessage(primes);
}

// 결과
$ node prime-worker.js
Prime: 1951.714ms
664579

8개의 스레드로 나누어 처리하여서 2초정도 걸렸다. 범위가 딱 정해져있어서 일을 나눌수 있다.

8개의 스레드를 썻다고 해서 8배 빨라지는 것은 아니다. 스레드를 생성하고 스레드 사이에 통신하는데 상당한 비용이 발생한다. 이를 고려하여 멀티 스레딩을 진행해야 한다고 한다. 최악의 경우 싱글 스레드로 처리할 때보다 더 느려질 수도 있다.


3.5.8 child_process

노드에서 다른 프로그램을 실행하고 싶거나 명령어를 수행하고 싶을 때 사용하는 모듈이다. 이 모듈을 통해 다른 언어의 코드를 실행하고 결과값을 받아올 수 있다. (파이썬의 subprocess와 비슷한 개념인것 같다.) 이름이 child_process(자식 프로세스)인 이유는 현재 노드 프로세스 외에 새로운 프로세스를 띄워서 명령을 수행하고, 노드 프로세스에 결과를 알려주기 때문이다.

  • exec.js
const exec = require('child_process').exec;

const process = exec('ls');

process.stdout.on('data', function(data){
    console.log(data.toString());
});

process.stderr.on('data', function(data){
    console.error(data.toString());
})

// 결과
$ node child_process.js
child_process.js
cipher.js
dep1.js
dep2.js
dep-run.js
exit.js

ls 명령어의 결과로, 파일 목록들이 출력된다.

 

  • spawn.js
const spawn = require('child_process').spawn;
const process = spawn('python', ['hello.py']);

process.stdout.on('data', function(data){
    console.log(data.toString());
});

process.stderr.on('data', function(data){
    console.error(data.toString());
})

// 결과
$ node spawn.js
hello python

spawn의 첫 번째 인수로 명령어, 두번째 인수로 옵션 배열을 넣는다.

exec는 셸을 실행해서 명령어를 수행하고, spawn 은 새로운 프로세스를 띄우면서 명령어를 수행한다. spawn에서도 세 번째 인수로 {shell: true}를 제공하면 exec처럼 셸을 실행해서 명령어를 수행한다.

3.5.9 기타 모듈들

  • assert: 값을 비교하여 프로그램이 제대로 동작하는지 테스트하는 데 사용
  • dns: 도메인 이름에 대한 IP 주소를 얻어내는데 사용
  • net: HTTP보다 로우 레벨인 TCP나 IPC 통신을 할 때 사용
  • string_decoder: 버퍼 데이터를 문자열로 바꾸는데 사용
  • tls: TLS와 SSL에 관련된 작업을 할 때 사용
  • tty: 터미널과 관련된 작업을 할 때 사용
  • dgram: UDP와 관련된 작업을 할 때 사용
  • v8: V8 엔진에 직접 접근할 때 사용
  • vm: 가상 머신에 직접 접근할 때 사용

3.6 파일 시스템 접근하기

  • readFile.js
const fs = require('fs');

fs.readFile('./readFile.txt', 'utf8', function(err, data){
    if(err){
        throw err;
    }
    console.log(data);
});

or

const fs = require('fs');

fs.readFile('./readFile.txt', function(err, data){
    if(err){
        throw err;
    }
    console.log(data.toString());
});

// 결과
$ node readFile.js
read file text.

두 번째 인자로 'utf8'을 빼고 아무것도 넣지 않으면 buffer로 출력된다. 버퍼를 메모리의 데이터라고 생각하고 넘어가자.

  • readFilePromise.js
const fs = require('fs').promises;

fs.readFile('./readFile.txt')
    .then((data)=>{
        console.log(data);
        console.log(data.toString());
    })
    .catch((err)=>{
        console.error(err);
    });
    
// 결과
$ node readFilePromise.js
<Buffer 72 65 61 64 20 66 69 6c 65 20 74 65 78 74 2e>
read file text.

실무에서 사용하기 불편하므로 프로미스 형식으로 바꾸어 사용한다고 한다.

fs 모듈에서 promise 속성을 불러오면 프로미스 기반의 fs 모듈을 사용할 수 있게 된다.

 

  • writeFile.js
const { writeFile } = require('fs');

const fs = require('fs').promises;

fs.writeFile('./writeFile.txt', 'writeFile text.')
    .then(()=>{
        return fs.readFile('./writeFile.txt');
    })
    .then((data)=>{
        console.log(data.toString());
    })
    .catch((err)=>{
        console.error(err);
    });

// 결과
$ node writeFile.js
writeFile text.

 

3.6.1 동기 메서드와 비동기 메서드

노드에서는 대부분의 메서드를 비동기 방식으로 처리한다. 그리고 비동기인 메서드를 동기 메서드로 사용할 수 있도록 제공한다. readFileSync 등등

  • async.js
const fs = require('fs');

console.log('시작');
fs.readFile('./readFile.txt', (err, data)=>{
    if(err){
        throw err;
    }
    console.log('첫번째', data.toString());
});

fs.readFile('./readFile.txt', (err, data)=>{
    if(err){
        throw err;
    }
    console.log('두번째', data.toString());
});

fs.readFile('./readFile.txt', (err, data)=>{
    if(err){
        throw err;
    }
    console.log('세번째', data.toString());
});

console.log('끝');

// 결과
$ node async.js
시작
끝
두번째 read file text.
첫번째 read file text.
세번째 read file text.

첫번째, 두번째, 세번째 순서는 달라질 수 있다. 여러번 실행해보면 된다. 

비동기 메서드들은 백그라운데 해당 파일을 읽으라고만 요청하고 다음 작업으로 넘어간다. 따라서 읽기 요청만 세 번 보내고 '끝'을 출력한다. 나중에 읽기가 완료되면 백그라운드가 다시 메인 스레드에 알린다. 메인 스레드는 그제서야 등록된 콜백 함수를 실행한다.

노드에서는 동기-블로킹, 비동기-논 블로킹 방식이 대부분이다.
동기-블로킹 방식에서는 백그라운드 작업 완료 여부를 계속 확인하며, 호출한 함수가 바로 return 되지 않고 백그라운드 작업이 끝나야 return 된다.
비동기-논 블로킹 방식에서는 호출한 함수가 바로 return되어 다음 작업으로 넘어가며, 백그라운드 작업 완료 여부는 신경 쓰지 않고 나중에 백그라운가 알림을 줄 때 비로소 처리한다.

 

시작, 첫번째, 두번째, 세번째, 끝 순서대로 출력되게 하려면 중첩 콜백방식이나 프로미스 또는 async/await으로 어느 정도 해결할 수 있다.

  • asyncOrder.js
const fs = require('fs').promises;


console.log('시작');
fs.readFile('./readFile.txt')
    .then((data)=>{
        console.log('첫번째', data.toString());
        return fs.readFile('./readFile.txt');
    })
    .then((data)=>{
        console.log('두번째', data.toString());
        return fs.readFile('./readFile.txt');
    })
    .then((data)=>{
        console.log('세번째', data.toString());
        console.log('끝');
    })
    .catch((err)=>{
        console.error(err);
    })
    
// 결과
$ node asyncOrder.js
시작
첫번째 read file text.
두번째 read file text.
세번째 read file text.
끝

다음으로 data.toString()으로 변환하는 이유를 살표본다. 결론적으로는 data가 버퍼이기 때문이다. 버퍼가 무엇인지 알아본다.


3.6.2 버퍼와 스트림 이해하기

파일을 읽거나 쓰는 방식에는 크게 두 가지 방식이 있다.

  1. 버퍼를 이용하는 방식
  2. 스트림을 이용하는 방식

인터넷으로 영상을 로딩할때 버퍼링한다고 하고, 영상을 실시간으로 송출할 때는 스트리밍 한다고 한다.

버퍼링은 영상을 재생할 수 있을 때까지 데이터를 모으는 동작이고, 스트리밍은 방송인의 컴퓨터에서 시청자의 컴퓨터로 영상 데이터를 조금씩 전송하는 동작이다. 스트리밍하는 과정에서 버퍼링을 할 수도 있다. 전송이 너무 느리면 화면을 내보내기 까지 최소한의 데이터를 모아야 하고, 영상 데이터가 재생 속도보다 빠르게 전송되어도 미리 전송받은 데이터를 저장할 공간이 필요하기 때문이다.

노드의 버퍼와 스트림도 비슷한 개념이라고 한다. 노드는 파일을 읽을 때 메모리에 파일 크기만큼 공간을 마련해두며 파일 데이터를 메모리에 저장한 뒤 사용자가 조작할 수 있도록 한다. 이때 메모리에 저장된 데이터가 바로 버퍼이다.

  • buffer.js (버퍼를 직접 다루는 클래스 예제)
const buffer = Buffer.from('저를 버퍼로 바꿔보세요');

console.log('from():', buffer);
console.log('length:', buffer.length);
console.log('toString():', buffer.toString());

const array = [Buffer.from('띄엄'), Buffer.from('띄엄'), Buffer.from('띄어쓰기')];
const buffer2 = Buffer.concat(array);
console.log('concat():', buffer2.toString());

const buffer3 = Buffer.alloc(5);
console.log('alloc():', buffer3);

// 결과
$ node buffer.js
from(): <Buffer ec a0 80 eb a5 bc 20 eb b2 84 ed 8d bc eb a1 9c 20 eb b0 94 ea bf 94 eb b3 b4 ec 84 b8 ec 9a 94>
length: 32
toString(): 저를 버퍼로 바꿔보세요
concat(): 띄엄띄엄띄어쓰기
alloc(): <Buffer 00 00 00 00 00>

- from(문자열): 문자열을 버퍼로 바꾼다. length 속성은 버퍼의 크기를 알 수 있다. 바이트 단위이다.

- toString(버퍼): 버퍼를 문자열로 바꾼다. 이때 base64나 hex를 인수로 넣으면 해당 인코딩으로도 변환 가능

- concat(배열): 배열 안에 든 버퍼들을 하나로 합친다

- alloc(바이트): 빈 버퍼를 생성한다. 바이트를 인수로 넣으면 해당 크기의 버퍼가 생성된다.

 

readFile 방식의 버퍼가 편리하기는 하지만 문제점이 있다. 만약 용량이 100MB인 파일이 있으면, 해당 파일을 읽을 때 메모리에 100MB의 버퍼를 만들어야 한다. 이 작업을 동시에 열 개만 한다고 쳐도 약 1GB의 메모리가 사용된다. 특히 서버처럼 몇 명이 이용할지 모르는 환경에서는 메모리 문제가 발생할 수 있다.

또한 모든 내용을 버퍼에 다 쓴 후에야 다음 동작으로 넘어가므로 파일 읽기, 압축, 파일 쓰기 등의 조작을 연달아 할 때 매번 전체 용량을 버퍼로 처리해야 다음 단계로 넘어갈 수 있다.

그래서 버퍼의 크기를 작게 만든 후 여러 번으로 나눠 보내는 방식이 등장했다. 예를 들어 버퍼 1MB를 만든 후에 100MB 파일을 백 번으로 나누어 보내는 것이다. 이로써 버퍼 1MB로 100MB 파일을 전송할 수 있고, 이를 편리하게 만든 것이 스트림이다.

  • createReadStream.js
const fs = require('fs');

// 두 번째 인자는 옵션 객체. highWaterMark라는 옵션은 버퍼의 크기(바이트 단위)를 정함. 기본 단위는 64KB
const readStream = fs.createReadStream('./read.txt', { highWaterMark: 16});

const data = [];

readStream.on('data', (chunk)=>{
    data.push(chunk);
    console.log('data: ', chunk, chunk.toString(), chunk.length);
});

readStream.on('end', ()=>{
    console.log('end: ', Buffer.concat(data).toString());
});

readStream.on('error', (err)=>{
    console.log('error:', err);
})

// 결과
$ node createReadStream.js
data:  <Buffer ec a0 80 eb 8a 94 20 ec a1 b0 ea b8 88 ec 94 a9> 16
data:  <Buffer 20 ec a1 b0 ea b8 88 ec 94 a9 20 eb 82 98 eb 88> 16
data:  <Buffer a0 ec 84 9c 20 ec a0 84 eb 8b ac eb 90 a9 eb 8b> 16
data:  <Buffer 88 eb 8b a4 2e 20 eb 82 98 eb 88 a0 ec a7 84 20> 16
data:  <Buffer ec a1 b0 ea b0 81 ec 9d 84 20 63 68 75 6e 6b eb> 16
data:  <Buffer 9d bc ea b3 a0 20 eb b6 80 eb a6 85 eb 8b 88 eb> 16
data:  <Buffer 8b a4 2e> 3
end:  저는 조금씩 조금씩 나눠서 전달됩니다. 나눠진 조각을 chunk라고 부릅니다.

파일을 다 읽으면 end 이벤트가 발생함.

  • createWriteStream.js
const fs = require('fs');

const writeStream = fs.createWriteStream('./writeStream.txt');
writeStream.on('finish', ()=>{
    console.log('파일 쓰기 완료');
});

writeStream.write('writeStream 테스트1');
writeStream.write('writeStream 테스트2');
writeStream.end();

// 결과
$ node createWriteStream.js
파일 쓰기 완료

writeStream.txt
writeStream 테스트1writeStream 테스트2

파일을 다 쓰면 end() 메서드로 종료를 알린다. 종료할 때 finish 이벤트가 발생한다.

 

  • pipe.js
const fs = require('fs');

// 스트림 끼리 연결하는 것을 '파이핑한다'고 표현한다.
// on.('data')를 쓸 필요 없이 pipe메서드로 연결하면 readStream으로 읽어온 내용이 writeStream으로 넘어간다. 노드 8.5 전까지 사용. 새로운 복사 방식은 3.6.3절
const readStream = fs.createReadStream('./writeStream.txt');
const writeStream = fs.createWriteStream('./writeStreaPipe.txt');
readStream.pipe(writeStream);

 

  • gzip.js
const zlib = require('zlib');
const fs = require('fs');

// pipe는 스트림 사이에 여러 번 연결할 수 있다. 이 코드는 파일을 읽은 후 gzip 방식으로 압축하는 코드이다.
const readStream = fs.createReadStream('./writeFile.txt');
const zlibStream = zlib.createGzip();
const writeStream = fs.createWriteStream('./writeFileZlib.txt.gz');
readStream.pipe(zlibStream).pipe(writeStream);

zlib 모듈의 createGzip 메서드가 스트림을 지원하므로 readStream과 writeStream 중간에서 파이핑을 할 수 있다. 버퍼 데이터가 전달되다가 gzip 압축을 거친 후 파일로 써진다.

 

  • createBigFile.js
const fs = require('fs');
const { fileURLToPath } = require('url');

const writeStream = fs.createWriteStream('./bigFile.txt');

for(let i=0; i<10000000; i++){
    writeStream.write('안녕하세요. 엄청나게 큰 파일을 만들어 볼 것입니다. 각오 단단히 하세요!\n');
}

writeStream.end();

약 1GB짜리 텍스트 파일을 만든다. 오래 걸린다.

 

  • buffer-memory.js
const fs = require('fs');
console.log('before:', process.memoryUsage().rss);

const data1 = fs.readFileSync('./bigFile.txt');
fs.writeFileSync('./big2.txt', data1);
console.log('buffer:', process.memoryUsage().rss);

// 결과 (메모리 용량이 약 18MB에서 약 1GB를 넘어감. 파일 복사를 위해 메모리에 파일을 모두 올려둔 후 writeFileSync를 수행했기 때문)
// $ node buffer-memory.js
// before: 18989056
// buffer: 1020112896

 

  • stream-memory.js
const fs = require('fs');

console.log('before:', process.memoryUsage().rss);

const readStream = fs.createReadStream('./bigFile.txt');
const writeStream = fs.createWriteStream('./big3.txt');
readStream.pipe(writeStream);
readStream.on('end', ()=>{
    console.log('stream:', process.memoryUsage().rss);
})

// 결과 메모리를 약 84MB까지 사용함. 큰 파일을 조각내서 보냈기 때문.
// 이렇게 스트림을 사용하면 효과적으로 데이터를 전송할 수 있음. 그래서 동영상 같은 큰 파일을 전송할 때 스트림을 사용한다.
// $ node stream-memory.js
// before: 18898944
// stream: 84111360

3.6.3 기타 fs 메서드 알아보기

이 전까지 읽기, 쓰기를 했고 이외에도 파일과 폴더의 생성 및 삭제등을 학습한다.

  • fsCreate.js
const fs = require('fs').promises;
const constants = require('fs').constants;

fs.access('./folder', constants.F_OK | constants.W_OK | constants.R_OK)
    .then(()=>{
        return Promise.reject('이미 폴더 있음');
    })
    .catch((err)=>{
        if(err.code==='ENOENT'){
            console.log('폴더 없음');
            return fs.mkdir('./folder');
        }
        return Promise.reject(err);
    })
    .then(()=>{
        console.log('폴더 만들기 성공');
        return fs.open('./folder/file.js', 'w');
    })
    .then((fd)=>{
        console.log('빈 파일 만들기 성공', fd);
        fs.rename('./folder/file.js', './folder/newfile.js');
    })
    .then(()=>{
        console.log('이름 바꾸기 성공');
    })
    .catch((err)=>{
        console.error(err);
    });
    
// 결과
$ node fsCreate.js
폴더 없음
폴더 만들기 성공
빈 파일 만들기 성공 FileHandle {
  close: [Function: close],
  [Symbol(kHandle)]: FileHandle {},
  [Symbol(kFd)]: 3
}
이름 바꾸기 성공

사용한 네 가지 메서드 모두 비동기 메서드이므로 한 메서드의 콜백에서 다른 메서드를 호출한다.

- fs.access(경로, 옵션, 콜백): 폴더나 파일에 접근할 수 있는지를 체크한다. 두 번째 인수로 상수들(constants를 통해 가져오는)을 넣는다. F_OK는 파일 존재 여부, R_OK는 읽기 권한 여부, W_OK는 쓰기 권한 여부를 체크한다. 파일/폴더나 권한이 없다면 에러가 발생하는데 파일/폴더가 없을 때의 에러 코드는 ENOENT이다.

- fs.mkdir(경로, 콜백): 폴더를 만드는 메서드이다. 이미 폴더가 있다면 에러가 발생하므로 먼저 access 메서드를 호출해서 확인하는 것이 중요하다.

- fs.open(경로, 옵션, 콜백): 파일의 아이디(fd 변수)를 가져오는 메서드이다. 파일이 없다면 파일을 생성한 뒤 그 아이디를 가져온다. 가져온 아이디를 이용해 fs.read나 fs.write로 읽거나 쓸 수 있다. 두 번째 인수로 어떤 동작을 할 것인지를 설정할 수 있다. 쓰려면 w, 읽으려면 r, 기존 파일에 추가하려면 a이다. 위의 코드에서는 w를 했으므로 파일이 없을 때 새로 만들 수 있다. r이었다면 에러가 발생했을 것이다.

- fs.rename(기존 경로, 새 경로, 콜백): 파일의 이름을 바꾸는 메서드이다. 기존 파일 위치와 새로운 파일 위치를 적으면 된다. 꼭 같은 폴더를 지정할 필요는 없으므로 잘라내기 같은 기능을 할 수도 있다.

 

  • fsDelete.js
const fs = require('fs').promises;

fs.readdir('./folder')
    .then((dir)=>{
        console.log('폴더 내용 확인:', dir);
        return fs.unlink('./folder/newFile.js');
    })
    .then(()=>{
        console.log('파일 삭제 성공');
        return fs.rmdir('./folder');
    })
    .then(()=>{
        console.log('폴더 삭제 성공');
    })
    .catch((err)=>{
        console.error(err);
    });

// 결과
$ node fsDelete.js
폴더 내용 확인: [ 'newfile.js' ]
파일 삭제 성공
폴더 삭제 성공

- fs.readdir(경로, 콜백): 폴더 안의 내용물을 확인할 수 있다. 배열 안에 내부 파일과 폴더명이 나온다.

- fs.unlink(경로, 콜백): 파일을 지울 수 있다. 파일이 없다면 에러가 발생하므로 먼저 파일이 있는지를 확인해야 한다.

- fs.rmdir(경로, 콜백): 폴더를 지울 수 있다. 폴더 안에 파일들이 있다면 에러가 발생하므로 먼저 내부 파일을 모두 지우고 호출해야 한다.

 

  • copyFile.js
const fs = require('fs').promises;

fs.copyFile('./read.txt', 'readCopy.txt')
    .then(()=>{
        console.log('복사 완료');
    })
    .catch((err)=>{
        console.error(err);
    });
    
// 결과
$ node copyFile.js
복사 완료

노드 8.5 버전 이후로는 createReadStream과 createWriteStream을 pipe하지 않아도 위와같이 파일을 복사할 수 있다. 첫 번째 인수로 복사할 파일을, 두 번째 인수로 복사될 경로를, 세 번째 인수로 복사 후 실행될 콜백 함수를 넣는다.

 

  • watch.js
const fs = require('fs');
fs.watch('./target.txt', (eventType, filename)=>{
    console.log(eventType, filename);
});

파일/폴더의 변경 사항을 감시할 수 있는 메서드인 watch이다. 빈 텍스트 파일 target.txt를 만든 뒤, watch.js를 실행하고 내용을 수정하거나 삭제하면 이벤트가 발생한다. rename 이벤트가 발생한 후에는 더이상 watch가 수행되지 않는다.


3.6.4 스레드풀 알아보기

비동기 메서드들은 백그라운드에서 실행되고, 실행된 후에는 다시 메인 스레드의 콜백 함수나 프로미스의 then 부분이 실행된다. 이때 fs 메서드를 여러 번 실행해도 백그라운드에서 동시에 처리되는데, 바로 스레드풀이 있기 때문이다.

fs, crypto, zlib, dns.lookup 등이 내부적으로 스레드풀을 사용한다.

스레드풀을 쓰는 crypto.pbkdf2 메서드의 예제로 스레드풀의 존재를 확인해보는 코드다.

  • threadpool.js
const crypto = require('crypto');
const pass = 'pass';
const salt = 'salt';
const start = Date.now();

crypto.pbkdf2(pass, salt, 1000000, 128, 'sha512', ()=>{
    console.log('1:', Date.now() - start);
});

crypto.pbkdf2(pass, salt, 1000000, 128, 'sha512', ()=>{
    console.log('2:', Date.now() - start);
});

crypto.pbkdf2(pass, salt, 1000000, 128, 'sha512', ()=>{
    console.log('3:', Date.now() - start);
});

crypto.pbkdf2(pass, salt, 1000000, 128, 'sha512', ()=>{
    console.log('4:', Date.now() - start);
});

crypto.pbkdf2(pass, salt, 1000000, 128, 'sha512', ()=>{
    console.log('5:', Date.now() - start);
});

crypto.pbkdf2(pass, salt, 1000000, 128, 'sha512', ()=>{
    console.log('6:', Date.now() - start);
});

crypto.pbkdf2(pass, salt, 1000000, 128, 'sha512', ()=>{
    console.log('7:', Date.now() - start);
});

crypto.pbkdf2(pass, salt, 1000000, 128, 'sha512', ()=>{
    console.log('8:', Date.now() - start);
});

// 결과
$ node threadpool.js
$ node threadpool.js
2: 1475
4: 1495
1: 1502
3: 1524
8: 3046
7: 3177
5: 3248
6: 3622

실행할 때마다 순서와 시간이 달라진다. 스레드풀이 작업을 동시에 처리하므로 여덟 개의 작업 중에서 어느 것이 먼저 처리될지 모른다. 실행하다보면 첫번째~네번째 작업이 비슷한 시간으로 나오고 나머지 작업이 시간이 더 오래 걸리는것처럼 보인다. 

이는 기본적인 스레드풀의 개수가 네 개이기 때문이다. (내 컴퓨터는 코어 개수가 8개라서 상관 없음)

우리가 직접 스레드풀을 컨트롤할 수는 없지만 개수를 조절할 수는 있다. 윈도우라면 명령 프롬프트에 SET UV_THREADPOOL_SIZE=1, 맥과 리눅스면 UV_THREADPOOL_SIZE=1을 입력한 다음 테스트 해보면 결과에 차이가 생길 것이다.


3.7 이벤트 이해하기

위의 스트림에서 on('data', 콜백) 또는 on('end',콜백)을 사용했다. 바로 data와 end라는 이벤트가 발생할 때 콜백 함수를 호출하도록 이벤트를 등록한 것이다. createReadStream 같은 경우는 내부적으로 알아서 data와 end 이벤트를 호출하지만, 직접 이벤트를 만들 수도 있다.

  • event.js
const EventEmitter = require('events');

const myEvent = new EventEmitter();
myEvent.addListener('event1', ()=>{
    console.log('이벤트1');
});
myEvent.on('event2', ()=>{
    console.log('이벤트2');
});
myEvent.on('event2', ()=>{
    console.log('이벤트 2 추가');
});

// 한 번만 실행됨
myEvent.once('event3', ()=>{
    console.log('이벤트3 ');
});

myEvent.emit('event1'); // 이벤트 호출
myEvent.emit('event2'); // 이벤트 호출

myEvent.emit('event3'); // 이벤트 호출
myEvent.emit('event3'); // 실행 안 됨

myEvent.on('event4', ()=>{
    console.log('이벤트 4');
});

myEvent.removeAllListeners('event4');
myEvent.emit('event4'); // 실행 안됨

const listener = () =>{
    console.log('이벤트 5');
};

myEvent.on('event5', listener);
myEvent.removeAllListeners('event5', listener);
myEvent.emit('event5'); // 실행 안됨
console.log(myEvent.listenerCount('event2'));

// 결과
$ node event.js
이벤트1
이벤트2
이벤트 2 추가
이벤트3
2

events 모듈을 사용해서 myEvent라는 객체를 생성한다. 이 객체는 이벤트 관리를 위한 메서드를 가지고 있다.

- on(이벤트명, 콜백): 이벤트 이름과 이벤트 발생 시의 콜백을 연결한다. 이렇게 연결하는 동작을 이벤트 리스닝이라고 부른다. event2처럼 이벤트 하나에 이벤트 여러 개를 달아줄 수도 있다.

- addListener(이벤트명, 콜백): on과 기능이 같다.

- emit(이벤트명): 이벤트를 호출하는 메서드이다. 이벤트 이름을 인수로 넣으면 미리 등록한 이벤트 콜백이 실행된다.

- once(이벤트명, 콜백): 한 번만 실행되는 이벤트다. myEvent.emit('event3')을 두 번 연속 호출했지만 콜백이 한 번만 실행된다.

- removeAllListeners(이벤트명): 이벤트에 연결된 모든 이벤트 리스너를 제거한다. event4가 호출되기 전에 리스너를 제거했으므로 event4의 콜백은 호출되지 않는다.

- removeListener(이벤트명, 리스터): 이벤트에 연결된 리스너를 하나씩 제거한다. 리스너를 넣어야 하므로 event5의 콜백도 호출되지 않는다.

- off(이벤트명, 콜백): 노드 10 버전에서 추가된 메서드로, removeListener와 기능이 같다.

- listenerCount(이벤트명): 현재 리스너가 몇 개 연결되어 있는지 확인한다.

on('data')나 on('end')는 겉으로 이 이벤트를 호출하는 코드는 없지만 내부적으로는 chunk를 전달할 때마다 data 이벤트를 emit 한다. 완료되면 end 이벤트를 emit한 것이다.

직접 이벤트를 만들어서 다양한 동작을 직접 구현할 수 있다. 웹서버를 구축할 때 많이 사용된다고 한다.


3.8 예외 처리하기

노드에서는 예외 처리가 중요하다. 예외란 보통 처리하지 못한 에러를 가리킨다. 이런한 예외들은 실행 준인 노드 프로세스를 멈추게 만든다.

멀티 스레드 프로그램에서는 스레드 하나가 멈추면 멈춘 스레드의 일을 다른 스레드가 대신한다. 하지만 노드의 메인 스레드는 하나뿐이므로 그럴 수가 없다. 메인 스레드가 에러로 인해 멈춘다는건 메인 스레드를 갖고 있는 프로세스가 멈춘다는 것을 뜻하고, 이는 전체 서버가 멈춘다는걸 의미한다.

따라서 에러 로그가 기록되어도 작업은 계속 되도록 에러를 처리하는 방법을 익혀두어야 한다. 

문법상의 에러는 없다고 가정하고 진행한다.

아래 예제에서는 프로세스가 멈추지 않도록 에러를 처리한다. 에러가 발생할 것 같은 부분을 try/catch문으로 감싸면 된다.

  • error1.js
setInterval(()=>{
    console.log('시작');
    try{
        throw new Error('서버를 고장내주마!');
    }catch(err){
        console.error(err);
    }
}, 1000);

// 결과
$ node error1.js
시작
Error: 서버를 고장내주마!
    at Timeout._onTimeout (H:\VSCodeFiles\JS\practice\error1.js:4:15)
    at listOnTimeout (internal/timers.js:549:17)
    at processTimers (internal/timers.js:492:7)
시작
Error: 서버를 고장내주마!
    at Timeout._onTimeout (H:\VSCodeFiles\JS\practice\error1.js:4:15)
    at listOnTimeout (internal/timers.js:549:17)
    at processTimers (internal/timers.js:492:7)
시작
Error: 서버를 고장내주마!
    at Timeout._onTimeout (H:\VSCodeFiles\JS\practice\error1.js:4:15)
    at listOnTimeout (internal/timers.js:549:17)
    at processTimers (internal/timers.js:492:7)

setInterval을 사용해 프로세스가 멈추는지의 여부를 체크한다. 프로세스가 멈추면 setInterval도 멈출 것이다. setInterval 내부에 throw new Error()를 써서 에러를 강제로 발생시키는 코드다.

에러는 발생하지만 try/catch로 잡을 수 있고 setInterval도 직접 멈추기 전까지 계속 실행된다. 이렇게 에러가 발생할 것 같은 부분을 미리 try/catch로 감싸면 된다.

 

다음으로 노드 자체에서 잡아주는 에러를 알아본다.

  • error2.js
const fs = require('fs');

setInterval(()=>{
    fs.unlink('/notFile.js', (err)=>{ // 존재하지 않는 파일 삭제
        if(err){
            console.error(err);
        }
    });
}, 1000);

// 결과
$ node error2.js
[Error: ENOENT: no such file or directory, unlink 'H:\notFile.js'] {
  errno: -4058,
  code: 'ENOENT',
  syscall: 'unlink',
  path: 'H:\\notFile.js'
}
[Error: ENOENT: no such file or directory, unlink 'H:\notFile.js'] {
  errno: -4058,
  code: 'ENOENT',
  syscall: 'unlink',
  path: 'H:\\notFile.js'
}
[Error: ENOENT: no such file or directory, unlink 'H:\notFile.js'] {
  errno: -4058,
  code: 'ENOENT',
  syscall: 'unlink',
  path: 'H:\\notFile.js'
}

fs.unlink로 존재하지 않는 파일을 지운다. 에러가 발생하지만, 노드 내장 모듈의 에러는 실행 중인 프로세스를 멈추지 않는다. 에러 로그를 기록해두고 나중에 원인을 찾아 수정하면 된다.

3.6.1절의 예제등을 보면 에러를 throw했는데, throw를 하면 노드 프로세스가 멈춰버린다. 따라서 throw를 하는 경우에는 반드시 try/catch문으로 throw한 에러를 잡아야 한다.

  • error3.js
const fs = require('fs').promises;

setInterval(()=>{
    console.log('시작');
    fs.unlink('/notFile.js');
}, 1000);

// 결과
$ node error3.js
시작
(node:18404) UnhandledPromiseRejectionWarning: Error: ENOENT: no such file or directory, unlink 'H:\notFile.js'
(node:18404) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:18404) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

프로미스의 에러는 catch하지 않아도 알아서 처리된다. 다만 프로미스의 에러를 알아서 처리해주는 동작은 노드 버전이 올라감에 따라 바뀔 수 있다. 따라서 프로미스를 사용할 때는 catch를 붙여주는게 권장된다.

다음으로 정말 예측이 불가능한 에러를 처리하는 방법을 알아본다.

  • error4.js
process.on('uncaughtException', (err)=>{
    console.error('예기치 못한 에러', err);
});

setInterval(()=>{
    throw new Error('서버를 고장내주마!');
}, 1000);

setTimeout(()=>{
    console.log('실행됩니다.');
}, 2000);

// 결과
$ node error4.js
예기치 못한 에러 Error: 서버를 고장내주마!
    at Timeout._onTimeout (H:\VSCodeFiles\JS\practice\error4.js:6:11)
    at listOnTimeout (internal/timers.js:549:17)
    at processTimers (internal/timers.js:492:7)
실행됩니다.
예기치 못한 에러 Error: 서버를 고장내주마!
    at Timeout._onTimeout (H:\VSCodeFiles\JS\practice\error4.js:6:11)
    at listOnTimeout (internal/timers.js:549:17)
    at processTimers (internal/timers.js:492:7)
예기치 못한 에러 Error: 서버를 고장내주마!
    at Timeout._onTimeout (H:\VSCodeFiles\JS\practice\error4.js:6:11)
    at listOnTimeout (internal/timers.js:549:17)
    at processTimers (internal/timers.js:492:7)

process 객체에 uncaughtException 이벤트 리스너를 추가했다. 처리하지 못한 에러가 발생했을 때 이벤트 리스너가 실행되고 프로세스가 유진된다. 이 부분이 없다면 위 예제에서 setTimeout()은 실행되지 않는다. 실행 후 1초만에 setInterval에서 에러가 발생하여 프로세스가 멈추기 때문이다. 하지만 uncaughException 이벤트 리스너가 연결되어 있으므로 프로세스가 멈추지 않는다. 아래는 process.on 부분을 없앴을 때의 로그이다. 프로세스가 멈춘다.

$ node error4.js
H:\VSCodeFiles\JS\practice\error4.js:4
    throw new Error('서버를 고장내주마!');
    ^

Error: 서버를 고장내주마!
    at Timeout._onTimeout (H:\VSCodeFiles\JS\practice\error4.js:4:11)
?[90m    at listOnTimeout (internal/timers.js:549:17)?[39m
?[90m    at processTimers (internal/timers.js:492:7)?[39m

 

어떻게 보면 uncaughtException 이벤트 리스너로 모든 에러를 처리할 수 있을 것처럼 보인다. 하지만 노드 공식 문서에서는 uncaughtException 이벤트를 최후의 수단으로 사용할 것을 명시하고 있다. 노드는 uncaughException 이벤트 발생 후 다음 동작이 제대로 동작하는지를 보증하지 않는다.

따라서 uncaughtException은 단순히 에러 내용을 기록하는 정도로 사용하고, 에러를 기록한 후 process.exit()으로 프로세스를 종료하는 것이 좋다. 에러가 발생하는 코드를 수정하지 않는 이상, 프로세스가 샐행되는 동안 에러는 계속 발생한다.

서버 운영은 에러와의 싸움이다. 모든 에러 상황에 대비하는 것이 최선이지만, 시간이나 비용, 인력 등의 제약으로 미처 대비하지 못한 에러가 발생할 수 있다. 따라서 에러 발생 시 철저히 기록(로깅)하는 습관을 들이고, 주기적으로 로그를 확인하면서 보완해나가야 한다. 운영 중인 서버가 에러로 인해 종료되었을 때 자동으로 재시작하는 방법은 15.1.5절에서 알아본다.

3.8.1 자주 발생하는 에러들

  • node: command not found: 환경 변수가 제대로 설정되지 않아서 발생하는 오류
  • ReferenceError: 모듈 is not defined: 모듈을 require 했는지 확인한다.
  • Error: Cannot find module 모듈명: 해당 모듈을 require했지만 설치하지 않았다. npm i 명령어로 설치한다.
  • Error: Can't set headers after they are sent: 요청에 대한 응답을 보낼 때 응답을 두 번 이상 보냈다. 요청에 대한 응답은 한 번만 보내야 한다.
  • FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory: 코드를 실행할 때 메모리가 부족하여 스크립트가 정상 작동하지 않는 경우이다. 코드가 잘못되었을 확률이 높다. 잘못되지 않았다면 노드가 활용할 수 있는 메모리를 늘릴 수 있다.(node --max-old-space-size=4096) 4096은 4GB
  • UnhandledPromiseRejectionWarning: Ungandled promise rejection: 프로미스 사용 시 catch 메서드를 붙이지 않으면 발생한다. 항상 catch를 붙이도록 한다.
  • EADDRINUSE 포트 번호: 해당 포트 번호에 이미 다른 프로세스가 연결되어 있다. 그 프로세스를 종료하고나 다른 포트 번호를 사용하도록 한다.
윈도에서 프로세스 종료하기

netstat -ano | findstr 포트
taskkill /pid 포르세스아이디 /f
포트가 3000이고 netstat -ano | findstr 3000 을 수행한 결과의 프로세스 아이디가 12345였을 경우, taskkill /pid 12345 /f를 하면 프로세스가 종료된다.

맥/리눅스에서 프로세스 종료하기
lsof -i tcp:포트
kill -9 프로세스아이디
포트가 3000이고 lsof -i tcp:3000을 수행한 결과의 프로세스 아이디가 12345라면 kill -9 12345를 하여 프로세스를 종료한다.
  • EACCES 또는 EPERM: 노드가 작업을 수행하는데 권한지 충분하지 않다. 파일/폴더 수정, 삭제, 생성 권한을 확이해 보면 좋다. 맥이나 리눅스라면 명령어 앞에 sudo를 붙이는 것도 방법
  • EJSONPARSE: package.json 등의 json 파일에 문법 오류가 있을 때 발생한다.
  • ECONNREFUSED: 요청을 보냈으나 연결이 성립하지 않을 때 발생한다. 요청을 받는 서버의 주소가 올바른지, 꺼져 있지는 않은지 확인해보아야 한다.
  • ETARGET: package.json에 기록한 패키지 버전이 존재하지 않을 때 발생한다. 해당 버전이 존재하는지 확인하도록 한다.
  • ETIMEOUT: 요청을 보냈으나 일정 시간 내에 오지 않을 때 발생한다. 요청을 받는 서버의 상태를 점검해봐야 한다.
  • ENOENT: no such file or directory: 지정한 폴더나 파일이 존재하지 않는 경우이다.
함께 보면 좋은 자료

노드 공식 문서
NODE_OPTIONS
UV_THREADPOOL_SIZE
에러 코드
uncaughtException

'NodeJS' 카테고리의 다른 글

[nodejs 교과서] 5장 패키지 매니저  (0) 2020.10.28
[nodejs 교과서] 4장  (0) 2020.10.28
[nodejs 교과서] 1장  (0) 2020.10.16
[express] session store 삭제  (0) 2020.10.12
[NodeJS] Connect-flash를 이용한 알림 메시지  (0) 2020.10.05
Comments