웹 서버 기초 (#3. Express.js로 만드는 Pokemon Dashboard) - END -
2025-12-08
이 가이드는 기존 TypeScript 프로젝트를
Express.js5.0 웹 서버로 변환하는 과정을 단계별로 설명합니다.
src/
├── index.ts # Express.js 메인 서버
├── routes/
│ └── api.ts # API 라우터
└── __tests__/ # 테스트 파일들
express: Express.js >= 5.0 프레임워크tsx: 개발 시 자동 재시작 도구cors: Cross-Origin Resource Sharing 미들웨어@types/express: Express.js TypeScript 타입 정의@types/cors: CORS 미들웨어 타입 정의start: 프로덕션 환경에서 서버 실행dev: 개발 환경에서 nodemon으로 서버 실행// src/index.ts
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import apiRoutes from './routes/api.js';
const app = express();
const PORT = process.env.PORT || 3000;
// 미들웨어 설정
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 로깅 미들웨어
app.use((req: Request, res: Response, next: NextFunction) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
// 기본 라우트
app.get('/', (req: Request, res: Response) => {
res.json({
message: 'Express.js 5.0 with TypeScript 서버가 실행 중입니다!',
version: '1.0.0',
timestamp: new Date().toISOString(),
endpoints: {
api: '/api',
health: '/api/health'
}
});
});
// 라우터 설정
app.use('/api', apiRoutes);
// 에러 핸들링 미들웨어
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error('에러 발생:', err);
res.status(500).json({
error: '서버 내부 오류가 발생했습니다.',
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
});
// 404 핸들러
app.use((req: Request, res: Response) => {
res.status(404).json({
error: '요청한 리소스를 찾을 수 없습니다.',
path: req.originalUrl
});
});
// 서버 시작
app.listen(PORT, () => {
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`);
console.log(`http://localhost:${PORT}`);
});
export default app;// src/routes/api.ts
import { Router, Request, Response } from 'express';
const router = Router();
// 헬스 체크
router.get('/health', (req: Request, res: Response) => {
res.json({
status: 'OK',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development'
});
});
// API 정보
router.get('/info', (req: Request, res: Response) => {
res.json({
name: 'Express.js 5.0 TypeScript API',
version: '1.0.0',
description: 'TypeScript 기반 Express.js 5.0 API 서버',
endpoints: {
health: '/api/health',
info: '/api/info'
}
});
});
export default router;GET / - 서버 정보GET /api/health - 헬스 체크GET /api/info - API 정보Postman 등을 활용해서 테스트 가능
npm install -D vitest vite-tsconfig-paths supertest @types/supertest
import request from 'supertest';
import app from '../index.js';
describe('API Health Endpoint', () => {
describe('GET /api/health', () => {
it('should return health status with correct structure', async () => {
const response = await request(app)
.get('/api/health')
.expect(200);
// status가 'OK'인지 확인
expect(response.body.status).toBe('OK');
// 필수 필드들이 존재하는지 확인
expect(response.body).toHaveProperty('status');
expect(response.body).toHaveProperty('uptime');
expect(response.body).toHaveProperty('timestamp');
expect(response.body).toHaveProperty('environment');
});
it('should return 200 status code', async () => {
await request(app)
.get('/api/health')
.expect(200);
});
it('should have correct content type', async () => {
const response = await request(app)
.get('/api/health')
.expect(200);
expect(response.headers['content-type']).toMatch(/application\/json/);
});
});
});import sqlite3 from 'sqlite3';
import path from 'path';
export class DatabaseConnection {
// 속성
private db: sqlite3.Database | null = null;
private dbPath: string;
constructor(dbPath: string = 'db/pokedex.db') {
this.dbPath = path.resolve(dbPath);
}
// 데이터베이스 연결
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
console.error('데이터베이스 연결 실패:', err);
reject(err);
} else {
console.log(`데이터베이스 연결 성공: ${this.dbPath}`);
resolve();
}
});
});
}
// 데이터베이스 연결 해제
async disconnect(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.db) {
this.db.close((err) => {
if (err) {
console.error('데이터베이스 연결 해제 실패:', err);
reject(err);
} else {
console.log('데이터베이스 연결 해제 성공');
this.db = null;
resolve();
}
});
} else {
resolve();
}
});
}
// 쿼리 실행 (SELECT)
async query(sql: string, params: any[] = []): Promise<any[]> {
if (!this.db) {
throw new Error('데이터베이스가 연결되지 않았습니다.');
}
return new Promise((resolve, reject) => {
this.db!.all(sql, params, (err, rows) => {
if (err) {
console.error('쿼리 실행 실패:', err);
reject(err);
} else {
resolve(rows);
}
});
});
}
// 단일 행 조회
async get(sql: string, params: any[] = []): Promise<any> {
if (!this.db) {
throw new Error('데이터베이스가 연결되지 않았습니다.');
}
return new Promise((resolve, reject) => {
this.db!.get(sql, params, (err, row) => {
if (err) {
console.error('쿼리 실행 실패:', err);
reject(err);
} else {
resolve(row);
}
});
});
}
isConnected(): boolean {
return this.db !== null;
}
}
let dbInstance: DatabaseConnection | null = null;
export const getDatabase = (): DatabaseConnection => {
if (!dbInstance) {
dbInstance = new DatabaseConnection();
}
return dbInstance;
};
export default DatabaseConnection;import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import apiRoutes from './routes/api';
import { getDatabase } from './database/connection';
const app = express();
const PORT = process.env.PORT || 3000;
// 미들웨어 설정
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 로깅 미들웨어
app.use((req: Request, _res: Response, next: NextFunction) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
// 기본 라우트
app.get('/', (_req: Request, res: Response) => {
res.json({
message: 'Express.js 5.0 with TypeScript 서버가 실행 중입니다!',
version: '1.0.0',
timestamp: new Date().toISOString(),
endpoints: {
api: '/api',
health: '/api/health',
},
});
});
// 라우터 설정
app.use('/api', apiRoutes);
// 에러 핸들링 미들웨어
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error('에러 발생:', err);
res.status(500).json({
error: '서버 내부 오류가 발생했습니다.',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
});
});
// 404 핸들러
app.use((req: Request, res: Response) => {
res.status(404).json({
error: '요청한 리소스를 찾을 수 없습니다.',
path: req.originalUrl,
});
});
// index.ts
// 데이터베이스 연결 및 서버 시작
async function startServer() {
try {
await getDatabase().connect();
app.listen(PORT, () => {
console.log(`http://localhost:${PORT} 에서 실행 중입니다.`);
});
} catch (error) {
console.error('서버 시작 실패:', error);
process.exit(1);
}
}
// index.ts
// 서버 종료
process.on('SIGINT', async () => {
console.log('\n서버를 종료합니다...');
try {
await getDatabase().disconnect();
console.log('데이터베이스 연결이 해제되었습니다.');
process.exit(0);
} catch (error) {
console.error('데이터베이스 연결 해제 실패:', error);
process.exit(1);
}
});
startServer();
export default app;import { DatabaseConnection } from '../database/connection';
export interface Pokemon {
id: number;
pokedex_number: string;
name: string;
description: string;
types: string;
height: number;
category: string;
weight: number;
gender: string;
abilities: string;
}
export interface PokemonQuery {
page?: number;
limit?: number;
search?: string;
type?: string;
sortBy?: 'id' | 'name' | 'pokedex_number';
sortOrder?: 'ASC' | 'DESC';
}
export class PokemonService {
private db: DatabaseConnection;
constructor(database: DatabaseConnection) {
this.db = database;
}
async getAllPokemon(query: PokemonQuery = {}): Promise<{ pokemon: Pokemon[]; total: number; page: number; limit: number }> {
const {
page = 1,
limit = 20,
search = '',
type = '',
sortBy = 'id',
sortOrder = 'ASC'
} = query;
const offset = (page - 1) * limit;
// WHERE 조건 구성
let whereConditions: string[] = [];
let params: any[] = [];
if (search) {
whereConditions.push('(name LIKE ? OR description LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (type) {
whereConditions.push('types LIKE ?');
params.push(`%${type}%`);
}
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
// 전체 개수 조회
const countSql = `SELECT COUNT(*) as total FROM pokemon ${whereClause}`;
const countResult = await this.db.get(countSql, params);
const total = countResult.total;
// 데이터 조회
const sql = `
SELECT id, pokedex_number, name, description, types, height, category, weight, gender, abilities
FROM pokemon
${whereClause}
ORDER BY ${sortBy} ${sortOrder}
LIMIT ? OFFSET ?
`;
const pokemon = await this.db.query(sql, [...params, limit, offset]);
return {
pokemon,
total,
page,
limit
};
}
}
export default PokemonService;import { Router, Request, Response } from 'express';
import { PokemonService, PokemonQuery } from '../services/pokemonService';
import { getDatabase } from '../database/connection';
const router = Router();
const pokemonService = new PokemonService(getDatabase());
router.get('/', async (req: Request, res: Response) => {
try {
const query: PokemonQuery = {
page: req.query.page ? parseInt(req.query.page as string) : undefined,
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
search: req.query.search as string,
type: req.query.type as string,
sortBy: req.query.sortBy as 'id' | 'name' | 'pokedex_number',
sortOrder: req.query.sortOrder as 'ASC' | 'DESC'
};
const result = await pokemonService.getAllPokemon(query);
res.json({
success: true,
data: result.pokemon,
pagination: {
page: result.page,
limit: result.limit,
total: result.total,
totalPages: Math.ceil(result.total / result.limit)
}
});
} catch (error) {
console.error('포켓몬 목록 조회 실패:', error);
res.status(500).json({
success: false,
error: '포켓몬 목록을 조회하는 중 오류가 발생했습니다.'
});
}
});
export default router;import { Router, Request, Response } from 'express';
import pokemonRoutes from './pokemon';
const router = Router();
router.use('/pokemon', pokemonRoutes);
// 헬스 체크
router.get('/health', (_req: Request, res: Response) => {
res.json({
status: 'OK',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development',
});
});
// API 정보
router.get('/info', (_req: Request, res: Response) => {
res.json({
name: 'Express.js 5.0 TypeScript API',
version: '1.0.0',
description: 'TypeScript 기반 Express.js 5.0 API 서버',
endpoints: {
health: '/api/health',
info: '/api/info',
},
});
});
export default router;doctype html
html(lang="ko")
head
meta(charset="UTF-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
title= title || '포켓몬 API'
link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css")
link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css")
style.
.pokemon-card {
transition: transform 0.2s;
}
.pokemon-card:hover {
transform: translateY(-5px);
}
.type-badge {
font-size: 0.8em;
}
.equal-height-card {
display: flex;
flex-direction: column;
height: 100%;
}
.equal-height-card .card-body {
flex: 1;
display: flex;
flex-direction: column;
}
.equal-height-card .table-responsive {
flex: 1;
overflow-y: auto;
}
body
nav.navbar.navbar-expand-lg.navbar-dark.bg-primary
.container
a.navbar-brand(href="/")
i.fas.fa-dragon.me-2
| 포켓몬 API
.navbar-nav
a.nav-link(href="/") 홈
a.nav-link(href="/pokemon") 포켓몬 목록
a.nav-link(href="/pokemon/stats") 통계
a.nav-link(href="/docs") API 문서
a.nav-link(href="/health") 시스템 상태
main.container.mt-4
block content
footer.bg-light.mt-5.py-4
.container.text-center
p.text-muted © 2025 포켓몬 API. 모든 포켓몬 데이터는 교육 목적으로만 사용됩니다.
script(src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js")
script(src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js")
script(src="https://cdn.jsdelivr.net/npm/chart.js")
block scriptsextends layout
block content
.row
.col-12
.jumbotron.bg-primary.text-white.p-5.rounded.mb-4
h1.display-4
i.fas.fa-dragon.me-3
| 포켓몬 API
p.lead 포켓몬 데이터를 조회하고 탐색할 수 있는 웹 인터페이스입니다.
a.btn.btn-light.btn-lg(href="/pokemon", role="button")
i.fas.fa-list.me-2
| 포켓몬 목록 보기
.row
.col-md-3.mb-4
.card.h-100
.card-body.text-center
i.fas.fa-search.fa-3x.text-primary.mb-3
h5.card-title 포켓몬 검색
p.card-text 이름, 타입, 카테고리로 포켓몬을 검색할 수 있습니다.
a.btn.btn-primary(href="/pokemon") 검색하기
.col-md-3.mb-4
.card.h-100
.card-body.text-center
i.fas.fa-chart-bar.fa-3x.text-success.mb-3
h5.card-title 통계 조회
p.card-text 포켓몬의 타입별, 카테고리별 통계를 확인할 수 있습니다.
a.btn.btn-success(href="/pokemon/stats") 통계 보기
.col-md-3.mb-4
.card.h-100
.card-body.text-center
i.fas.fa-code.fa-3x.text-info.mb-3
h5.card-title API 문서
p.card-text RESTful API를 통해 프로그래밍 방식으로 데이터에 접근할 수 있습니다.
a.btn.btn-info(href="/docs") API 정보
.col-md-3.mb-4
.card.h-100
.card-body.text-center
i.fas.fa-heartbeat.fa-3x.text-success.mb-3
h5.card-title 시스템 상태
p.card-text 서버 상태, 가동 시간, 메모리 사용량 등 시스템 정보를 확인할 수 있습니다.
a.btn.btn-success(href="/health") API 상태
.row.mt-5
.col-12
.card
.card-header
h5.mb-0
i.fas.fa-info-circle.me-2
| API 정보
.card-body
.row
.col-md-6
h6 API 엔드포인트
ul.list-unstyled
li
code GET /api/pokemon
| - 포켓몬 목록 조회
li
code GET /api/pokemon/:id
| - 특정 포켓몬 조회
li
code GET /api/pokemon/search/name/:name
| - 이름으로 검색
li
code GET /api/pokemon/type/:type
| - 타입별 조회
li
code GET /api/pokemon/stats/overview
| - 통계 조회
.col-md-6
h6 사용 가능한 파라미터
ul.list-unstyled
li
strong page
| - 페이지 번호
li
strong limit
| - 페이지당 항목 수
li
strong search
| - 검색어
li
strong type
| - 포켓몬 타입
li
strong sortBy
| - 정렬 기준
li
strong sortOrder
| - 정렬 순서 (ASC/DESC)
block scripts
script.
// API 상태 확인
async function checkApiStatus() {
try {
const response = await axios.get('/api/health');
console.log('API 상태:', response.data);
} catch (error) {
console.error('API 상태 확인 실패:', error);
}
}
// 페이지 로드 시 API 상태 확인
document.addEventListener('DOMContentLoaded', checkApiStatus);import { Router, Request, Response } from 'express';
import { PokemonService } from '../services/pokemonService';
import { getDatabase } from '../database/connection';
const router = Router();
const pokemonService = new PokemonService(getDatabase());
// 홈페이지
router.get('/', (_req: Request, res: Response) => {
res.render('index', {
title: '포켓몬 API - 홈'
});
});
// 포켓몬 목록 페이지
router.get('/pokemon', async (req: Request, res: Response) => {
try {
const query = {
page: req.query.page ? parseInt(req.query.page as string) : 1,
limit: req.query.limit ? parseInt(req.query.limit as string) : 20,
search: req.query.search as string,
type: req.query.type as string,
sortBy: req.query.sortBy as 'id' | 'name' | 'pokedex_number',
sortOrder: req.query.sortOrder as 'ASC' | 'DESC'
};
const result = await pokemonService.getAllPokemon(query);
res.render('pokemon', {
title: '포켓몬 목록',
pokemon: result.pokemon,
pagination: {
page: result.page,
limit: result.limit,
total: result.total,
totalPages: Math.ceil(result.total / result.limit)
},
query: query
});
} catch (error) {
console.error('포켓몬 목록 조회 실패:', error);
res.status(500).render('error', {
title: '서버 오류',
error: '포켓몬 목록을 불러오는 중 오류가 발생했습니다.'
});
}
});
export default router;import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import apiRoutes from './routes/api';
import webRoutes from './routes/web';
import { getDatabase } from './database/connection';
const app = express();
const PORT = process.env.PORT || 3000;
// PUG 템플릿 엔진 설정
app.set('view engine', 'pug');
app.set('views', './views');
// 미들웨어 설정
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 로깅 미들웨어
app.use((req: Request, _res: Response, next: NextFunction) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
// 기본 라우트 - PUG 템플릿으로 홈페이지 렌더링
app.get('/', (_req: Request, res: Response) => {
res.render('index', {
title: '포켓몬 API - 홈'
});
});
// 라우터 설정
app.use('/api', apiRoutes);
app.use('/', webRoutes);
// 에러 핸들링 미들웨어
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error('에러 발생:', err);
res.status(500).json({
error: '서버 내부 오류가 발생했습니다.',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
});
});
// 404 핸들러
app.use((req: Request, res: Response) => {
res.status(404).json({
error: '요청한 리소스를 찾을 수 없습니다.',
path: req.originalUrl,
});
});
// 데이터베이스 연결 및 서버 시작
async function startServer() {
try {
await getDatabase().connect();
app.listen(PORT, () => {
console.log(`http://localhost:${PORT} 에서 실행 중입니다.`);
});
} catch (error) {
console.error('서버 시작 실패:', error);
process.exit(1);
}
}
// 서버 종료
process.on('SIGINT', async () => {
console.log('\n서버를 종료합니다...');
try {
await getDatabase().disconnect();
console.log('데이터베이스 연결이 해제되었습니다.');
process.exit(0);
} catch (error) {
console.error('데이터베이스 연결 해제 실패:', error);
process.exit(1);
}
});
startServer();
export default app;