[Node.js / Express 5] Node.js 핵심 개념과 프로젝트 구조

1. Node.js란 무엇인가

Node.js는 Chrome V8 엔진으로 빌드된 JavaScript 런타임이다. "서버 언어"가 아니라 "JavaScript 실행 환경"이다. 서버, CLI 도구, 빌드 툴(Webpack, Vite), 자동화 스크립트 어디에나 쓸 수 있다.

실제 사용처: Netflix, LinkedIn, PayPal, NASA, 네이버, 카카오 등이 Node.js를 서버로 사용한다.

Node.js가 서버로 적합한 이유: I/O가 많고 동시 접속이 많은 서비스에 강하다. 단, CPU를 많이 쓰는 연산(이미지 처리, 암호화)에는 약하다.

2. 싱글 스레드와 Non-blocking I/O

Node.js는 하나의 메인 스레드로 코드를 실행한다. 그런데 어떻게 동시에 여러 요청을 처리할 수 있을까?

핵심은 Non-blocking I/O다. 파일 읽기, DB 쿼리 같은 I/O 작업을 OS/libuv에 위임하고, 메인 스레드는 기다리지 않고 다음 코드를 실행한다. I/O가 완료되면 콜백이 이벤트 큐에 들어오고, 이벤트 루프가 이를 처리한다.

멀티 스레드 vs 싱글 스레드

구분 멀티 스레드 (Java, C++) 싱글 스레드 (Node.js)
동시성 처리 여러 스레드가 병렬 실행 이벤트 루프로 비동기 처리
메모리 스레드마다 스택 메모리 필요 단일 콜 스택, 메모리 효율적
복잡성 동기화, 데드락 위험 동기화 불필요, 간단
적합 CPU 집약 작업 I/O 집약 작업 (웹 서버)

Non-blocking I/O 실행 순서

  1. console.log('A') 실행 — 즉시 출력 (Call Stack에서 동기 실행)
  2. fs.readFile(...) 호출 — OS에 위임 후 바로 반환 (메인 스레드는 기다리지 않는다)
  3. console.log('C') 실행 — B보다 먼저 출력 (Call Stack이 비어있으므로 즉시 실행)
  4. 파일 읽기 완료 → 콜백이 이벤트 큐에 추가 (Event Loop가 큐에서 꺼내 실행 → 'B' 출력)
const fs = require('fs')

console.log('A')

fs.readFile('file.txt', (err, data) => {
  console.log('B — 파일 내용:', data)
})

console.log('C')
// 출력 순서: A → C → B

B가 C보다 나중에 출력되는 이유: readFile은 비동기 I/O다. OS에 위임하고 메인 스레드는 C를 먼저 실행한다.

Blocking vs Non-blocking 코드 비교

Blocking (동기): 파일 읽기가 끝날 때까지 다음 코드가 실행되지 않는다.

const fs = require('fs');

const data = fs.readFileSync('file.txt');
console.log(data.toString());
console.log('다음 작업');
// 출력 순서: 파일 내용 → 다음 작업

Non-blocking (비동기): 파일 읽기를 OS에 위임하고 다음 코드를 먼저 실행한다.

const fs = require('fs');

fs.readFile('file.txt', (err, data) => {
  console.log(data.toString());
});
console.log('다음 작업');
// 출력 순서: 다음 작업 → 파일 내용 (비동기라 콜백이 나중에 실행된다)

setTimeout(0) 예시

console.log('A')
setTimeout(() => console.log('B'), 0)
console.log('C')
// 출력 순서: A → C → B

setTimeout 0이어도 B가 마지막인 이유: 콜백은 이벤트 큐를 통해 실행되므로 현재 콜 스택이 비워진 후에야 실행된다.

3. 이벤트 루프

이벤트 루프는 메인 스레드와 이벤트 큐 사이의 스케줄러다.

동작 원리

  1. 메인 스레드가 코드를 실행한다 (Call Stack)
  2. 비동기 작업은 libuv(OS)에 위임한다
  3. I/O 완료 시 콜백이 이벤트 큐에 추가된다
  4. Call Stack이 비면 이벤트 루프가 큐에서 콜백을 꺼내 실행한다
Call Stack  →  libuv / OS  →  Event Queue  →  Event Loop
    ↑                                               |
    └───────────────────────────────────────────────┘
              Stack이 비면 다시 실행

Call Stack이 비면 Event Loop가 Event Queue에서 콜백을 꺼내 실행한다.

이벤트 루프 6단계

이벤트 루프는 내부적으로 6개의 단계를 순환한다. 각 단계마다 처리하는 콜백의 종류가 다르다.

단계 이름 처리 내용
1 timers setTimeout, setInterval 콜백 실행
2 pending callbacks I/O 에러 콜백 등
3 idle, prepare 내부 사용
4 poll I/O 이벤트 대기 및 실행
5 check setImmediate 콜백 실행
6 close callbacks 소켓 close 이벤트 등

4. ES6와 ES Module

ECMAScript(ES)는 JavaScript의 공식 표준 명세다. Ecma International이 관리한다.

