안녕하세요, 이번 글에서는 Node.js 핵심 개념과 MidwayJS 프레임워크에 대해 깊이 있게 알아보겠습니다.
1. Node.js 모듈 시스템
CommonJS vs ES Module (ESM)
JavaScript의 두 가지 주요 모듈 규격으로, Node.js는 현재 두 방식을 모두 지원합니다.
| 특성 | CommonJS | ES Module (ESM) |
|---|---|---|
| 문법 | require() / module.exports |
import / export |
| 로딩 방식 | 동적 로딩、실행 시점 동기 처리 | 정적 로딩、컴파일 시점 해석 |
| 값 참조 | 값의 복사 (내보낸 값의 사본) | 값의 참조 (원본 값에 대한 읽기 전용 참조) |
최상위 this |
현재 모듈을 가리킴 | undefined를 가리킴 |
| 순환 의존성 | 지원되나 처리 로직 복잡 | 지원되며 설계상 더 우수한 처리 |
| 적용 분야 | Node.js 서버 환경 | 브라우저와 최신 Node.js 환경 |
주요 차이점 예시:
// counter.cjs (CommonJS)
let counterValue = 0;
module.exports = { counterValue, increment: () => counterValue++ };
// main.cjs
const { counterValue, increment } = require('./counter.cjs');
console.log(counterValue); // 0
increment();
console.log(counterValue); // 0 (값의 복사본이므로 변하지 않음)
// counter.mjs (ESM)
export let counterValue = 0;
export const increment = () => counterValue++;
// main.mjs
import { counterValue, increment } from './counter.mjs';
console.log(counterValue); // 0
increment();
console.log(counterValue); // 1 (원본 값에 대한 참조이므로 변함)
Node.js에서의 사용법:
- 기본적으로
.js와.cjs파일은 CJS로,.mjs파일은 ESM으로 해석됩니다. package.json에"type": "module"을 설정하면.js파일이 ESM으로 처리됩니다.- 상호 운용성: CJS에서
require()로 ESM 모듈을 로드할 수 없습니다(오류 발생). ESM에서는 동적 임포트를 통해 CJS 모듈을 사용할 수 있습니다(import('./legacy.cjs').then(...)), 임포트된 것은 여전히 CJS 스타일의 내보내기를 따릅니다.
require의 검색 경로
require('X')를 사용할 때 Node.js는 다음 순서로 검색합니다:
- 코어 모듈(예:
fs,path): 즉시 반환됩니다. - 절대/상대 경로(예:
require('./myModule')): 해당 경로로 직접 로드합니다. - 서드파티 모듈(예:
require('lodash')): a. 현재 디렉토리의node_modules폴더에서 검색합니다. b. 찾지 못하면 부모 디렉토리로 재귀 검색을 수행하며 파일 시스템 루트까지 진행합니다. c.node_modules에서package.json의main필드로 지정된 파일을 찾습니다. d.package.json이나main필드가 없다면index.js,index.json,index.node을 순서대로 시도합니다. - 디렉토리를 모듈로 사용(예:
require('./some-folder')): a.some-folder/package.json의main필드를 찾습니다. b. 없다면some-folder/index.js를 시도합니다.
2. 이벤트 기반, 비동기 I/O 모델
Node.js의 고동시성 능력의 기반이 되는 부분입니다.
-
비동기 I/O: Node.js가 I/O 작업(파일 읽기, 네트워크 요청 등)을 실행할 때, 결과를 기다리지 않습니다. 즉시 반환하고 다음 JavaScript 코드를 계속 실행합니다. I/O 작업이 완료되면 콜백 함수(또는 Promise, async/await)를 통해 알리고 결과를 처리합니다.
-
장점: 단일 스레드가 여러 I/O 요청을 동시에 처리할 수 있으며, 느린 I/O 작업에 의해 차단되지 않습니다.
-
이벤트 기반: Node.js의 핵심은 **이벤트 루프(Event Loop)**입니다. 지속적으로 처리 대기 중인 이벤트(완료된 I/O 작업, 타이머 만료 등)가 있는지 확인합니다. 이벤트가 있다면 해당 이벤트를 꺼내와 콜백 함수를 실행합니다.
-
libuv: Node.js는 C 라이브러리인 libuv를 사용하여 이러한 비동기 I/O 작업과 이벤트 루프를 추상화하고 관리합니다.
고동시성 원리도해설: 웹 서버가 요청을 처리하는 과정을 상상해 보세요:
flowchart TD A[클라이언트 요청A 도착] --> B[메인 스레드 요청 수신<br>비동기 파일 읽기 I/O 시작] B -- I/O 작업을 libuv 스레드 품에 전달 --> C[libuv 스레드 풀] B -- 메인 스레드 차단되지 않음 --> D[클라이언트 요청B 도착] D --> E[메인 스레드 요청B 수신<br>비동기 DB 쿼리 I/O 시작] E -- I/O 작업을 libuv에 전달 --> C C -- 파일 읽기 완료 --> F[콜백 함수를 이벤트 큐에 추가] E -- DB 쿼리 완료 --> G[콜백 함수를 이벤트 큐에 추가] subgraph H[이벤트 루프] direction LR I[이벤트 큐 확인] --> J[콜백 함수A 실행] J --> K[콜백 함수B 실행] end F --> H G --> H 요약: Node.js의 단일 스레드는 JavaScript 코드 실행과 이벤트 루프가 단일 스레드에서 이루어진다는 의미입니다. 하지만 파일, 네트워크 등 I/O 작업은 libuv의 스레드 풀(기본 4개) 또는 운영체제 자체(예: 네트워크 요청)가 병렬로 처리합니다. 이러한 "하나의 메인 스레드 + 스레드 풀" 모델은 적은 자원(하나의 프로세스)으로 매우 높은 I/O 동시성을 구현하며, 웹 서버, API 게이트웨이와 같은 I/O 집약적인 애플리케이션에 매우 적합합니다.
3. Stream(스트림)
스트림은 연속적인 데이터를 처리하기 위한 추상화 인터페이스로, 대용량 파일이나 지속적으로 생성되는 데이터를 처리하는 데 특히 적합합니다.
네 가지 스트림 유형
- Readable: 읽을 수 있는 스트림(데이터 소스). 예:
fs.createReadStream,http request등. - Writable: 쓸 수 있는 스트림(데이터 목적지). 예:
fs.createWriteStream,http response등. - Duplex: 양방향 스트림(읽기와 쓰기 모두 가능). 예:
TCP socket등. - Transform: 변환 스트림(읽고 쓰는 과정에서 데이터를 수정하거나 변환). 예:
zlib.createGzip()(압축) 등.
대용량 파일 처리
스트림을 사용하면 메모리 효율성을 크게 높일 수 있습니다. 데이터를 모두 메모리에 로드한 후 처리하는 대신, 마치 물 흐름처럼 일부씩 처리하기 때문입니다.
잘못된 예시(메모리 폭발):
// file이 매우 큰 경우(예: 2GB), 메모리가 부족해짐
fs.readFile('huge-file.txt', (err, data) => {
// ... data 처리
});
올바른 예시(스트림 처리):
const inputDataStream = fs.createReadStream('input.txt');
const outputDataStream = fs.createWriteStream('output.txt');
// 파이프: 읽기 스트림의 데이터를 쓰기 스트림에 직접 연결
// 데이터가 청크(chunk) 단위로 흐르므로 메모리에는 일부만 유지됩니다
inputDataStream.pipe(outputDataStream);
// 중간에 변환 스트림을 추가할 수 있음, 예: 압축
// inputDataStream.pipe(zlib.createGzip()).pipe(outputDataStream);
백프레셔 문제 (Back Pressure)
문제: 데이터 소스(Readable 스트림)가 데이터를 생성하는 속도 > 데이터 목적지(Writable 스트림)가 데이터를 소비하는 속도. 이로 인해 데이터가 메모리에 쌓여 결국 메모리가 고갈됩니다.
해결책: Node.js 스트림 메커니즘에는 백프레셔 제어가 내장되어 있습니다.
writeStream.write(chunk)가false를 반환하면, 쓰기 큐가 가득 찼음(버퍼가 가득 참)을 의미하며 읽기 일시 중지를 권장합니다.- 읽기 스트림이 이 신호를 받으면 데이터 흐름을 일시 중지(pause)합니다.
- 쓰기 큐가 비워지면 Writable 스트림이
'drain'이벤트를 발생시킵니다. - 읽기 스트림이
'drain'이벤트를 받으면 데이터 흐름을 다시 시작(resume)합니다.
.pipe() 메소드는 이러한 모든 백프레셔 로직을 자동으로 처리해 주므로, 이 방식을 사용하는 것이 권장됩니다.
4. 프로세스와 클러스터
Node.js는 단일 프로세스이므로, 다중 CPU 코어를 효율적으로 활용하려면 여러 프로세스를 시작해야 합니다.
Child Process 모듈
하위 프로세스를 생성하고 관리하는 데 사용됩니다. 주요 메소드:
spawn: 가장 기본적인 방법으로, 명령을 실행하기 위해 하위 프로세스를 시작합니다.fork:spawn의 특별한 경우로, 새로운 Node.js 하위 프로세스를 전문적으로 생성합니다. 부모 프로세스와 하위 프로세스 간에 IPC 통신 채널이 설정되며,send()와on('message')로 메시지를 교환할 수 있습니다.exec: 셸 명령을 실행하고 결과를 버퍼링한 후 콜백에서 반환합니다. 출력량이 적은 명령에 적합합니다.execFile:exec와 유사하지만, 셸을 먼저 시작하지 않고 직접 실행 파일을 실행합니다.
Cluster 모듈
child_process.fork()와 net 모듈을 기반으로, 동일한 포트를 공유하는 다중 프로세스 웹 서버 생성 과정을 단순화합니다.
작동 원리(마스터-슬레이브 모드):
- 마스터 프로세스:
- 시작 및 관리를 담당합니다.
- 포트를 수신 대기합니다.
- 여러 워커 프로세스(일반적으로 CPU 코어 수만큼)를
fork합니다. - 수신된 연결 요청을 라운드 로빈 방식으로 각 워커에 분배합니다(기본 전략).
- 워커 프로세스:
- 마스터 프로세스에서
fork되어 생성됩니다. - 실제 비즈니스 로직(예: HTTP 서버 코드)을 실행합니다.
- 동일한 서버 포트를 공유합니다.
const processCluster = require('cluster');
const httpServer = require('http');
const cpuCount = require('os').cpus().length;
if (processCluster.isMaster) {
console.log(`마스터 ${process.pid} 실행 중`);
// 워커 생성
for (let i = 0; i < cpuCount; i++) {
processCluster.fork();
}
} else {
// 워커는 모든 TCP 연결을 공유할 수 있음
// 이 경우 HTTP 서버
httpServer.createServer((req, res) => {
res.writeHead(200);
res.end('안녕하세요, 세계!\n');
}).listen(8000);
console.log(`워커 ${process.pid} 시작됨`);
}
PM2 원리
PM2는 강력한 프로덕션 환경 프로세스 관리자이며, 핵심 원리는 Cluster 모듈을 기반으로 합니다.
- 프로세스 관리: PM2가 마스터 프로세스로 동작하며, 애플리케이션을 워커 프로세스로 시작합니다.
- 제로 다운타임 재시작: 재시작 시 새 프로세스를 먼저 시작하고, 이전 프로세스를 우아하게 종료하여 서비스 중단 없이 유지합니다.
- 로드 밸런싱: Cluster 모듈의 로드 밸런싱 기능을 내장하고 있습니다.
- 모니터링 및 로깅: 모든 워커 프로세스의 로그를 집계하고 풍부한 모니터링 기능을 제공합니다.
5. MidwayJS 프레임워크
MidwayJS는 TypeScript와 의존성 주입을 기반으로 한 미래 지향적인 Node.js 풀스택 프레임워크입니다.
의존성 주입(IoC) 원리 및 장점
- 원리: **제어 반전(IoC)**은 객체 생성과 의존성 관리의 권한을 코드 내부에서 반전시켜 **외부 컨테이너(IoC 컨테이너)**가 담당하도록 하는 설계 사상입니다. **의존성 주입(DI)**은 IoC를 구현하는 주요 방식입니다.
- Midway에서의 작동 방식:
@Provide()데코레이터를 사용하여 클래스(Service, Controller)가 컨테이너에서 관리될 수 있음을 선언합니다.- 사용이 필요한 곳에서
@Inject()데코레이터를 사용하여 필요한 의존성의 인스턴스를 선언합니다. - 프레임워크의 IoC 컨테이너가 시작 시 모든 파일을 스캔하여 이러한 클래스의 인스턴스를 자동으로 생성하고, 필요한 곳에 의존성을 자동으로 주입합니다.
// user.service.ts - 서비스 선언
@Provide() // <- 컨테이너에 "제공할 수 있다"고 알림
export class UserDataService {
async retrieveUserData() {
return { name: 'Midway' };
}
}
// user.controller.ts - 컨트롤러 선언 및 서비스 주입
@Provide()
@Controller('/user')
export class UserController {
@Inject() // <- 컨테이너에 "UserDataService 인스턴스를 주입해주세요"라고 알림
userDataService: UserDataService;
@Get('/')
async getUserData() {
const user = await this.userDataService.retrieveUserData(); // 직접 사용, new 필요 없음
return user;
}
}
- 장점:
- 결합도 감소: 코드가 의존성이 어떻게 생성되는지 신경 쓸 필요 없이 인터페이스만 신경 쓰므로 결합도가 매우 낮습니다.
- 테스트 용이성: 단위 테스트 시 Mock 객체를 쉽게 주입할 수 있습니다.
- 유지보수성: 의존성 관계가 명확하여 코드 구조가 우아합니다.
생명주기, 미들웨어, 필터 메커니즘
- 생명주기: Midway는 애플리케이션 시작 및 중지 시점의 후크를 제공합니다.
@Config(): 설정 로딩 후 실행됩니다.@Init(): 의존성 주입 초기화 후 실행됩니다.@Destroy(): 애플리케이션 중지 전 실행됩니다. 리소스 해제(예: 데이터베이스 연결 닫기)에 사용됩니다.
- 미들웨어: Koa/Egg 미들웨어 메커니즘과 같은 맥락을 가집니다. 양파 모델 기반의 HTTP 요청 처리 흐름입니다.
- 로깅, 인증, Body 파싱 등 공통 로직을 처리하는 미들웨어를 작성할 수 있습니다.
configuration.ts에서 전역 또는 로컬으로 등록합니다.
- 필터: 전체 요청 처리 과정에서 발생하는 예외를 통합 처리합니다.
- 정의된 예외 필터는 모든 처리되지 않은 오류를 캡처하고 클라이언트에 통일된 형식으로 반환합니다.
- 이는 Midway가 표준 Koa 모델을 강화한 것으로, 오류 처리를 매우 우아하고 중앙 집중화된 방식으로 만들어 줍니다.
다양한 데이터베이스 연결 및 조작
Midway는 컴포넌트 시스템을 통해 다양한 데이터베이스를 통합하며, 의존성 주입 원칙을 따릅니다.
- 컴포넌트 설치:
npm install @midwayjs/typeorm@3 typeorm mysql2(TypeORM + MySQL 예시) - 설정:
src/config/config.default.ts에서 데이터베이스 연결 정보를 구성합니다. - 엔티티(Entity) 정의: TypeORM 데코레이터를 사용하여 데이터 모델을 정의합니다.
- Service에서 Repository 주입:
@InjectEntityModel을 통해 데이터베이스 작업의 진입점을 주입합니다.
// app.config.ts - 컴포넌트 가져오기
import { AppConfiguration } from '@midwayjs/core';
import * as typeorm from '@midwayjs/typeorm';
@Configuration({
imports: [typeorm], // 컴포넌트 가져오기
})
export class MainConfiguration {}
// entity/user.entity.ts - 엔티티 정의
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class UserInfo {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}
// service/user.service.ts - 데이터베이스 작업
import { Service } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { UserInfo } from '../entity/user.entity';
@Service()
export class UserDataService {
@InjectEntityModel(UserInfo) // UserInfo 엔티티의 Repository 주입
userInfoRepo: Repository<UserInfo>;
async findUserById(id: number) {
return await this.userInfoRepo.findOne({ where: { id } });
}
}
ClickHouse 등 다른 데이터베이스 작업은 유사한 방식으로 진행됩니다. 일반적으로 해당 Midway 컴포넌트(예: @midwayjs/clickhouse)를 찾거나 개발한 후, 컴포넌트 문서에 따라 구성하고 주입하여 사용합니다. 핵심 아이디어는 IoC 컨테이너를 통해 구성된 클라이언트 인스턴스를 주입받는 것입니다.