FastAPI + React 챗봇 Railway & Vercel 배포 완전 가이드 (SmartHee Chatbot)
아내를 위한 전용 AI 챗봇을 만들었습니다. 네, 세상에서 단 한 명만 쓸 수 있는 챗봇입니다. 그 한 명이 만족하면 그걸로 충분합니다. 배포까지의 트러블슈팅을 공유합니다.
📌 프로젝트 개요
| 항목 | 내용 |
|---|---|
| 프로젝트명 | SmartHee Chatbot |
| GitHub | parkjongmin-ddam/SmartHee_Chatbot |
| 백엔드 | FastAPI + Anthropic Claude API |
| 프론트엔드 | React + TypeScript + Vite |
| 백엔드 배포 | Railway |
| 프론트엔드 배포 | Vercel |
| 접근 제한 | 비밀번호 잠금 화면 + 백엔드 토큰 인증 |
🗂️ 프로젝트 구조
SmartHee_Chatbot/
├── backend/
│ ├── main.py
│ ├── api/
│ │ └── chat.py
│ ├── requirements.txt
│ └── .env.example ← API 키 형식만, 실제 키는 미포함
├── frontend/
│ ├── src/
│ │ ├── App.tsx
│ │ ├── vite-env.d.ts
│ │ └── components/
│ │ ├── ChatWindow.tsx
│ │ ├── ChatWindow.module.css
│ │ ├── PasswordGate.tsx
│ │ ├── PasswordGate.module.css
│ │ ├── TokenDashboard.tsx
│ │ └── TokenDashboard.module.css
│ ├── vercel.json
│ ├── vite.config.ts
│ └── package.json
└── .gitignore
⚠️
.env파일은.gitignore에 포함시켜 GitHub에 올리지 않습니다. API 키는 각 플랫폼의 환경변수에서 직접 입력합니다.
왜 Railway + Vercel 조합인가?
백엔드와 프론트엔드를 굳이 다른 플랫폼에 나눠 배포한 데는 이유가 있습니다.
| 구분 | 플랫폼 | 선택 이유 |
|---|---|---|
| 백엔드 (FastAPI) | Railway | Python 서버를 별도 설정 없이 GitHub 연동만으로 배포 가능. 항상 켜져 있어야 하는 서버에 적합 |
| 프론트엔드 (React) | Vercel | React/Vite 프로젝트에 최적화된 빌드 파이프라인 제공. 정적 파일 배포에 특화 |
Vercel에 무료로 정적 파일을 올리고, API 요청만 Railway 백엔드로 프록시하는 구조입니다. 덕분에 프론트엔드 호스팅 비용이 0원이고, 백엔드는 Railway Hobby 플랜($5/월)만으로 운영됩니다.
브라우저 → Vercel (프론트엔드, 무료)
↓ /api/* 요청
Railway (백엔드, $5/월)
↓
Claude API
💡 Vercel의
rewrites설정 덕분에 브라우저 입장에서는 모든 요청이 같은 도메인으로 보입니다. CORS 문제가 없고, 백엔드 URL이 프론트엔드 코드에 직접 노출되지 않습니다.
1단계: Railway 백엔드 배포
Railway란?
Railway는 GitHub 레포지토리를 연동해 서버를 자동으로 빌드·배포해주는 클라우드 플랫폼입니다.
| 플랜 | 가격 | 내용 |
|---|---|---|
| Trial | 무료 | $5 크레딧 제공, 소진 시 종료 |
| Hobby | $5/월 | 개인 프로젝트에 적합 |
| Pro | $20/월 | 팀/상업용 |
개인 프로젝트면 Hobby 플랜이 충분합니다.
배포 순서
1. Railway 접속 및 로그인
railway.app → Login with GitHub
2. 새 프로젝트 생성
- New Project 클릭
- Deploy from GitHub repo 클릭
SmartHee_Chatbot선택
3. Root Directory 설정 (중요!)
모노레포 구조이므로 백엔드 폴더를 루트로 지정해야 합니다.
- 생성된 서비스 클릭
- Settings 탭 → Source 섹션 이동
- Add Root Directory 클릭
/backend입력 후 저장
💡 Railway UI에서
Add Root Directory가 버튼처럼 보이지 않아도, 텍스트 자체가 클릭 가능한 링크입니다.
4. 환경변수 설정
- Variables 탭 클릭
- New Variable 클릭
- Key:
ANTHROPIC_API_KEY/ Value: 실제 API 키 입력
5. 배포 확인
Deployments 탭에서 Active 상태 확인 후, Settings → Networking → Generate Domain 클릭해 URL 생성.
https://YOUR_RAILWAY_URL
브라우저에서 /docs 경로로 접속해 FastAPI Swagger UI가 뜨면 성공입니다.
https://YOUR_RAILWAY_URL/docs
2단계: 프론트엔드 비밀번호 보호 기능 추가
배포 전, 특정 사람만 접근하도록 비밀번호 잠금 화면을 추가합니다.
PasswordGate 컴포넌트
// src/components/PasswordGate.tsx
import { useState, KeyboardEvent } from 'react'
import styles from './PasswordGate.module.css'
const PASSWORD = import.meta.env.VITE_APP_PASSWORD ?? ''
interface Props {
onUnlock: () => void
}
export default function PasswordGate({ onUnlock }: Props) {
const [input, setInput] = useState('')
const [error, setError] = useState(false)
const handleSubmit = () => {
if (input === PASSWORD) {
sessionStorage.setItem('unlocked', 'true') // 탭 닫으면 자동 초기화
onUnlock()
} else {
setError(true)
}
}
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleSubmit()
}
return (
<div className={styles.container}>
<div className={styles.box}>
<h1 className={styles.title}>🔐 SmartHee</h1>
<input
type="password"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
className={styles.input}
placeholder="비밀번호 입력"
/>
{error && <p className={styles.error}>비밀번호가 틀렸습니다.</p>}
<button onClick={handleSubmit} className={styles.button}>입장</button>
</div>
</div>
)
}
App.tsx에 연동
// src/App.tsx
import { useState } from 'react'
import ChatWindow from './components/ChatWindow'
import PasswordGate from './components/PasswordGate'
export default function App() {
const [unlocked, setUnlocked] = useState(
sessionStorage.getItem('unlocked') === 'true' // 탭 닫으면 재입력 요구
)
if (!unlocked) {
return <PasswordGate onUnlock={() => setUnlocked(true)} />
}
return <ChatWindow />
}
⚠️
localStoragevssessionStorage:localStorage는 브라우저를 닫아도 유지되지만,sessionStorage는 탭/브라우저를 닫으면 자동으로 사라집니다. 보안을 위해sessionStorage를 사용합니다.
⚠️ 보안 주의: 비밀번호를 코드에 하드코딩하면 GitHub에 그대로 노출됩니다.
import.meta.env.VITE_APP_PASSWORD로 환경변수 처리 후 Vercel에서만 값을 입력합니다.
3단계: TypeScript 타입 선언 추가
CSS Modules와 Vite 환경변수 타입 오류를 방지하기 위해 타입 선언 파일을 추가합니다.
// src/vite-env.d.ts
/// <reference types="vite/client" />
declare module '*.module.css' {
const classes: { [key: string]: string }
export default classes
}
4단계: Vercel 프록시 설정
Vercel에서 /api 요청을 Railway 백엔드로 전달하도록 설정합니다.
// frontend/vercel.json
{
"rewrites": [
{
"source": "/api/:path*",
"destination": "https://YOUR_RAILWAY_URL/api/:path*"
}
]
}
5단계: Vercel 프론트엔드 배포
1. Vercel 접속 및 로그인
vercel.com → Continue with GitHub
2. 프로젝트 Import
New Project → SmartHee_Chatbot 선택 → Import 클릭
3. Root Directory 설정
Root Directory: frontend 로 변경 (Edit 버튼 클릭)
4. 환경변수 설정
| Key | Value |
|---|---|
VITE_APP_PASSWORD |
사용할 비밀번호 |
5. Deploy 클릭
배포 성공 후 생성된 URL:
https://smart-hee-chatbot.vercel.app/
6단계: 빌드 오류 트러블슈팅
오류 1: CSS Modules 타입 오류
error TS2307: Cannot find module './PasswordGate.module.css'
원인: TypeScript가 .module.css 파일을 인식하지 못함
해결: src/vite-env.d.ts에 CSS Modules 타입 선언 추가
declare module '*.module.css' {
const classes: { [key: string]: string }
export default classes
}
오류 2: import.meta.env 타입 오류
error TS2339: Property 'env' does not exist on type 'ImportMeta'
원인: Vite 환경변수 타입 미선언
해결: vite-env.d.ts 상단에 아래 추가
/// <reference types="vite/client" />
오류 3: tsc 빌드 실패
tsc 타입 체크에서 오류 발생 시 빌드 명령어를 단순화합니다.
// package.json
"scripts": {
"build": "vite build" // tsc 제거
}
tsc를 완전히 제거하기보다, 타입 오류를 모두 해결하는 것이 이상적이지만, 빠른 배포가 필요한 경우 임시 방편으로 사용 가능합니다.
7단계: 보안 강화
배포 후 뒤늦게 깨달았습니다. “아, 이거 그냥 두면 누구나 API 직접 호출할 수 있겠구나.” 부랴부랴 3가지를 추가했습니다.
문제 1: CORS가 전체 허용 상태
# 수정 전 — 전 세계 누구나 API 호출 가능 ❌
allow_origins=["*"]
# 수정 후 — Vercel URL만 허용 ✅
allow_origins=["https://smart-hee-chatbot.vercel.app"]
문제 2: 토큰 없이 API 직접 호출 가능
프론트엔드 비밀번호는 브라우저에서만 확인합니다. 개발자 도구에서 sessionStorage.setItem('unlocked', 'true') 한 줄이면 통과되고, 백엔드 URL을 알면 바로 API를 직접 호출할 수 있습니다.
백엔드에 토큰 검증을 추가해 해결했습니다.
# backend/api/chat.py
@router.post("/chat", response_model=ChatResponse)
@limiter.limit("N/minute") # 적절한 횟수로 설정
async def chat(
request: Request,
body: ChatRequest,
x_app_token: str = Header(default=None)
):
expected_token = os.getenv("APP_TOKEN")
if not expected_token or not secrets.compare_digest(x_app_token or "", expected_token):
raise HTTPException(status_code=401, detail="Unauthorized")
...
// frontend — 요청 시 토큰 헤더 포함
headers: {
'Content-Type': 'application/json',
'X-App-Token': import.meta.env.VITE_APP_TOKEN ?? '',
}
Railway에 APP_TOKEN, Vercel에 VITE_APP_TOKEN을 동일한 값으로 등록합니다. 토큰 값은 아래로 생성합니다.
python -c "import secrets; print(secrets.token_hex(32))"
문제 3: Rate Limit 없음
토큰을 탈취당한 최악의 상황에서도 크레딧을 무한 소비당하지 않도록 slowapi로 분당 30회 제한을 걸었습니다.
# main.py
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
보안 테스트 결과
적용 후 직접 테스트했습니다.
토큰 없이 API 호출 시:
curl -X POST https://YOUR_RAILWAY_URL/api/chat \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "test"}]}'
# 결과
{"detail":"Unauthorized"} ✅
외부 사이트에서 직접 호출 시:
차단됨: Failed to fetch ✅
| 항목 | 결과 |
|---|---|
| CORS 차단 | ✅ 외부 Origin 차단 확인 |
| 토큰 검증 | ✅ 401 Unauthorized 확인 |
| Rate Limit | ✅ 토큰 없는 요청 401 선차단 확인 |
8단계: 토큰 사용량 대시보드 추가
채팅 화면 옆 사이드바에 토큰 사용량과 예상 비용을 실시간으로 표시합니다.
구성 요소
- 이번 세션: 입력/출력/총 토큰, 예상 비용
- 최근 7일 사용량: 일별 막대 그래프 (
localStorage에 누적 저장) - 대화별 사용량: 응답 단위로 토큰 수와 비용 표시
// src/components/TokenDashboard.tsx 핵심 로직
// claude-sonnet-4-5 기준 단가
const INPUT_COST_PER_TOKEN = 3 / 1_000_000 // $3 / 1M tokens
const OUTPUT_COST_PER_TOKEN = 15 / 1_000_000 // $15 / 1M tokens
// 일별 사용량 localStorage에 최근 7일 저장
function saveTodayUsage(input: number, output: number) {
const today = new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' })
const history = loadDailyHistory()
const existing = history.find(d => d.date === today)
if (existing) {
existing.input = input
existing.output = output
} else {
history.push({ date: today, input, output })
}
localStorage.setItem('tokenHistory', JSON.stringify(history.slice(-7)))
}
ChatWindow에 사이드바 연결
// src/components/ChatWindow.tsx
return (
<div className={styles.layout}> {/* flex 레이아웃 */}
<div className={styles.container}> {/* 채팅 영역 */}
...
</div>
<TokenDashboard messages={messages} totalTokens={totalTokens} />
</div>
)
/* ChatWindow.module.css */
.layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
💡 localStorage vs sessionStorage 혼용: 비밀번호 인증은
sessionStorage(탭 닫으면 초기화), 토큰 히스토리는localStorage(브라우저 닫아도 누적 유지)로 각각의 목적에 맞게 구분해서 사용합니다.
✅ 최종 결과
| 항목 | URL |
|---|---|
| 백엔드 API | Railway 배포 후 생성된 URL + /docs |
| 프론트엔드 | https://smart-hee-chatbot.vercel.app/ |
- 접속 시 비밀번호 입력 화면 표시
- 브라우저/탭 종료 시 자동으로 비밀번호 재입력 요구 (
sessionStorage) - 정확한 비밀번호 입력 시 채팅 화면으로 진입
- 채팅 화면 우측 사이드바에 토큰 사용량 및 예상 비용 실시간 표시
💡 배운 점
- 모노레포 배포 시 Root Directory 설정이 핵심 — Railway와 Vercel 모두 서브폴더 지정이 필요
- 민감 정보는 절대 코드에 하드코딩 금지 — 환경변수로 관리하고 플랫폼 콘솔에서 입력
- Vite 프로젝트 TypeScript 타입 선언 —
vite-env.d.ts파일로 CSS Modules 및import.meta.env타입 해결 - Vercel의 rewrites — 프록시 설정으로 CORS 없이 백엔드 API 호출 가능
- 프론트엔드 인증은 믿으면 안 된다 — 브라우저 검증은 우회가 쉬우므로 백엔드에서 반드시 재검증
allow_origins=["*"]는 개발용 — 배포 시 반드시 실제 도메인으로 교체- Rate Limit은 보험 — 토큰이 탈취되더라도 피해를 최소화하는 마지막 방어선
localStoragevssessionStorage목적 구분 — 인증 상태는 세션 단위, 누적 데이터는 영구 저장
Comments