Express 운영 가이드 (PostgreSQL 17 · MariaDB 11.4)
Node.js 24 + Express 5 + pnpm 10 + PM2 6 (cluster) 환경에서 PostgreSQL 17 또는 MariaDB 11.4를 사용하는 운영 매뉴얼.
💡 요약 정리
- 런타임: Node.js 24 + Express 5, 프로세스 매니저는 PM2 6 클러스터 모드
- DB: PostgreSQL 17 또는 MariaDB 11.4 선택 사용
- 앱 listen:
127.0.0.1:3000— 외부 직접 접속 차단, nginx 리버스 프록시 경유 - 배포 시 핵심:
ecosystem.config.js와 env 파일은 절대 업로드 금지 - 환경변수:
/etc/[프로젝트]/env에서 관리, 코드에서는process.env로 참조
1. 환경 매니페스트
| 항목 | 값 |
|---|---|
| OS | Ubuntu 24.04 LTS |
| 언어 / 런타임 | Node.js 24 |
| 프레임워크 | Express 5 |
| 패키지 매니저 | pnpm 10 |
| 프로세스 매니저 | PM2 6 (cluster) |
| 앱 listen | 127.0.0.1:3000 |
| 리버스 프록시 | nginx 1.24 |
| 앱 디렉토리 | /opt/[프로젝트] |
| 환경변수 | /etc/[프로젝트]/env |
| 로그 | /var/log/[프로젝트] (PM2 logs) |
| systemd 서비스 | pm2-appuser.service (공용) |
| ecosystem 파일 | /opt/[프로젝트]/ecosystem.config.js |
| 주 코드 | /opt/[프로젝트]/index.js |
DB 매니페스트
| 항목 | PostgreSQL 17 | MariaDB 11.4 |
|---|---|---|
| 인증 | peer auth | unix_socket auth |
| 포트 | 5432 | 3306 |
| 서비스명 | postgresql@17-main | mariadb |
| 드라이버 | pg | mysql2 |
2. 서버 접속
2-1. SSH (root)
ssh root@[아이디].mycafe24.com
PROJECT_NAME=myapp; export PROJECT_NAME
2-2. SFTP / rsync
rsync -avz --exclude='node_modules/' \
./ root@[아이디].mycafe24.com:/opt/$PROJECT_NAME/
ssh root@... "chown -R appuser:appuser /opt/$PROJECT_NAME"
2-3. appuser로 작업 (디버깅)
sudo -u appuser bash
cd /opt/$PROJECT_NAME
pm2 status
pm2 logs $PROJECT_NAME --lines 50
3. 환경 확인
ls -la /opt/$PROJECT_NAME
sudo cat /etc/$PROJECT_NAME/env
sudo systemctl is-active pm2-appuser
sudo -u appuser pm2 status
sudo -u appuser pm2 logs $PROJECT_NAME --lines 50
ss -tlnp | grep 3000
curl -sf http://127.0.0.1:3000/
4. DB 직접 접속
4-1. PostgreSQL 17
sudo -u postgres psql
\l ; \du ; \c [DB명] ; \dt ; \q
4-1'. MariaDB 11.4
sudo mariadb
SHOW DATABASES; USE [DB명]; SHOW TABLES; EXIT;
4-2. 앱 사용자
psql "postgresql://[DB사용자]:[비밀번호]@127.0.0.1:5432/[DB명]"
mariadb -u [DB사용자] -p [DB명]
4-3. 샘플 DDL
PG:
CREATE TABLE items (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
MariaDB:
CREATE TABLE items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
payload JSON,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
5. 코드 배포 워크플로우
표준 시퀀스
sudo -u appuser bash -c "cd /opt/$PROJECT_NAME && git pull && pnpm install"
sudo -u appuser pm2 restart $PROJECT_NAME --update-env
curl -sf http://127.0.0.1:3000/
5-A. 배포 시 위험 회피
⚠️ 가장 중요한 주의사항
자체 코드를 통째로 업로드하면 자동 구성된 DB·보안·PM2 ecosystem 설정이 사라져 서비스가 깨질 수 있습니다.
위험 5종 매트 릭스
| # | 위험 | 증상 | Express 안전 패턴 |
|---|---|---|---|
| 1 | DB 환경변수 참조 깨짐 | HTTP 200은 떠도 모든 DB 쿼리 실패 (undefined / 인증 오류) | process.env.DATABASE_URL 또는 process.env.DB_HOST 등을 코드에서 그대로 참조 |
| 2 | 외부 IP에 listen | 외부에서 :3000 직접 접속 가능 → nginx 우회 → fail2ban·rate limit 무력화 | app.listen(3000, '127.0.0.1') 명시 |
| 3 | 엔트리 포인트 변경 | PM2가 시작 시 파일 못 찾음 ("Cannot find module") | /opt/[프로젝트]/index.js 위치 유지 (ecosystem script) |
| 4 | 의존성 동기화 누락 | "Cannot find module" / 런타임 ReferenceError | 업로드 후 pnpm install --frozen-lockfile |
| 5 | ecosystem.config.js 덮어씀 | env 변수 주입 안 됨 → DB 연결 실패 | ecosystem.config.js 절대 업로드 대상 포함 금지 |
안전한 index.js 패턴
// index.js
const express = require('express');
const app = express();
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = '127.0.0.1'; // ✅ 외부 노출 차단
// DB 클라이언트 (PG 예시)
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.use(express.json());
app.get('/', (req, res) => res.json({ status: 'ok' }));
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ status: 'ok', db: 'ok' });
} catch (e) {
res.status(500).json({ status: 'error', error: e.message });
}
});
app.listen(PORT, HOST, () => console.log(`listening on ${HOST}:${PORT}`));
MariaDB 사용 시
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: parseInt(process.env.DB_PORT || '3306', 10),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
charset: 'utf8mb4',
connectionLimit: 10,
});
절대 업로드 금지
🚫 절대 업로드 금지 파일
-
/opt/[프로젝트]/ecosystem.config.js -
/etc/[프로젝트]/env
3가지 배포 방법 (안전 순서)
| 방법 | 설명 | 적합 |
|---|---|---|
| 방법 1 — 변경 파일만 업로드 | rsync로 .js 파일만 (가장 안전, DB·PM2 설정 안전) | 일상 배포 |
| 방법 2 — 소스 통째 업로드 + 서버 빌드 | node_modules/, dist/ 제외 필수 | 큰 변경, 의존성 추가 |
| 방법 3 — 외부 설정 분 리 | DB 등은 env 파일만, 코드는 자유 | 권장 베이스 |
배포 후 4가지 체크
sudo -u appuser pm2 status # 1. online 상태
ss -tlnp | grep 3000 | grep '127.0.0.1' # 2. 127.0.0.1 listen
curl -sf http://127.0.0.1:3000/ # 3. 헬스 OK
curl -sI https://[도메인]/ # 4. 외부 응답
✅ 배포 후 반드시 확인할 4가지
pm2 status에서 online 상태 확인ss -tlnp에서 127.0.0.1로 listen 중인지 확인curl로 로컬 헬스체크 응답 확인- 외부 도메인으로 HTTPS 응답 확인
5-B. Express 배포 7단계
# 1. 업로드 (node_modules 제외)
# 2. 권한
ssh root@... "chown -R appuser:appuser /opt/$PROJECT_NAME"
# 3. 의존성
sudo -u appuser bash -c "cd /opt/$PROJECT_NAME && pnpm install --frozen-lockfile"
# 4. (Express는 빌드 단계 없음)
# 5. PM2 재시작
sudo -u appuser pm2 restart $PROJECT_NAME --update-env
# 6. PM2 save
sudo -u appuser pm2 save
# 7. 검증
curl -sI https://[도메인]/
sudo -u appuser pm2 logs $PROJECT_NAME --lines 30
자주 만나는 실수
| 실수 | 결과 | 회복 |
|---|---|---|
node_modules/ 통째 업로드 | OS/Node 버전 불일치 | rm -rf node_modules && pnpm install |
app.listen(3000) 호스트 미명시 | 0.0.0.0에 listen → 외부 노출 | app.listen(3000, '127.0.0.1') |
pool 미사용, 매 요청 new Client() | 커넥션 누수 | Pool 사용 + 재사용 |
5-C. 환영 페이지 끄고 내 코드 띄우기 (필독)
설치 직후 도메인으로 접속하면 "서버가 정상 동작 중입니다" 환영 페이지가 표시됩니다. 이는 Nginx가 /var/www/cafe24-welcome/index.html을 우선 서빙하기 때문입니다. 본인 코드의 / 라우트가 보이게 하려면 이 환영 파일 1개만 정리하면 됩니다.
5-C-1. 환영 페이지 끄기
| 방법 | 명령 |
|---|---|
| 방법 A — 삭제 | sudo rm /var/www/cafe24-welcome/index.html |
| 방법 B — 백업 후 비활성 (원복 가능) | sudo mv /var/www/cafe24-welcome/index.html /var/www/cafe24-welcome/index.html.bak |
| 현재 상태 확인 | ls /var/www/cafe24-welcome/ |
5-C-2. 확인
도메인 재접속 또는 curl -sI https://[도메인]/ 결과로 본인 앱이 응답하는지 확인합니다. 캐시가 남으면 시크릿 창 또는 강제 새로고침을 권장합니다.
Tip: 환영 파일을 백업해두면 트러블슈팅 시 "Nginx는 살아있나" 확인용으로 다시 활성화할 수 있습니다.
6. DB 연동 실전 코드
CRUD (PG)
const express = require('express');
const { Pool } = require('pg');
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.use(express.json());
app.get('/items', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, name, payload, created_at FROM items ORDER BY created_at DESC LIMIT 100'
);
res.json(rows);
});
app.post('/items', async (req, res) => {
const { name, payload } = req.body;
const { rows } = await pool.query(
'INSERT INTO items (name, payload) VALUES ($1, $2) RETURNING id',
[name, payload]
);
res.json({ id: rows[0].id });
});
app.listen(3000, '127.0.0.1');
CRUD (MariaDB)
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: '127.0.0.1', port: 3306,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
charset: 'utf8mb4',
connectionLimit: 10,
});
app.get('/items', async (req, res) => {
const [rows] = await pool.execute(
'SELECT id, name, payload, created_at FROM items ORDER BY created_at DESC LIMIT 100'
);
res.json(rows);
});
app.post('/items', async (req, res) => {
const { name, payload } = req.body;
const [r] = await pool.execute(
'INSERT INTO items (name, payload) VALUES (?, ?)',
[name, JSON.stringify(payload)]
);
res.json({ id: r.insertId });
});
7. 정적 파일 / 미디어
// app.use(express.static('public')); // 작은 사이트만
큰 트래픽이면 nginx 직접 서빙:
location /static/ {
alias /opt/[프로젝트]/public/;
expires 7d;
}
8. 로그 / 모니터링
sudo -u appuser pm2 logs $PROJECT_NAME
sudo -u appuser pm2 monit
sudo tail -f /var/log/[프로젝트]/out.log
sudo tail -f /var/log/[프로젝트]/error.log
sudo tail -f /var/log/nginx/[프로젝트]_access.log
logrotate
sudo -u appuser pm2 install pm2-logrotate
sudo -u appuser pm2 set pm2-logrotate:max_size 50M
sudo -u appuser pm2 set pm2-logrotate:retain 30
📋 로그 관리 참고
- PM2 로그는
/var/log/[프로젝트]경로에 저장됩니다. pm2-logrotate를 설치하면 로그 파일이 자동으로 로테이션됩니다.max_size를 50M,retain을 30으로 설정하면 50MB 초과 시 로테이션되며 최근 30개 파일이 유지됩니다.
9. HTTPS / 도메인
sudo certbot --nginx -d example.com -d www.example.com
10. 트러블슈팅 + FAQ
트러블슈팅 매트릭스
| 증상 | 원인 | 해결 |
|---|---|---|
| HTTP 502 | Express 다운 | pm2 status + pm2 logs |
ECONNREFUSED (DB) | DB 미기동 | systemctl status postgresql@17-main 또는 mariadb |
| 메모리 누수 | pool 미사용 / closure 캐시 | max_memory_restart 임시 대처 + 코드 점검 |
EADDRINUSE :3000 | 포트 점유 | pm2 list → 중복 앱 정리 |
| env 변경 미반영 | --update-env 누락 | pm2 restart $PROJECT_NAME --update-env |
FAQ
Q. cluster 모드에서 메모리 공유 안 됨
정상입니다. cluster 워커는 독립 프로 세스이므로, Redis 등 외부 캐시로 공유해야 합니다.
Q. WebSocket 사용 가능한가요?
가능합니다. socket.io + nginx upgrade 헤더 설정이 필요합니다. 자세한 내용은 공통 운영 가이드의 nginx 레시피를 참조하세요.
async 핸들러 native 지원으로, router.get('/', async (req, res) => {...}) 안에서 throw하면 automatic next(err)가 동작합니다. 다른 변경 사항은 Express 5 마이그레이션 가이드를 참조하세요.
12. 추가 운영 / 커스터마이징
추가적인 운영 및 커스터마이징이 필요한 경우, 공통 운영·커스터마이징 가이드와 FAQ·트러블슈팅 문서를 참조하세요.
💬 추가 도움이 필요하신가요?
운영 중 해결되지 않는 문제가 있다면 1:1 문의게시판을 통해 문의해 주세요.