ES6(2015)가 중요한 이유: 가장 큰 변화가 있었다. let/const, 화살표 함수, 구조 분해, 템플릿 리터럴, Promise, class 등이 모두 ES6에서 등장했다.

ES6 주요 문법

const name = 'Node.js';
const version = 20;
console.log(`${name} v${version}`);

const { host, port } = config;

const numbers = [1, 2, 3];
const doubled = numbers.map(n => n * 2);

const merged = { ...defaults, ...overrides };

CommonJS vs ES Module

구분 CommonJS ES Module
문법 require() / module.exports import / export
로드 방식 런타임에 동적으로 로드 실행 전 정적 분석
Tree Shaking 불가 지원
확장자/설정 .cjs 또는 기본 Node.js .mjs 또는 "type": "module"

ES Module 장점

  • 실행 전 정적 분석 가능 → Tree Shaking (사용하지 않는 코드 제거)
  • IDE 자동완성 향상
  • 비동기 모듈 로딩 지원 (Top-level await)

Node.js에서 ES Module 사용

package.json에 다음을 추가한다.

{
  "type": "module"
}

5. 프로젝트 파일 구조: 3계층 아키텍처

서비스 규모가 커지면 코드를 역할에 따라 분리해야 한다. Controller-Service-Repository 패턴이 널리 쓰인다.

3계층 아키텍처 흐름

Client (HTTP 요청 / 응답)
  ↓ 요청 전달
Controller (요청 파싱, 응답 반환)
  ↓ 비즈니스 로직 호출
Service (핵심 비즈니스 로직)
  ↓ DB 조회 / 저장 요청
Repository (DB 쿼리 / ORM 호출)
  ↓ SQL / ORM
DB (데이터 저장소)

각 계층 역할

  • Controller: 요청을 받아 Service를 호출하고 응답을 반환한다. 비즈니스 로직을 포함하지 않는다.
  • Service: 핵심 비즈니스 로직을 담당한다. Controller와 Repository 사이를 중재한다.
  • Repository: DB 접근만 담당한다. SQL 쿼리나 ORM 호출을 여기에 모아 둔다.

디렉토리 구조

src/
├── controllers/
│   └── user.controller.js
├── services/
│   └── user.service.js
├── repositories/
│   └── user.repository.js
├── dtos/
│   └── user.dto.js
└── index.js

6. DTO (Data Transfer Object)

DTO는 계층 간 데이터를 전달할 때 필요한 속성만 추출하거나 변환하는 객체다.

DTO가 필요한 이유

  • DB 모델에는 password, secret 같은 민감한 필드가 있다. 이를 그대로 응답으로 내보내면 안 된다.
  • req.body를 검증 없이 바로 쓰면 예상치 못한 필드가 들어올 수 있다.
export const bodyToUser = (body) => ({
  email: body.email,
  password: body.password,
  name: body.name,
})

export const userToResponse = (user) => ({
  userId: user.id,
  email: user.email,
  name: user.name,
})

Controller에서 DTO 변환 흐름

Controller는 Request에서 필요한 데이터만 추출해서 DTO로 만들어 Service에 전달한다.

const signUpUser = async (req, res) => {
  const dto = {
    email: req.body.email,
    name: req.body.name,
    password: req.body.password,
  };

  const result = await userService.signUp(dto);
  res.status(201).json(result);
};

7. 환경 변수와 dotenv

포트 번호, DB 접속 정보, 시크릿 키는 코드에 직접 쓰면 안 된다. .env 파일에 분리하고 .gitignore에 추가한다.

PORT=3000
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=secret
import 'dotenv/config'
const PORT = process.env.PORT ?? 3000

.gitignore와 .env.example

.env 파일에는 DB 비밀번호, API 키 등 민감한 정보가 담겨 있어 git에 올리면 안 된다. .gitignore.env를 추가해서 커밋에서 제외한다. 대신 .env.example 파일에 필요한 환경 변수 목록만 작성해서 공유한다.

DATABASE_HOST=
DATABASE_PORT=
DATABASE_USER=
DATABASE_PASSWORD=
DATABASE_NAME=

핵심 키워드

Node.js

Chrome V8 기반 JavaScript 런타임. 서버 외에도 CLI, 빌드 도구 등에 사용한다.

싱글 스레드

하나의 메인 스레드로 실행된다. Non-blocking I/O 덕분에 동시 처리가 가능하다.

Non-blocking I/O

I/O 작업을 OS에 위임하고 기다리지 않고 다음 코드를 실행한다.

Event Loop

Call Stack이 비면 이벤트 큐에서 콜백을 가져와 실행하는 스케줄러다. timers, poll, check 등 6단계를 순환한다.

ES Module

import/export 기반. 정적 분석, Tree Shaking, Top-level await를 지원한다.

Service Layer Pattern

Controller / Service / Repository 3계층으로 역할을 분리하는 아키텍처다.

DTO (Data Transfer Object)

계층 간 필요한 데이터만 추출하거나 변환하는 객체다.