Projects
사이드 2026

CrossStitch

GitHub 커밋 기록으로 20×20 픽셀 아트를 만들고 README 카드로 삽입할 수 있는 웹 서비스. CHALLENGE 모드에서는 실제 커밋 수만큼만 셀을 채울 수 있습니다.

Role

기획 · 디자인 · 개발 (1인)

Period

2026

Stack

Next.js 15, React 19, TypeScript, Tailwind CSS 4, Firebase Auth, Firestore, Firebase Storage, Vitest, GitHub Actions, Vercel

만든 이유

GitHub 프로필 README를 꾸미고 싶어도 마땅한 방법이 없었습니다. 잔디(contribution graph)는 이미 있지만 개성을 담기 어렵고, 외부 뱃지 서비스는 디자인이 획일적입니다. “내 커밋으로 픽셀 아트를 그려서 README에 박으면 어떨까?” 라는 아이디어에서 출발했습니다.

주요 기능

  • GitHub OAuth 로그인 — Firebase Auth + httpOnly 쿠키로 토큰을 안전하게 관리
  • 20×20 인터랙티브 픽셀 그리드 — 드래그로 여러 셀을 한 번에 색칠, 색상 피커 지원
  • NORMAL 모드 — 제한 없이 자유롭게 20×20 그리드를 색칠할 수 있는 기본 모드
  • CHALLENGE 모드 — 이번 달 실제 커밋 수만큼만 셀을 채울 수 있습니다. 커밋을 더 할수록 픽셀이 더 채워지는 구조로, 잔디를 심는 동기를 자극합니다. 매달 1일 크론 잡이 실행되어 전월 커밋 수 대비 초과된 셀을 자동으로 정리하고, 다음 달 목표치로 초기화됩니다
  • 픽셀 아트 템플릿 — 직접 그리기 어려운 경우 제공된 템플릿으로 빠르게 시작
  • readme-card SVG API — 픽셀 아트 + GitHub 통계(레포수·팔로워·PR·이슈)를 담은 SVG 카드를 API로 제공
  • 원클릭 README 삽입 — 완성 후 마크다운 코드를 클립보드에 복사, GitHub README에 바로 붙여넣기

AI 협업

문제 Claude Code를 코드 생성 도구로만 쓰면 결과를 그대로 믿고 넘어가기 쉽습니다. 실제로 초기 버전에서 AI가 작성한 applyRandomRemovalCells는 slice(0, removeCount)로 제거할 셀이 아니라 남아야 할 셀을 반환하는, 의도와 정반대로 동작하는 코드였고, homeLogic.test.ts는 프로덕션 로직을 테스트 파일 안에 그대로 복사해 검증하는 구조라 HomeContent.tsx가 바뀌어도 항상 통과하는 — 회귀를 잡지 못하는 테스트였습니다.

분석 AI가 만든 코드를 신뢰하고 넘어가는 방식과 직접 검증하는 방식을 비교했습니다. CodeRabbit 리뷰만으로는 의도-구현 불일치나 가짜 테스트까지 걸러내지 못한다고 판단해, 핵심 로직은 리뷰와 별개로 직접 읽고 검증하는 쪽을 선택했습니다.

실행 매번 전체 코드를 재검토할 수는 없다는 제약 속에서, 프로젝트 루트에 CLAUDE.md를 두고 main 직접 커밋 금지, feat/·fix/·chore/ 브랜치 → PR → 머지 순서 준수 같은 AI의 행동 범위부터 먼저 고정한 뒤, 그 위에서 생성된 코드를 검증하는 순서로 관리했습니다. applyRandomRemovalCells는 slice(removeCount)로, isHeaderVisible은 user is { uid: string } type predicate로 고쳤고, 테스트는 homeState.ts로 deriveHomeState·isHeaderVisible을 순수 함수로 분리해 HomeContent.tsx·Header.tsx·테스트가 모두 이 파일을 참조하는 단일 소스로 통일했습니다.

결과 의도와 반대로 동작하는 로직 버그 2건을 발견·수정했고, 이후부터는 프로덕션 로직이 바뀌면 테스트가 반드시 깨지는 구조가 됐습니다.

OAuth 토큰 XSS 탈취 경로 차단

문제 GitHub OAuth 토큰을 클라이언트 스토리지(localStorage 등)에 저장하면 XSS 공격으로 탈취될 위험이 있었습니다.

분석 클라이언트 스토리지 저장과 httpOnly 쿠키 저장을 비교했습니다. 클라이언트 스토리지는 JS에서 직접 접근 가능해 XSS에 노출되지만, httpOnly 쿠키는 JS로 읽을 수 없어 근본적으로 탈취 경로를 차단한다고 판단했습니다.

