Fullstack 113

heo4·어제

Fullstack

목록 보기
68/69

풀스택

🕹️ 심심오락실 개발기 #2 — 윈도우 XP·98 테마, 그리고 축구선수 1000명 힌트 퀴즈

지난 글에서 "게임을 하나씩 쉽게 추가할 수 있는 뼈대"를 만들었다면,
이번에는 그 뼈대 위에 사이트 분위기(테마) 를 입히고, 제 담당인 카드 1번 게임 — 힌트 퀴즈 를 만들었습니다.
하루 동안 PR 11개를 머지하면서 겪은 시행착오까지 정리해 봤습니다.

🌐 https://simsim-arcade.pages.dev


1. 오늘 한 일 한눈에 보기

PR내용
#10 · #13README 정리 — 배포 배지·링크, 저장소 설정, 프로젝트 목표
#12🪟 윈도우 XP 테마 + 클래식 테마 전환
#15메인 화면 카드 4칸 + "준비 중" 카드
#16XP 시작 메뉴 + Q&A 페이지
#19🎯 힌트 퀴즈 게임 추가 (카드 1)
#20문제 조회 API 에 장르(category) 필터
#22장르 선택 + 축구선수 1000명 (위키데이터)
#24난이도 완화 + 부분·유사 정답 인정
#25문항 수 선택 + 문제당 제한시간 + 시간 보너스 점수
#26🖥️ 윈도우 98 테마 + 테마 3종 선택

같은 날 팀원(카드 3번)의 과일 슬라이서도 머지되면서, 메인 화면에 드디어 게임이 여러 개 생겼습니다. 🍉


2. 이 프로젝트의 목표

README 에 프로젝트 목표를 한 줄로 못 박아 두었습니다.

팀원 각자가 게임 하나를 맡아 처음부터 끝까지 직접 만들면서, 풀스택 개발자로서의 역량을 확인하는 프로젝트

  • 1인 1게임 — 화면, 게임 로직, 점수 계산, 문제 데이터(DB 시드), 서버 점수 검증까지 혼자
  • 공통 기반은 역할대로 함께 — 레이아웃·공통 컴포넌트·API·DB 스키마는 프론트/백엔드로 나눠서
  • 실제 서비스처럼 운영 — PR 리뷰, CI, main 머지 = 자동 배포

3. 저장소 운영 — 팀원 권한과 main 보호

팀원 초대는 GitHub 저장소 Write 권한이면 충분했습니다.
브랜치 push, PR, 리뷰 승인, 머지, 태그까지 다 되고, 설정·Secrets 만 관리자 몫입니다.

대신 main 보호 규칙(Ruleset) 이 있어야 규칙이 "말"이 아니라 "강제"가 됩니다.

규칙설정
PR 필수main 직접 push 불가
리뷰 승인1명 이상
필수 체크CI(typecheck · lint · format · build) 통과
머지 방식Squash and merge 만
강제 push / 삭제금지

💡 점검해 보니 관리자 계정이 Ruleset 우회 목록에 "Always"로 들어가 있었습니다.
main 에 올라가는 순간 운영 배포 + 운영 DB 시드가 도는 구조라, 우회 권한은 꼭 필요한 만큼만 두는 게 안전합니다.


4. 🪟 윈도우 XP · 🖥️ 98 테마 — 그리고 테마 전환

"추억의 오락실" 콘셉트에 맞춰 사이트 전체를 윈도우 XP 느낌으로 바꿔 봤습니다.

  • 푸른 하늘 + 초록 언덕 바탕화면 (이미지 없이 CSS 그라디언트로만)
  • 파란 제목 표시줄과 최소화·최대화·닫기 버튼이 있는 창(Window) 공통 컴포넌트
  • 하단 작업 표시줄 — 시작 버튼, 열린 창 버튼, 시계
  • XP 기본 버튼, 초록 블록 진행 막대, 파란 선택색 랭킹

로고나 공식 배경 사진 같은 저작권 있는 이미지는 쓰지 않고, 분위기만 CSS 로 재현했습니다.

