[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 실행 순서
console.log('A')실행 — 즉시 출력 (Call Stack에서 동기 실행)fs.readFile(...)호출 — OS에 위임 후 바로 반환 (메인 스레드는 기다리지 않는다)console.log('C')실행 — B보다 먼저 출력 (Call Stack이 비어있으므로 즉시 실행)- 파일 읽기 완료 → 콜백이 이벤트 큐에 추가 (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. 이벤트 루프
이벤트 루프는 메인 스레드와 이벤트 큐 사이의 스케줄러다.
동작 원리
- 메인 스레드가 코드를 실행한다 (Call Stack)
- 비동기 작업은 libuv(OS)에 위임한다
- I/O 완료 시 콜백이 이벤트 큐에 추가된다
- 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)
계층 간 필요한 데이터만 추출하거나 변환하는 객체다.