축구 선수의 인생 스토리를 읽고 이름을 맞히는 퀴즈 게임을 만들었다.
단순한 퀴즈 앱이 아니라, 글로벌 랭킹·다국어·익명 인증·보안까지 직접 설계해보는 것이 목표였다.
프론트엔드는 React + TypeScript, 백엔드는 NestJS + Prisma로 구성했다.
배포는 Vercel(프론트) + Railway(백엔드).
📌 프로젝트 소개
축구 선수의 인생 스토리를 읽고 이름을 맞히는 웹 퀴즈 게임.
주요 기능
- 일반 퀴즈 모드 (10문제)
- 무한 퀴즈 모드
- 글로벌 랭킹 시스템
- 오답노트
- 다국어 지원 (10개 언어)
- 익명 유저 코드 기반 기록 관리
🗓️ Day 1 — 4/20 | 아이디어 → 프로토타입
처음엔 “축구 관련으로 재미있는 걸 만들어보자” 정도였다.
Football-Data.org와 Wikidata API를 붙여 선수 데이터를 수집했고,
선수 커리어를 기반으로 문제를 만들기 시작했다.
예를 들어:
“브라질 출신 공격수, 바르셀로나와 PSG에서 활약”
같은 식으로 힌트를 보여주고 이름을 맞히는 방식이다.
이 시점에는 그냥 데이터 실험 수준이었다.
🗓️ Day 2~3 — 4/21~23 | 핵심 퀴즈 UI 완성
퀴즈 화면과 결과 화면을 만들었다.
구현한 것
- QuizView
- ResultView
- 반응형 UI
- 컴포넌트 분리
- 선수 사진 필터링
- 첫 Vercel 배포
처음으로 실제 “게임처럼 보이는” 상태가 됐다.
🗓️ Day 4~5 — 4/27~29 | 글로벌 출시 준비
단순 한국어 서비스보다, 해외 사용자도 들어올 수 있게 만들고 싶었다.
그래서 i18n을 붙였다.
🌍 10개국어 지원
- ko
- en
- es
- pt
- ar
- ja
- de
- fr
- it
- id
여기서 번역 키 관리가 꽤 복잡해졌다.
추가로:
- 선수 인생 스토리 UI
- 오답노트
- Google Analytics
- AdSense
- Netlify/Vercel 배포 설정
까지 진행했다.
🗓️ Day 6 — 5/2 | Vue3 → React 전면 리빌드
원래는 Vue3로 시작했다.
그런데 개발하다 보니 TypeScript 생태계와 장기적인 성장 측면에서 React가 더 맞는다고 판단했다.
결국 프로젝트를 React + TypeScript로 전면 재작성했다.
처음엔 꽤 부담됐다.
이미 만들어둔 걸 버리고 다시 시작하는 건 생각보다 쉽지 않았다.
하지만 시간이 지나고 보니, 기술 부채를 초반에 정리한 게 맞는 선택이었다.
처음부터 다시 짜는 건 두렵지만, 기술 부채를 일찍 해결하는 게 낫다고 판단했다.
🗓️ Day 7~8 — 5/3~4 | 첫 백엔드 구축 (NestJS)
여기서부터 프로젝트 분위기가 완전히 달라졌다.
처음으로 직접 백엔드를 만들기 시작했다.
사용 스택
- NestJS
- Prisma
- PostgreSQL
- Redis
배포는 Railway를 사용했다.
🏆 랭킹 시스템 구현
랭킹 제출과 조회 기능을 만들었다.
엔드포인트
- POST /ranking/submit
- GET /ranking
- GET /ranking/myrecord
- GET /ranking/mybest
⚡ Redis 캐싱
랭킹 조회는 요청이 많을 수 있어서 Redis 캐싱을 붙였다.
top50 랭킹을 60초 TTL로 캐싱했다.
const cached = await this.redis.get(RANKING_CACHE_KEY)
if (cached) {
return JSON.parse(cached)
}
const data = await this.prisma.ranking.findMany(...)
await this.redis.set(
RANKING_CACHE_KEY,
JSON.stringify(data),
60
)
새 점수가 등록되면 캐시를 즉시 무효화해서 최신 상태를 유지했다.
🔒 DTO Validation
입력 검증도 처음부터 붙였다.
@IsNotEmpty()
@Matches(/^[a-zA-Z0-9_-]+$/)
recoveringCode!: string
@Min(10)
@Max(9999)
streak!: number
그리고 main.ts에서 ValidationPipe를 적용했다.
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
)
🛡️ HMAC-SHA256 해싱
여기서 꽤 중요한 설계 고민이 있었다.
원래는 recoveringCode를 localStorage와 DB에 그대로 저장했다.
그런데 생각해보니:
- DB가 털리면?
- recoveringCode가 그대로 노출된다.
처음엔 bcrypt를 떠올렸다.
하지만 recoveringCode는 “검색 키” 역할이라 문제가 있었다.
bcrypt는 같은 입력이어도 매번 다른 해시를 생성한다.
즉:
WHERE recoveringCode = ?
같은 조회가 불가능하다.
결국 HMAC-SHA256을 선택했다.
function hashCode(code: string): string {
return createHmac(
'sha256',
process.env.HMAC_SECRET ?? '',
)
.update(code)
.digest('hex')
}
이 방식은:
- 항상 동일한 해시 생성
- 서버 secret 없이는 역산 어려움
- DB 조회 가능
이라는 장점이 있다.
submit, myrecord, mybest 전부 해시 기반으로 변경했다.
🍪 HttpOnly 쿠키 인증
또 다른 문제는 localStorage였다.
localStorage는 JS에서 접근 가능하다.
즉, XSS 공격이 발생하면 유저 코드가 그대로 탈취될 수 있다.
그래서 인증 방식을 바꿨다.
기존 방식
localStorage.setItem('recoveringCode', code)
변경 후
- HttpOnly 쿠키 사용
- sameSite: 'strict'
- secure 옵션 적용
GET /auth/me 구현
쿠키가 없으면 새 코드를 발급하고,
있으면 기존 코드를 반환한다.
@Get('/me')
getMe(
@Req() req,
@Res({ passthrough: true }) res
) {
const code = req.cookies['fq_code']
if (code) {
return { code }
}
const newCode =
this.authService.generateCode()
res.cookie('fq_code', newCode, {
httpOnly: true,
secure:
process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge:
365 * 24 * 60 * 60 * 1000,
})
return { code: newCode }
}
⚔️ sameSite와의 전쟁
프론트는 Vercel,
백엔드는 Railway라 도메인이 달랐다.
그래서 쿠키 관련 문제가 엄청 많이 터졌다.
삽질했던 흐름
sameSite: strict
→ 쿠키 안 붙음
sameSite: none
→ 붙긴 함
근데 보안이 약해짐
→ 다시 strict 고민
프록시 설정
→ 다시 수정
여기서 브라우저 쿠키 정책과 CORS를 엄청 많이 공부하게 됐다.
🧠 React Hook 구조 변경
원래는:
const code = localStorage.getItem(...)
방식이었다.
하지만 쿠키 기반으로 바뀌면서:
const [code, setCode] =
useState<string | null>(null)
useEffect(() => {
getMyCode().then(setCode)
}, [])
패턴으로 변경했다.
여기서 배운 점
- useState 초기값 함수는 동기만 가능
- 비동기 데이터는 useEffect에서 처리
🛡️ Rate Limiting
랭킹 도배 방지를 위해 Rate Limit 가드도 붙였다.
Redis incr + expire 조합을 사용했다.
const count = await redis.incr(key)
if (count === 1) {
await redis.expire(key, 60)
}
if (count > 5) {
throw new TooManyRequestsException()
}
60초 동안 5회 이상 요청 시 429 반환.
실제로 Insomnia로 테스트도 했다.
🗓️ Day 9 — 5/8 | 크로스오리진 디버깅
이 날은 거의 브라우저와 싸운 날이었다.
쿠키가 안 붙고,
CORS가 막히고,
credentials 옵션이 빠지고,
sameSite가 꼬였다.
특히:
withCredentials: true
이 옵션 하나 때문에 몇 시간을 날렸다.
🗓️ Day 10 — 5/9 | v1 출시 + 프로덕션 버그 수정
출시 직후 바로 버그가 터졌다.
버그 1 — n.map is not a function
원인
- Redis 캐시 데이터가 배열이 아님
해결
Array.isArray(data)
방어 처리 추가.
버그 2 — VITE_API_URL undefined
원인
- .env.production이 빌드 서버에 없음
결과
- API 대신 index.html 요청
해결
VITE_API_URL=/api
설정 추가.
여기서 배운 점
.env는 gitignore라 자동 배포 서버에 없다.
버그 3 — 다국어 번역 누락
About 페이지 번역 키가 8개 언어에서 빠져 있었다.
전부 추가해서 해결.
🔒 보안 점검 결과
항목상태
| CORS origin 제한 | ✅ |
| HttpOnly 쿠키 | ✅ |
| sameSite: strict | ✅ |
| secure 쿠키 | ✅ |
| XSS 기본 방어 | ✅ |
| Prisma ORM | ✅ |
| DTO Validation | ✅ |
| Rate Limiting | ✅ |
| HMAC 해싱 | ✅ |
🛠️ 최종 기술 스택
| 구분 | 기술 |
| Frontend | React, TypeScript, Vite, Tailwind CSS |
| i18n | i18next |
| Backend | NestJS, Prisma |
| Database | PostgreSQL |
| Cache | Redis |
| Deploy | Vercel, Railway |
| Analytics | Google Analytics |
| 광고 | AdSense |
🎯 v2 목표
- 광고 시스템 실제 활성화
- 선수 데이터 확장
- 무한 퀴즈 UX 개선
- 이어서하기 기능 활성화
- 회원 관리/로그인/관리자 기능
- Swagger
마무리
이번 프로젝트에서 가장 크게 배운 건 “기능 구현”보다 “설계”였다.
처음엔:
- localStorage
- 평문 저장
- sameSite 미설정
처럼 단순하게 접근했다.
하지만 개발하면서:
- 왜 HttpOnly 쿠키가 필요한지
- 왜 bcrypt가 lookup key에 안 맞는지
- 왜 CORS와 sameSite가 중요한지
직접 부딪히며 이해하게 됐다.
아직 부족한 부분은 많다.
그래도 처음으로:
- 프론트
- 백엔드
- 배포
- 캐싱
- 인증
- 보안
전체 흐름을 직접 끝까지 경험한 프로젝트였다.
🔗 직접 플레이해보기
이번 글에서 정리한 내용은 실제 서비스에 반영되어 있습니다.
아래 링크에서 확인할 수 있습니다.
→ https://football-quiz-web.vercel.app/
축덕 퀴즈
football-quiz-web.vercel.app
'프로젝트 > 축덕 퀴즈' 카테고리의 다른 글
| 축덕 퀴즈: 복구코드 추가 구현 + UI 개선 + UX 개선 기록 (0) | 2026.06.16 |
|---|---|
| 축덕 퀴즈 운영 및 유지보수 - 버그 수정부터 TDD, Redis 캐시 개선까지 (0) | 2026.05.25 |
| 축덕 퀴즈 개발기 - V1 확장을 결심한 이유 (0) | 2026.05.11 |
| Football Quiz Web 개발 기록 (0) | 2026.05.05 |