프로젝트/축덕 퀴즈

축덕 퀴즈 개발 일지 - 20일간의 기능 추가

ksc-dev 2026. 5. 12. 20:15

축구 선수의 인생 스토리를 읽고 이름을 맞히는 퀴즈 게임을 만들었다.
단순한 퀴즈 앱이 아니라, 글로벌 랭킹·다국어·익명 인증·보안까지 직접 설계해보는 것이 목표였다.

프론트엔드는 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