ENFRKOantony langlois
작업

LeetClimb

웹사이트모바일 앱NestJSReact NativeReactPostgreSQL

클라이밍 짐 플랫폼 · 하나의 NestJS + Prisma API가 React Native 클라이머 앱, 관리자 웹 콘솔, 상시 구동되는 TV 디스플레이에 서비스를 제공합니다 · 게임화된 XP, 실시간 리더보드, EN/KO 이중 언어

해결할 문제

실내 볼더링 짐의 클라이머들은 올바른 기술을 익히고 의도된 방식으로 완등하기 위해 특정 볼더 문제의 해결 영상을 보고 싶어 합니다. 하지만 저는 그들이 인스타그램에서 관련 영상을 찾느라 종종 많은 시간을 낭비하는 것을 보았습니다. 영상이 여러 계정에 흩어져 있고, 각 계정마다 여러 게시물이 있으며, 각 게시물에는 여러 영상이 담겨 있어 전체 과정이 사용자에게 매우 시간 소모적이고 번거로웠기 때문입니다.

또한 제가 본 다른 점은, 사람들이 자신을 촬영해 클라이밍 전용 별도 인스타그램 계정에 영상을 올리는 것을 좋아하면서 동시에 자신이 완등한 볼더 수와 성공한 레벨을 계정 소개란이나 게시물 캡션에 카운터로 유지한다는 것입니다. 또한 클라이머들 사이에서 크루와 커뮤니티를 만드는 것도 즐긴다는 점입니다.

마지막으로 제가 주목한 것은 클라이밍 이벤트와 대회의 운영이 매우 어수선하고 체계가 없다는 점이었습니다. 점수가 종이에 적히고 참가자들의 신뢰에 기반하다 보니, 최종 점수의 부정확성과 전반적인 혼란으로 이어질 수 있었습니다.

나의 아이디어

저의 핵심 아이디어는 관련 해결 영상을 찾는 전체 과정을 빠르고 아주 단순하게 만드는 것이었습니다. 또한 휴대폰 없이 클라이밍하는 사람들이 영상 해결책을 볼 수 있도록 대안을 제공하는 것이었습니다.

두 번째 개념은 클라이머들이 커뮤니티에 소속감을 느끼고, 클라이밍할 때 진전과 보상의 감각을 제공함으로써 자신의 클라이밍 여정에 몰입할 수 있는 환경을 만드는 것이었습니다.

그리고 퍼즐의 마지막 조각은 이벤트 생성과 등록을 아주 손쉽게 만들기 위해 제대로 된 관리자 대시보드와 이벤트 생성 시스템을 제공하는 것이었습니다.

아키텍처

Expo · React Native
Frontend · 모바일 앱
Climber App
apps/mobile · expo router
오프라인 우선: TanStack Query 캐시 + 크래시에도 살아남고 재연결 시 드레인되는 영속화된 Zustand 업로드 큐
React · Vite
Frontend · 관리자 웹
Admin Dashboard
apps/web · react router
벽 사진 위 루트 세팅, 실시간 제출물 리뷰, 짐 애널리틱스, 이벤트 관리
public web
Frontend · 디스플레이
TV + Kiosk
apps/web · /display /kiosk
실시간 리더보드 + 완등 피드, FLIP 애니메이션, 화면 절전 후 스스로 복구
SaaS
Auth · SaaS
Clerk
JWT 세션, OAuth + 이메일 OTP · 웹훅이 사용자와 아바타를 동기화
Node · Railway
Core · REST + SSE API
NestJS API
apps/api · 19 modules
전역 Clerk 가드가 사용자를 자동 프로비저닝 · 승인 이벤트가 진실의 원천으로부터 전체 XP 재계산을 구동
제출물 리뷰XP · 레벨 · 업적리더보드 · SQL + 캐시주간 챌린지대회 이벤트SSE 팬아웃
Prisma · Railway
데이터베이스
PostgreSQL
apps/api/prisma
어디서나 소프트 삭제 · 커서 페이지네이션 · 기간별 리더보드를 위한 원시 SQL
S3 API
Storage · CDN
Cloudflare R2
벽 사진은 공개 CDN에 · 증빙 영상은 사전 서명된 URL 뒤에 두고 리뷰 후 삭제
SaaS
Push · SaaS
Expo Push
사용자별 환경설정으로 게이트되는 6가지 이벤트 기반 알림 유형

