이미지 로딩 중...
AI Generated
2025. 11. 11. · 3 Views
타입스크립트로 Express REST API 서버 구축하기
비트코인 클론 프로젝트를 통해 TypeScript와 Express로 안전하고 확장 가능한 REST API 서버를 구축하는 방법을 배웁니다. 라우팅, 미들웨어, 에러 핸들링, 타입 안정성까지 실무에 필요한 모든 것을 다룹니다.
목차
- Express 서버 초기 설정 - TypeScript 환경 구축
- REST API 라우터 구조 설계 - 모듈화된 엔드포인트 관리
- Request/Response 타입 정의 - 타입 안전한 API 통신
- 에러 핸들링 미들웨어 - 통합된 에러 처리
- 비동기 라우트 핸들러 - async/await 패턴
- 미들웨어 체이닝 - 요청 전처리 파이프라인
- 입력 검증 - 안전한 데이터 처리
- CORS 설정 - 안전한 크로스 오리진 요청
- 환경 변수 관리 - 안전한 설정 관리
- 헬스 체크 엔드포인트 - 서버 상태 모니터링
1. Express 서버 초기 설정 - TypeScript 환경 구축
시작하며
여러분이 Node.js로 백엔드를 만들려고 할 때, JavaScript로 작성하다가 런타임 에러 때문에 디버깅에 몇 시간씩 소비한 적 있나요? 특히 API 요청의 데이터 타입이 예상과 다를 때, 서버가 갑자기 크래시되는 상황은 정말 답답합니다.
이런 문제는 특히 팀 프로젝트나 규모가 큰 서비스에서 치명적입니다. 한 명이 작성한 코드를 다른 팀원이 사용할 때, 어떤 타입의 데이터를 전달해야 하는지 매번 코드를 읽어봐야 하고, 실수로 잘못된 타입을 전달하면 프로덕션 환경에서 에러가 발생합니다.
바로 이럴 때 필요한 것이 TypeScript와 Express를 결합한 서버 구축입니다. 개발 단계에서 타입 에러를 미리 잡아내고, IDE의 자동완성 기능으로 생산성을 높이며, 코드의 안정성을 크게 향상시킬 수 있습니다.
개요
간단히 말해서, TypeScript Express 서버는 타입 안정성을 갖춘 Node.js 백엔드 애플리케이션입니다. Express는 Node.js에서 가장 인기 있는 웹 프레임워크지만, 기본적으로 JavaScript로 작성되기 때문에 타입 안정성이 없습니다.
TypeScript를 도입하면 요청/응답 객체의 타입, 미들웨어의 파라미터, 비즈니스 로직의 데이터 구조를 모두 타입으로 정의할 수 있습니다. 예를 들어, 사용자 인증 API를 만들 때 req.body에 어떤 필드가 있어야 하는지 타입으로 명확히 정의하면, 잘못된 요청이 들어왔을 때 컴파일 단계에서 미리 발견할 수 있습니다.
기존에는 JavaScript로 Express 서버를 만들고 JSDoc 주석으로 타입을 표현했다면, 이제는 TypeScript의 강력한 타입 시스템으로 런타임 에러를 사전에 방지할 수 있습니다. TypeScript Express 서버의 핵심 특징은 첫째, 컴파일 타임 타입 체크로 에러를 조기에 발견하고, 둘째, IDE 자동완성으로 개발 속도를 높이며, 셋째, 인터페이스와 타입 정의로 코드 문서화가 자동으로 이루어진다는 점입니다.
이러한 특징들이 대규모 프로젝트에서 코드 품질과 유지보수성을 크게 향상시킵니다.
코드 예제
// package.json 설정
{
"scripts": {
"dev": "ts-node-dev --respawn src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}
// tsconfig.json - TypeScript 컴파일 설정
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
// src/server.ts - 기본 서버 설정
import express, { Application } from 'express';
const app: Application = express();
const PORT = process.env.PORT || 3000;
app.use(express.json()); // JSON 파싱 미들웨어
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
설명
이것이 하는 일: TypeScript 환경에서 Express 서버를 실행할 수 있도록 개발 환경을 구축하고, 타입 안정성을 확보하는 기본 설정을 수행합니다. 첫 번째로, package.json의 scripts 섹션은 개발과 프로덕션 환경을 구분합니다.
dev 스크립트는 ts-node-dev를 사용하여 TypeScript 파일을 직접 실행하고, 파일이 변경될 때마다 자동으로 서버를 재시작합니다. --respawn 옵션 덕분에 코드를 수정할 때마다 수동으로 서버를 재시작할 필요가 없어 개발 생산성이 크게 향상됩니다.
그 다음으로, tsconfig.json이 TypeScript 컴파일러의 동작을 정의합니다. strict: true는 가장 중요한 설정으로, 모든 엄격한 타입 체크를 활성화합니다.
이는 null/undefined 체크, 암시적 any 타입 금지, 함수 파라미터의 정확한 타입 매칭 등을 강제하여 런타임 에러를 최소화합니다. outDir과 rootDir 설정은 소스 코드와 컴파일된 JavaScript를 분리하여 프로젝트 구조를 깔끔하게 유지합니다.
마지막으로, server.ts 파일은 Express 애플리케이션의 진입점입니다. Application 타입을 명시하여 app 변수가 Express 앱임을 타입스크립트에 알려주고, 이후 모든 메서드 호출에서 타입 체크와 자동완성을 받을 수 있습니다.
express.json() 미들웨어는 들어오는 요청의 JSON 데이터를 자동으로 파싱하여 req.body에 객체로 만들어줍니다. 여러분이 이 코드를 사용하면 개발 중에는 실시간 리로드로 빠르게 개발하고, 프로덕션에서는 최적화된 JavaScript로 실행하며, 전체 과정에서 타입 안정성을 보장받을 수 있습니다.
IDE에서 자동완성과 타입 힌트를 제공받아 개발 속도가 빨라지고, 잘못된 타입 사용은 컴파일 단계에서 즉시 발견됩니다.
실전 팁
💡 ts-node-dev 대신 nodemon과 ts-node 조합도 가능하지만, ts-node-dev가 더 빠른 재시작 속도를 제공하므로 개발 환경에서는 ts-node-dev를 추천합니다
💡 tsconfig.json에서 strictNullChecks를 true로 설정하면 null/undefined 관련 버그를 사전에 잡을 수 있어 특히 데이터베이스 쿼리 결과 처리 시 유용합니다
💡 .env 파일로 환경 변수를 관리하고 dotenv 패키지를 사용하면 개발/스테이징/프로덕션 환경을 쉽게 분리할 수 있습니다
💡 esModuleInterop을 true로 설정하면 CommonJS 모듈을 ES6 import 문법으로 가져올 때 발생하는 호환성 문제를 해결할 수 있습니다
💡 소스맵을 활성화하려면 tsconfig.json에 "sourceMap": true를 추가하여 디버깅 시 원본 TypeScript 코드를 볼 수 있습니다
2. REST API 라우터 구조 설계 - 모듈화된 엔드포인트 관리
시작하며
여러분이 API 엔드포인트가 10개, 20개로 늘어나면서 server.ts 파일이 수백 줄로 커진 경험이 있나요? 모든 라우트가 하나의 파일에 뭉쳐있으면, 특정 API를 찾기도 어렵고, 여러 명이 동시에 작업할 때 Git 충돌도 자주 발생합니다.
이런 문제는 프로젝트가 성장할수록 심각해집니다. 블록체인 API, 지갑 API, 트랜잭션 API가 모두 한 파일에 있으면 코드 리뷰도 힘들고, 새로운 팀원이 합류했을 때 코드 구조를 파악하는 데만 며칠이 걸립니다.
특정 기능을 수정할 때 관련 없는 코드까지 건드릴 위험도 커집니다. 바로 이럴 때 필요한 것이 Express Router를 활용한 모듈화된 라우터 구조입니다.
도메인별로 라우터를 분리하면 코드를 찾기 쉽고, 유지보수가 간편하며, 팀 협업도 원활해집니다.
개요
간단히 말해서, Express Router는 관련된 라우트들을 그룹화하여 독립적인 모듈로 관리할 수 있게 해주는 미니 애플리케이션입니다. Express의 Router는 일종의 미들웨어 컨테이너로, 특정 경로 prefix 아래의 모든 라우트를 담당합니다.
예를 들어, /api/blockchain으로 시작하는 모든 엔드포인트를 하나의 라우터 파일로 분리하면, 블록체인 관련 로직만 독립적으로 관리할 수 있습니다. 이는 마치 큰 조직을 부서별로 나누는 것과 같아서, 각 부서(라우터)가 자신의 책임을 명확히 가지게 됩니다.
기존에는 모든 app.get(), app.post()를 메인 파일에 작성했다면, 이제는 도메인별 라우터 파일을 만들고 app.use()로 연결하여 관심사를 분리할 수 있습니다. 라우터 구조의 핵심 특징은 첫째, 코드의 응집도를 높여 관련 기능을 한 곳에 모으고, 둘째, 경로 prefix를 중앙에서 관리하여 URL 구조를 쉽게 변경할 수 있으며, 셋째, 각 라우터에 독립적인 미들웨어를 적용할 수 있다는 점입니다.
이러한 특징들이 대규모 API 서버를 체계적으로 관리할 수 있게 만듭니다.
코드 예제
// src/routes/blockchain.routes.ts
import { Router, Request, Response } from 'express';
const router = Router();
// GET /api/blockchain/blocks - 모든 블록 조회
router.get('/blocks', (req: Request, res: Response) => {
// 블록체인에서 모든 블록 가져오기
res.json({ blocks: [] });
});
// POST /api/blockchain/mine - 새 블록 채굴
router.post('/mine', (req: Request, res: Response) => {
const { data } = req.body;
// 새 블록 채굴 로직
res.json({ message: 'Block mined successfully' });
});
export default router;
// src/server.ts - 라우터 연결
import express from 'express';
import blockchainRoutes from './routes/blockchain.routes';
const app = express();
app.use(express.json());
// 라우터를 /api/blockchain 경로에 마운트
app.use('/api/blockchain', blockchainRoutes);
설명
이것이 하는 일: 블록체인 관련 API 엔드포인트를 독립적인 라우터 모듈로 분리하여, 메인 서버 파일과 분리된 구조로 관리합니다. 첫 번째로, blockchain.routes.ts 파일은 블록체인 도메인의 모든 엔드포인트를 담당하는 전용 라우터를 생성합니다.
Router() 생성자로 만든 라우터 인스턴스는 독립적인 라우팅 시스템으로 작동하며, 이 라우터에 정의된 경로는 나중에 메인 앱에 마운트될 때의 prefix를 기준으로 상대 경로로 작동합니다. 예를 들어, router.get('/blocks')는 실제로는 /api/blockchain/blocks로 접근됩니다.
그 다음으로, 각 라우트 핸들러는 Request와 Response 타입을 명시하여 타입 안정성을 확보합니다. req.body, req.params, req.query 등에 접근할 때 TypeScript가 자동완성을 제공하고, 잘못된 속성 접근을 컴파일 시점에 잡아냅니다.
POST /mine 엔드포인트처럼 요청 바디에서 데이터를 받을 때, 나중에 커스텀 타입을 정의하면 더욱 엄격한 타입 체크가 가능합니다. 마지막으로, server.ts에서 app.use('/api/blockchain', blockchainRoutes)로 라우터를 마운트합니다.
이는 blockchainRoutes의 모든 경로 앞에 /api/blockchain을 자동으로 붙여주는 것으로, 라우터 파일 내부에서는 /blocks, /mine처럼 짧은 경로만 작성하면 됩니다. 나중에 API 버전을 바꾸거나 경로 구조를 변경할 때 이 한 줄만 수정하면 되므로 유연성이 매우 높습니다.
여러분이 이 구조를 사용하면 블록체인, 지갑, 트랜잭션 등 도메인별로 라우터를 분리하여 각 파일이 단일 책임을 가지게 되고, 팀원들이 각자 다른 라우터 파일에서 작업하여 Git 충돌을 최소화할 수 있습니다. 새로운 기능을 추가할 때도 해당 도메인의 라우터 파일만 열면 되므로 인지 부하가 줄어듭니다.
실전 팁
💡 라우터 파일 이름은 *.routes.ts 또는 *.router.ts 컨벤션을 사용하면 프로젝트 구조를 파악하기 쉽고, IDE에서 파일을 검색할 때도 편리합니다
💡 복잡한 라우터는 routes/blockchain/index.ts를 만들고 하위에 blocks.ts, mining.ts 등으로 더 세분화할 수 있습니다
💡 router.route('/blocks').get(getBlocks).post(createBlock) 체이닝 문법을 사용하면 같은 경로의 다른 HTTP 메서드를 깔끔하게 정의할 수 있습니다
💡 각 라우터에 특화된 미들웨어(인증, 로깅 등)를 router.use()로 적용하면 해당 라우터의 모든 엔드포인트에 자동 적용됩니다
💡 라우터 파일이 너무 커지면 컨트롤러 패턴을 도입하여 라우터는 경로만 정의하고 실제 로직은 컨트롤러 함수로 분리하세요
3. Request/Response 타입 정의 - 타입 안전한 API 통신
시작하며
여러분이 API 엔드포인트를 만들었는데, 클라이언트가 보낸 요청 데이터의 필드 이름을 잘못 입력하거나, 필수 필드를 빠뜨려서 서버가 에러를 뱉은 경험이 있나요? req.body.usrname처럼 오타를 내도 런타임에 가서야 문제를 발견하게 되고, 이미 프로덕션에 배포된 후라면 사용자들이 에러를 겪게 됩니다.
이런 문제는 특히 프론트엔드와 백엔드 개발자가 다를 때 더 빈번합니다. API 스펙 문서를 만들어도 실제 코드와 동기화가 안 되고, Postman 테스트는 통과했는데 실제 프론트 통합에서 데이터 형식이 달라 문제가 생기는 경우도 많습니다.
또한 응답 데이터의 구조가 바뀌었을 때 어디서 영향을 받는지 추적하기 어렵습니다. 바로 이럴 때 필요한 것이 Request와 Response의 명확한 타입 정의입니다.
TypeScript 인터페이스로 API 계약을 코드로 표현하면, 컴파일 시점에 잘못된 사용을 잡아내고, 타입 정의 자체가 살아있는 문서가 됩니다.
개요
간단히 말해서, Request/Response 타입 정의는 API의 입력과 출력 데이터 구조를 TypeScript 타입으로 명확히 선언하는 것입니다. Express의 Request와 Response는 제네릭 타입을 지원하여, req.body, req.params, req.query의 구조를 타입으로 지정할 수 있습니다.
예를 들어, 트랜잭션 생성 API에서 req.body에 from, to, amount 필드가 반드시 있어야 한다면, 이를 인터페이스로 정의하고 Request<{}, {}, TransactionBody> 처럼 타입을 지정합니다. 이렇게 하면 req.body.from에 접근할 때 자동완성이 되고, 존재하지 않는 필드에 접근하면 컴파일 에러가 발생합니다.
기존에는 req.body를 any 타입으로 사용하거나 런타임에 수동으로 검증했다면, 이제는 타입 시스템이 자동으로 검증하고 IDE가 개발 중에 즉시 피드백을 제공합니다. 타입 정의의 핵심 특징은 첫째, API 계약을 코드로 표현하여 문서와 구현의 불일치를 방지하고, 둘째, 리팩토링 시 영향받는 모든 부분을 컴파일러가 찾아주며, 셋째, 프론트엔드와 타입을 공유하면 완벽한 타입 안정성을 얻을 수 있다는 점입니다.
이러한 특징들이 API 개발의 안정성과 생산성을 동시에 높입니다.
코드 예제
// src/types/transaction.types.ts
export interface CreateTransactionRequest {
from: string; // 송신자 주소
to: string; // 수신자 주소
amount: number; // 전송 금액
privateKey: string; // 서명용 개인키
}
export interface TransactionResponse {
id: string;
from: string;
to: string;
amount: number;
timestamp: number;
signature: string;
}
// src/routes/transaction.routes.ts
import { Router, Request, Response } from 'express';
import { CreateTransactionRequest, TransactionResponse } from '../types/transaction.types';
const router = Router();
// 타입이 지정된 요청 핸들러
router.post('/create',
(req: Request<{}, {}, CreateTransactionRequest>,
res: Response<TransactionResponse>) => {
const { from, to, amount, privateKey } = req.body; // 타입 안전한 구조분해
// 트랜잭션 생성 로직
const transaction: TransactionResponse = {
id: generateId(),
from,
to,
amount,
timestamp: Date.now(),
signature: signTransaction(from, to, amount, privateKey)
};
res.json(transaction); // Response<TransactionResponse> 타입 체크
});
export default router;
설명
이것이 하는 일: API 엔드포인트의 입력 데이터와 출력 데이터 구조를 TypeScript 타입으로 엄격하게 정의하여, 잘못된 데이터 접근을 컴파일 시점에 방지합니다. 첫 번째로, transaction.types.ts에서 인터페이스를 정의합니다.
CreateTransactionRequest는 클라이언트가 보내야 하는 요청 바디의 정확한 구조를 나타내며, 각 필드의 타입과 의미를 주석으로 문서화합니다. TransactionResponse는 서버가 반환할 응답 데이터의 구조로, 프론트엔드 개발자는 이 타입만 보고도 어떤 데이터를 받게 될지 정확히 알 수 있습니다.
이 타입 파일을 프론트엔드와 공유하거나, 자동 생성 도구를 사용하면 완벽한 타입 동기화가 가능합니다. 그 다음으로, Request 제네릭의 세 번째 파라미터에 CreateTransactionRequest를 지정합니다.
Request<Params, ResBody, ReqBody, ReqQuery> 순서로 타입을 전달하며, 여기서는 ReqBody만 지정했습니다. 이제 req.body는 CreateTransactionRequest 타입으로 추론되어, req.body.from, req.body.amount 등에 접근할 때 자동완성이 작동하고, req.body.wrongField처럼 존재하지 않는 필드는 컴파일 에러가 발생합니다.
Response<TransactionResponse> 타입 지정은 res.json()에 전달하는 객체가 TransactionResponse 구조와 일치하는지 검증합니다. 만약 timestamp 필드를 빠뜨리거나, amount를 문자열로 보내면 TypeScript가 즉시 에러를 표시합니다.
구조분해 할당으로 req.body에서 필요한 필드를 추출할 때도 타입 추론이 작동하여 from, to, amount가 각각 string, string, number 타입임을 자동으로 알 수 있습니다. 여러분이 이 패턴을 사용하면 API 스펙 변경 시 타입 정의만 수정하면 영향받는 모든 코드에서 컴파일 에러가 발생하여 누락 없이 수정할 수 있고, 새로운 팀원도 타입 정의를 보고 API 사용법을 바로 이해할 수 있습니다.
또한 Postman 대신 타입스크립트로 통합 테스트를 작성하면 타입 안정성을 유지하면서 테스트할 수 있습니다.
실전 팁
💡 express-validator나 zod 같은 런타임 검증 라이브러리와 함께 사용하면 타입 체크와 실제 데이터 검증을 동시에 할 수 있어 더욱 안전합니다
💡 타입 파일을 별도 npm 패키지로 분리하면 프론트엔드 프로젝트에서 import하여 동일한 타입을 사용할 수 있습니다
💡 req.params나 req.query도 타입 지정이 가능하며, Request<{ id: string }, {}, {}>처럼 첫 번째 제네릭에 params 타입을 넣으면 됩니다
💡 유틸리티 타입 Partial, Pick, Omit을 활용하면 업데이트용 타입처럼 일부 필드만 필요한 경우를 쉽게 표현할 수 있습니다
💡 enum 대신 string literal union 타입(type Status = 'pending' | 'confirmed' | 'failed')을 사용하면 JSON 직렬화가 자연스럽고 타입 안정성도 확보됩니다
4. 에러 핸들링 미들웨어 - 통합된 에러 처리
시작하며
여러분이 API를 만들다가 데이터베이스 연결 실패, 유효하지 않은 입력, 권한 없는 접근 등 다양한 에러를 각 라우트 핸들러에서 try-catch로 일일이 처리한 경험이 있나요? 모든 엔드포인트에 중복된 에러 처리 코드가 들어가고, 어떤 곳에서는 500 에러를, 어떤 곳에서는 400 에러를 같은 상황에 다르게 반환하는 일관성 문제도 생깁니다.
이런 문제는 코드 중복뿐 아니라 유지보수성을 크게 떨어뜨립니다. 에러 응답 형식을 변경하려면 모든 라우트 핸들러를 찾아서 수정해야 하고, 로깅 방식을 바꾸려면 수십 개의 catch 블록을 업데이트해야 합니다.
또한 예상치 못한 에러가 발생했을 때 서버가 크래시되거나, 민감한 에러 정보가 클라이언트에 노출되는 보안 문제도 발생할 수 있습니다. 바로 이럴 때 필요한 것이 중앙화된 에러 핸들링 미들웨어입니다.
Express의 에러 핸들링 미들웨어를 사용하면 모든 에러를 한 곳에서 처리하고, 일관된 응답 형식을 유지하며, 로깅과 모니터링을 체계적으로 관리할 수 있습니다.
개요
간단히 말해서, 에러 핸들링 미들웨어는 애플리케이션의 모든 에러를 중앙에서 잡아서 일관된 방식으로 처리하는 Express의 특수한 미들웨어입니다. Express에서 4개의 파라미터(err, req, res, next)를 가진 미들웨어는 에러 핸들러로 인식됩니다.
어떤 라우트 핸들러나 미들웨어에서 에러가 발생하거나 next(error)를 호출하면, Express는 자동으로 이 에러 핸들러로 제어를 넘깁니다. 예를 들어, 블록 채굴 중 유효성 검증 실패가 발생하면 ValidationError를 throw하고, 에러 핸들러가 이를 잡아서 400 상태 코드와 함께 클라이언트에 친절한 메시지를 반환합니다.
기존에는 각 라우트에서 try-catch를 중복으로 작성했다면, 이제는 에러를 throw하거나 next(error)로 전달하기만 하면 중앙 핸들러가 자동으로 처리합니다. 에러 핸들링 미들웨어의 핵심 특징은 첫째, 모든 에러를 한 곳에서 처리하여 코드 중복을 제거하고, 둘째, 커스텀 에러 클래스로 에러 타입별 처리를 체계화하며, 셋째, 프로덕션 환경에서 민감한 정보를 숨기고 클라이언트 친화적인 메시지를 제공한다는 점입니다.
이러한 특징들이 안정적이고 예측 가능한 API를 만들어줍니다.
코드 예제
// src/errors/AppError.ts - 커스텀 에러 클래스
export class AppError extends Error {
constructor(
public statusCode: number, // HTTP 상태 코드
public message: string, // 에러 메시지
public isOperational: boolean = true // 예상된 에러인지
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
}
}
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError';
export const errorHandler = (
err: Error | AppError,
req: Request,
res: Response,
next: NextFunction
) => {
// AppError인 경우 정의된 상태 코드 사용
if (err instanceof AppError) {
return res.status(err.statusCode).json({
status: 'error',
message: err.message
});
}
// 예상치 못한 에러는 500으로 처리
console.error('Unexpected error:', err); // 로깅
res.status(500).json({
status: 'error',
message: 'Internal server error'
});
};
// src/routes/blockchain.routes.ts - 에러 발생 예시
router.post('/mine', async (req, res, next) => {
try {
const { data } = req.body;
if (!data) {
throw new AppError(400, 'Data is required for mining');
}
const block = await mineBlock(data);
res.json(block);
} catch (error) {
next(error); // 에러를 미들웨어로 전달
}
});
// src/server.ts - 에러 핸들러 등록 (모든 라우터 뒤에!)
app.use('/api/blockchain', blockchainRoutes);
app.use(errorHandler); // 마지막에 등록
설명
이것이 하는 일: 애플리케이션 전체에서 발생하는 모든 에러를 한 곳에서 잡아서, 에러 타입에 따라 적절한 HTTP 상태 코드와 메시지를 클라이언트에 반환합니다. 첫 번째로, AppError 클래스는 예상 가능한 비즈니스 로직 에러를 표현하는 커스텀 에러입니다.
statusCode로 HTTP 상태 코드를 지정하고, isOperational 플래그로 예상된 에러(유효성 검증 실패 등)와 예상치 못한 에러(프로그래밍 버그 등)를 구분합니다. Object.setPrototypeOf를 사용하는 이유는 TypeScript에서 Error를 extends할 때 instanceof가 제대로 작동하도록 프로토타입 체인을 수정하기 위함입니다.
그 다음으로, errorHandler 미들웨어는 4개의 파라미터를 받아 Express에서 에러 핸들러로 인식됩니다. err instanceof AppError 체크로 의도적으로 throw된 에러인지 확인하고, 맞다면 정의된 상태 코드와 메시지를 그대로 사용합니다.
그렇지 않은 경우는 예상치 못한 프로그래밍 에러이므로 500 상태 코드를 반환하고, 실제 에러 내용은 로그로만 기록하여 민감한 정보가 클라이언트에 노출되지 않도록 합니다. 라우트 핸들러에서는 try-catch로 에러를 잡고 next(error)로 에러 핸들러에게 전달합니다.
동기 코드에서 throw된 에러는 Express가 자동으로 잡지만, async 함수에서는 반드시 try-catch를 사용해야 합니다. 만약 data가 없다면 AppError를 throw하여 400 Bad Request와 함께 명확한 에러 메시지를 반환합니다.
이 방식으로 비즈니스 로직에서는 에러만 throw하면 되고, 응답 형식이나 로깅은 신경 쓸 필요가 없습니다. 여러분이 이 패턴을 사용하면 새로운 엔드포인트를 추가할 때 에러 처리 보일러플레이트를 작성할 필요 없이 적절한 AppError만 throw하면 되고, 에러 응답 형식을 변경할 때 errorHandler만 수정하면 전체 API에 즉시 적용됩니다.
또한 로깅 서비스 통합, Sentry 같은 에러 모니터링 도구 연동도 이 한 곳에서 처리할 수 있어 유지보수가 매우 간편합니다.
실전 팁
💡 express-async-errors 패키지를 사용하면 async 라우트 핸들러에서 try-catch 없이도 에러를 자동으로 잡아줍니다
💡 에러 타입별로 NotFoundError, ValidationError, UnauthorizedError 등의 서브클래스를 만들면 에러 처리가 더욱 명확해집니다
💡 개발 환경에서는 err.stack을 포함하여 디버깅을 쉽게 하고, 프로덕션에서는 숨기도록 NODE_ENV로 분기 처리하세요
💡 404 에러는 미들웨어 체인의 마지막(에러 핸들러 바로 앞)에 별도 미들웨어로 처리하면 존재하지 않는 라우트를 잡을 수 있습니다
💡 에러 응답에 에러 코드(errorCode: 'INVALID_TRANSACTION')를 추가하면 프론트엔드에서 특정 에러에 대한 맞춤 UI를 보여줄 수 있습니다
5. 비동기 라우트 핸들러 - async/await 패턴
시작하며
여러분이 데이터베이스 쿼리, 외부 API 호출, 파일 읽기 같은 비동기 작업을 라우트 핸들러에서 수행할 때, 콜백 지옥이나 Promise 체이닝으로 코드가 복잡해진 경험이 있나요? 특히 여러 비동기 작업을 순차적으로 실행하거나, 조건에 따라 다른 비동기 작업을 수행해야 할 때 코드 가독성이 급격히 떨어집니다.
이런 문제는 블록체인처럼 복잡한 비즈니스 로직에서 더 심각합니다. 트랜잭션을 검증하고, 이전 블록을 조회하고, 작업 증명을 계산하고, 데이터베이스에 저장하는 과정이 모두 비동기인데, 이를 then() 체이닝으로 작성하면 에러 처리도 어렵고 중간에 로직을 수정하기도 힘듭니다.
Promise를 제대로 처리하지 않으면 unhandled rejection으로 서버가 멈출 수도 있습니다. 바로 이럴 때 필요한 것이 async/await를 사용한 비동기 라우트 핸들러입니다.
동기 코드처럼 보이지만 비동기로 작동하는 깔끔한 코드를 작성할 수 있고, try-catch로 자연스럽게 에러를 처리할 수 있습니다.
개요
간단히 말해서, async/await 패턴은 Promise 기반 비동기 코드를 동기 코드처럼 읽기 쉽게 작성할 수 있게 해주는 JavaScript/TypeScript 문법입니다. Express 라우트 핸들러를 async 함수로 선언하면, 내부에서 await 키워드로 Promise가 resolve될 때까지 기다릴 수 있습니다.
예를 들어, await blockchain.getLastBlock()은 마치 동기 함수 호출처럼 보이지만 실제로는 비동기로 작동하며, 블록을 가져올 때까지 다음 줄로 진행하지 않습니다. 이는 코드 흐름이 위에서 아래로 자연스럽게 읽히게 만들어 복잡한 로직도 이해하기 쉽습니다.
기존에는 getLastBlock().then(block => validateBlock(block).then(...))처럼 중첩된 Promise를 사용했다면, 이제는 const block = await getLastBlock(); await validateBlock(block);처럼 순차적으로 작성할 수 있습니다. async/await의 핵심 특징은 첫째, 비동기 코드의 가독성을 동기 코드 수준으로 높이고, 둘째, try-catch로 에러 처리를 일반 예외처럼 다룰 수 있으며, 셋째, Promise.all()과 결합하여 병렬 처리도 쉽게 구현할 수 있다는 점입니다.
이러한 특징들이 복잡한 비즈니스 로직을 깔끔하게 구현할 수 있게 만듭니다.
코드 예제
// src/services/blockchain.service.ts
export class BlockchainService {
async getLastBlock(): Promise<Block> {
// 데이터베이스에서 마지막 블록 조회
return await db.blocks.findLast();
}
async mineBlock(data: string): Promise<Block> {
const lastBlock = await this.getLastBlock();
// 새 블록 생성 (작업 증명 포함)
const newBlock = await this.createBlock(data, lastBlock.hash);
// 데이터베이스에 저장
await db.blocks.insert(newBlock);
return newBlock;
}
async createBlock(data: string, previousHash: string): Promise<Block> {
// 작업 증명 계산 (시간이 오래 걸리는 작업)
const nonce = await this.proofOfWork(previousHash, data);
return {
index: Date.now(),
timestamp: Date.now(),
data,
previousHash,
hash: calculateHash(data, previousHash, nonce),
nonce
};
}
}
// src/routes/blockchain.routes.ts
import { BlockchainService } from '../services/blockchain.service';
const blockchainService = new BlockchainService();
// async 라우트 핸들러
router.post('/mine', async (req, res, next) => {
try {
const { data } = req.body;
// 여러 비동기 작업을 순차적으로 실행
const block = await blockchainService.mineBlock(data);
// 블록 검증
const isValid = await blockchainService.validateBlock(block);
if (!isValid) {
throw new AppError(400, 'Invalid block');
}
res.json({ success: true, block });
} catch (error) {
next(error); // 에러를 미들웨어로 전달
}
});
설명
이것이 하는 일: 데이터베이스 조회, 블록 채굴, 검증 같은 여러 비동기 작업을 마치 동기 함수처럼 순차적으로 실행하면서도, 실제로는 논블로킹 방식으로 작동하게 합니다. 첫 번째로, BlockchainService의 각 메서드는 async로 선언되어 Promise를 반환합니다.
getLastBlock()은 데이터베이스 쿼리를 await하여 결과를 받을 때까지 기다리고, 그 결과를 Block 타입으로 반환합니다. TypeScript는 async 함수의 반환 타입을 자동으로 Promise로 감싸므로, 명시적으로 Promise<Block>으로 타입을 지정해야 합니다.
이렇게 하면 호출하는 쪽에서도 await의 결과가 Block 타입임을 타입스크립트가 추론할 수 있습니다. 그 다음으로, mineBlock() 메서드는 여러 비동기 작업을 순차적으로 실행하는 복잡한 로직을 담고 있습니다.
먼저 마지막 블록을 가져오고, 그 해시를 사용하여 새 블록을 생성하고, 데이터베이스에 저장합니다. 각 단계가 이전 단계의 결과에 의존하므로 순차적으로 await해야 하며, 이는 코드를 위에서 아래로 읽으면 자연스럽게 로직을 이해할 수 있게 만듭니다.
만약 중간에 에러가 발생하면 try-catch로 잡히거나 호출한 쪽으로 Promise rejection이 전파됩니다. 라우트 핸들러에서는 async (req, res, next) => 형태로 선언하여 내부에서 await를 사용할 수 있게 합니다.
blockchainService.mineBlock(data)를 await하면 채굴이 완료될 때까지 기다렸다가 결과를 block 변수에 할당하고, 그 다음 검증을 수행합니다. try-catch로 모든 비동기 에러를 한 곳에서 처리하며, catch 블록에서 next(error)로 에러 핸들링 미들웨어에게 전달합니다.
여러분이 이 패턴을 사용하면 복잡한 비즈니스 로직도 절차적으로 읽히는 코드로 작성할 수 있고, Promise 체이닝보다 에러 처리가 명확하며, 디버깅 시 스택 트레이스도 더 읽기 쉽습니다. 또한 필요한 경우 Promise.all([task1(), task2()])로 병렬 실행도 쉽게 구현하여 성능을 최적화할 수 있습니다.
실전 팁
💡 순차적으로 실행할 필요 없는 독립적인 비동기 작업은 const [user, posts] = await Promise.all([getUser(), getPosts()])처럼 병렬로 실행하여 성능을 높이세요
💡 express-async-handler 패키지를 사용하면 try-catch 없이도 async 핸들러의 에러를 자동으로 next()로 전달할 수 있습니다
💡 await는 Promise가 아닌 값에 사용해도 되며, 이 경우 즉시 resolve된 Promise처럼 동작합니다
💡 루프 안에서 await를 사용하면 순차 실행되므로, 병렬 실행이 필요하면 map()과 Promise.all()을 조합하세요
💡 top-level await (함수 밖에서 await)는 ES2022부터 모듈에서 사용 가능하지만, CommonJS 환경에서는 async IIFE로 감싸야 합니다
6. 미들웨어 체이닝 - 요청 전처리 파이프라인
시작하며
여러분이 API를 만들 때 인증 확인, 요청 로깅, 입력 데이터 검증, 권한 체크 같은 공통 로직을 모든 라우트 핸들러에 반복해서 작성한 경험이 있나요? 각 엔드포인트마다 동일한 검증 코드가 중복되고, 나중에 인증 방식을 변경하려면 수십 개의 파일을 수정해야 하는 상황이 발생합니다.
이런 문제는 코드 중복뿐 아니라 일관성 문제도 유발합니다. 어떤 엔드포인트는 인증 체크를 빠뜨리거나, 로깅 형식이 제각각이거나, 검증 로직이 조금씩 달라서 보안 취약점이 생길 수 있습니다.
또한 새로운 공통 기능(예: API 사용량 제한)을 추가할 때 모든 라우트를 찾아서 수정해야 하므로 실수할 가능성이 높습니다. 바로 이럴 때 필요한 것이 미들웨어 체이닝입니다.
Express의 미들웨어 시스템을 활용하면 공통 로직을 재사용 가능한 함수로 분리하고, 여러 미들웨어를 조합하여 강력한 요청 처리 파이프라인을 구축할 수 있습니다.
개요
간단히 말해서, 미들웨어 체이닝은 여러 미들웨어 함수를 순서대로 실행하여 요청을 단계적으로 처리하는 Express의 핵심 패턴입니다. Express에서 미들웨어는 (req, res, next) => void 형태의 함수로, 요청 객체를 받아서 처리한 후 next()를 호출하여 다음 미들웨어로 제어를 넘깁니다.
예를 들어, 인증 미들웨어가 토큰을 검증하고 req.user에 사용자 정보를 추가하면, 그 다음 권한 체크 미들웨어에서 req.user를 사용할 수 있습니다. 이는 마치 공장의 조립 라인처럼 각 단계가 특정 작업을 수행하고 다음 단계로 넘기는 구조입니다.
기존에는 각 라우트 핸들러 내부에 모든 로직을 작성했다면, 이제는 작은 미들웨어 함수들을 만들고 이를 조합하여 복잡한 처리 흐름을 만들 수 있습니다. 미들웨어 체이닝의 핵심 특징은 첫째, 관심사 분리로 각 미들웨어가 단일 책임을 가지게 하고, 둘째, 재사용성으로 동일한 미들웨어를 여러 라우트에 적용할 수 있으며, 셋째, 조합 가능성으로 다양한 미들웨어를 섞어서 커스텀 파이프라인을 만들 수 있다는 점입니다.
이러한 특징들이 유지보수하기 쉽고 확장 가능한 API를 만들어줍니다.
코드 예제
// src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError';
// 인증 토큰 검증 미들웨어
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
throw new AppError(401, 'Authentication required');
}
try {
const decoded = verifyToken(token);
req.user = decoded; // 요청 객체에 사용자 정보 추가
next(); // 다음 미들웨어로 제어 전달
} catch (error) {
throw new AppError(401, 'Invalid token');
}
};
// 권한 체크 미들웨어 팩토리
export const authorize = (...roles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
throw new AppError(403, 'Insufficient permissions');
}
next();
};
};
// 요청 로깅 미들웨어
export const logger = (req: Request, res: Response, next: NextFunction) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
};
// src/routes/blockchain.routes.ts - 미들웨어 체이닝 적용
import { authenticate, authorize, logger } from '../middleware/auth.middleware';
import { validateRequest } from '../middleware/validation.middleware';
// 여러 미들웨어를 배열로 체이닝
router.post('/mine',
logger, // 1. 요청 로깅
authenticate, // 2. 인증 확인
authorize('admin', 'miner'), // 3. 권한 체크
validateRequest(mineSchema), // 4. 입력 검증
async (req, res, next) => { // 5. 실제 핸들러
const { data } = req.body;
const block = await blockchainService.mineBlock(data);
res.json(block);
}
);
// 전체 라우터에 공통 미들웨어 적용
router.use(logger); // 이 라우터의 모든 엔드포인트에 로깅 적용
설명
이것이 하는 일: 요청이 실제 핸들러에 도달하기 전에 여러 단계의 전처리를 거치게 하여, 인증, 권한 확인, 로깅, 검증 등을 체계적으로 수행합니다. 첫 번째로, authenticate 미들웨어는 요청 헤더에서 JWT 토큰을 추출하고 검증합니다.
토큰이 없거나 유효하지 않으면 AppError를 throw하여 에러 핸들링 미들웨어로 제어가 넘어가고, 유효하다면 디코딩된 사용자 정보를 req.user에 추가합니다. 이렇게 하면 이후 미들웨어나 핸들러에서 req.user로 현재 사용자 정보에 접근할 수 있습니다.
next()를 호출해야만 다음 미들웨어로 진행되며, next()를 호출하지 않으면 요청이 여기서 멈춥니다. 그 다음으로, authorize는 미들웨어를 동적으로 생성하는 팩토리 함수입니다.
authorize('admin', 'miner')를 호출하면 실제 미들웨어 함수가 반환되며, 이 함수는 req.user.role이 'admin' 또는 'miner'인지 확인합니다. 이 패턴을 사용하면 엔드포인트마다 다른 권한 요구사항을 쉽게 지정할 수 있고, authorize('admin')처럼 관리자 전용 엔드포인트도 간단히 만들 수 있습니다.
라우트 정의에서 미들웨어를 배열로 나열하면 Express가 순서대로 실행합니다. POST /mine 엔드포인트는 먼저 요청을 로깅하고, 토큰을 검증하고, 권한을 확인하고, 입력 데이터를 검증한 후 마지막으로 실제 핸들러를 실행합니다.
중간에 어떤 미들웨어에서든 에러가 발생하면 나머지 체인은 실행되지 않고 에러 핸들러로 점프합니다. router.use(logger)처럼 라우터 레벨에서 미들웨어를 적용하면 해당 라우터의 모든 엔드포인트에 자동으로 적용됩니다.
여러분이 이 패턴을 사용하면 공통 로직을 한 번만 작성하고 여러 곳에서 재사용할 수 있으며, 새로운 보안 정책이나 검증 규칙을 추가할 때 미들웨어만 수정하면 되고, 라우트 핸들러는 순수하게 비즈니스 로직에만 집중할 수 있습니다. 또한 미들웨어 순서만 바꿔도 동작이 달라지므로 유연한 요청 처리 흐름을 만들 수 있습니다.
실전 팁
💡 미들웨어에서 res.send()나 res.json()을 호출하면 응답이 전송되고 체인이 종료되므로, next()를 호출하지 않아야 합니다
💡 TypeScript에서 req.user 같은 커스텀 속성을 추가할 때는 타입 선언 파일(types/express.d.ts)로 Request 인터페이스를 확장해야 타입 에러가 발생하지 않습니다
💡 조건부 미들웨어가 필요하면 (req, res, next) => { if (condition) return next(); doSomething(); next(); } 패턴을 사용하세요
💡 third-party 미들웨어(helmet, cors, compression 등)도 동일한 방식으로 체이닝에 포함시킬 수 있습니다
💡 미들웨어 순서가 중요합니다 - body parser는 입력 검증 전에, 에러 핸들러는 모든 라우터 뒤에 배치해야 합니다
7. 입력 검증 - 안전한 데이터 처리
시작하며
여러분이 API를 만들었는데, 클라이언트가 amount 필드에 문자열을 보내거나, 음수를 보내거나, 아예 필수 필드를 누락해서 서버 로직이 엉망이 된 경험이 있나요? TypeScript 타입 체크는 컴파일 시점에만 작동하므로, 런타임에 실제로 들어오는 데이터가 예상한 타입인지는 보장하지 못합니다.
이런 문제는 보안 취약점으로 이어질 수 있습니다. SQL Injection, XSS 공격, 서비스 거부 공격 등 많은 보안 문제가 입력 검증 부족에서 시작됩니다.
예를 들어, 블록 데이터 길이 제한이 없다면 공격자가 수 GB 크기의 데이터를 보내 서버 메모리를 고갈시킬 수 있고, 금액 검증이 없다면 음수를 보내 잔액을 늘리는 버그를 악용할 수 있습니다. 바로 이럴 때 필요한 것이 체계적인 입력 검증 시스템입니다.
zod나 joi 같은 스키마 검증 라이브러리를 사용하면 타입스크립트 타입과 런타임 검증을 동시에 수행하고, 명확한 에러 메시지를 클라이언트에 제공할 수 있습니다.
개요
간단히 말해서, 입력 검증은 클라이언트가 보낸 데이터가 예상한 형식, 타입, 범위에 맞는지 런타임에 확인하여 잘못된 데이터를 거부하는 프로세스입니다. Zod는 TypeScript 우선 스키마 검증 라이브러리로, 스키마를 정의하면 자동으로 TypeScript 타입도 추론됩니다.
예를 들어, z.object({ amount: z.number().positive() })로 스키마를 만들면, 이 스키마는 런타임에 데이터를 검증하고, TypeScript 컴파일러는 이 스키마에서 타입을 자동으로 추출합니다. 이는 타입과 검증 로직의 중복을 제거하고, 항상 동기화 상태를 유지하게 만듭니다.
기존에는 if (!data || typeof data !== 'string') 같은 수동 검증 코드를 작성했다면, 이제는 선언적 스키마로 복잡한 검증 규칙을 표현하고 자동으로 실행할 수 있습니다. 입력 검증의 핵심 특징은 첫째, 타입 안정성과 런타임 안전성을 동시에 확보하고, 둘째, 명확한 에러 메시지로 클라이언트가 무엇을 잘못 보냈는지 알려주며, 셋째, 스키마를 재사용하여 일관된 검증 규칙을 적용할 수 있다는 점입니다.
이러한 특징들이 안정적이고 사용자 친화적인 API를 만들어줍니다.
코드 예제
// src/schemas/transaction.schema.ts
import { z } from 'zod';
// 트랜잭션 생성 요청 스키마
export const createTransactionSchema = z.object({
body: z.object({
from: z.string().min(1, 'Sender address is required'),
to: z.string().min(1, 'Recipient address is required'),
amount: z.number().positive('Amount must be positive'),
privateKey: z.string().length(64, 'Invalid private key length')
})
});
// 스키마에서 TypeScript 타입 자동 추출
export type CreateTransactionInput = z.infer<typeof createTransactionSchema>;
// src/middleware/validation.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
import { AppError } from '../errors/AppError';
export const validate = (schema: AnyZodObject) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
// 요청 전체(body, params, query)를 검증
await schema.parseAsync({
body: req.body,
params: req.params,
query: req.query
});
next(); // 검증 성공 시 다음 미들웨어로
} catch (error) {
if (error instanceof ZodError) {
// Zod 에러를 사용자 친화적인 메시지로 변환
const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
throw new AppError(400, messages.join(', '));
}
next(error);
}
};
};
// src/routes/transaction.routes.ts
import { validate } from '../middleware/validation.middleware';
import { createTransactionSchema } from '../schemas/transaction.schema';
router.post('/create',
validate(createTransactionSchema), // 검증 미들웨어
async (req, res) => {
// 여기 도달하면 req.body는 이미 검증됨
const { from, to, amount, privateKey } = req.body;
const transaction = await transactionService.create({
from,
to,
amount,
privateKey
});
res.json(transaction);
}
);
설명
이것이 하는 일: 클라이언트 요청이 핸들러에 도달하기 전에 데이터 구조, 타입, 제약 조건을 자동으로 검증하고, 문제가 있으면 구체적인 에러 메시지와 함께 거부합니다. 첫 번째로, createTransactionSchema는 Zod 스키마로 요청 바디의 정확한 구조를 정의합니다.
z.string().min(1)은 빈 문자열을 거부하고, z.number().positive()는 0이나 음수를 거부하며, z.string().length(64)는 정확히 64자가 아니면 에러를 발생시킵니다. 각 검증에 커스텀 에러 메시지를 전달하면 사용자가 무엇을 잘못 입력했는지 명확히 알 수 있습니다.
z.infer<typeof schema>로 스키마에서 TypeScript 타입을 자동으로 추출하므로, 스키마를 수정하면 타입도 자동으로 업데이트됩니다. 그 다음으로, validate 미들웨어는 제네릭 검증 로직을 담고 있어 어떤 스키마든 재사용할 수 있습니다.
schema.parseAsync()는 비동기로 데이터를 검증하며, req.body, req.params, req.query를 모두 포함한 객체를 검증합니다. 검증에 실패하면 ZodError가 throw되며, 이 에러 객체는 어떤 필드에서 어떤 문제가 발생했는지 상세한 정보를 담고 있습니다.
error.errors 배열을 순회하며 사용자 친화적인 메시지로 변환하여 400 Bad Request와 함께 반환합니다. 라우트에서 validate(createTransactionSchema)를 미들웨어로 추가하면, 이 검증을 통과한 요청만 핸들러에 도달합니다.
핸들러는 req.body의 데이터가 이미 검증되었다고 확신할 수 있으므로, 추가적인 if 체크 없이 바로 비즈니스 로직을 실행할 수 있습니다. TypeScript도 이 시점에서 req.body가 올바른 타입임을 알고 있어 자동완성과 타입 체크가 정확하게 작동합니다.
여러분이 이 시스템을 사용하면 보안 취약점을 사전에 차단하고, 잘못된 요청에 대해 명확한 피드백을 제공하며, 비즈니스 로직과 검증 로직을 분리하여 코드 가독성을 높일 수 있습니다. 또한 스키마를 공유하면 프론트엔드에서도 동일한 검증을 수행하여 불필요한 서버 요청을 줄일 수 있습니다.
실전 팁
💡 Zod는 .transform()으로 검증 후 데이터를 변환할 수 있어, 문자열을 숫자로 파싱하거나 날짜를 Date 객체로 변환하는 등의 작업이 가능합니다
💡 .refine()으로 커스텀 검증 로직을 추가할 수 있어, 비밀번호 확인이 일치하는지 같은 복잡한 규칙도 표현 가능합니다
💡 환경변수 검증에도 Zod를 사용하면 서버 시작 시 필수 설정이 누락되었는지 즉시 발견할 수 있습니다
💡 express-validator는 Express 특화 검증 라이브러리로 Zod의 대안이며, class-validator는 클래스 기반 접근을 선호할 때 유용합니다
💡 OpenAPI 스펙 생성 도구와 통합하면 Zod 스키마에서 자동으로 API 문서를 생성할 수 있습니다
8. CORS 설정 - 안전한 크로스 오리진 요청
시작하며
여러분이 프론트엔드 앱을 개발하고 로컬 서버(localhost:3000)에서 백엔드 API(localhost:5000)를 호출했을 때, 브라우저 콘솔에 "CORS policy" 에러가 뜨면서 요청이 차단된 경험이 있나요? 백엔드는 정상 작동하는데 브라우저가 보안 정책으로 막아버려서 개발도 못 하는 답답한 상황이 발생합니다.
이런 문제는 브라우저의 Same-Origin Policy 때문에 발생합니다. 보안상 이유로 브라우저는 다른 도메인, 포트, 프로토콜의 리소스 접근을 기본적으로 차단합니다.
프로덕션에서도 프론트엔드는 myapp.com이고 API는 api.myapp.com처럼 도메인이 다른 경우가 많아서, CORS를 제대로 설정하지 않으면 서비스가 작동하지 않습니다. 반대로 너무 느슨하게 설정하면 보안 취약점이 생깁니다.
바로 이럴 때 필요한 것이 적절한 CORS 설정입니다. Express에서 cors 미들웨어를 사용하여 어떤 도메인에서 어떤 메서드로 어떤 헤더와 함께 요청할 수 있는지 세밀하게 제어할 수 있습니다.
개요
간단히 말해서, CORS(Cross-Origin Resource Sharing)는 웹 브라우저가 다른 도메인의 리소스에 접근할 수 있도록 서버가 명시적으로 허용하는 보안 메커니즘입니다. 브라우저는 보안을 위해 JavaScript가 다른 오리진의 API를 호출하는 것을 차단하는데, 서버가 특정 HTTP 헤더(Access-Control-Allow-Origin 등)를 응답에 포함하면 이 제약을 완화할 수 있습니다.
예를 들어, Access-Control-Allow-Origin: https://myapp.com 헤더를 보내면 myapp.com에서 오는 요청만 허용하고, *를 사용하면 모든 도메인을 허용합니다. Preflight 요청(OPTIONS 메서드)을 통해 브라우저는 실제 요청 전에 서버가 허용하는지 미리 확인합니다.
기존에는 res.setHeader()로 수동으로 CORS 헤더를 추가했다면, 이제는 cors 미들웨어가 자동으로 처리하고 복잡한 설정도 객체로 간편하게 표현할 수 있습니다. CORS 설정의 핵심 특징은 첫째, 개발과 프로덕션 환경별로 허용 도메인을 다르게 설정할 수 있고, 둘째, HTTP 메서드와 헤더를 세밀하게 제어하여 보안을 강화하며, 셋째, 인증 정보(쿠키, Authorization 헤더)를 포함한 요청도 안전하게 허용할 수 있다는 점입니다.
이러한 특징들이 보안과 사용성의 균형을 맞춰줍니다.
코드 예제
// src/config/cors.config.ts
import { CorsOptions } from 'cors';
// 허용할 오리진 목록
const allowedOrigins = [
'http://localhost:3000', // 로컬 개발 환경
'https://myapp.com', // 프로덕션 프론트엔드
'https://www.myapp.com', // www 서브도메인
];
// CORS 옵션 설정
export const corsOptions: CorsOptions = {
origin: (origin, callback) => {
// origin이 없는 경우(모바일 앱, Postman 등) 또는 허용 목록에 있는 경우 허용
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true, // 쿠키, Authorization 헤더 허용
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], // 허용할 HTTP 메서드
allowedHeaders: ['Content-Type', 'Authorization'], // 허용할 헤더
exposedHeaders: ['X-Total-Count'], // 클라이언트가 접근 가능한 커스텀 헤더
maxAge: 86400, // Preflight 결과 캐싱 시간(초)
};
// src/server.ts
import express from 'express';
import cors from 'cors';
import { corsOptions } from './config/cors.config';
const app = express();
// CORS 미들웨어 적용 (모든 라우터보다 먼저!)
app.use(cors(corsOptions));
// JSON 파싱 미들웨어
app.use(express.json());
// 라우터 등록
app.use('/api/blockchain', blockchainRoutes);
app.use('/api/transactions', transactionRoutes);
// 환경별 설정 예시
if (process.env.NODE_ENV === 'development') {
// 개발 환경에서는 모든 오리진 허용
app.use(cors({ origin: '*' }));
} else {
// 프로덕션에서는 엄격한 설정
app.use(cors(corsOptions));
}
설명
이것이 하는 일: 브라우저의 Same-Origin Policy를 안전하게 우회하여, 지정된 도메인에서 오는 크로스 오리진 요청을 서버가 명시적으로 허용하도록 HTTP 헤더를 자동으로 추가합니다. 첫 번째로, allowedOrigins 배열은 신뢰할 수 있는 도메인 목록을 관리합니다.
이 목록에 프론트엔드 도메인을 추가하면 해당 도메인에서 오는 요청만 허용됩니다. origin 옵션을 함수로 정의하면 요청마다 동적으로 허용 여부를 결정할 수 있습니다.
origin이 undefined인 경우는 서버 간 요청(Postman, curl, 모바일 앱 등)이므로 필요에 따라 허용할 수 있습니다. callback(null, true)는 허용, callback(new Error(...))는 거부를 의미합니다.
그 다음으로, credentials: true는 매우 중요한 설정으로, 쿠키나 Authorization 헤더 같은 인증 정보를 포함한 요청을 허용합니다. 이 옵션을 사용하면 origin을 *로 설정할 수 없고 반드시 구체적인 도메인을 지정해야 합니다.
methods 배열로 허용할 HTTP 메서드를 제한하여, TRACE나 CONNECT 같은 잠재적으로 위험한 메서드를 차단할 수 있습니다. allowedHeaders로 클라이언트가 보낼 수 있는 헤더를 제한하면 보안이 강화됩니다.
exposedHeaders는 브라우저에서 JavaScript로 접근할 수 있는 응답 헤더를 지정합니다. 기본적으로 브라우저는 몇 가지 표준 헤더만 노출하므로, 페이지네이션에 사용하는 X-Total-Count 같은 커스텀 헤더를 프론트엔드에서 읽으려면 여기에 추가해야 합니다.
maxAge는 Preflight 요청(OPTIONS) 결과를 브라우저가 캐싱하는 시간으로, 86400초(24시간)로 설정하면 동일한 요청의 Preflight를 하루 동안 생략하여 성능을 향상시킵니다. 여러분이 이 설정을 사용하면 개발 환경에서는 편리하게 모든 오리진을 허용하고, 프로덕션에서는 엄격하게 신뢰할 수 있는 도메인만 허용하여 보안을 유지할 수 있습니다.
인증 정보를 포함한 요청도 안전하게 처리하며, 불필요한 Preflight 요청을 줄여 성능도 최적화할 수 있습니다. 환경 변수로 허용 도메인을 관리하면 배포 환경마다 다른 설정을 쉽게 적용할 수 있습니다.
실전 팁
💡 개발 중에는 cors()처럼 옵션 없이 사용하면 모든 오리진을 허용하지만, 프로덕션에서는 반드시 구체적인 설정을 사용하세요
💡 프록시 서버(Nginx, CloudFlare 등)를 사용하면 CORS 설정을 프록시에서 처리하고 백엔드는 신경 쓰지 않아도 되는 경우가 있습니다
💡 credentials: true를 사용할 때는 쿠키의 SameSite 속성도 함께 설정해야 크로스 사이트 쿠키가 제대로 작동합니다
💡 특정 라우터에만 CORS를 적용하려면 app.use(cors()) 대신 router.use(cors())를 해당 라우터에서 사용하세요
💡 복잡한 요청(PUT, DELETE, 커스텀 헤더 포함)은 Preflight가 발생하므로 OPTIONS 메서드 처리가 중요하며, cors 미들웨어가 자동으로 처리합니다
9. 환경 변수 관리 - 안전한 설정 관리
시작하며
여러분이 데이터베이스 비밀번호, API 키, JWT 시크릿 같은 민감한 정보를 코드에 직접 하드코딩한 경험이 있나요? 이런 코드를 Git에 커밋하면 누구나 볼 수 있고, 개발 환경과 프로덕션 환경에서 다른 설정을 사용하려면 코드를 매번 수정해야 하는 문제가 생깁니다.
이런 문제는 보안 사고로 이어질 수 있습니다. GitHub에 실수로 AWS 액세스 키를 커밋하면 몇 분 안에 봇이 탐지하여 악용하는 사례가 많고, 한 번 커밋된 비밀 정보는 히스토리에 남아 삭제하기 어렵습니다.
또한 팀원마다 다른 로컬 데이터베이스를 사용하거나, 스테이징 서버와 프로덕션 서버의 설정이 다를 때 코드를 분기하면 관리가 복잡해집니다. 바로 이럴 때 필요한 것이 환경 변수를 사용한 설정 관리입니다.
.env 파일로 환경별 설정을 분리하고, dotenv 패키지로 안전하게 로드하며, TypeScript 타입으로 필수 환경 변수를 강제할 수 있습니다.
개요
간단히 말해서, 환경 변수는 운영 체제 수준에서 관리되는 키-값 쌍으로, 코드와 설정을 분리하여 보안과 유연성을 높이는 방법입니다. Node.js에서 process.env 객체를 통해 환경 변수에 접근할 수 있으며, dotenv 패키지는 .env 파일의 내용을 자동으로 process.env에 로드합니다.
예를 들어, .env 파일에 DATABASE_URL=mongodb://localhost:27017/mydb를 작성하면, 코드에서 process.env.DATABASE_URL로 접근할 수 있습니다. .env 파일은 .gitignore에 추가하여 절대 커밋하지 않고, .env.example로 필요한 환경 변수 목록만 공유합니다.
기존에는 설정을 코드에 하드코딩하거나 JSON 파일로 관리했다면, 이제는 환경 변수로 민감한 정보를 안전하게 보호하고 배포 환경마다 다른 값을 쉽게 적용할 수 있습니다. 환경 변수 관리의 핵심 특징은 첫째, 민감한 정보를 코드에서 분리하여 보안을 강화하고, 둘째, 환경별 설정을 코드 변경 없이 전환할 수 있으며, 셋째, 클라우드 플랫폼의 환경 변수 설정과 자연스럽게 통합된다는 점입니다.
이러한 특징들이 안전하고 유연한 애플리케이션 설정을 가능하게 합니다.
코드 예제
// .env (Git에 커밋하지 않음!)
NODE_ENV=development
PORT=5000
DATABASE_URL=mongodb://localhost:27017/blockchain
JWT_SECRET=your-super-secret-key-change-in-production
JWT_EXPIRES_IN=7d
CORS_ORIGIN=http://localhost:3000
// .env.example (Git에 커밋하여 필요한 변수 목록 공유)
NODE_ENV=
PORT=
DATABASE_URL=
JWT_SECRET=
JWT_EXPIRES_IN=
CORS_ORIGIN=
// src/config/env.config.ts - 타입 안전한 환경 변수 설정
import dotenv from 'dotenv';
import { z } from 'zod';
// 환경 변수 로드
dotenv.config();
// 환경 변수 스키마 정의
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().transform(Number).pipe(z.number().positive()),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
JWT_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().url(),
});
// 환경 변수 검증 및 파싱
const envValidation = envSchema.safeParse(process.env);
if (!envValidation.success) {
console.error('❌ Invalid environment variables:');
console.error(envValidation.error.format());
process.exit(1); // 서버 시작 중단
}
// 타입 안전한 환경 변수 export
export const env = envValidation.data;
// src/server.ts
import { env } from './config/env.config';
const app = express();
// 타입 안전하게 환경 변수 사용
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
console.log(`Environment: ${env.NODE_ENV}`);
console.log(`Database: ${env.DATABASE_URL}`);
});
// JWT 생성 예시
const token = jwt.sign({ userId: user.id }, env.JWT_SECRET, {
expiresIn: env.JWT_EXPIRES_IN
});
설명
이것이 하는 일: .env 파일에서 환경별 설정을 로드하고, 스키마 검증으로 필수 환경 변수가 올바른 형식인지 확인한 후, 타입 안전하게 애플리케이션 전체에서 사용할 수 있게 합니다. 첫 번째로, .env 파일은 KEY=VALUE 형식으로 환경 변수를 정의합니다.
이 파일에는 데이터베이스 URL, API 키, JWT 시크릿처럼 민감하거나 환경마다 다른 값들을 저장합니다. 반드시 .gitignore에 .env를 추가하여 Git 히스토리에 남지 않도록 해야 합니다.
.env.example은 실제 값 없이 키 이름만 나열하여, 새로운 팀원이 어떤 환경 변수를 설정해야 하는지 알려주는 템플릿 역할을 합니다. 그 다음으로, envSchema는 Zod로 환경 변수의 타입과 제약 조건을 정의합니다.
NODE_ENV는 정해진 값 중 하나여야 하고, PORT는 문자열을 숫자로 변환한 후 양수인지 확인하며, DATABASE_URL은 유효한 URL 형식이어야 하고, JWT_SECRET은 최소 32자 이상이어야 합니다. safeParse()로 검증하면 성공 시 data를, 실패 시 error를 포함한 결과 객체가 반환됩니다.
검증 실패 시 process.exit(1)로 서버를 즉시 종료하여, 잘못된 설정으로 서버가 시작되는 것을 방지합니다. 검증된 env 객체는 타입이 정확히 추론되므로, env.PORT는 number, env.DATABASE_URL은 string으로 TypeScript가 인식합니다.
이제 코드 전체에서 env.PORT처럼 타입 안전하게 환경 변수에 접근하고, 존재하지 않는 변수를 참조하면 컴파일 에러가 발생합니다. jwt.sign()처럼 보안에 중요한 곳에서 env.JWT_SECRET을 사용하며, 실수로 하드코딩된 값을 사용할 위험이 없습니다.
여러분이 이 패턴을 사용하면 로컬, 스테이징, 프로덕션 환경에서 각기 다른 .env 파일을 사용하여 코드 변경 없이 배포할 수 있고, 민감한 정보가 소스 코드에 노출되지 않으며, 서버 시작 시 필수 설정이 누락되었는지 자동으로 확인하여 런타임 에러를 방지할 수 있습니다. Docker나 Kubernetes, Vercel, Heroku 같은 플랫폼에서도 환경 변수 설정만으로 쉽게 배포할 수 있습니다.
실전 팁
💡 클라우드 플랫폼(AWS, GCP, Azure 등)에서는 비밀 관리 서비스(Secrets Manager, Key Vault 등)를 사용하여 더욱 안전하게 환경 변수를 관리할 수 있습니다
💡 .env.development, .env.production처럼 환경별 파일을 만들고 dotenv-flow 패키지로 자동으로 로드할 수 있습니다
💡 민감하지 않은 설정(기본 타임아웃, 페이지 크기 등)은 config.ts 파일에 상수로 정의해도 괜찮습니다
💡 프론트엔드에서 환경 변수를 사용할 때는 VITE_나 NEXT_PUBLIC_ prefix가 필요하며, 빌드 시점에 번들에 포함되므로 민감한 정보를 절대 넣으면 안 됩니다
💡 .env 파일을 실수로 커밋했다면 git-secrets나 truffleHog 같은 도구로 Git 히스토리를 스캔하여 제거하고, 노출된 비밀은 즉시 재생성하세요
10. 헬스 체크 엔드포인트 - 서버 상태 모니터링
시작하며
여러분이 서버를 배포한 후 실제로 정상 작동하는지 확인하고 싶을 때, 브라우저로 접속해서 수동으로 확인하거나, 특정 API를 호출해보는 방식으로 체크한 경험이 있나요? 로드 밸런서나 모니터링 시스템이 서버가 살아있는지 자동으로 확인하려면 간단하고 빠르게 응답하는 전용 엔드포인트가 필요합니다.
이런 문제는 특히 무중단 배포, 오토 스케일링, 컨테이너 오케스트레이션(Kubernetes 등)을 사용할 때 중요해집니다. 로드 밸런서는 서버가 요청을 처리할 준비가 되었는지 주기적으로 확인하고, 준비되지 않은 서버에는 트래픽을 보내지 않습니다.
데이터베이스 연결이 끊어졌거나 필수 서비스가 다운된 서버를 자동으로 감지하여 제외하려면 상태를 정확히 보고하는 헬스 체크가 필수입니다. 바로 이럴 때 필요한 것이 헬스 체크 엔드포인트입니다.
서버의 기본 상태뿐 아니라 데이터베이스 연결, 외부 서비스 상태 등을 체크하여 서버가 정상인지 종합적으로 판단할 수 있습니다.
개요
간단히 말해서, 헬스 체크 엔드포인트는 서버와 의존 서비스들의 상태를 확인하여 모니터링 시스템이나 로드 밸런서에게 보고하는 특수한 API입니다. 일반적으로 /health, /healthz, /health/ready 같은 경로를 사용하며, GET 요청에 간단한 JSON 응답을 반환합니다.
Liveness 체크는 "서버 프로세스가 살아있는가"를 확인하여 죽은 프로세스를 재시작하는 데 사용되고, Readiness 체크는 "서버가 요청을 처리할 준비가 되었는가"를 확인하여 트래픽 라우팅을 결정하는 데 사용됩니다. 예를 들어, 서버는 실행 중이지만 데이터베이스 연결이 안 되면 Liveness는 성공하지만 Readiness는 실패하여 트래픽을 받지 않게 됩니다.
기존에는 메인 페이지나 특정 API 엔드포인트로 상태를 확인했다면, 이제는 전용 헬스 체크 엔드포인트로 빠르고 정확하게 서버 상태를 보고할 수 있습니다. 헬스 체크의 핵심 특징은 첫째, 로드 밸런서와 오케스트레이터가 자동으로 서버 상태를 모니터링할 수 있고, 둘째, 의존 서비스의 상태까지 포함하여 종합적인 건강 상태를 보고하며, 셋째, 간단하고 빠르게 응답하여 모니터링 오버헤드를 최소화한다는 점입니다.
이러한 특징들이 안정적이고 자동화된 인프라 운영을 가능하게 합니다.
코드 예제
// src/routes/health.routes.ts
import { Router, Request, Response } from 'express';
import { db } from '../database/connection';
import { redis } from '../cache/redis';
const router = Router();
// 기본 Liveness 체크 - 서버가 살아있는가?
router.get('/health', (req: Request, res: Response) => {
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(), // 서버 가동 시간(초)
});
});
// 상세 Readiness 체크 - 요청 처리 준비가 되었는가?
router.get('/health/ready', async (req: Request, res: Response) => {
const checks = {
server: 'ok',
database: 'unknown',
cache: 'unknown',
};
let isHealthy = true;
// 데이터베이스 연결 확인
try {
await db.ping(); // 간단한 쿼리로 연결 테스트
checks.database = 'ok';
} catch (error) {
checks.database = 'error';
isHealthy = false;
}
// Redis 캐시 연결 확인
try {
await redis.ping();
checks.cache = 'ok';
} catch (error) {
checks.cache = 'error';
// 캐시는 선택적이므로 실패해도 서버는 동작 가능
// isHealthy = false; // 필요시 활성화
}
const statusCode = isHealthy ? 200 : 503;
res.status(statusCode).json({
status: isHealthy ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
checks,
uptime: process.uptime(),
memory: process.memoryUsage(), // 메모리 사용량
});
});
// 간단한 Liveness 체크 (Kubernetes용)
router.get('/healthz', (req: Request, res: Response) => {
res.status(200).send('OK');
});
export default router;
// src/server.ts
import healthRoutes from './routes/health.routes';
// 헬스 체크는 인증 없이 접근 가능하도록 먼저 등록
app.use(healthRoutes);
// 그 다음 다른 라우터들
app.use('/api/blockchain', blockchainRoutes);
설명
이것이 하는 일: 서버 프로세스의 기본 상태와 데이터베이스, 캐시 같은 의존 서비스의 연결 상태를 확인하여, 로드 밸런서와 모니터링 시스템에게 서버가 정상인지 보고합니다. 첫 번째로, GET /health는 가장 단순한 Liveness 체크로, 서버가 응답만 할 수 있으면 200 OK를 반환합니다.
timestamp로 현재 시간을, uptime으로 서버가 얼마나 오래 실행되었는지를 포함하여 기본적인 상태 정보를 제공합니다. 이 엔드포인트는 매우 빠르게 응답해야 하므로 데이터베이스 쿼리 같은 무거운 작업을 하지 않습니다.
Kubernetes의 Liveness Probe가 이를 주기적으로 호출하여 실패하면 컨테이너를 재시작합니다. 그 다음으로, GET /health/ready는 더 상세한 Readiness 체크로, 서버가 실제로 요청을 처리할 준비가 되었는지 확인합니다.
데이터베이스에 ping() 쿼리를 보내 연결이 살아있는지 확인하고, Redis 캐시도 마찬가지로 체크합니다. 모든 필수 서비스가 정상이면 200 OK를, 하나라도 실패하면 503 Service Unavailable을 반환합니다.
로드 밸런서는 503을 받으면 해당 서버를 트래픽 풀에서 제외하여, 문제 있는 서버로 요청이 가지 않도록 합니다. checks 객체는 각 의존 서비스의 상태를 개별적으로 보고하여, 어떤 부분에 문제가 있는지 명확히 알 수 있게 합니다.
캐시 실패를 선택적으로 처리하는 것처럼, 중요도에 따라 특정 서비스 실패를 무시하거나 degraded 상태로 보고할 수 있습니다. memory 정보를 포함하면 메모리 누수를 모니터링하는 데 유용하며, 메모리 사용량이 임계값을 넘으면 알림을 보내도록 설정할 수 있습니다.
여러분이 이 엔드포인트를 구현하면 AWS ELB, Google Cloud Load Balancer, Kubernetes 같은 인프라가 자동으로 서버 상태를 체크하고, 문제가 생긴 서버를 자동으로 교체하며, 무중단 배포 시 새 버전이 준비되었는지 확인하여 안전하게 트래픽을 전환할 수 있습니다. 또한 모니터링 대시보드에서 /health/ready를 주기적으로 호출하여 서비스 가용성을 실시간으로 추적할 수 있습니다.
실전 팁
💡 헬스 체크는 인증을 요구하지 않아야 하므로 미들웨어 체인에서 인증 전에 등록하세요
💡 의존 서비스 체크에 타임아웃을 설정하여 느린 서비스 때문에 헬스 체크 자체가 타임아웃되지 않도록 하세요
💡 Prometheus 같은 메트릭 시스템과 통합하려면 /metrics 엔드포인트를 추가하여 prom-client 라이브러리로 메트릭을 노출할 수 있습니다
💡 상세한 에러 정보는 로그에만 기록하고 헬스 체크 응답에는 포함하지 않아 민감한 정보가 노출되지 않도록 하세요
💡 Docker Compose나 Kubernetes의 헬스 체크 설정에서 initialDelaySeconds를 충분히 주어 서버 시작 중에는 체크하지 않도록 하세요