이전 디자인도 살리고 싶어서 → 테마 전환

XP 가 마음에 들었지만 처음 디자인도 버리기 아까워서 두 테마를 버튼 하나로 전환하게 만들었습니다.

<html data-theme="xp">   <!-- 또는 classic -->
  • 테마별 색·모서리 토큰은 global.css 에서 :root / :root[data-theme='xp'] 로 나눔
  • 공통 컴포넌트는 Xxx.classic.module.css / Xxx.xp.module.css 로 스타일 분리
  • 선택한 테마는 localStorage 에 저장, index.html 에서 렌더링 전에 먼저 적용해 깜빡임 방지

가장 좋았던 점은 게임 담당자가 따로 할 일이 없다는 것입니다.
게임 CSS 에서 var(--accent), var(--surface) 같은 토큰만 쓰면 두 테마를 자동으로 따라갑니다.

시작 메뉴와 Q&A

XP 모드의 [시작] 버튼을 누르면 진짜 시작 메뉴처럼 열립니다.

┌──────────────────────────────┐
│ 🕹️ 심심오락실                  │
├───────────────┬──────────────┤
│ 게임 목록       │ 🏠 게임 목록   │
│ (registry 자동) │ ❓ Q&A · 도움말 │
│               │ 📁 프로젝트 소개 │
├───────────────┴──────────────┤
│              🎨 클래식 테마로 바꾸기 │
└──────────────────────────────┘

게임 목록은 registry.ts 에서 자동으로 만들어져서, 게임이 추가되면 메뉴에도 알아서 들어갑니다.

🖥️ 내친김에 윈도우 98 까지

