Node.js 핵심 개념과 MidwayJS 프레임워크 심층 분석

안녕하세요, 이번 글에서는 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는 다음 순서로 검색합니다:

  1. 코어 모듈(예: fs, path): 즉시 반환됩니다.
  2. 절대/상대 경로(예: require('./myModule')): 해당 경로로 직접 로드합니다.
  3. 서드파티 모듈(예: require('lodash')): a. 현재 디렉토리의 node_modules 폴더에서 검색합니다. b. 찾지 못하면 부모 디렉토리로 재귀 검색을 수행하며 파일 시스템 루트까지 진행합니다. c. node_modules에서 package.jsonmain 필드로 지정된 파일을 찾습니다. d. package.json이나 main 필드가 없다면 index.js, index.json, index.node을 순서대로 시도합니다.
  4. 디렉토리를 모듈로 사용(예: require('./some-folder')): a. some-folder/package.jsonmain 필드를 찾습니다. 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(스트림)

스트림은 연속적인 데이터를 처리하기 위한 추상화 인터페이스로, 대용량 파일이나 지속적으로 생성되는 데이터를 처리하는 데 특히 적합합니다.

네 가지 스트림 유형

  1. Readable: 읽을 수 있는 스트림(데이터 소스). 예: fs.createReadStream, http request 등.
  2. Writable: 쓸 수 있는 스트림(데이터 목적지). 예: fs.createWriteStream, http response 등.
  3. Duplex: 양방향 스트림(읽기와 쓰기 모두 가능). 예: TCP socket 등.
  4. 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 스트림 메커니즘에는 백프레셔 제어가 내장되어 있습니다.

  1. writeStream.write(chunk)false를 반환하면, 쓰기 큐가 가득 찼음(버퍼가 가득 참)을 의미하며 읽기 일시 중지를 권장합니다.
  2. 읽기 스트림이 이 신호를 받으면 데이터 흐름을 일시 중지(pause)합니다.
  3. 쓰기 큐가 비워지면 Writable 스트림이 'drain' 이벤트를 발생시킵니다.
  4. 읽기 스트림이 '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 모듈을 기반으로, 동일한 포트를 공유하는 다중 프로세스 웹 서버 생성 과정을 단순화합니다.

작동 원리(마스터-슬레이브 모드):

  1. 마스터 프로세스:
  • 시작 및 관리를 담당합니다.
  • 포트를 수신 대기합니다.
  • 여러 워커 프로세스(일반적으로 CPU 코어 수만큼)를 fork합니다.
  • 수신된 연결 요청을 라운드 로빈 방식으로 각 워커에 분배합니다(기본 전략).
  1. 워커 프로세스:
  • 마스터 프로세스에서 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 모듈을 기반으로 합니다.

  1. 프로세스 관리: PM2가 마스터 프로세스로 동작하며, 애플리케이션을 워커 프로세스로 시작합니다.
  2. 제로 다운타임 재시작: 재시작 시 새 프로세스를 먼저 시작하고, 이전 프로세스를 우아하게 종료하여 서비스 중단 없이 유지합니다.
  3. 로드 밸런싱: Cluster 모듈의 로드 밸런싱 기능을 내장하고 있습니다.
  4. 모니터링 및 로깅: 모든 워커 프로세스의 로그를 집계하고 풍부한 모니터링 기능을 제공합니다.

5. MidwayJS 프레임워크

MidwayJS는 TypeScript와 의존성 주입을 기반으로 한 미래 지향적인 Node.js 풀스택 프레임워크입니다.

의존성 주입(IoC) 원리 및 장점

  • 원리: **제어 반전(IoC)**은 객체 생성과 의존성 관리의 권한을 코드 내부에서 반전시켜 **외부 컨테이너(IoC 컨테이너)**가 담당하도록 하는 설계 사상입니다. **의존성 주입(DI)**은 IoC를 구현하는 주요 방식입니다.
  • Midway에서의 작동 방식:
  1. @Provide() 데코레이터를 사용하여 클래스(Service, Controller)가 컨테이너에서 관리될 수 있음을 선언합니다.
  2. 사용이 필요한 곳에서 @Inject() 데코레이터를 사용하여 필요한 의존성의 인스턴스를 선언합니다.
  3. 프레임워크의 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 객체를 쉽게 주입할 수 있습니다.
  • 유지보수성: 의존성 관계가 명확하여 코드 구조가 우아합니다.

생명주기, 미들웨어, 필터 메커니즘

  1. 생명주기: Midway는 애플리케이션 시작 및 중지 시점의 후크를 제공합니다.
  • @Config(): 설정 로딩 후 실행됩니다.
  • @Init(): 의존성 주입 초기화 후 실행됩니다.
  • @Destroy(): 애플리케이션 중지 전 실행됩니다. 리소스 해제(예: 데이터베이스 연결 닫기)에 사용됩니다.
  1. 미들웨어: Koa/Egg 미들웨어 메커니즘과 같은 맥락을 가집니다. 양파 모델 기반의 HTTP 요청 처리 흐름입니다.
  • 로깅, 인증, Body 파싱 등 공통 로직을 처리하는 미들웨어를 작성할 수 있습니다.
  • configuration.ts에서 전역 또는 로컬으로 등록합니다.
  1. 필터: 전체 요청 처리 과정에서 발생하는 예외통합 처리합니다.
  • 정의된 예외 필터는 모든 처리되지 않은 오류를 캡처하고 클라이언트에 통일된 형식으로 반환합니다.
  • 이는 Midway가 표준 Koa 모델을 강화한 것으로, 오류 처리를 매우 우아하고 중앙 집중화된 방식으로 만들어 줍니다.

다양한 데이터베이스 연결 및 조작

Midway는 컴포넌트 시스템을 통해 다양한 데이터베이스를 통합하며, 의존성 주입 원칙을 따릅니다.

  1. 컴포넌트 설치: npm install @midwayjs/typeorm@3 typeorm mysql2 (TypeORM + MySQL 예시)
  2. 설정: src/config/config.default.ts에서 데이터베이스 연결 정보를 구성합니다.
  3. 엔티티(Entity) 정의: TypeORM 데코레이터를 사용하여 데이터 모델을 정의합니다.
  4. 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 컨테이너를 통해 구성된 클라이언트 인스턴스주입받는 것입니다.

태그: Node.js MidwayJS CommonJS ES Module Event Loop

5월 29일 04:12에 게시됨