ENFRKOantony langlois
프로젝트

LeetClimb

웹사이트모바일 앱NestJSReact NativeReactPostgreSQL
0:00 / 0:00
LeetClimb demo

클라이밍장 플랫폼 · 하나의 NestJS + Prisma API로 React Native 클라이머 앱, 관리자 웹 콘솔, 상시 운영되는 TV 화면을 지원합니다 · 게임형 XP, 실시간 리더보드, 한국어와 영어 지원

해결할 문제

실내 볼더링을 하다 보면 문제의 의도와 동작을 파악하려고 베타 영상을 찾게 됩니다. 하지만 관련 영상은 여러 Instagram 계정과 게시물에 흩어져 있고, 게시물 하나에도 영상이 여러 개씩 담겨 있어 원하는 영상을 찾는 데 시간이 많이 걸렸습니다.

클라이머들은 완등 영상을 전용 Instagram 계정에 올리고, 프로필이나 게시물에 완등 수와 성공한 난이도를 기록하며 서로 크루와 커뮤니티를 만들기도 합니다. 이런 활동과 기록을 한곳에서 이어갈 공간도 필요했습니다.

클라이밍 이벤트와 대회 운영도 체계적이지 않았습니다. 점수를 종이에 기록하고 참가자의 신뢰에 의존하다 보니 최종 점수가 틀리거나 현장이 혼란스러워질 수 있었습니다.

아이디어

핵심은 원하는 베타 영상을 빠르고 간단하게 찾는 것입니다. 휴대폰을 들고 오르지 않는 사람도 클라이밍장의 화면에서 영상을 확인할 수 있게 했습니다.

완등 기록과 보상을 통해 성장을 체감하고, 다른 클라이머와 자연스럽게 어울릴 수 있는 환경도 만들고 싶었습니다.

마지막으로 관리자 대시보드와 이벤트 시스템을 제공해 대회 개설과 참가 신청을 쉽게 만들었습니다.

아키텍처

Expo · React Native
프런트엔드 · 모바일 앱
Climber App
apps/mobile · expo router
오프라인 우선: TanStack Query 캐시 + 앱 종료 후에도 유지되고 재연결 시 처리되는 Zustand 업로드 대기열
React · Vite
프런트엔드 · 관리자 웹
Admin Dashboard
apps/web · react router
벽 사진에서 루트 설정 · 실시간 제출 검토 · 클라이밍장 분석 · 이벤트 관리
public web
프런트엔드 · 디스플레이
TV + Kiosk
apps/web · /display /kiosk
실시간 리더보드 + 완등 피드, FLIP 애니메이션, 화면 절전 후 스스로 복구
SaaS
인증 · SaaS
Clerk
JWT 세션, OAuth + 이메일 OTP · 웹훅이 사용자와 아바타를 동기화
Node · Railway
핵심 · REST + SSE API
NestJS API
apps/api · 19 modules
전역 Clerk 가드가 사용자를 자동 생성 · 승인 이벤트마다 기준 데이터에서 전체 XP 재계산
제출물 리뷰XP · 레벨 · 업적리더보드 · SQL + 캐시주간 챌린지대회 이벤트SSE 팬아웃
Prisma · Railway
데이터베이스
PostgreSQL
apps/api/prisma
전체 데이터에 소프트 삭제 적용 · 커서 페이지네이션 · 기간별 리더보드용 원시 SQL
S3 API
저장소 · CDN
Cloudflare R2
벽 사진은 공개 CDN에 저장 · 증빙 영상은 사전 서명 URL로 보호하고 검토 후 삭제
SaaS
푸시 알림 · SaaS
Expo Push
사용자별 설정에 따라 전송하는 이벤트 기반 알림 6종

클라이머가 Climber App에서 완등을 기록하면 증빙 영상이 기기의 업로드 대기열에 저장됩니다. 클라이밍장 Wi-Fi가 연결되는 즉시 Clerk JWT와 함께 POST /submissions로 전송됩니다. NestJS API는 토큰을 검증하고 제출 기록은 PostgreSQL에, 영상은 Cloudflare R2에 저장합니다. 이어 SSE로 submission.new 이벤트를 Admin 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를 하루 종일 최신 상태로 유지
  • 한국어 / 영어 · 데이터베이스부터 UI까지 두 언어 지원
  • 오프라인 우선 · 완등 기록을 기기에 보관해 앱이 종료돼도 유지

보여준 역량: 증분 카운터 대신 이벤트 기반 재계산 · 조건부 업데이트를 통한 TOCTOU 안전 상태 전환 · 브라우저 연결 한도를 고려한 SSE 팬아웃 · 앱 초기화와 인증 상태를 고려한 오프라인 대기열 처리 · 캐시 무효화를 포함한 소프트 삭제 전파 · 웹훅 멱등성(P2002 upsert 충돌)

문제와 해결책

모든 선택은 불안정한 Wi-Fi, 공용 TV, 파트타임 관리자가 있는 실제 클라이밍장 환경을 고려했습니다. 하나의 API로 세 가지 클라이언트를 지원해야 했습니다.

█ XP는 누적하지 않고 다시 계산합니다

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

