📦 Node 와 npm — 서버 개발의 시작점
http://localhost:9100 을 열면 내가 만든 Node 서버가 hello my-board 를 돌려줍니다.
왜 필요한가
지금까지 쓴 JavaScript 는 전부 브라우저 안에서 돌았다. 브라우저는 안전을 위해 JS 가 하드디스크를 읽거나 네트워크 포트를 여는 걸 막아둔다. 그래서 브라우저 JS 로는 서버를 만들 수 없다.
Node 는 JS 엔진(V8)을 브라우저에서 꺼내 컴퓨터 위에 올려놓은 것이다. 껍데기가 바뀌니 할 수 있는 일이 바뀐다. 파일을 읽고, 포트를 열고, DB 에 접속한다.
3장의 NestJS, 2장의 Next.js, 4장의 mysql2 — 전부 Node 위에서 돈다. 그 밑바닥을 오늘 30줄짜리 코드로 직접 만들어 본다. 오늘 만드는 5줄짜리 서버가 3장에서 NestJS 로 바뀌는 것뿐이다.
개념
브라우저 JS vs Node JS
| 브라우저 | Node | |
|---|---|---|
| 전역 객체 | window, document | global, process |
| 파일 읽기 | 불가 | fs 모듈 |
| 포트 열기 | 불가 | http 모듈 |
| DOM | 있음 | 없음 (document 를 쓰면 에러) |
| 모듈 | ESM (import) | ESM 과 CJS 둘 다 |
| 쓰는 곳 | 화면 | API 서버, 빌드 도구, CLI |
Node 와 npm 과 node_modules
[내 프로젝트]
package.json ← "이 프로젝트가 뭘 쓰는지" 적은 명세서 (사람이 관리)
package-lock.json ← "정확히 어떤 버전을 깔았는지" 기록 (npm 이 관리)
node_modules/ ← 실제 라이브러리 파일들 (git 에 안 올림)
▲
│ npm install / npm ci 가 채운다
│
[npm 저장소 (인터넷)]
package.json 과 package-lock.json 은 git 에 올린다. node_modules/ 는 안 올린다(.gitignore).
node_modules 는 수만 개 파일에 수백 MB 다. 그래서 커밋하지 않고, 필요할 때 package-lock.json 을 보고 똑같이 복원한다. 9장에서 EC2 에 배포할 때 하는 일이 정확히 이것이다.
npm install vs npm ci
npm install (= npm i) | npm ci | |
|---|---|---|
| 기준 | package.json | package-lock.json 만 |
| lock 파일 | 필요하면 수정함 | 수정 안 함. 안 맞으면 에러 |
node_modules | 있으면 살려 씀 | 통째로 지우고 새로 설치 |
| 속도 | 보통 | 더 빠름 |
| 쓰는 곳 | 내 노트북에서 개발할 때 | CI, 배포 서버(EC2) |
npm i, 배포는 npm ci.
dependencies vs devDependencies
npm i mysql2 # dependencies → 운영에서도 필요
npm i -D typescript # devDependencies → 개발할 때만 필요
dependencies 는 운영에서도 돌아야 하는 것(@nestjs/core, mysql2, axios, next), devDependencies 는 빌드까지만 필요한 것(typescript, eslint, prettier, jest, @types/*)이다.
@types/node 처럼 타입 정의만 주는 패키지는 컴파일 후 사라지니 -D 로 넣는다.
ESM vs CJS 한 줄
CJS(CommonJS)는 require() / module.exports, ESM은 import / export 다. package.json 에 "type": "module" 을 넣거나 확장자를 .mjs 로 하면 ESM 이고, 아니면 CJS 다. NestJS 서버는 보통 CJS 로 빌드하고 Next.js 는 ESM 을 쓴다.
비동기 — 서버 코드는 전부 이거다
DB 조회, S3 업로드, 외부 API 호출 — 전부 "요청하고 답이 올 때까지 기다리는" 일이다. Node 는 기다리는 동안 다른 요청을 처리한다. 그래서 서버 코드에는 async 가 붙어 있다.
[동기] 주문 → 서서 기다림 → 받음 → 다음 손님 (한 명씩)
[비동기] 주문 → 진동벨 받고 자리로 → 다음 손님 주문 받음 → 벨 울리면 픽업
비동기를 표현하는 방식은 콜백(fs.readFile(p, (err, data) => ...)) → Promise(getUser(1).then().catch()) → async/await(const u = await getUser(1)) 순으로 발전했다. 지금은 세 번째만 쓴다.
await 은 "Promise 가 끝날 때까지 이 함수만 멈춰라"는 뜻이다. await 은 async 함수 안에서만 쓸 수 있다.
따라하기
1단계. Node 를 계산기처럼 써보기
node -e "console.log(1 + 1)"
node -e "console.log(process.version, process.platform)"
node -e "console.log(Object.keys(process.env).length + '개의 환경변수')"
-e 는 execute. 파일 없이 한 줄을 바로 실행한다. 4장에서 DB 연결을 빠르게 테스트할 때도 이걸 쓴다.
대화형으로 쓰려면 node 만 치면 된다. 나올 땐 Ctrl + D 또는 .exit.
2단계. 프로젝트 초기화
mkdir -p ~/Desktop/study/my-board/연습-node
cd ~/Desktop/study/my-board/연습-node
npm init -y
cat package.json
{
"name": "연습-node",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
}
}
-y 는 "전부 기본값으로"다. 빼면 이름·버전을 하나씩 물어본다.
3단계. scripts 로 명령에 별명 붙이기
package.json 의 scripts 는 자주 치는 긴 명령에 짧은 별명을 붙이는 곳이다.
npm pkg set scripts.hello="node -e \"console.log('안녕 Node')\""
npm run hello
> 연습-node@1.0.0 hello
> node -e "console.log('안녕 Node')"
안녕 Node
참고 레포의 서버는 이렇게 생겼다.
| script | 실제 명령 | 언제 |
|---|---|---|
npm run start:dev | nest start --watch | 개발 중 (코드 고치면 자동 재시작) |
npm run build | nest build | 배포 전 TS → JS 컴파일 |
npm run start:prod | node dist/main | 운영 (9장에서 PM2 가 이걸 돌린다) |
npm run lint | eslint ... --fix | 코드 검사 |
start 와 test 만 npm start 처럼 run 없이 쓸 수 있고, 나머지는 전부 npm run 이름 이다.
4단계. 패키지 설치해 보기
npm i dayjs
npm i -D typescript
ls node_modules | head -5
cat package.json
dependencies 에 dayjs, devDependencies 에 typescript 가 들어갔는지 확인한다.
node -e "const d=require('dayjs'); console.log(d().format('YYYY-MM-DD HH:mm'))"
package-lock.json 도 같이 생겼다. ls -al 로 확인한다.
npm ci 도 체험해 본다.
rm -rf node_modules
npm ci
ls node_modules | wc -l
node_modules 가 통째로 복원된다. 9장에서 EC2 가 하는 일이 정확히 이것이다. 코드는 git 으로 받고, 라이브러리는 npm ci 로 복원한다.
5단계. async/await 복습
cat > async-연습.js <<'EOF'
// 1초 뒤에 값을 주는 가짜 DB 조회
function findUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === 0) reject(new Error('id 는 1 이상이어야 한다'));
else resolve({ id, nickname: '보드지기' + id });
}, 1000);
});
}
async function main() {
console.log('1. 시작');
const user = await findUser(1); // 여기서 1초 멈춘다
console.log('2. 조회 결과:', user);
// 여러 개를 동시에 — 총 1초면 끝난다 (순서대로 하면 3초)
const users = await Promise.all([findUser(2), findUser(3), findUser(4)]);
console.log('3. 동시 조회:', users.map((u) => u.nickname).join(', '));
// 에러는 try/catch 로 잡는다
try {
await findUser(0);
} catch (e) {
console.log('4. 에러 잡음:', e.message);
}
console.log('5. 끝');
}
main();
EOF
node async-연습.js
1. 시작
2. 조회 결과: { id: 1, nickname: '보드지기1' }
3. 동시 조회: 보드지기2, 보드지기3, 보드지기4
4. 에러 잡음: id 는 1 이상이어야 한다
5. 끝
여기서 꼭 챙길 것 세 가지.
await은 그 함수 안에서만 멈춘다. 서버 전체가 멈추는 게 아니다.- 독립적인 여러 작업은
Promise.all로 동시에 돌린다. 3초가 1초가 된다. - 비동기 에러는 반드시
try/catch. 안 잡으면unhandledRejection으로 프로세스가 죽는다.
try/catch 로 잡지 않으면 unhandledRejection 으로 프로세스가 통째로 죽는다. 참고 레포에서도 백그라운드 작업 때문에 서버가 통째로 죽은 사고가 있었다.
6단계. HTTP 서버를 5줄로 띄우기 — 오늘의 핵심
cat > 서버.js <<'EOF'
const http = require('http');
http
.createServer((req, res) => res.end('hello my-board'))
.listen(9100, () => console.log('http://localhost:9100 에서 대기 중'));
EOF
node 서버.js
http://localhost:9100 에서 대기 중
브라우저에서 http://localhost:9100 을 연다. hello my-board 가 보인다.
끄려면 터미널에서 Ctrl + C.
방금 무슨 일이 일어났나.
[브라우저] GET http://localhost:9100/ ──▶ [9100 포트를 듣고 있는 node 프로세스]
│
(req, res) 콜백 실행
│
[브라우저] ◀── "hello my-board" (200 OK) ────────────┘
이게 서버의 전부다. 포트를 열고 → 요청이 오면 → 응답을 돌려준다. 3장의 NestJS 는 이 위에 라우팅·검증·의존성 주입을 얹은 것뿐이다.
7단계. 경로에 따라 다르게 응답하기 (JSON)
명세 5-2절의 GET /posts 흉내를 내본다.
cat > 서버.js <<'EOF'
const http = require('http');
const posts = [
{ id: 1, title: '첫 글', content: '안녕' },
{ id: 2, title: '둘째 글', content: '반갑다' },
];
const server = http.createServer((req, res) => {
console.log(req.method, req.url); // 요청 로그
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }));
}
if (req.method === 'GET' && req.url === '/posts') {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify(posts));
}
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ statusCode: 404, message: 'Not Found' }));
});
server.listen(9100, () => console.log('9100 대기 중'));
EOF
node 서버.js
다른 터미널 탭(Command + T)에서 확인한다.
curl http://localhost:9100/posts
curl -i http://localhost:9100/health
curl -i http://localhost:9100/없는경로
-i 는 응답 헤더까지 보여준다. curl 은 다음 문서에서 제대로 다룬다.
여기서 이미 명세의 규칙 두 개를 지키고 있다. 응답을 { data: ... } 로 감싸지 않고 데이터를 직접 반환했고, 에러는 { statusCode, message } 형태다.
8단계. 코드를 고치면 자동 재시작 — --watch
지금은 코드를 고칠 때마다 Ctrl + C → node 서버.js 를 반복해야 한다. Node 18 부터는 기본 기능이 있다.
node --watch 서버.js
이제 서버.js 를 저장하면 자동으로 재시작된다.
예전부터 쓰던 도구인 nodemon 도 같은 일을 한다. 참고 레포와 우리 프로젝트는 NestJS 가 제공하는 nest start --watch(= npm run start:dev) 를 쓰므로 nodemon 을 따로 깔 일은 없다.
npm pkg set scripts.dev="node --watch 서버.js"
npm run dev
✅ 확인 — 이렇게 보이면 성공
터미널 A:
cd ~/Desktop/study/my-board/연습-node
node --watch 서버.js
터미널 B:
curl -s http://localhost:9100/posts
echo
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:9100/없는경로
[{"id":1,"title":"첫 글","content":"안녕"},{"id":2,"title":"둘째 글","content":"반갑다"}]
404
터미널 A 에는 요청 로그가 쌓인다.
GET /posts
GET /%ec%97%86%eb%8a%94%ea%b2%bd%eb%a1%9c
브라우저 주소창에 http://localhost:9100/posts 를 넣으면 JSON 이 그대로 보인다.
🔧 흔한 에러
| 증상 | 원인 | 해결 |
|---|---|---|
Error: listen EADDRINUSE :::9100 | 이전 서버가 안 죽고 9100 을 점유 중 | lsof -ti :9100 | xargs kill -9 후 재실행 |
SyntaxError: Cannot use import statement outside a module | CJS 파일에서 import 를 씀 | require() 로 바꾸거나 package.json 에 "type": "module" 추가 |
ReferenceError: require is not defined | ESM 모드인데 require 를 씀 | import 로 바꾸거나 "type": "module" 을 제거 |
SyntaxError: await is only valid in async functions | async 없는 함수 안에서 await | 바깥 함수에 async 를 붙인다 |
UnhandledPromiseRejection 으로 프로세스가 죽음 | Promise 에러를 안 잡음 | await 호출을 try/catch 로 감싼다 |
Cannot find module 'dayjs' | 설치 안 했거나 다른 폴더에서 실행 | pwd 확인 후 해당 폴더에서 npm i dayjs |
npm ci 가 package-lock.json not found 로 실패 | lock 파일이 없거나 커밋 안 됨 | npm install 을 한 번 돌려 lock 을 만들고 커밋 |
브라우저에 한글이 � 로 깨짐 | Content-Type 에 charset 이 없음 | 'application/json; charset=utf-8' 로 지정 |
| 코드를 고쳤는데 응답이 그대로 | --watch 없이 실행 중 | Ctrl + C 후 node --watch 서버.js |
npm i 를 sudo 로 실행했더니 이후 계속 권한 에러 | 폴더 소유자가 root 가 됨 | sudo chown -R $(whoami) ~/.npm node_modules 후 sudo 없이 재시도 |
참고 레포에서 보기
| 파일 | 무엇을 볼지 |
|---|---|
/Users/me/Desktop/plutosaju/plutosaju_server/package.json |
scripts 의 start:dev(nest start --watch)·build·start:prod(node dist/main) 세 개를 본다. 9장에서 PM2 가 실행하는 게 start:prod 다. dependencies 에 mysql2·@nestjs/platform-fastify·class-validator 가, devDependencies 에 typescript·jest·@types/* 가 나뉘어 있는 것도 확인한다 |
/Users/me/Desktop/runepluto/runepluto_web/package.json |
프론트 쪽. dev/build/start 가 next 명령이고, 포트를 -p 로 지정한다. 우리 web 은 3100 을 쓸 것이다 |
/Users/me/Desktop/plutosaju/plutosaju_server/src/main.ts |
실제 서버의 시작점. 오늘 만든 http.createServer(...).listen(9100) 과 같은 자리다. NestFactory.create → 미들웨어 등록 → listen 순서를 눈으로만 훑어본다. 3장에서 한 줄씩 해부한다 |
/Users/me/Desktop/runepluto/runepluto_server/package.json |
dependencies 목록을 우리 명세와 비교해 본다. bcrypt(5장), @aws-sdk/client-s3(7장)이 미리 보인다 |
📝 과제
-
5줄 서버를 직접 띄우고 브라우저로 확인.
연습-node/서버.js를 만들어 9100 포트에서hello my-board를 응답한다.
완료 조건: 브라우저http://localhost:9100에 문구가 뜨고,Ctrl + C로 끄면 브라우저가 "연결할 수 없음"을 보여준다. -
POST /posts흉내 내기.
7단계 서버에 분기를 추가한다.req.method === 'POST' && req.url === '/posts'일 때 요청 본문을 읽어posts배열에 넣고 201 과 함께 새 글을 반환한다.
힌트: 본문은 조각으로 온다.let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { const p = JSON.parse(body); /* ... */ });완료 조건:
curl -X POST http://localhost:9100/posts -H 'Content-Type: application/json' -d '{"title":"세번째","content":"테스트"}'가 201 과 새 글을 돌려주고, 이어서curl http://localhost:9100/posts에 3개가 보인다. -
npm ci와npm i의 차이 체감하기.
rm -rf node_modules && time npm ci와rm -rf node_modules && time npm i를 각각 실행해 걸린 시간을 비교하고,package-lock.json을 일부러 지운 뒤npm ci를 돌려 어떤 에러가 나는지 확인한다.
완료 조건: 두 명령의 차이를 학습일지에 두 줄로 적는다. (lock 파일은npm i로 복원한다)
🎯 요약 (3줄)
- Node 는 브라우저 밖에서 JS 를 돌리는 껍데기다.
http.createServer(...).listen(9100)이면 이미 서버이고, NestJS 는 그 위에 얹힌 도구일 뿐이다. package.json은 명세서,package-lock.json은 버전 고정 기록,node_modules는 산출물이다. 개발은npm i, 배포는npm ci.- 서버 코드는 전부 비동기다.
await은async안에서만 쓰고, 독립 작업은Promise.all로 묶고, 에러는 반드시try/catch로 잡는다.
'코딩 시작하기' 카테고리의 다른 글
| git 기초 — 첫 커밋부터 비밀키 보호까지 (0) | 2026.09.22 |
|---|---|
| 터미널 명령어 정리 - 개발자가 진짜 쓰는 20개 (초보자용 치트시트) (0) | 2026.09.20 |
























