사이드 프로젝트를 시작할 때마다 같은 고민이 반복된다. 프론트엔드는 React나 Next.js로 금방 결정되는데, 백엔드는 어떻게 할 것인가. Express? NestJS? 별도 서버를 띄우면 인프라가 두 배가 된다.
pixelDiff를 만들면서 "Next.js API Routes만으로 얼마나 갈 수 있을까?"를 실험해봤다. 결론부터 말하면, 프로덕션급 서비스까지 충분히 가능하다. 38개 엔드포인트, OAuth 인증, PostgreSQL 연동, Rate Limiting까지 모두 Next.js 안에서 해결했다.
왜 별도 백엔드를 두지 않았나
선택지는 세 가지였다.
| 방식 | 장점 | 단점 |
|---|---|---|
| Express/Fastify + React | 자유도 높음, 검증된 패턴 | 두 개의 배포, 두 개의 인프라 |
| NestJS + React | 체계적인 구조, DI 지원 | 러닝커브, 오버엔지니어링 가능성 |
| Next.js API Routes | 하나의 배포, 타입 공유 | 복잡한 로직엔 한계? |
사이드 프로젝트에서 가장 중요한 건 속도다. 두 개의 서버를 관리하는 순간 CI/CD 파이프라인이 복잡해지고, 배포 시 동기화 문제가 생기고, 로컬 개발 환경 설정도 두 배가 된다.
Next.js를 선택한 결정적 이유는 타입 공유였다. 백엔드에서 정의한 응답 타입을 프론트엔드에서 그대로 쓸 수 있다. API 스펙이 바뀌면 TypeScript가 컴파일 에러로 알려준다. 별도 서버라면 OpenAPI 스펙을 생성하고 클라이언트 코드를 생성하는 과정이 필요하다.
실제 구현 구조
현재 pixelDiff의 API 구조는 이렇다.
apps/web/app/api/
├── auth/[...nextauth]/ # OAuth 인증
├── user/ # 사용자 관리
├── projects/ # 프로젝트 CRUD
│ └── [id]/
│ ├── figmas/ # Figma 아이템
│ └── snapshots/ # 스냅샷
├── shares/ # 공유 링크
├── admin/ # 관리자 기능
│ ├── stats/
│ └── feedback/
└── health/ # 헬스 체크
38개 엔드포인트에서 51개 HTTP 메서드를 처리한다. 인증, 파일 업로드, 페이지네이션, 관리자 대시보드까지 모두 API Routes로 구현했다.
인증: NextAuth.js + JWT
인증은 NextAuth.js를 썼다. Figma OAuth를 커스텀 프로바이더로 구현했는데, 핵심은 세션 전략 선택이었다.
// lib/auth.ts
export const authOptions: NextAuthOptions = {
providers: [
{
id: 'figma',
name: 'Figma',
type: 'oauth',
authorization: {
url: 'https://www.figma.com/oauth',
params: { scope: 'files:read' }
},
token: 'https://api.figma.com/v1/oauth/token',
userinfo: 'https://api.figma.com/v1/me',
// ...
}
],
session: {
strategy: 'jwt', // 여기가 핵심
maxAge: 30 * 24 * 60 * 60 // 30일
},
callbacks: {
async jwt({ token, user, account }) {
if (account) {
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
}
return token
}
}
}
처음엔 database 전략을 썼다. 매 요청마다 세션 테이블을 조회하니까 응답이 400ms씩 걸렸다. JWT로 바꾸니 5ms로 줄었다. 세션 정보가 토큰에 들어있으니 DB 쿼리가 필요 없다.
트레이드오프가 있다. JWT는 서버에서 즉시 무효화할 수 없다. 사용자를 강제 로그아웃시키려면 토큰 만료까지 기다려야 한다. 하지만 대부분의 사이드 프로젝트에서 이건 문제가 되지 않는다.
데이터베이스: Prisma + PostgreSQL
Prisma를 ORM으로 쓴다. 스키마 정의부터 마이그레이션까지 한 곳에서 관리된다.
// prisma/schema.prisma
model Project {
id String @id @default(cuid())
userId String
name String
status String @default("draft")
comparisonState Json?
deletedAt DateTime?
user User @relation(fields: [userId], references: [id])
layers Layer[]
@@index([userId, deletedAt])
}
model Layer {
id String @id @default(cuid())
projectId String
source String // 'figma' | 'snapshot'
imageUrl String
width Int
height Int
project Project @relation(fields: [projectId], references: [id])
@@index([projectId, source])
}
API 라우트에서 직접 Prisma 클라이언트를 호출한다. 별도의 서비스 레이어 없이 라우트 핸들러에서 바로 쿼리한다.
// app/api/projects/route.ts
export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new AppError('AUTH_UNAUTHORIZED', 401)
}
const projects = await prisma.project.findMany({
where: {
userId: session.user.id,
deletedAt: null
},
include: {
layers: true
},
orderBy: { updatedAt: 'desc' }
})
return NextResponse.json(projects.map(toProjectResponse))
}
NestJS였다면 Controller → Service → Repository 레이어를 거쳤을 것이다. API Routes에선 그냥 바로 쿼리한다. 보일러플레이트가 줄어드는 대신 로직이 커지면 파일이 비대해지는 단점이 있다.
마주한 문제들
Rate Limiting
Next.js 미들웨어는 Edge Runtime에서 돌아간다. 기존 Rate Limiting 라이브러리들이 Node.js 런타임을 가정하고 있어서 직접 구현해야 했다.
// middleware.ts
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
export async function middleware(request: NextRequest) {
const ip = request.ip ?? 'unknown'
const now = Date.now()
const record = rateLimitMap.get(ip)
if (!record || now > record.resetTime) {
rateLimitMap.set(ip, { count: 1, resetTime: now + 60000 })
} else if (record.count >= 100) {
return new NextResponse('Too Many Requests', { status: 429 })
} else {
record.count++
}
return NextResponse.next()
}
메모리 기반이라 서버 재시작 시 초기화되고, 멀티 인스턴스 환경에선 제대로 동작하지 않는다. 프로덕션에서 Redis 기반으로 바꿔야 하지만, 단일 서버로 운영하는 사이드 프로젝트에선 이 정도로 충분하다.
Fire-and-Forget 패턴
사용자 활동 기록처럼 응답을 기다릴 필요 없는 작업들이 있다. 이런 건 Promise를 await하지 않고 그냥 실행한다.
// app/api/projects/route.ts
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions)
const project = await prisma.project.create({
data: { userId: session.user.id, name: 'New Project' }
})
// 응답 후에 실행됨 (비차단)
updateLastActivity(session.user.id)
trackUsageEvent({ userId: session.user.id, event: 'project_created' })
return NextResponse.json(project)
}
에러가 나도 사용자는 모른다. 로깅해두고 나중에 확인하는 방식이다. 핵심 로직이 아닌 부가 기능에만 이 패턴을 쓴다.
에러 처리 표준화
API가 많아지면 에러 처리가 일관되지 않기 쉽다. 커스텀 에러 클래스와 에러 코드 체계를 만들었다.
// lib/api/errors.ts
export class AppError extends Error {
constructor(
public code: ErrorCode,
public statusCode: number,
message?: string
) {
super(message ?? getDefaultMessage(code))
}
}
// 사용
throw new AppError('PROJECT_NOT_FOUND', 404)
throw new AppError('AUTH_UNAUTHORIZED', 401)
throw new AppError('UPLOAD_FILE_TOO_LARGE', 413)
프론트엔드에서 에러 코드로 다국어 메시지를 매핑한다. 백엔드는 코드만 던지고, UI 텍스트는 프론트엔드가 결정한다.
이 방식이 적합한 경우
Next.js만으로 백엔드를 구축하는 게 항상 정답은 아니다.
적합한 경우:
- 사이드 프로젝트나 MVP
- 프론트엔드와 백엔드를 한 사람이 개발
- CRUD 위주의 비즈니스 로직
- 빠른 이터레이션이 필요할 때
부적합한 경우:
- 복잡한 도메인 로직 (DDD가 필요한 수준)
- 백엔드 팀이 따로 있는 경우
- 실시간 처리나 배치 작업이 많은 경우
- WebSocket이 핵심 기능인 경우
pixelDiff는 전형적인 CRUD + 파일 업로드 서비스다. 복잡한 비즈니스 로직이 없고, 실시간 기능도 없다. 이런 특성 때문에 Next.js만으로 충분했다.
만약 복잡한 도메인 로직이 필요했다면 NestJS를 선택했을 것이다. 하지만 "혹시 복잡해질까봐" 미리 무거운 구조를 도입하는 건 오버엔지니어링이다. 단순하게 시작하고, 필요해지면 분리하는 게 사이드 프로젝트에서의 현실적인 전략이다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd > React' 카테고리의 다른 글
| Zustand로 Undo/Redo 구현하기 (0) | 2026.01.13 |
|---|---|
| Next.js 14 완벽 번역 (1) | 2023.10.27 |
| React Todo에서 UX개선 (1) | 2023.04.28 |
| 리액트에서 ...state 와 prev => ...prev 의 차이 (0) | 2023.04.25 |
| 최초 로그인시 토큰이 null로 보내지는 버그 (0) | 2023.04.16 |