클라이머가 Climber App에서 완등을 기록합니다: 증빙 영상이 기기에서 큐에 쌓이고, 짐 WiFi가 협조하는 순간 Clerk JWT와 함께 POST /submissions로 업로드됩니다. NestJS API는 토큰을 검증하고 해당 행을 PostgreSQL에, 영상을 Cloudflare R2에 저장한 뒤, SSE로 submission.newAdmin Dashboard에 푸시합니다. 여기서 리뷰어가 영상을 보고 원자적 상태 전환으로 승인합니다. 승인은 도메인 이벤트를 발생시킵니다: XP와 업적이 재계산되고, Expo Push가 클라이머에게 알림을 보내며, 짐의 모든 TV + Kiosk 디스플레이가 리더보드와 완등 피드를 실시간으로 갱신합니다.

배포: API → Railway · Docker, 부팅 시 Prisma 마이그레이션 · Web + TV → Vercel · Mobile → Expo EAS 빌드 · 데이터베이스 → Railway PostgreSQL · 미디어 → Cloudflare R2 + CDN · CI → GitHub Actions

성과

  • 3개 클라이언트 · 모바일, 관리자 웹, TV · 하나의 API 계약
  • 15초 · SSE 하트비트가 짐 TV를 하루 종일 실시간으로 유지
  • EN / KO · 데이터베이스부터 UI까지 완전한 이중 언어
  • 오프라인 우선 · 완등 기록이 기기에서 큐에 쌓이고 앱 종료에도 살아남음

보여준 역량: 증분 카운터 대신 이벤트 기반 재계산 · 조건부 업데이트를 통한 TOCTOU 안전 상태 전환 · 브라우저 연결 예산 내의 SSE 팬아웃 · 하이드레이션 게이트가 걸린 드레인을 갖춘 오프라인 큐 · 캐시 무효화를 동반한 소프트 삭제 캐스케이드 · 웹훅 멱등성 (P2002 upsert 충돌)

문제와 해결책

모든 선택은 같은 제약에 답합니다: 불안정한 WiFi, 공용 TV, 파트타임 관리자가 있는 실제 짐 현장 · 하나의 API, 세 개의 클라이언트.

█ XP는 파생되며, 결코 증분되지 않습니다

문제: 승인, 거절, 루트 리셋, 챌린지 보너스는 모두 클라이머의 XP를 변경합니다. 이벤트마다 카운터를 증분하면 이벤트가 누락되거나 재생되거나 되돌려지는 순간 값이 어긋나고, 눈에 띄게 잘못된 리더보드는 게임 전체에 대한 신뢰를 무너뜨립니다.

진실의 원천은 승인된 제출물과 챌린지 완료의 집합입니다. 모든 도메인 이벤트는 해당 사용자 진행 상황의 전체 재계산을 트리거합니다; UserProgress 행은 리더보드 읽기를 위한 캐시일 뿐입니다.

  • 증분 카운터 배제: 재계산이 모든 변경을 멱등적이고 자가 치유적으로 만듭니다
  • 승인 이벤트당 추가 쿼리 비용 수용, 짐 규모에서는 저렴합니다
  • 무료 백필 확보: 규칙 변경 후 하나의 스크립트가 모든 진행 상황을 다시 파생합니다

▚ 짐 데드존을 위한 오프라인 우선 업로드

문제: 클라이머들은 신호가 없는 콘크리트 건물 깊숙한 곳에서 증빙 영상을 촬영합니다. 힘들게 얻은 완등 후 업로드가 실패하면 증빙이 사라지고 사용자가 화가 나며, 모바일 OS는 백그라운드 앱을 업로드 도중에 종료합니다.

영상은 앱의 문서 디렉터리로 복사되어 영속화된 Zustand 큐로 추적됩니다. 드레인 훅은 스토어 하이드레이션과 Clerk 인증을 기다리고, 3회 재시도하며, 3초 디바운스를 걸어 포그라운드 업로드와 절대 경쟁하지 않습니다.

  • fail-and-toast 배제: 재시도 부담이 사용자가 아닌 코드에 놓입니다
  • 중단된 업로드는 재하이드레이션 시 큐 상태로 리셋되므로, 업로드 도중 크래시가 나도 복구 가능합니다
  • 완등 손실 제로의 대가로 큐 상태 복잡성(하이드레이션 게이트, UUID 기반 중복 제거) 수용

