이미지 로딩 중...
AI Generated
2025. 11. 16. · 5 Views
Fastify와 Socket.io 프로젝트 초기 설정 완벽 가이드
실시간 웹 애플리케이션을 만들고 싶으신가요? Fastify의 빠른 성능과 Socket.io의 강력한 실시간 통신을 결합하는 방법을 배워보세요. 프로젝트 설정부터 TypeScript 환경 구성, 폴더 구조 설계까지 실무에서 바로 사용할 수 있는 완벽한 가이드입니다.
목차
1. Fastify 설치 및 초기 설정
시작하며
여러분이 실시간 채팅 서비스나 협업 도구를 만들려고 할 때, 어떤 백엔드 프레임워크를 선택하시나요? Express는 익숙하지만 성능이 아쉽고, Nest.js는 너무 무겁게 느껴질 수 있습니다.
이런 고민은 많은 개발자들이 겪는 문제입니다. 빠른 성능이 필요하지만 동시에 간단하고 직관적인 API를 원하는 경우가 많죠.
특히 실시간 기능을 추가하려면 프레임워크가 가볍고 확장 가능해야 합니다. 바로 이럴 때 필요한 것이 Fastify입니다.
Express보다 최대 2배 빠른 성능을 제공하면서도 플러그인 시스템을 통해 Socket.io 같은 기능을 쉽게 통합할 수 있습니다.
개요
간단히 말해서, Fastify는 Node.js를 위한 초고속 웹 프레임워크입니다. Express와 비슷한 방식으로 사용하지만 내부적으로 훨씬 더 효율적으로 설계되어 있습니다.
왜 Fastify가 필요한지 실무 관점에서 보면, 많은 동시 접속자를 처리해야 하는 실시간 서비스에서 매우 유용합니다. 예를 들어, 실시간 주식 거래 플랫폼이나 멀티플레이어 게임 서버 같은 경우 서버의 응답 속도가 곧 사용자 경험으로 직결됩니다.
기존에는 Express로 서버를 만들고 성능 문제가 생기면 나중에 최적화를 고민했다면, 이제는 처음부터 Fastify로 시작하여 성능 걱정 없이 기능 개발에 집중할 수 있습니다. Fastify의 핵심 특징은 다음과 같습니다: 첫째, 스키마 기반 검증으로 요청/응답을 자동으로 검증합니다.
둘째, 강력한 플러그인 아키텍처로 기능을 모듈화할 수 있습니다. 셋째, TypeScript를 공식적으로 지원하여 타입 안정성을 보장합니다.
이러한 특징들이 대규모 프로젝트에서도 안정적으로 서비스를 운영할 수 있게 해줍니다.
코드 예제
// package.json 생성 및 필요한 패키지 설치
// npm init -y
// npm install fastify
// server.ts - Fastify 서버의 기본 구조
import Fastify from 'fastify';
// Fastify 인스턴스 생성 - logger 옵션으로 요청/응답 로깅
const fastify = Fastify({
logger: true // 개발 중 디버깅에 매우 유용합니다
});
// 간단한 헬스체크 엔드포인트
fastify.get('/health', async (request, reply) => {
return { status: 'ok', timestamp: new Date() };
});
// 서버 시작 함수
const start = async () => {
try {
await fastify.listen({ port: 3000, host: '0.0.0.0' });
console.log('서버가 3000번 포트에서 실행 중입니다!');
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
설명
이것이 하는 일: 위 코드는 Fastify를 사용하여 가장 기본적인 HTTP 서버를 생성하고 실행합니다. 헬스체크 엔드포인트를 통해 서버가 정상적으로 작동하는지 확인할 수 있습니다.
첫 번째로, Fastify 인스턴스를 생성하는 부분에서 logger 옵션을 true로 설정합니다. 이렇게 하면 모든 HTTP 요청과 응답이 자동으로 콘솔에 기록됩니다.
개발 중에 어떤 요청이 들어오는지, 응답 시간이 얼마나 걸리는지 한눈에 볼 수 있어서 디버깅이 훨씬 쉬워집니다. 그 다음으로, fastify.get() 메서드로 라우트를 등록합니다.
이 부분은 Express의 app.get()과 거의 동일하게 작동하지만, Fastify는 내부적으로 더 빠른 라우터를 사용합니다. async/await를 사용하여 비동기 작업을 깔끔하게 처리할 수 있습니다.
마지막으로, fastify.listen() 메서드가 실제로 서버를 시작합니다. host를 '0.0.0.0'으로 설정하면 외부에서도 접근 가능한 서버가 됩니다.
Docker 컨테이너 환경에서는 반드시 이렇게 설정해야 합니다. try-catch 블록으로 에러를 처리하여 서버 시작 실패 시 프로세스를 안전하게 종료합니다.
여러분이 이 코드를 사용하면 몇 줄의 코드만으로 프로덕션에서 사용할 수 있는 견고한 서버를 만들 수 있습니다. 자동 로깅으로 모니터링이 쉽고, 에러 핸들링이 내장되어 있어 안정성이 높으며, TypeScript 지원으로 타입 안정성까지 보장됩니다.
실전 팁
💡 개발 환경에서는 logger를 true로, 프로덕션에서는 { level: 'error' }로 설정하세요. 불필요한 로그가 줄어들어 성능이 향상됩니다.
💡 fastify.listen() 대신 fastify.listen({ port: 3000 })처럼 옵션 객체를 사용하세요. 나중에 host, backlog 등 추가 설정이 필요할 때 코드 변경이 최소화됩니다.
💡 서버 시작 전에 fastify.ready()를 호출하여 모든 플러그인이 로드되었는지 확인하세요. 특히 여러 플러그인을 사용할 때 예상치 못한 에러를 방지할 수 있습니다.
💡 환경 변수로 포트를 관리하세요 (process.env.PORT || 3000). 배포 환경마다 다른 포트를 사용할 수 있어 유연성이 높아집니다.
💡 fastify.addHook()를 사용하여 요청 전후에 실행될 로직을 추가하세요. 인증, 로깅, 성능 측정 등을 일관되게 처리할 수 있습니다.
2. Socket.io 플러그인 통합
시작하며
여러분이 Fastify 서버를 만들었는데, 이제 실시간 채팅 기능을 추가하려고 합니다. Socket.io를 별도로 설정하려니 포트를 따로 열어야 하고, Fastify와 연결하는 것도 복잡해 보입니다.
이런 문제는 두 개의 독립적인 서버를 관리해야 하는 복잡성을 만듭니다. HTTP 요청은 Fastify로, WebSocket은 Socket.io로 처리하면 코드가 분산되고 배포도 어려워집니다.
또한 인증 로직을 두 곳에서 따로 관리해야 하는 불편함도 있습니다. 바로 이럴 때 필요한 것이 fastify-socket.io 플러그인입니다.
하나의 서버에서 HTTP와 WebSocket을 모두 처리하여 코드를 통합하고 관리를 단순화할 수 있습니다.
개요
간단히 말해서, fastify-socket.io는 Fastify 서버에 Socket.io를 완벽하게 통합해주는 공식 플러그인입니다. 별도의 서버 없이 같은 포트에서 HTTP와 WebSocket을 함께 사용할 수 있습니다.
왜 이 플러그인이 필요한지 실무 관점에서 보면, 마이크로서비스 아키텍처에서 서비스당 하나의 포트만 사용하는 것이 관리하기 훨씬 쉽습니다. 예를 들어, Kubernetes 환경에서 하나의 서비스로 배포하면 로드밸런싱, 헬스체크, 모니터링이 모두 단순해집니다.
기존에는 Express + Socket.io를 같은 HTTP 서버에 붙이는 방식을 사용했다면, 이제는 Fastify의 플러그인 시스템을 활용하여 더 깔끔하게 통합할 수 있습니다. 이 플러그인의 핵심 특징은 다음과 같습니다: 첫째, Fastify의 데코레이터 패턴으로 fastify.io에서 Socket.io 인스턴스에 접근할 수 있습니다.
둘째, Fastify의 라이프사이클 훅과 완벽하게 통합되어 서버 시작/종료가 자동으로 관리됩니다. 셋째, CORS 설정을 Fastify와 공유하여 설정 중복을 방지합니다.
이러한 특징들이 코드의 일관성과 유지보수성을 크게 향상시킵니다.
코드 예제
// 필요한 패키지 설치
// npm install fastify-socket.io socket.io
// server.ts - Socket.io 플러그인 통합
import Fastify from 'fastify';
import fastifySocketIO from 'fastify-socket.io';
const fastify = Fastify({ logger: true });
// Socket.io 플러그인 등록 - CORS 설정 포함
await fastify.register(fastifySocketIO, {
cors: {
origin: 'http://localhost:5173', // 프론트엔드 주소
credentials: true
}
});
// Fastify가 준비된 후 Socket.io 이벤트 리스너 등록
fastify.ready().then(() => {
// fastify.io를 통해 Socket.io 인스턴스 접근
fastify.io.on('connection', (socket) => {
console.log(`새 클라이언트 연결: ${socket.id}`);
// 메시지 수신 이벤트
socket.on('message', (data) => {
console.log('받은 메시지:', data);
// 모든 클라이언트에게 브로드캐스트
fastify.io.emit('message', data);
});
socket.on('disconnect', () => {
console.log(`클라이언트 연결 해제: ${socket.id}`);
});
});
});
await fastify.listen({ port: 3000, host: '0.0.0.0' });
설명
이것이 하는 일: 위 코드는 Fastify 서버에 Socket.io를 플러그인으로 등록하고, 클라이언트 연결을 처리하며, 실시간 메시지를 주고받는 완전한 WebSocket 서버를 만듭니다. 첫 번째로, fastify.register()로 플러그인을 등록하는 부분에서 CORS 설정을 함께 전달합니다.
이렇게 하면 프론트엔드 앱이 다른 도메인에서 실행되어도 WebSocket 연결이 가능합니다. credentials: true는 쿠키를 포함한 인증 정보를 주고받을 때 필수입니다.
개발 환경에서는 origin을 명시하고, 프로덕션에서는 환경 변수로 관리하는 것이 좋습니다. 그 다음으로, fastify.ready()를 사용하여 모든 플러그인이 완전히 로드된 후에 Socket.io 이벤트를 등록합니다.
이 단계가 중요한 이유는, 플러그인 로딩이 완료되기 전에 fastify.io에 접근하면 undefined 에러가 발생하기 때문입니다. ready() 안에서 작업하면 이런 타이밍 문제를 완벽하게 해결할 수 있습니다.
마지막으로, connection 이벤트에서 각 클라이언트의 socket 객체를 받아 메시지 처리 로직을 구현합니다. socket.on('message')로 특정 이벤트를 듣고, fastify.io.emit()으로 모든 연결된 클라이언트에게 메시지를 전송합니다.
이 패턴은 채팅방, 실시간 알림, 협업 도구 등 다양한 실시간 기능의 기초가 됩니다. 여러분이 이 코드를 사용하면 복잡한 설정 없이 실시간 양방향 통신이 가능한 서버를 만들 수 있습니다.
하나의 포트로 REST API와 WebSocket을 모두 제공하여 인프라가 단순해지고, Fastify의 플러그인 생태계를 활용하여 인증, 로깅 등을 일관되게 적용할 수 있으며, TypeScript 타입 지원으로 실수를 사전에 방지할 수 있습니다.
실전 팁
💡 Socket.io 네임스페이스를 사용하여 기능별로 연결을 분리하세요. fastify.io.of('/chat')처럼 사용하면 채팅, 알림, 게임 등을 독립적으로 관리할 수 있습니다.
💡 socket.join(roomId)으로 룸 기능을 구현하세요. 특정 그룹에만 메시지를 보낼 수 있어 채팅방이나 게임 로비를 쉽게 만들 수 있습니다.
💡 연결 시 인증을 반드시 구현하세요. socket.handshake.auth에서 토큰을 확인하고, 유효하지 않으면 socket.disconnect()로 연결을 끊어야 보안이 강화됩니다.
💡 이벤트 이름을 상수로 관리하세요. 프론트엔드와 백엔드가 같은 상수를 import하면 오타로 인한 버그를 완전히 제거할 수 있습니다.
💡 socket.volatile.emit()을 사용하여 중요하지 않은 데이터를 전송하세요. 네트워크가 느릴 때 자동으로 스킵되어 성능이 향상됩니다 (예: 마우스 커서 위치).
3. TypeScript 환경 구성
시작하며
여러분이 Fastify와 Socket.io로 서버를 만들고 있는데, 자동완성이 제대로 안 되고 타입 에러가 계속 발생합니다. JavaScript로 작성하면 편하지만, 프로젝트가 커질수록 어디서 버그가 생길지 불안합니다.
이런 문제는 대부분의 JavaScript 프로젝트에서 겪는 고질적인 문제입니다. 함수의 파라미터가 뭔지 일일이 문서를 찾아봐야 하고, 리팩토링할 때 어디를 고쳐야 할지 찾기 어렵습니다.
특히 팀 프로젝트에서는 다른 사람이 만든 코드를 이해하는 데 시간이 너무 오래 걸립니다. 바로 이럴 때 필요한 것이 TypeScript 환경 구성입니다.
처음 설정은 조금 복잡하지만, 한 번 제대로 설정하면 개발 속도가 눈에 띄게 빨라지고 버그도 획기적으로 줄어듭니다.
개요
간단히 말해서, TypeScript는 JavaScript에 타입 시스템을 추가한 언어로, 코드를 작성하는 동안 에러를 미리 발견할 수 있게 해줍니다. Fastify와 Socket.io 모두 TypeScript를 공식 지원하여 완벽한 타입 안정성을 제공합니다.
왜 TypeScript가 필요한지 실무 관점에서 보면, 대규모 프로젝트에서 코드의 신뢰성이 매우 중요합니다. 예를 들어, API 응답 타입이 바뀌었을 때 TypeScript는 영향받는 모든 코드를 자동으로 찾아주어 배포 전에 문제를 해결할 수 있습니다.
기존에는 런타임에 에러가 발생하고 나서야 문제를 알 수 있었다면, 이제는 코드를 작성하는 순간 IDE가 빨간 줄로 문제를 알려주어 개발 시간이 크게 단축됩니다. TypeScript 환경의 핵심 특징은 다음과 같습니다: 첫째, tsconfig.json으로 프로젝트 전체의 타입 체크 규칙을 통일합니다.
둘째, @types 패키지로 외부 라이브러리의 타입 정의를 자동으로 가져옵니다. 셋째, ts-node나 tsx로 TypeScript 파일을 직접 실행하여 개발 경험을 개선합니다.
이러한 특징들이 팀 전체의 코드 품질을 일정 수준 이상으로 유지하게 해줍니다.
코드 예제
// package.json에 필요한 패키지 추가
// npm install -D typescript @types/node tsx
// npm install -D @types/socket.io
// tsconfig.json - TypeScript 설정 파일
{
"compilerOptions": {
"target": "ES2022", // 최신 JavaScript 기능 사용
"module": "ESNext", // ES 모듈 시스템 사용
"moduleResolution": "node", // Node.js 방식으로 모듈 해석
"lib": ["ES2022"], // 사용 가능한 JavaScript API
"outDir": "./dist", // 컴파일된 파일 출력 폴더
"rootDir": "./src", // 소스 코드 루트 폴더
"strict": true, // 모든 엄격한 타입 체크 활성화
"esModuleInterop": true, // CommonJS 모듈과의 호환성
"skipLibCheck": true, // 라이브러리 타입 체크 스킵 (빌드 속도 향상)
"forceConsistentCasingInFileNames": true, // 파일명 대소문자 일관성 체크
"resolveJsonModule": true // JSON 파일 import 허용
},
"include": ["src/**/*"], // 컴파일할 파일 패턴
"exclude": ["node_modules", "dist"] // 제외할 폴더
}
설명
이것이 하는 일: 위 설정은 TypeScript 컴파일러가 여러분의 코드를 어떻게 해석하고 검사할지 정의합니다. 최신 JavaScript 기능을 사용하면서도 엄격한 타입 체크로 안정성을 보장하는 균형잡힌 설정입니다.
첫 번째로, target과 module 설정은 어떤 버전의 JavaScript로 컴파일할지 결정합니다. ES2022를 사용하면 async/await, optional chaining, nullish coalescing 같은 최신 기능을 모두 사용할 수 있습니다.
Node.js 16 이상을 사용한다면 이 설정이 최적입니다. ESNext 모듈 시스템은 import/export 문법을 그대로 유지하여 번들러와의 호환성이 좋습니다.
그 다음으로, strict: true가 가장 중요한 설정입니다. 이 하나의 옵션이 noImplicitAny, strictNullChecks, strictFunctionTypes 등 10개 이상의 엄격한 규칙을 한 번에 활성화합니다.
처음에는 빨간 줄이 많이 보여 당황스러울 수 있지만, 이 에러들이 모두 실제 버그가 될 수 있는 부분입니다. 예를 들어, null이나 undefined 체크를 강제하여 런타임 에러를 사전에 방지합니다.
마지막으로, outDir과 rootDir은 프로젝트 구조를 깔끔하게 유지하게 해줍니다. src 폴더에 TypeScript 소스 코드를 작성하면, 컴파일된 JavaScript 파일은 dist 폴더에 생성됩니다.
배포할 때는 dist 폴더만 서버에 올리면 되므로 배포 패키지 크기가 줄어들고 관리가 쉬워집니다. 여러분이 이 설정을 사용하면 IDE의 자동완성이 매우 정확해지고, 리팩토링할 때 놓치는 부분이 없으며, 팀원들과 코드 스타일이 자동으로 통일됩니다.
특히 Fastify의 request.body나 Socket.io의 이벤트 데이터 타입을 명시하면, 실수로 잘못된 속성에 접근하는 것을 완전히 방지할 수 있습니다.
실전 팁
💡 package.json에 "type": "module"을 추가하여 ES 모듈을 기본으로 사용하세요. import 문법이 일관되게 작동하여 혼란이 줄어듭니다.
💡 개발 중에는 tsx로 실행하세요 (npx tsx src/server.ts). 파일 변경을 감지하여 자동으로 재시작하는 --watch 옵션을 함께 사용하면 생산성이 크게 향상됩니다.
💡 paths 옵션으로 절대 경로 alias를 설정하세요. "@/utils/logger" 같은 방식으로 import하면 ../../../../ 같은 상대 경로 지옥에서 벗어날 수 있습니다.
💡 strictNullChecks가 활성화되면 optional chaining(?.)과 nullish coalescing(??)를 적극 활용하세요. 코드가 간결해지면서도 null 안정성이 보장됩니다.
💡 @types 패키지는 자동으로 인식되므로 별도로 import할 필요가 없습니다. node_modules/@types 폴더에만 있으면 TypeScript가 알아서 찾아줍니다.
4. 프로젝트 폴더 구조 설계
시작하며
여러분이 프로젝트를 시작할 때 모든 파일을 src 폴더에 한꺼번에 넣고 작업하시나요? 처음에는 파일이 몇 개 없어서 괜찮지만, 프로젝트가 커지면 어떤 파일이 어디 있는지 찾기 어려워집니다.
이런 문제는 팀 프로젝트에서 더욱 심각해집니다. 새로운 팀원이 합류했을 때 코드 구조를 설명하는 데만 한나절이 걸리고, 라우터를 수정하려는데 비즈니스 로직까지 함께 있어서 어디를 고쳐야 할지 헷갈립니다.
특히 Socket.io 이벤트 핸들러와 HTTP 라우터가 섞여 있으면 유지보수가 악몽이 됩니다. 바로 이럴 때 필요한 것이 명확한 폴더 구조 설계입니다.
처음부터 역할별로 폴더를 나누면 코드를 찾기 쉽고, 테스트하기 쉬우며, 나중에 확장하기도 쉬워집니다.
개요
간단히 말해서, 폴더 구조는 코드의 역할에 따라 파일을 분류하는 규칙입니다. Fastify와 Socket.io 프로젝트에서는 라우터, 플러그인, 핸들러, 유틸리티를 명확히 분리하는 것이 핵심입니다.
왜 체계적인 폴더 구조가 필요한지 실무 관점에서 보면, 코드 리뷰할 때 어떤 파일을 봐야 할지 바로 알 수 있습니다. 예를 들어, 인증 로직을 수정한다면 src/plugins/auth.ts를 보면 되고, 채팅 기능을 추가한다면 src/socket/handlers/chat.ts를 만들면 됩니다.
이런 예측 가능성이 개발 속도를 크게 높입니다. 기존에는 파일명으로만 구분하거나 (user-routes.ts, user-service.ts) 기능별로 묶었다면 (users/routes.ts, users/service.ts), 이제는 계층별로 명확히 분리하여 의존성 방향을 한눈에 파악할 수 있습니다.
좋은 폴더 구조의 핵심 특징은 다음과 같습니다: 첫째, 각 폴더가 하나의 명확한 책임을 가집니다 (Single Responsibility). 둘째, 의존성이 항상 상위에서 하위로만 흐릅니다 (routes → services → utils).
셋째, 새로운 기능을 추가할 때 어디에 파일을 만들지 고민할 필요가 없습니다. 이러한 특징들이 장기적으로 프로젝트의 건강성을 유지하게 해줍니다.
코드 예제
// 프로젝트 폴더 구조
src/
├── server.ts // 서버 진입점 - Fastify 인스턴스 생성 및 시작
├── app.ts // 앱 설정 - 플러그인 등록 및 라우터 연결
├── config/ // 설정 파일들
│ ├── index.ts // 환경 변수 로드 및 검증
│ └── socket.ts // Socket.io 전용 설정
├── plugins/ // Fastify 플러그인들
│ ├── auth.ts // JWT 인증 플러그인
│ ├── cors.ts // CORS 설정 플러그인
│ └── socket.ts // Socket.io 플러그인 등록
├── routes/ // HTTP 라우터들
│ ├── index.ts // 모든 라우터 모음
│ ├── health.ts // 헬스체크 엔드포인트
│ └── users.ts // 사용자 관련 API
├── socket/ // Socket.io 관련 코드
│ ├── index.ts // Socket.io 이벤트 등록
│ └── handlers/ // 이벤트 핸들러들
│ ├── chat.ts // 채팅 이벤트 처리
│ └── notification.ts // 알림 이벤트 처리
├── services/ // 비즈니스 로직
│ └── user.service.ts
├── types/ // TypeScript 타입 정의
│ ├── socket.ts // Socket.io 이벤트 타입
│ └── api.ts // API 요청/응답 타입
└── utils/ // 공통 유틸리티 함수
└── logger.ts
설명
이것이 하는 일: 위 폴더 구조는 각 파일의 역할을 명확히 정의하여 코드를 쉽게 찾고 수정할 수 있게 합니다. 수평적으로는 기능별로, 수직적으로는 계층별로 분리하는 균형잡힌 구조입니다.
첫 번째로, server.ts와 app.ts를 분리하는 것이 중요합니다. server.ts는 단순히 서버를 시작하는 역할만 하고, app.ts에서 모든 플러그인과 라우터를 등록합니다.
이렇게 분리하면 테스트할 때 서버를 실제로 시작하지 않고 app만 import하여 테스트할 수 있습니다. 통합 테스트에서는 server를, 유닛 테스트에서는 app을 사용하는 식으로 유연하게 대응할 수 있습니다.
그 다음으로, plugins 폴더에 모든 Fastify 플러그인을 모아둡니다. 인증, CORS, Socket.io, 데이터베이스 연결 등 서버 전역에서 사용하는 기능들이 여기 들어갑니다.
각 플러그인은 독립적으로 테스트할 수 있고, 필요에 따라 활성화/비활성화하기 쉽습니다. 예를 들어, 개발 환경에서는 auth 플러그인을 건너뛰고 싶다면 조건부로 등록하면 됩니다.
세 번째로, socket 폴더를 routes와 분리한 것이 핵심입니다. HTTP와 WebSocket은 통신 방식이 완전히 다르므로 한 곳에 섞어두면 혼란스럽습니다.
socket/handlers 안에 이벤트별로 파일을 만들면, 각 핸들러가 독립적으로 동작하여 테스트와 재사용이 쉬워집니다. chat.ts에서 메시지 관련 로직만, notification.ts에서 알림만 처리하는 식입니다.
여러분이 이 구조를 사용하면 새로운 API를 추가할 때 routes 폴더에, 새로운 실시간 기능을 추가할 때 socket/handlers에 파일을 만들기만 하면 됩니다. 코드 리뷰할 때도 "routes를 수정했으니 HTTP API가 바뀐 거구나", "services를 수정했으니 비즈니스 로직이 바뀐 거구나"라고 즉시 이해할 수 있습니다.
또한 types 폴더에 타입을 중앙 관리하여 프론트엔드와 타입을 공유하기도 쉽습니다.
실전 팁
💡 각 폴더에 index.ts를 만들어 export를 모아두세요. 다른 곳에서 import할 때 "from '@/routes'"처럼 깔끔하게 사용할 수 있습니다.
💡 services 폴더는 데이터베이스나 외부 API 호출을 담당합니다. routes에서 직접 DB를 호출하지 말고 service를 거치면 테스트할 때 service만 mock하면 되어 편리합니다.
💡 types 폴더의 타입을 프론트엔드와 공유하려면 별도 npm 패키지로 만드세요. @yourproject/types 같은 패키지로 배포하면 타입 불일치 버그가 사라집니다.
💡 utils는 순수 함수만 넣으세요. Fastify나 Socket.io에 의존하지 않는 함수들을 모아두면 다른 프로젝트에서도 재사용할 수 있습니다.
💡 tests 폴더를 src 안에 만들어 테스트 파일을 가까이 두세요. user.service.ts 옆에 user.service.test.ts가 있으면 테스트 작성과 유지보수가 훨씬 쉬워집니다.
5. 환경 변수 및 설정 파일 관리
시작하며
여러분이 개발 서버와 프로덕션 서버에서 다른 포트를 사용하고, 다른 데이터베이스에 연결해야 합니다. 코드에 직접 "localhost:3000"이라고 적어두면 배포할 때마다 코드를 수정해야 하고, 실수로 프로덕션 DB 주소를 커밋하면 보안 사고가 발생할 수 있습니다.
이런 문제는 설정을 코드와 분리하지 않아서 생깁니다. API 키, 데이터베이스 비밀번호 같은 민감한 정보가 GitHub에 올라가면 몇 분 안에 해킹당할 수 있습니다.
또한 환경마다 다른 설정을 사용하려면 if문으로 분기 처리하는데, 이게 쌓이면 코드가 지저분해집니다. 바로 이럴 때 필요한 것이 환경 변수 관리입니다.
.env 파일로 설정을 외부화하고, 타입 체크까지 추가하면 안전하고 유지보수하기 쉬운 설정 시스템을 만들 수 있습니다.
개요
간단히 말해서, 환경 변수는 코드 외부에서 주입하는 설정 값입니다. dotenv 라이브러리로 .env 파일을 로드하고, TypeScript로 타입을 검증하여 실수를 방지할 수 있습니다.
왜 환경 변수가 필요한지 실무 관점에서 보면, 12 Factor App 원칙에서 설정을 환경에 저장하라고 명시하고 있습니다. 예를 들어, Docker 컨테이너로 배포할 때 환경 변수만 바꾸면 같은 이미지로 개발/스테이징/프로덕션 환경을 모두 운영할 수 있습니다.
기존에는 설정 파일을 환경별로 따로 만들었다면 (config.dev.json, config.prod.json), 이제는 하나의 코드에 환경 변수만 바꿔서 주입하면 되므로 훨씬 간단합니다. 환경 변수 관리의 핵심 특징은 다음과 같습니다: 첫째, .env 파일은 .gitignore에 추가하여 절대 커밋하지 않습니다.
둘째, .env.example 파일로 필요한 환경 변수 목록을 문서화합니다. 셋째, 애플리케이션 시작 시 필수 환경 변수가 없으면 에러를 발생시켜 잘못된 설정으로 실행되는 것을 방지합니다.
이러한 특징들이 설정 관련 버그를 사전에 차단합니다.
코드 예제
// .env 파일 (절대 커밋하지 말 것!)
NODE_ENV=development
PORT=3000
HOST=0.0.0.0
CORS_ORIGIN=http://localhost:5173
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=your-super-secret-key-change-in-production
SOCKET_PING_TIMEOUT=60000
// src/config/index.ts - 환경 변수 로드 및 검증
import { config as dotenvConfig } from 'dotenv';
import { z } from 'zod'; // npm install zod
// .env 파일 로드
dotenvConfig();
// 환경 변수 스키마 정의 - 타입과 검증 규칙
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().transform(Number).pipe(z.number().min(1).max(65535)),
HOST: z.string().default('0.0.0.0'),
CORS_ORIGIN: z.string().url(),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32), // 최소 32자 이상
SOCKET_PING_TIMEOUT: z.string().transform(Number)
});
// 환경 변수 파싱 및 검증 - 실패 시 프로세스 종료
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ 환경 변수 검증 실패:', parsed.error.format());
process.exit(1);
}
// 타입 안전한 설정 객체 export
export const config = parsed.data;
설명
이것이 하는 일: 위 코드는 .env 파일에서 환경 변수를 읽어와 타입을 검증하고, 모든 검증이 통과하면 타입 안전한 설정 객체를 제공합니다. 잘못된 설정으로 서버가 실행되는 것을 원천 차단합니다.
첫 번째로, dotenvConfig()가 .env 파일을 읽어 process.env에 주입합니다. 이 함수는 프로젝트 루트에서 .env 파일을 찾아 KEY=VALUE 형식을 파싱합니다.
주의할 점은 환경 변수는 항상 문자열이라는 것입니다. PORT=3000이라고 써도 process.env.PORT는 "3000"이지 숫자 3000이 아닙니다.
그래서 타입 변환이 필요합니다. 그 다음으로, zod 스키마로 각 환경 변수의 타입과 규칙을 정의합니다.
z.string().transform(Number)는 문자열을 숫자로 변환하고, pipe(z.number().min(1))로 추가 검증을 합니다. JWT_SECRET처럼 민감한 값은 min(32)로 최소 길이를 강제하여 약한 시크릿 키를 사전에 차단합니다.
CORS_ORIGIN은 url() 검증으로 잘못된 URL 형식을 거부합니다. 마지막으로, safeParse()가 실제 검증을 수행합니다.
모든 규칙을 통과하면 parsed.success가 true가 되고, 하나라도 실패하면 에러 메시지를 출력하고 process.exit(1)로 서버를 종료합니다. 이렇게 하면 잘못된 설정으로 서버가 실행되어 런타임 에러가 발생하는 최악의 상황을 방지할 수 있습니다.
특히 프로덕션에서 DATABASE_URL이 없는데 서버가 시작되면 큰 문제가 되는데, 이를 완벽하게 차단합니다. 여러분이 이 패턴을 사용하면 설정 관련 버그가 사라집니다.
TypeScript 덕분에 config.PORT를 사용할 때 자동완성이 되고, 오타를 치면 즉시 에러가 발생합니다. 새로운 환경 변수를 추가할 때도 스키마에 정의하면 모든 환경에서 일관되게 검증되어 "로컬에서는 되는데 프로덕션에서 안 돼요" 같은 문제가 없어집니다.
또한 .env.example을 만들어 팀원들과 공유하면 누구나 빠르게 개발 환경을 세팅할 수 있습니다.
실전 팁
💡 .env.example 파일을 반드시 만들어 커밋하세요. 실제 값 대신 설명을 적어두면 (JWT_SECRET=your-secret-key-here) 새 팀원이 무엇을 설정해야 할지 바로 알 수 있습니다.
💡 민감한 정보는 절대 기본값을 주지 마세요. DATABASE_URL 같은 것은 .default()를 쓰지 말고 필수로 만들어야 실수로 빈 값으로 실행되는 것을 막을 수 있습니다.
💡 여러 환경을 관리하려면 .env.development, .env.production 파일을 만들고 NODE_ENV에 따라 로드하세요. dotenv-cli를 사용하면 "dotenv -e .env.production -- node dist/server.js"처럼 실행할 수 있습니다.
💡 Docker를 사용한다면 .env 파일 대신 docker-compose.yml의 environment 섹션을 사용하세요. 컨테이너 오케스트레이션에서 설정을 중앙 관리할 수 있습니다.
💡 config 객체를 동결하세요 (Object.freeze(config)). 런타임에 실수로 설정을 변경하는 것을 방지하여 예측 가능성이 높아집니다.