XP 가 있으니 98 도 빠질 수 없죠. 하루의 마지막 작업으로 세 번째 테마를 추가했습니다.

  • 청록색(#008080) 단색 바탕화면
  • 회색 입체 테두리 창 + 남색 → 파랑 그라디언트 제목 표시줄 + 작은 회색 □ 버튼
  • 입체 [시작] 버튼, 눌린 모양 + 바둑판 무늬의 활성 창 버튼, 들어간 시계 칸
  • 왼쪽에 세로 배너 "심심오락실 98" 이 있는 시작 메뉴
  • 남색 블록 진행 막대, 굴림 글꼴

98 특유의 입체감은 box-shadow 를 네 겹 겹쳐서 만들었습니다. 바깥은 흰색/검은색, 안쪽은 밝은 회색/어두운 회색.

:root[data-theme='win98'] {
  /* 튀어나온 버튼 */
  --bevel-raised:
    inset -1px -1px #0a0a0a, inset 1px 1px #ffffff,
    inset -2px -2px #808080, inset 2px 2px #dfdfdf;
  /* 눌린 버튼 — 빛 방향만 뒤집으면 끝 */
  --bevel-pressed:
    inset -1px -1px #ffffff, inset 1px 1px #0a0a0a,
    inset -2px -2px #dfdfdf, inset 2px 2px #808080;
}

[data-theme='win98'] .btn        { box-shadow: var(--bevel-raised); }
[data-theme='win98'] .btn:active { box-shadow: var(--bevel-pressed); }

XP 와 구조는 공유, 스타일만 분리

XP 와 98 은 바탕화면 · 창 · 작업 표시줄 · 시작 메뉴 라는 화면 구조가 똑같습니다.
그래서 컴포넌트는 그대로 두고 CSS 만 *.win98.module.css 로 추가했습니다.

export type Theme = 'classic' | 'xp' | 'win98';

// 바탕화면 구조를 쓰는 테마인지 (XP·98) — 클래식만 헤더·푸터 구조
export const isDesktopTheme = (theme: Theme) => theme !== 'classic';

// Record<Theme, T> 라서 테마를 추가하면 스타일이 빠진 곳을 타입 검사가 알려 준다
const styles = useThemeStyles({ classic: classicStyles, xp: xpStyles, win98: win98Styles });

Theme 타입에 'win98' 한 단어를 추가하자마자 TypeScript 가 98 스타일이 빠진 컴포넌트를 전부 에러로 알려 줘서,
빠뜨린 곳 없이 채울 수 있었습니다.

테마가 셋이 되면서 "다음 테마로 넘기는 토글 버튼"은 불편해져서, 고르는 방식으로 바꿨습니다.

테마바꾸는 곳
XP · 98[시작] 메뉴 맨 아래 — 🪟 XP / 🖥️ 98 / 📄 클래식 버튼 (지금 테마는 눌린 모양)
클래식헤더 오른쪽 테마 선택 목록

역시 게임 담당자는 할 일이 없습니다. 토큰만 쓴 게임 화면은 98 에서도 자동으로 회색·남색 톤이 됩니다.


5. 메인 화면 카드 4칸 — "준비 중"

게임이 아직 다 없어도 카드 1~4번 자리를 미리 보여주고 싶었습니다.

// registry.ts — 카드 칸은 공통 코드
export const GAME_CARD_SLOTS = [
  { card: 1, owner: '혁' },
  { card: 2, owner: '경수' },
  { card: 3, owner: '신영' },
  { card: 4, owner: '동한' },
];

// 게임 담당자는 자기 항목에 card 번호만 적으면 끝
{ id: 'hint-quiz', ..., card: 1 }

게임 담당자는 여전히 "항목 1개 추가" 규칙만 지키면 되고, 비어 있는 칸은 🚧 "준비 중" 카드로 표시됩니다.

🐛 과일 슬라이서는 이 기능보다 먼저 머지돼서 card: 3 이 빠져 있었고, 3번 칸이 "준비 중" + 게임은 맨 뒤에 따로 뜨는 문제가 있었습니다.
다음 업데이트 PR 에서 한 줄이 추가되며 해결 — 공통 규칙이 바뀌면 이미 머지된 코드도 같이 봐야 한다는 교훈.


6. 🎯 내 게임: 힌트 퀴즈

콘셉트

제시어가 아니라 힌트가 하나씩 열리고, 적은 힌트로 먼저 맞힐수록 이기는 퀴즈입니다.

[축구선수]                        지금 맞히면 +85점
          이 축구선수는 누구일까요?
 ① 국적        대한민국
 ② 출생 연도    1989년
 ③ 🔒 힌트 3
 ...
 ⑦ 🔒 힌트 7   (마지막은 이름 초성!)

"먼저 맞히는 사람이 이긴다"를 실시간 대결로 만들려면 Durable Objects 가 필요한데, 팀 규칙상 파티 모드는 나중이라
지금은 1인용 + 랭킹 경쟁으로 만들었습니다.

규칙이 바뀌어 온 과정

직접 플레이해 보면서 규칙을 계속 고쳤습니다.

버전규칙문제점 → 개선
v1힌트 5개, 10초마다 자동 공개시간이 너무 짧고 힌트가 어려움
v220초, 힌트 순서 변경, 이름 초성 힌트 추가, 못 맞히면 정답 공개 후 클릭해서 다음풀네임만 정답이라 억울함
v3부분·유사 정답 인정문항 수가 고정
v4문항 수 선택(5·10·20), 문제당 60초, 시간 초과 = 0점, 빨리 맞히면 보너스👍

점수 — 문항 수가 달라도 공평하게

5문제와 20문제 판이 하나의 랭킹을 쓰기 때문에, 점수를 그냥 더하면 20문제 판이 항상 이깁니다.
그래서 문제당 평균으로 환산했습니다.

문제 점수 = 힌트 점수(100 → 85 → 70 → … → 10) × 시간 보너스(10초 이내 ×1.0 → 60초 ×0.5)
최종 점수 = 문제당 평균 × 10   (최고 1000점)
판결과최종 점수
5문제3개 만점600점
20문제12개 만점600점

같은 실력이면 같은 점수가 나옵니다. 서버의 점수 상한(MAX_SCORE_BY_GAME)도 1000 으로 맞췄습니다.


7. 장르 선택 — 공통 API 는 따로 PR 로

장르(축구선수·동물·나라·음식)를 고르면 그 장르 문제만 나와야 하는데,
기존 문제 API 는 게임 전체에서 랜덤으로만 뽑았습니다.

GET /api/games/hint-quiz/questions?limit=10&category=동물

quiz_item.meta.category 로 거르는 범용 필터를 추가했습니다. (다른 게임도 사용 가능)

.where(
  <and(
    eq(quizItem.gameId, gameId),
    // 값은 바인딩 파라미터로 전달된다 (SQL 인젝션 안전)
    category ? sql`json_extract(${quizItem.meta}, '$.category') = ${category}` : undefined,
  ),
)

API 는 공통 코드(백엔드 담당 영역) 라 게임 PR 에 섞지 않고 feature/be-* PR 로 분리해서 백엔드 팀원 리뷰를 받았습니다.
빈 값, 너무 긴 값, ' OR 1=1 -- 같은 인젝션 시도까지 로컬에서 확인했습니다.


8. ⚽ 축구선수 1000명 — 위키데이터로 수집하기

왜 위키데이터?

처음엔 구글이나 구단 공식 사이트를 생각했지만, 이용약관·저작권 문제가 있고 1000명을 하나씩 검증하기도 어렵습니다.
위키데이터(Wikidata) 는 위키백과의 구조화 데이터로, 라이선스가 CC0(자유 이용) 이고 SPARQL 로 조회할 수 있습니다.

SELECT ?p ?links WHERE {
  ?p wdt:P106 wd:Q937857;          # 직업: 축구선수
     wikibase:sitelinks ?links.     # 위키백과 문서 수 = 유명도
  ?p wdt:P413 []; wdt:P2048 []; wdt:P569 [].   # 포지션·신장·생년 있는 사람만
  FILTER EXISTS { ?p rdfs:label ?ko FILTER(LANG(?ko) = "ko") }   # 한국어 이름
}
ORDER BY DESC(?links)

수집 스크립트(seeds/scripts/hint-quiz-football.mjs)를 레포에 남겨 두어서, 다시 돌리면 최신 데이터로 갱신됩니다.
결과는 약 330KB 짜리 시드 SQL — D1 은 SQL 문 하나가 100KB 를 넘으면 안 돼서 100행씩 나눠 INSERT 합니다.

수집하면서 만난 함정들

① 알베르 카뮈가 축구선수?
작가 카뮈도 젊은 시절 골키퍼여서 "축구선수"로 잡혔습니다. → 포지션·신장·소속팀 이력이 모두 있는 사람만 남기도록 필터 강화.

② 수아레스의 소속팀이 인터 마이애미 하나뿐?
위키데이터는 "현재 소속팀"에 우선 순위(preferred rank) 가 붙어 있으면, 간단한 조회(wdt:)로는 그 팀만 돌려줍니다.
소속팀 기록은 12개나 있었는데 말이죠. → 모든 기록을 읽는 p:P54/ps:P54 방식으로 변경.

?p p:P54 ?st. ?st ps:P54 ?team.
FILTER NOT EXISTS { ?st wikibase:rank wikibase:DeprecatedRank }

③ 한국 선수가 1명뿐
전 세계 "유명한 순"으로 뽑으니, 기성용·이강인·황희찬(문서 수 30~40개)은 밀려나고
한국어 문서가 많은 일본 J리그 선수가 180명이나 들어왔습니다.
→ 한국 선수 몫 200명 + 나머지 800명은 나라당 최대 70명 으로 선발 방식 변경.

④ 그 외

  • "네덜란드 왕국", "유고슬라비아 사회주의 연방공화국" → 흔히 쓰는 국가명으로 변환
  • 동명이인 괄호 제거: 이명주 (축구 선수) → 이명주
  • "주로 쓰는 발"도 힌트로 쓰고 싶었지만 유명 선수 454명 중 5명만 정보가 있어서 포기

최종 힌트 순서는 국적 → 출생 연도 → 포지션 → 소속팀 → 신장 → 다른 소속팀 → 이름 초성 입니다.


9. 정답 판정 — "메시"도, "음밥페"도 정답

외래어 표기는 사람마다 다르게 씁니다. 풀네임만 정답이면 억울한 경우가 너무 많았습니다.

종류예
부분 정답메시, 리오넬, 수아레스 / 한국 선수는 성 뺀 이름 흥민
유사 정답음밥페, 킬리앙 음바페, 데이빗 베컴, 엘링 홀란드, 주제 무리뉴

방법: 자모 단위 편집 거리

  1. 한글을 자모로 분해 (베컴 → ㅂ ㅔ ㅋ ㅓ ㅁ)
  2. 외래어에서 흔히 섞이는 자모를 합침 — ㅐ/ㅔ, 쌍자음/예사소리, 끼어드는 'ㅡ'(홀란/홀란드)
  3. 편집 거리가 이름 길이에 비례한 허용치 이하면 정답
function allowedDistance(length: number): number {
  if (length < 5) return 0;   // 짧은 이름은 정확히
  if (length < 9) return 1;
  if (length < 14) return 2;
  return 3;
}

너그러우면서도 틀릴 건 틀려야 한다

  • 한국 사람 이름은 유사 정답 제외 — 김민재 와 김민수 는 한 글자 차이지만 다른 사람
  • 부분 정답은 본 이름에서만 — 호나우지뉴의 별칭 "작은 호나우두" 때문에 호나우두 가 정답 처리되던 문제 수정
  • 호날두 ≠ 호나우두, 해리 케인 ≠ 해리 매과이어 는 오답

테스트로 검증했습니다.

  • 맞아야 할/틀려야 할 30개 케이스 전부 통과
  • 실제 문제 1030개 전체: 정답·별칭 자기 인정 실패 0건
  • 다른 선수 이름을 넣었을 때 오인정: 약 100만 조합 중 8건 (로이 킨 ↔ 로비 킨 처럼 실제로 한 글자 차이)

10. 트러블슈팅 메모

증상원인해결
코드를 고쳐도 화면이 안 바뀜저장소가 WSL 의 /mnt/c(Windows 디스크)에 있어 파일 변경 감지 불가 → Vite 가 예전 코드를 캐시개발 서버 재시작 (근본 해결: 저장소를 WSL 홈으로 이동 or usePolling)
이어서 올린 PR 을 만들 수 없음기반 PR 이 먼저 머지되며 브랜치가 삭제됨git rebase --onto origin/main <기반 커밋> 으로 옮긴 뒤 main 대상 PR
PR 에 두 번째 커밋이 빠짐push 하기 전에 PR 이 이미 머지됨빠진 커밋만 cherry-pick 해서 새 PR
Squash 머지 후 로컬 브랜치가 안 지워짐git branch --merged 는 Squash 머지를 인식 못함PR 머지 여부를 확인하고 git branch -D
시드 두 개가 서로 문제를 지움둘 다 DELETE WHERE game_id = ...파일마다 자기 문제만 지우도록 meta.source 로 구분

11. 마치며 & 다음 계획

하루 동안 공통 기반(XP·98 테마·시작 메뉴·카드 칸·API) 과 내 게임(힌트 퀴즈) 을 오가며 작업했습니다.
"게임 PR 엔 내 게임만, 공통 변경은 따로" 규칙 덕분에 PR 하나하나가 작고 리뷰하기 쉬웠고,
머지 = 자동 배포라서 PR 이 머지될 때마다 바로 운영 사이트에서 확인할 수 있었습니다.

다음 단계는

  • 힌트 퀴즈 장르 추가 — 국기 맞히기, 수도 맞히기, 야구선수
  • 동물·나라·음식 장르 문제 20개 이상으로 늘리기
  • 랭킹 기준이 바뀌었으니(500 → 1000점) 기존 기록 정리
  • 축구선수 난이도(유명도) 구분 검토
  • 나머지 카드(2번 임진 50, 4번) 게임 머지
  • 언젠가 실시간 대결 모드 (Durable Objects)

다음 글에서 또 만나요. 🎮

0개의 댓글