[Froppy 개발기 3편] NestJS로 스타트업 백엔드 설계하기 — 모듈 분리부터 Guard까지
53분 전
▣Froppy4개 기록▲ 목록 접기
NestJS로 스타트업 백엔드 설계하기
프레임워크의 가치는 "빨리 만들 때"가 아니라
"많이 만들어놓고 다시 읽을 때" 드러난다.
들어가며
Froppy 백엔드는 최종적으로 이렇게 커졌다.
- 도메인 모듈 15개 이상
- TypeORM 엔티티 27개
- REST API + WebSocket Gateway
- 결제 웹훅, 스케줄러, 관리자 API
혼자 만들었는데도 끝까지 길을 잃지 않았던 건
NestJS가 구조를 강제해줬기 때문이다.
이번 글에서는 그 구조를 어떻게 썼는지, 그리고 어디서 실수했는지를 정리한다.
1. 모듈 분리 — "기능"이 아니라 "도메인" 기준으로
폴더 구조는 이렇다.
backend/src/
├── auth/ 인증 (JWT, OAuth 3종, Guard, Strategy)
├── users/ 프로필, 계정
├── posts/ 게시글, 댓글, 좋아요, 북마크
├── follows/ 팔로우
├── memberships/ 멤버십 상품
├── subscriptions/ 정기 구독 + 스케줄러
├── payments/ 후원 결제, PortOne 연동
├── chat/ WebSocket Gateway
├── notifications/ 알림
├── files/ 파일 업로드
├── cloudflare/ Stream / Images API
├── r2/ Cloudflare R2
├── supabase/ Supabase Storage
├── legal-documents/ 약관 / 개인정보처리방침
├── legal-consents/ 동의 이력
├── inquiry/ 고객 문의
├── admin/ 운영 어드민
├── config/ 환경 설정 서비스
└── entities/ TypeORM 엔티티 (공용)
분리 기준 3가지
① 변경 이유가 같은 것끼리 묶는다
게시글, 댓글, 좋아요, 북마크는 "콘텐츠 피드"라는 하나의 이유로 바뀐다.
그래서 posts/ 안에 comment.service.ts, like.service.ts, bookmark.service.ts를 함께 뒀다.
② 외부 시스템 연동은 모듈로 격리한다
cloudflare/, r2/, supabase/는 각각 외부 서비스 하나씩만 감싼다.
Cloudflare Stream API가 바뀌어도 cloudflare/ 밖은 건드릴 필요가 없다.
결제 PG를 갈아끼울 일이 생겼을 때도(실제로 PG 심사 때문에 고민했다)
PortoneService 하나가 경계 역할을 해줬다.
③ 모듈 간 의존은 exports로만 연다
@Module({
imports: [
TypeOrmModule.forFeature([
Sponsorship, PaymentAccount, User,
SubscriptionPayment, Subscription,
]),
ConfigModule,
NotificationModule, // 결제 완료 시 알림 발송
LegalConsentsModule, // 결제 전 약관 동의 검증
],
controllers: [PaymentsController],
providers: [PaymentsService, PortoneService],
exports: [PaymentsService, PortoneService], // 밖에서 쓸 것만 공개
})
export class PaymentsModule {}
imports만 보면 "결제 모듈은 알림과 법적 동의에 의존한다"는 사실이 한눈에 보인다.
문서를 따로 쓰지 않아도 모듈 파일이 곧 의존성 지도다.
2. 인증 설계 — Guard를 3종으로 나누다
처음에는 JwtAuthGuard 하나면 될 줄 알았다.
하지만 서비스를 만들다 보니 요구가 세 가지로 갈렸다.
| Guard | 동작 | 사용처 |
|---|---|---|
JwtAuthGuard | 토큰 없으면 401 | 내 정보, 결제, 채팅 |
JwtOptionalAuthGuard | 토큰 없어도 통과 | 공개 피드, 게시글 상세 |
RolesGuard | 역할 불일치 시 403 | 관리자 API |
JwtAuthGuard — 기본 필수 인증
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest(err, user, info) {
if (err || !user) {
logger.warn(`인증 실패: ${info?.message}`);
throw new UnauthorizedException();
}
return user; // req.user에 주입
}
}
JwtOptionalAuthGuard — 비로그인도 보게 하되, 로그인하면 더 보여주기
이게 의외로 중요했다.
피드는 비로그인 사용자도 볼 수 있어야 한다(유입 때문에).
하지만 로그인 사용자에게는 "내가 좋아요 눌렀는지" 같은 정보를 같이 내려줘야 한다.
@Injectable()
export class JwtOptionalAuthGuard extends AuthGuard('jwt') {
handleRequest(err, user, info) {
return user; // 없으면 null → 요청은 통과
}
}
컨트롤러에서는 req.user가 있는지만 보면 된다.
@UseGuards(JwtOptionalAuthGuard)
@Get(':id')
async getPost(@Param('id') id: number, @Req() req) {
const viewerId = req.user?.id; // 비로그인이면 undefined
return this.postService.findOne(id, viewerId);
}
엔드포인트를 "공개용 / 로그인용"으로 중복 작성하지 않아도 된다.
RolesGuard — 데코레이터 기반 역할 검사
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles?.length) return true;
const { user } = context.switchToHttp().getRequest();
if (!requiredRoles.includes(user?.role)) {
throw new ForbiddenException('관리자 권한이 필요합니다.');
}
return true;
}
}
사용은 이렇게 한다.
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Controller('admin/disputes')
export class AdminDisputesController { ... }
클래스 레벨에 @Roles를 걸고, 특정 핸들러에서 덮어쓸 수도 있다.
(getAllAndOverride가 핸들러 → 클래스 순으로 우선순위를 준다.)
JwtStrategy — 토큰이 아니라 DB를 신뢰한다
JWT 페이로드만 믿으면 탈퇴/정지된 사용자의 토큰이 만료 전까지 살아있다.
그래서 JwtStrategy.validate()에서 매 요청마다 DB의 최신 사용자 정보를 조회해 req.user에 넣었다.
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, // 만료 토큰 즉시 거부
secretOrKey: appConfig.jwtSecret,
});
요청마다 쿼리가 1회 늘어나는 비용이 있지만,
Role 변경(관리자 승격/회수)이 즉시 반영된다는 이점이 더 컸다.
3. 환경 분리 — .env.{NODE_ENV} 전략
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [
`.env.${process.env.NODE_ENV || 'development'}`,
'.env', // fallback
],
}),
.env.development → 로컬 PostgreSQL, localhost CORS
.env.staging
.env.production → Supabase DB, 운영 도메인 CORS
package.json 스크립트로 환경을 선택한다.
"start:development": "set NODE_ENV=development&& nest start --watch",
"start:production": "set NODE_ENV=production&& node dist/main"
Windows에서 개발해서
set NODE_ENV=...&&를 썼다.
팀이 생기면cross-env로 바꿔야 한다. (macOS/Linux에서 바로 깨진다)
CORS도 환경변수로 뺐다.
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map((o) => o.trim())
: ['http://localhost:8080', 'http://127.0.0.1:8080'];
app.enableCors({ origin: allowedOrigins, credentials: true, ... });
운영 도메인이 바뀌어도 코드 수정 없이 환경변수만 바꾸면 된다.
4. 입력 검증 — DTO + class-validator
컨트롤러 진입 전에 ValidationPipe가 DTO 기준으로 검증한다.
@Post('register')
async register(@Body(ValidationPipe) dto: CreateUserDto) {
return this.authService.register(dto);
}
잘못된 입력은 서비스 로직에 닿기 전에 400으로 끊긴다.
서비스 레이어에서 if (!email) 같은 방어 코드를 쓰지 않아도 되어
비즈니스 로직이 훨씬 깨끗해졌다.
5. 스케줄러 — 정기 구독은 결국 크론이다
멤버십 구독은 결국 "매일 자정에 결제일이 된 구독을 찾아 청구"하는 일이다.
@Injectable()
export class SubscriptionScheduler {
@Cron('0 0 * * *', { timeZone: 'Asia/Seoul' })
async handleDailyBilling() { ... }
}
@nestjs/schedule 한 줄로 끝났다.
인프라 사용량 수집(infra-usage.service.ts)도 @Cron('0 6 * * *')로
매일 아침 Supabase/Railway 사용량을 스냅샷으로 저장했다.
주의: 서버를 2대 이상으로 늘리면 크론이 중복 실행된다.
단일 인스턴스일 때만 안전한 구조라는 걸 알고 써야 한다.
6. 성능/캐시 헤더 — 미들웨어로 경로별 정책 적용
main.ts에서 경로별로 Cache-Control을 설정했다.
인증, 피드, 채팅, 알림, 결제처럼 즉시 반영되어야 하는 API는 전부 no-store다.
app.use((req, res, next) => {
res.setHeader('Connection', 'keep-alive');
if (req.path.includes('/auth') || req.path.includes('/users')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate, max-age=0');
}
// ...
next();
});
돌아보기 — 잘한 것
- 모듈 경계: 결제 PG 교체, Cloudflare 연동 변경에도 영향 범위가 작았다
- Guard 3종 분리: "공개 + 로그인 시 개인화" 요구를 깔끔하게 해결했다
- 환경변수 기반 설정: 배포 환경이 바뀌어도 코드는 그대로였다
- 스케줄러 내장: 별도 워커/큐 없이 구독 청구 로직을 단순하게 유지했다
돌아보기 — 아쉬운 것 (실전 교훈)
솔직하게 적는다. 이 부분이 가장 도움이 될 것 같다.
1) synchronize: true를 끝까지 못 끈 것
synchronize: true, // configService.get('NODE_ENV') === 'development'
원래는 development에서만 켜려 했는데, 주석 처리된 채 항상 true로 남았다.
초기에는 스키마가 하루에도 몇 번씩 바뀌어 편했다.
하지만 운영 DB에 붙은 상태에서 이 옵션은 엔티티 수정이 곧 운영 스키마 변경이라는 뜻이다.
컬럼 하나를 잘못 지우면 데이터가 날아갈 수 있다.
교훈: 첫 사용자가 생기기 전에
synchronize를 끄고 마이그레이션으로 전환하라.
늦을수록 전환 비용이 커진다.
2) 중복된 TypeORM 설정
DATABASE_URL 분기와 개별 환경변수 분기에서 엔티티 목록 27개를 두 번 복붙했다.
엔티티를 추가할 때 한쪽만 고치면 환경에 따라 다르게 동작한다.
autoLoadEntities: true를 쓰거나 공통 객체로 뽑았어야 했다.
3) .new.ts 파일의 잔재
auth.controller.new.ts, auth.service.new.ts, auth.module.new.ts가 남아 있다.
리팩터링을 하다 만 흔적이다.
Git이 있으니 파일에 .new를 붙이지 말고 브랜치를 써라.
4) 캐시 미들웨어의 반복
경로별 if/else가 전부 같은 헤더를 세팅한다.
결국 "전부 no-store"인데 분기만 늘었다.
정책이 같으면 분기를 합치는 게 맞다.
5) 비밀 값 관리
로컬 .env.development에 외부 서비스 키가 평문으로 쌓였다.
.gitignore 처리는 기본이고,
공개 저장소 전환이나 블로그 캡처 전에는 반드시 키를 로테이션해야 한다.
(이 시리즈를 쓰면서도 환경파일 내용은 일절 싣지 않았다.)
1인 개발자를 위한 NestJS 체크리스트
- 모듈은 도메인 단위로 쪼개고, 외부 연동은 전용 모듈로 격리했는가
- 인증 Guard를 "필수 / 선택 / 역할"로 구분했는가
- JWT 페이로드가 아닌 DB 최신 상태를 신뢰하는가
-
synchronize를 운영에서 끄고 마이그레이션을 쓰는가 - 환경별 설정이 코드가 아닌 환경변수에 있는가
- 크론 작업이 다중 인스턴스에서 중복 실행되지 않는가
- 비밀 값이 저장소와 문서에 노출되지 않는가
다음 편 예고
다음 편은 소셜 로그인 3종 세트다.
Google, Kakao, Naver는 모두 OAuth2인데도 응답 구조와 동의 항목이 제각각이다.
Flutter 웹과 모바일에서 리다이렉트를 처리하는 방식이 왜 달랐는지,
그리고 모바일 OAuth를 별도로 설계해야 했던 이유를 다룬다.
이 시리즈는 총 8편으로 구성됩니다.
- [1편] 기획 & 아키텍처 — Flutter + NestJS + Cloudflare를 선택한 이유
- [2편] Flutter로 Web과 Mobile 동시 개발하기
- [3편] NestJS로 스타트업 백엔드 설계하기 ← 현재 글
- [4편] 소셜 로그인 3종 세트 — Google / Kakao / Naver OAuth 연동 실전
- [5편] 결제 시스템 구축 — Toss Payments & PortOne 이중 연동기
- [6편] Supabase + Cloudflare로 저비용 인프라 구성하기
- [7편] 실시간 채팅 — WebSocket Gateway + Cloudflare R2
- [8편] 피벗 — 1년간 만든 서비스를 접는 결정