실행 서버 사이드에서만 토큰을 다뤄야 한다는 제약 속에서, Next.js API Route에서 GitHub OAuth 토큰을 수신한 뒤 httpOnly 쿠키(24시간 만료)로만 저장하고 이후 API 요청은 서버 사이드에서만 쿠키를 읽어 GitHub API를 호출하도록 구성했습니다.

결과 클라이언트 코드에서 토큰에 접근할 수 없어 XSS 탈취 경로 자체를 차단했음을 확인했습니다.

CHALLENGE 모드 — 커밋 기반 크론 잡 설계

문제 CHALLENGE 모드는 이번 달 커밋 수만큼만 픽셀을 채울 수 있는 모드입니다. 매월 초 리셋 처리와 전월 초과 셀 정리 로직이 필요했는데, 클라이언트에서 처리하면 접속하지 않은 유저는 리셋이 누락되는 문제가 생겼습니다.

분석 유저 접속 시점 처리와 서버 측 스케줄 배치를 비교했습니다. 접속 여부와 무관하게 모든 유저에게 일관되게 적용되는 서버 배치가 누락 위험이 없다고 판단해 선택했습니다.

실행 단순 삭제 시 아트가 부자연스럽게 사라진다는 제약 속에서, 매달 1일 GitHub Actions 크론 잡이 Firestore의 모든 CHALLENGE 모드 유저 셀 수와 현재 커밋 수를 비교해 초과된 셀을 그리드 오른쪽 하단부터 순서대로 제거하도록 구성했습니다.

결과 아트가 자연스럽게 줄어드는 UX를 구현했고, 유저가 다음에 접속하면 리셋 알림 모달이 표시되는 것을 확인했습니다.

readme-card SVG API — 별도 이미지 서버 없이

문제 픽셀 아트를 GitHub README에 삽입하려면 이미지로 제공해야 했습니다. PNG를 Firebase Storage에 업로드하는 방식은 파일 관리가 복잡하고, GitHub 마크다운에서 외부 이미지가 캐싱되면 업데이트가 반영되지 않는 문제가 있었습니다.

분석 클라이언트에서 이미지를 생성해 업로드하는 방식과 요청 시점에 서버에서 즉시 생성하는 방식을 비교했습니다. 후자가 별도 파일 관리 없이 항상 최신 상태를 보장한다고 판단했습니다.

실행 매 README 조회마다 재생성하면 서버 부하가 커진다는 트레이드오프를, /api/readme-card/[uid] 요청 시 Firestore의 그리드 데이터와 GitHub 통계를 조합해 SVG를 즉시 생성하되 최대 1시간 캐시를 적용하는 방식으로 관리했습니다.

결과 별도 이미지 서버 없이 마크다운 ![](url) 문법만으로 README에 삽입 가능한 SVG API를 구축했고, GitHub 서버 부하도 줄였습니다.

PR 기반 CI/CD — 코드 품질 자동화

문제 1인 프로젝트라도 main에 직접 push하는 습관이 생기면 검증 없이 배포되는 코드가 쌓일 위험이 있었습니다.

분석 로컬 커밋 훅과 PR 기반 CI를 비교했습니다. 로컬 훅은 우회 가능하지만 서버 측에서 강제되는 PR 기반 CI는 우회할 수 없다고 판단해 선택했습니다.

실행 검증 단계가 늘수록 PR 머지까지 걸리는 시간이 늘어난다는 트레이드오프를, ESLint → TypeScript 타입 체크 → Vitest 단위 테스트 순으로 빠르게 실패하는 단계부터 배치하는 방식으로 관리했습니다. main 브랜치는 Ruleset으로 직접 push를 차단했습니다.

결과 PR 통과 후 CodeRabbit이 자동 코드리뷰를 추가하고, CI 실패 시 에러 리포트가 PR에 자동 코멘트로 달려 원인을 빠르게 파악할 수 있는 구조를 확인했습니다.

트러블슈팅

Vercel CI/CD — [SENSITIVE] 환경변수 오류

GitHub Actions에서 Vercel 배포를 구성하던 중 SyntaxError: "[SENSITIVE]" is not valid JSON 오류가 났습니다.

vercel pull이 민감한 환경변수를 실제 값 대신 [SENSITIVE]라는 문자열로 .env에 써버리고, 빌드 시 JSON.parse("[SENSITIVE]")가 실행되는 게 원인이었습니다. Vercel CLI의 동작 방식을 파악한 뒤, FIREBASE_SERVICE_ACCOUNT_KEY를 GitHub Secrets에 등록하고 vercel build 스텝의 env: 블록에서 직접 주입해 [SENSITIVE] 값을 덮어쓰도록 해결했습니다.