XP의 기준 데이터는 승인된 제출물과 완료한 챌린지입니다. 도메인 이벤트가 발생할 때마다 해당 사용자의 진행 상황을 처음부터 다시 계산합니다. UserProgress 레코드는 리더보드를 빠르게 읽기 위한 캐시일 뿐입니다.

  • 증분 카운터를 쓰지 않아 모든 변경을 멱등적으로 처리하고 잘못된 값도 자동으로 바로잡습니다
  • 승인마다 쿼리가 추가되지만 클라이밍장 규모에서는 부담이 작습니다
  • 규칙이 바뀌어도 스크립트 하나로 모든 진행 상황을 다시 계산할 수 있습니다

▚ 통신 음영 지역을 위한 오프라인 우선 업로드

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

영상은 앱의 문서 디렉터리에 복사되고 영속화된 Zustand 대기열에서 관리됩니다. 처리 훅은 스토어 초기화와 Clerk 인증이 끝날 때까지 기다리고, 최대 3회 재시도합니다. 3초 디바운스를 적용해 포그라운드 업로드와 충돌하지 않게 했습니다.

  • 실패 알림만 띄우는 방식을 쓰지 않아 재시도 부담을 사용자에게 넘기지 않습니다
  • 중단된 업로드는 앱 상태를 복원할 때 대기 상태로 되돌리므로 업로드 도중 앱이 종료돼도 복구할 수 있습니다
  • 완등 기록을 잃지 않기 위해 대기열 초기화 제어와 UUID 기반 중복 제거의 복잡성을 감수했습니다

▤ 실시간 통신에 WebSocket 대신 SSE 사용

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

클라이밍장별 RxJS Subject와 15초 하트비트를 사용하는 단방향 Server-Sent Events 구조입니다. 페이지마다 연결 한도를 명시해 새 기능이 스트림 수를 고려하지 않은 채 추가되지 않도록 했습니다.

  • 클라이언트에서 서버로 보낼 실시간 메시지가 없어 양방향 WebSocket은 사용하지 않았습니다
  • TV 10대 이상이 리더보드를 계속 폴링하면 같은 쿼리가 하루 종일 반복되므로 폴링도 제외했습니다
  • 브라우저 연결 한도를 처음부터 명확한 설계 제약으로 다뤘습니다

▓ 벽을 재세팅해도 기록을 지키는 소프트 삭제

문제: 실제 클라이밍장은 몇 주마다 벽을 뜯고 다시 세팅합니다. 루트를 완전히 삭제하면 제출물, XP, 업적까지 연쇄 삭제되어 클라이머에게 소중한 기록이 사라집니다.

위치, 벽, 사진, 루트에 isActive 플래그와 명시적인 연쇄 규칙을 적용했습니다. 루트를 비활성화하면 관련 챌린지가 만료되고, 벽을 다시 활성화해도 기존 루트는 되살아나지 않습니다. 보관된 루트는 원래 홀드 표시와 함께 프로필에 계속 나타납니다.

  • 완전 삭제와 기록 스냅샷을 함께 쓰면 루트 데이터 사본끼리 결국 어긋나므로 제외했습니다
  • 모든 공개 쿼리에 isActive 필터가 필요하지만 기록 보존을 위해 감수했습니다
  • 데이터 손실 없이 클라이밍 기록을 영구 보관하고 벽을 안전하게 재세팅할 수 있습니다

▒ 자체 인증 대신 Clerk 자동 프로비저닝

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

전역 NestJS 가드가 Clerk JWT를 검증하고 사용자의 첫 요청에서 로컬 레코드를 upsert하므로 데이터베이스를 미리 채울 필요가 없습니다. 웹훅은 이메일과 아바타를 동기화합니다. 계정 삭제 시에는 로컬 데이터를 먼저 연쇄 삭제한 뒤 Clerk 계정을 삭제합니다.

  • 보안 부담이 큰 인증 코드를 직접 관리하지 않기 위해 Passport와 자체 인증 구현을 제외했습니다
  • 공급업체 종속과 웹훅 예외 상황은 감수하되 upsert 충돌은 명시적으로 처리합니다
  • 데이터베이스를 먼저 지우고 Clerk를 나중에 지워 남겨진 개인 데이터가 없도록 했습니다

▞ 두 언어를 항상 반환하는 API

문제: 영어권 클라이머도 찾는 한국 클라이밍장에서는 같은 벽 이름을 TV에는 한국어로, 모바일에는 사용자가 선택한 언어로 보여줘야 합니다. 관리자가 이름을 바꾸면 두 화면 모두 즉시 갱신돼야 합니다.

모든 이름에 선택적 nameKo 컬럼을 두고 API는 두 언어를 항상 반환합니다. 각 클라이언트는 공용 getLocalizedName() 헬퍼로 표시할 값을 고르고, TV는 두 언어를 함께 표시합니다.

  • Accept-Language 협상은 언어별로 캐시를 나누고 두 언어를 함께 쓰는 TV 화면을 지원할 수 없어 제외했습니다
  • 응답 크기가 조금 늘어나는 대신 어디서나 같은 형태의 응답을 캐시할 수 있습니다
  • 한국어 이름이 없으면 영어로 대체되므로 번역이 늦어져도 콘텐츠를 바로 게시할 수 있습니다