▤ 모든 실시간에 WebSocket 대신 SSE

문제: 짐 TV, 키오스크, 관리자 리뷰 화면 모두 실시간 갱신이 필요하지만, 브라우저는 HTTP/1.1을 호스트당 6개 연결로 제한하며, WebSocket 인프라는 하나의 작은 컨테이너에 스티키 세션과 재연결 상태를 요구합니다.

짐 위치별 RxJS Subject, 15초 하트비트, 그리고 어떤 기능도 자신의 스트림을 고려하지 않고는 출시되지 않도록 페이지별 명시적 연결 예산을 갖춘 단방향 Server-Sent Events.

  • WebSocket 배제: 클라이언트에서 서버로의 메시지가 필요한 적이 전혀 없었으므로, 양방향 인프라는 순수한 비용이었습니다
  • 폴링 배제: 10대 이상의 TV가 리더보드를 폴링하면 같은 쿼리를 하루 종일 두들기게 됩니다
  • 연결 예산을 프로덕션에서의 갑작스러운 문제가 아닌 확고한 설계 제약으로 수용

▓ 짐이 벽을 리셋하기 때문에 소프트 삭제

문제: 실제 짐은 몇 주마다 벽을 뜯고 다시 세팅합니다. 루트를 하드 삭제하면 제출물, XP, 업적을 통해 캐스케이드되어 클라이머들이 가장 아끼는 기록을 지우게 됩니다.

위치, 벽, 사진, 루트에 대한 isActive 플래그와 명시적 캐스케이드 규칙(루트를 비활성화하면 그 챌린지가 만료됩니다; 벽을 재활성화해도 그 루트는 되살아나지 않습니다). 보관된 루트는 여전히 원래 홀드 마커와 함께 프로필에 렌더링됩니다.

  • 하드 삭제 + 기록 스냅샷 배제: 루트 데이터의 두 복사본은 필연적으로 어긋납니다
  • 모든 공개 쿼리는 isActive로 필터링하며, 읽기 경로에 대해 수용된 세금입니다
  • 데이터 손실 제로로 영구 클라이밍 기록과 안전한 벽 리셋 확보

▒ 자체 인증이 아닌 자동 프로비저닝 Clerk

문제: 세 클라이언트는 Google OAuth, 이메일 OTP, 비밀번호, 아바타 동기화, 계정 삭제가 필요합니다. 이를 직접 만드는 것은 수 주의 작업이자 솔로로 유지되는 플랫폼에 영구적인 보안 부담입니다.

전역 NestJS 가드가 Clerk JWT를 검증하고 처음 보이는 시점에 로컬 사용자 행을 upsert하므로, 데이터베이스를 미리 시딩할 필요가 전혀 없습니다. 웹훅이 이메일과 아바타를 동기화 상태로 유지하며; 삭제는 먼저 로컬 데이터를 캐스케이드한 뒤 Clerk 계정을 삭제합니다.

  • Passport + 자체 사용자 테이블 배제: 인증 버그는 소유할 가치가 없는 유일한 범주입니다
  • 벤더 종속과 웹훅 엣지 케이스 수용, upsert 충돌은 명시적으로 처리됩니다
  • 삭제 순서(DB 먼저, Clerk 나중)가 고아 개인 데이터가 없음을 보장합니다

▞ 두 언어를 모두 반환하는 이중 언어

문제: 영어권 클라이머가 있는 한국 짐: 같은 벽 이름이 TV에서는 한국어로, 모바일에서는 뷰어의 언어로 렌더링되어야 하며, 관리자가 이름을 바꾸면 둘 다 즉시 갱신되어야 합니다.

모든 이름 옆에 선택적 nameKo 컬럼을 두고, API는 항상 둘 다 반환합니다. 클라이언트는 공유 getLocalizedName() 헬퍼로 선택하며; TV는 이중 언어 페이로드를 직접 렌더링합니다.

  • Accept-Language 협상 배제: 언어별로 캐시를 분할하고 이중 언어 TV 화면을 깨뜨립니다
  • 하나의 캐시 가능한 응답 형태를 위해 약간 더 큰 페이로드 수용
  • 한국어 이름이 null이면 영어로 폴백하므로, 번역이 콘텐츠보다 뒤처져도 됩니다