트립픽 개발 기록 · 5/5
기술 스택 선택의 진짜 기준 — '더 좋아서'가 아니라 '혼자 감당 가능해서'
1인 개발 회고 시리즈 5장. Next.js 14 한 덩어리로 프론트와 백엔드가 '타입 하나'로 흐르게 한 이유, Prisma의 진짜 가치가 성능이 아니라 '실수 방지'였던 이유, 그리고 17개 모델을 다섯 역할로 묶어 손에 쥔 사고법을 코드로 정리합니다. 죽은 필드 User.plan과 문자열로 저장한 CourseStop 시각까지, 모든 결정을 관통한 단 하나의 기준을 다룹니다.
이 글은 전자책 「혼자 만든 트립픽」 5장을 기술 블로그 톤으로 다시 풀어 쓴 글입니다. 책은 회고·내러티브 중심, 이 시리즈는 의사결정의 기술적 근거를 더 깊이 다룹니다.
1장에서 트립픽이 푸는 문제를 정의했고,
2장에서 "혼자" 라는 제약이 모든 아키텍처를
어떻게 지배했는지 봤어요. 3장에서
EventLog로 대체재를 측정했고, 4장에서
등급별 한도(quota)를 비용 방어 + 전환 설계로 그었죠. 이번 장의 질문은
그 경계 안을 무엇으로 짓느냐입니다.
"이 스택, 6개월 뒤의 내가 혼자 고칠 수 있나?"
"최신"이 아니라 "혼자 감당 가능한"
기술 스택을 고를 때 흔한 함정은 가장 좋은 것을 찾는 거예요. 1인 개발자의 기준은 다릅니다. "이걸로 혼자, 끝까지, 잠 자면서 운영할 수 있는가." 2장에서 세운 그 기준을, 이번엔 도구 하나하나에 들이댔어요.
그래서 트립픽 스택은 화려하지 않아요. Next.js 14 한 덩어리, MySQL 한 개, 관리형 호스팅. 이 지루함이 곧 전략입니다. 표로 먼저 정리할게요. 핵심은 '일반적 정답'과 '내 선택'이 다른 칸이에요.
| 영역 | 흔한 정답 | 트립픽의 선택 | 기준 |
|---|---|---|---|
| 앱 구조 | 프론트/백 분리 | Next.js 14 모놀리식 | 한 저장소·한 배포·한 언어 |
| ORM | 성능 위해 raw SQL | Prisma 5 + MySQL | 쿼리 속도보다 실수 방지 |
| 인프라 | Next.js엔 Vercel | AWS Amplify | 더 매끄러워서가 아니라 익숙해서 |
| 인증 | 자체 회원 시스템 | NextAuth 소셜 3종 | 비밀번호 CS를 통째로 외주 |
이 표의 모든 칸을 관통하는 한 줄이 있어요. "더 좋아서가 아니라 혼자 감당 가능해서." 하나씩 풀어볼게요.
하나의 앱으로 — Next.js 14 (App Router)
프론트와 백엔드를 안 나누고 Next.js 하나에 다 넣었어요. 한 저장소,
한 배포, 한 언어(TypeScript). 화면(page.tsx)과 서버 API(route.ts)가
같은 프로젝트 안에 있다는 게 1인에게 주는 이득은 생각보다 큽니다.
가장 큰 건 타입이 프론트-백 경계를 그대로 넘나든다는 점이에요. Prisma가 만든 모델 타입이 서버 라우트에서 한 번 좁혀지면, 그 추론된 타입이 그대로 화면 컴포넌트까지 흘러요. 한 언어로 흐르니까 문맥 전환 비용이 0이에요.
// Prisma 모델 → 추론 타입 → route.ts → page.tsx 까지 한 언어로 흐른다 (개념 예시)
// (1) DB 모델: Prisma가 타입을 만들어줌
import type { Course, CourseStop } from "@prisma/client";
// (2) 서버에서 모양을 한 번 좁히면
type CourseWithStops = Course & { stops: CourseStop[] };
// (3) route.ts — 외부 API는 '오직 여기'서만 부른다 → 키가 브라우저로 안 나간다
export async function POST(req: Request): Promise<Response> {
const weather = await fetchWeather(process.env.KOREA_WEATHER_API_KEY!); // 서버 전용 키
const course = await buildCourse(req, weather);
return Response.json(course); // 응답 타입 = CourseWithStops
}
// (4) page.tsx — 같은 타입을 import. 필드명 오타 내면 '여기서' 빨간 줄.
// 서버가 stop.startTime 을 "10:00" 으로 보내준다는 걸 컴파일러가 안다.여기서 보안 이점이 하나 딸려와요. 모든 외부 API 호출은 서버
라우트(route.ts)에서만 합니다. 기상청 키도, 모델 호출 키도
브라우저에 나갈 일이 원천 차단돼요. 클라이언트로 나가는 키는
카카오 지도 JS 키 하나뿐인데, 그건 애초에 공개가 의도된 키예요.
프론트-백을 나누면 경계마다 타입을 두 번 적어야 해요. 한쪽을 고치고 다른 쪽을 깜빡하면 런타임에서 터지죠. 모놀리식의 진짜 이득은 배포가 하나라는 게 아니라, 거짓말할 경계가 하나 줄어드는 거였어요.
ORM은 타입 안전이 전부 — Prisma + MySQL
MySQL을 고른 이유는 단순해요. 호스팅(AWS Amplify)이 RDS MySQL과 매끄럽게 붙고, 운영·복제·백업이 단순하니까. 1인에게 돌아가는 부품을 줄이는 것보다 중요한 건 없거든요(4장에서 리셋 배치 하나를 없앤 것과 같은 마음이에요).
Prisma는 스키마↔타입↔마이그레이션을 한 도구로 묶어줘요. prisma generate
한 번이면 17개 모델 전부 타입 안전해집니다. 트레이드오프도 분명해요.
Prisma는 raw SQL 최적화가 약해요. 하지만 지금 트래픽에 충분한가가
기준이지 이론상 가장 빠른가가 아니에요. 복잡한 집계가 필요한 곳도
(3장 깔때기처럼) groupBy로, 아니면 findMany + JS 집계로 충분했어요.
여기서 제가 진짜 깨달은 건, ORM의 가치가 쿼리 성능이 아니라 실수 방지라는 점이었어요. 오타를 내면 런타임이 아니라 편집기에서 빨간 줄이 그어져요. 자는 사이 터질 버그가 낮에 화면에서 잡히는 거예요.
// schema.prisma — 실제 필드명 기준. 이게 타입의 '원본'이 된다.
model TripPass {
id String @id @default(cuid())
userId String
type PassType // pass1day | pass3day | pass7day
startsAt DateTime
expiresAt DateTime
paymentId String? // 스키마상은 nullable. 유료 결제분은 항상 채워짐
createdAt DateTime @default(now())
@@index([userId, expiresAt])
@@index([expiresAt])
}
enum PassType {
pass1day // 하이픈 없음! "pass-1day"는 틀린 표기
pass3day
pass7day
}이 스키마에서 만약 제가 코드에 "pass-1day" 라고 적으면 어떻게 될까요?
Prisma가 만든 PassType 타입에 그런 값은 없으니까, 컴파일러가 즉시
거부해요. 4장에서 패스가 하루 300회 한도를 결정한다고 했죠. 그
판정의 입력값이 enum이라 오타가 끼어들 틈 자체가 없는 거예요.
Prisma를 쓰면서 가장 안심됐던 순간은 빠른 쿼리를 봤을 때가 아니라, 필드명을 잘못 친 순간 빨간 줄이 먼저 나를 막아줬을 때였어요. 1인은 코드 리뷰어가 없잖아요. 컴파일러가 두 번째 눈이 돼줬어요.
모델을 다섯 덩어리로 생각하기
스키마에 모델이 17개나 있어요. 숫자만 보면 많아 보이지만, 역할로 묶으면 손에 잡혀요. 저는 이걸 늘 다섯 덩어리로 떠올려요.
| 역할 묶음 | 대표 모델 | 한 줄 |
|---|---|---|
| 사용자·인증 | User, GuestUsage, BetaSignup | 누가 쓰는가 |
| 코스 | Place, Course, CourseStop, SavedCourse, SeoCourse | 무엇을 만드나 |
| 결제 | Payment, TripPass | 누가 돈을 냈나 |
| 운영·비용 | ApiQuota, AiUsageLog, AiResponseCache, AiDailyBudget, SystemConfig | 얼마나 들었나 |
| 로그 | EventLog, AdminAccessLog | 무슨 일이 있었나 |
4장에서 한도를 짤 때 등장한 ApiQuota도, 2장의 킬스위치가 읽던
AiDailyBudget·SystemConfig도 전부 이 운영·비용 묶음 안에 살아요.
모델을 외우는 게 아니라 "이건 어느 묶음 일을 하지?" 로 떠올리면,
17개가 5개처럼 가벼워집니다.
그리고 이 묶음들 안에는, 남들이 보면 이상하다고 할 설계 결정이 두 개 박혀 있어요.
결정 하나 — 죽은 필드를 일부러 남겼다
User에는 plan이라는 필드가 있어요. enum Plan { free, pro, premium },
기본값 free, 인덱스까지 걸려 있죠. 그런데 이 필드는 사실상
화석이에요. 구독 모델을 고민하던 시절의 흔적이거든요.
일회성 패스로 갈아탄 지금, 실제 등급은 TripPass가 결정해요. plan은
늘 free로 남아 있어요. 지울 수도 있었지만, 마이그레이션 리스크 vs
얻는 이득을 저울질해서 그냥 뒀어요.
// 등급 판정은 plan이 아니라 '살아있는 패스'를 본다 (개념 예시)
async function resolveTier(userId: string): Promise<"pass" | "free"> {
const now = new Date();
const pass = await db.tripPass.findFirst({
where: { userId, expiresAt: { gt: now } }, // @@index([userId, expiresAt]) 가 받쳐줌
orderBy: { expiresAt: "desc" },
});
// user.plan 은 여기서 '한 번도' 읽지 않는다 — 늘 free 라서 의미가 없다.
return pass ? "pass" : "free";
}핵심은 완벽한 스키마가 아니라 안전한 결정이에요. 죽은 필드 하나를 없애려다 마이그레이션에서 사고가 나면, 그 새벽에 깨는 건 저예요. "6개월 뒤 내가 이 결정을 후회 없이 설명할 수 있나" 가 정규화보다 앞섰어요.
결정 둘 — 시각을 문자열로 저장했다
CourseStop의 방문 시각은 DateTime이 아니라 문자열이에요.
startTime·endTime이 String이고, 값은 "10:00"·"11:30" 같은 형태죠.
교과서적으론 틀린 선택처럼 보여요.
model CourseStop {
id String @id @default(cuid())
courseId String
startTime String // "10:00" — DateTime이 아니라 문자열
endTime String // "11:30"
weatherIcon String? // 생성 시점 날씨 스냅샷
temperature Float? // 생성 시점 기온 스냅샷
aiReason String? @db.Text // 왜 이 시간에 여길 넣었는지
// ...
}이유는 둘이에요. 첫째, 코스는 특정 날짜에 묶이지 않는 하루 템플릿이에요.
어느 날에 떠나도 그대로 재사용되니까, 절대 시각으로 묶으면 오히려 매번
날짜를 갈아끼워야 하죠. 동선 엔진(6장)이 뱉는 결과도 원래 "10:00"~"11:30"
같은 하루 안의 슬롯이지 절대 시각이 아니에요. 굳이 DateTime으로
바꿨다가 다시 포맷하느니 그대로 저장하는 게 6개월 뒤의 제게 더
명료해요.
둘째, weatherIcon·temperature를 생성 시점 스냅샷으로 박았어요.
나중에 날씨가 바뀌어도 "그때 안내한 코스" 가 그대로 보존돼요. 이건
정규화로 풀 문제가 아니라, "사용자에게 약속한 화면을 지킨다" 는 제품
결정이었어요.
정규화는 미덕이지만, 1인에게 더 위인 미덕은 "6개월 뒤 내가 바로 이해하는가" 예요.
DateTime변환과 타임존 함정으로 새벽에 디버깅하느니,"10:00"다섯 글자가 잠을 지켜줬어요.
서버를 직접 만들지 않는다 — AWS Amplify
스택을 오래 고민한 영역은 사실 하나뿐이었어요. 인프라 — Vercel이냐 AWS냐. 나머지는 대부분 이미 손에 익은 것들이라 고민할 게 없었죠.
솔직히 말하면, Vercel이 Next.js와 더 매끄럽다는 걸 알면서도 AWS를 택했어요. 이유는 단 하나, 익숙해서. EC2 관리는 통째로 Amplify(Lambda 런타임)에 외주를 줬고요. "충분히 좋고 익숙한 기술로 빨리 시작"이 거의 항상 옳았어요. 익숙함 = 속도 = 완성 확률이거든요.
대신 Lambda에는 제약이 따라와요. 2장에서 짚은 그 콜드 스타트(메모리 초기화) 예요. 요청이 뜸하면 컨테이너가 사라지고, 메모리에 둔 건 0으로 리셋돼요. 그래서 캐시 전략을 그 제약에 맞춰 갈랐어요.
// 오래 살아야 할 상태는 DB, 사라져도 되는 것만 메모리 (개념 예시) — 2장과 같은 원칙
//
// [메모리 캐시] 짧은 TTL만. 콜드 스타트로 날아가도 무방한 것.
// 예: AiResponseCache 조회 결과의 일시 버퍼
//
// [DB 저장] 사라지면 안 되는 상태.
// 예: AiDailyBudget(누적 비용), SystemConfig(차단 플래그)
//
// 규칙: "이게 콜드 스타트로 0이 되면 사고인가?" → 그렇다면 DB.2장의 킬스위치가 왜 메모리가 아니라 DB였는지를 기억하면, 이건 같은 원칙의 연장이에요. 인프라의 제약(Lambda의 휘발성)이 거꾸로 설계 원칙을 강제한 거죠. 익숙한 걸 골랐더니, 그 익숙한 것의 약점까지 설계에 녹는 형태가 됐어요.
로그인은 남에게 맡긴다 — NextAuth 소셜 3종
회원 시스템을 직접 만들지 않았어요. NextAuth(v4)로 카카오·네이버·구글 소셜 로그인만 붙였고, 이메일/비밀번호 로그인은 아예 없어요. 각 provider는 환경변수 키가 있을 때만 켜지게 해뒀고요.
- 카카오 — 한국 점유율 1위, 사실상 기본값
- 네이버 — 카카오를 못 쓰는 사용자 보완
- 구글 — 해외·IT 사용자, 그리고 제 테스트용
비밀번호가 없으니 분실·재설정·유출 CS와 보안 부담이 통째로 사라져요. 이게 1인에게 얼마나 큰지는 2장 표에서 이미 짚었죠. 인증을 남에게 맡기는 것은 기능 포기가 아니라 책임 외주예요.
로그인 콜백에 가입 처리를 트리거하는데, 여기서 한 가지는 정확히 짚고 갈게요. 콜백이 하는 일은 딱 둘이에요.
// 소셜 로그인 콜백: 하는 일은 정확히 두 가지 (개념 예시)
async function onSignIn(profile: SocialProfile): Promise<void> {
// (1) 자동 회원가입 — 처음이면 만들고, 있으면 갱신 (upsert)
const user = await db.user.upsert({
where: { email: profile.email },
create: { email: profile.email, name: profile.name }, // plan은 default(free)로 들어감
update: { name: profile.name },
});
// (2) '베타 신청자'면 그 자리에서 3일 패스 자동 발급
await activateBetaPassIfEligible(user.id); // BetaSignup 대상이면 TripPass(pass3day) 생성
}자주 오해되는데, 콜백은 "신규 가입 시 무료 한도를 지급" 하는 게 아니에요. 정확히는 (1) 자동 회원가입, (2) 베타 대상이면 3일 패스 자동 발급 두 가지뿐입니다. 무료회원의 평생 30회는 별도 카운터로 관리되는 거고요(4장). 이 구분을 흐리면 한도 설계가 통째로 헷갈려져요.
참고로 로그도 역할에 맞게 보존 기간을 갈랐어요. EventLog와
AiUsageLog는 3개월 후 자동 삭제(lazy), AdminAccessLog는
1년 보관(개인정보보호법 시행령 §16). 같은 '로그' 묶음 안에서도
법이 요구하는 것과 내가 편하려고 두는 것을 분리한 거예요.
스택은 '나를 위한' 선택이었다
사용자는 트립픽이 Next.js인지 Remix인지 몰라요. 알 필요도 없고요. 하지만 새벽에 깨서 디버깅하는 건 저예요. 그래서 이 모든 결정의 최종 기준은 늘 하나였어요. "혼자 감당 가능한가."
모놀리식도, Prisma도, 익숙한 AWS도, 남에게 맡긴 인증도 — 전부 더 좋아서가 아니라 내가 끝까지 책임질 수 있어서 골랐어요. 결국 "혼자 감당 가능한가"가 1인 개발자에겐 가장 좋은 아키텍처 가이드였어요.
다음 장 미리보기
다음 장(6장 "날씨가 코스를 바꾼다")에서는, 이 스택 위에서 트립픽의
진짜 심장 — 동선 최적화 엔진을 다뤄요. CourseStop에 "10:00"을
박고 weatherIcon을 스냅샷으로 떠두던 그 결과가 어떻게 계산되는지,
1장에서 정의한 4-변수 동시 최적화를 알고리즘이 어떻게 푸는지, 그리고
왜 그 핵심을 LLM이 아니라 순수 알고리즘에 맡겼는지(2장 비용 방어와
이어져요)를 코드로 풀어볼게요.
💭 그때의 나에게: 멋진 신기술에 자꾸 눈이 갔지. RSC 최신 패턴, 더 빠른 ORM, Vercel의 매끄러운 배포… 근데 네 기준은 딱 하나였어야 했고, 결국 그거였어 — '6개월 뒤의 내가 혼자 고칠 수 있나.' 죽은 필드 하나 못 지운 게, 시각을 문자열로 둔 게 부끄러웠지? 아니야. 지루한 스택과 안전한 결정이 네 잠을 지켜줬어. 컴파일러를 두 번째 눈으로 두고, 익숙한 걸 골라. 그게 혼자 끝까지 가는 길이었어.