← 모든 프로젝트

생활체육 클럽 플랫폼

회원 대부분이 어르신이고, 두 칸만 채우면 가입되는 생활체육 클럽용 모바일 웹앱

기간
2026년 9월 – 지금
대상
스포츠 사회적협동조합
역할
1인: 설계, 개발, 배포, 운영
기술
TypeScript, Express, EJS, Prisma, SQLite, Docker, KakaoTalk messaging API, PWA
규모
커밋 385개, 코드 11만 8천 줄, 데이터베이스 모델 113개
외주 작업, neoworks

다른 언어: english

어떤 프로젝트인가요

사회적협동조합이 운영하는 생활체육 클럽의 모바일 웹앱이에요. 회원은 가입해서 종목을 고르고 경기에 신청해요. 종목은 필드·스크린 파크골프, 야구, 축구, 풋살, 배드민턴이고, 나중에 유소년부와 티볼이 추가됐어요. 클럽은 관리자 화면에서 경기, 구장, 후원사, 제휴 매장, 공지, 회원 기록을 전부 관리해요.

9월 2일에 요구사항 여섯 개로 시작했어요. 지금은 1.95 버전이고, 파크골프 리그, 야구 기록지, 쇼핑몰, 점심 주문 사이트까지 붙었어요. 릴리스 대부분은 클럽 회장님이 보낸 짧은 메시지에 대한 답이에요.

어려웠던 점: 휴대폰을 누가 들고 있나

회원 대부분은 나이가 있는 파크골프 동호인이에요. 의뢰하신 쪽에서도 이걸 기준으로 디자인해 달라고 했고, 그래서 저는 이걸 스타일이 아니라 요구사항으로 다뤘어요.

  • 본문 글자는 17px, 입력칸은 18~19px, 주요 버튼은 높이 60px이에요.
  • 가입은 이름과 전화번호 두 칸이 전부예요. 비밀번호 입력은 없어요. 서버가 전화번호 뒷자리 네 개로 비밀번호를 정하고, 다음 화면에 크게 보여줘요. 나머지 정보는 나중에 채워요.
  • 화면마다 주요 버튼은 하나예요. 모든 아이콘 옆에 한국어 단어를 붙였어요. 길게 누르기도, 숨은 메뉴도 없고, 하단 탭은 다섯 개예요.
  • alert, confirm, prompt는 안 써요. 대부분 카카오톡으로 링크를 여는데, 카카오톡 인앱 브라우저가 이걸 그냥 먹어버려요. "복사" 버튼은 창을 띄우는 대신 버튼 글자를 바꿔서 알려줘요.

두 칸짜리 입력과 큰 초록 버튼이 있는 가입 화면 가입: 이름, 전화번호, 끝. 안내 문구에 전화번호가 아이디이고 뒷자리 네 개가 비밀번호라고 적혀 있어요.

덜 뻔한 교훈도 있었어요. 이런 화면에서 "아무 반응이 없다"는 건 "한 번 더 누르라"는 뜻이에요. 어느 날 저녁에 같은 후원사가 3초 안에 세 번 등록됐는데, 로고 파일은 sha256까지 똑같았어요. 두 겹으로 고쳤어요. 첫 클릭 뒤에 작은 스크립트가 제출 버튼을 잠그고 "저장 중..."이라고 표시하고, 서버는 렌더링된 폼 토큰을 한 번만 받아요. 그래서 인앱 브라우저가 자바스크립트를 먹어버려도 두 번째 저장은 아무 일도 안 일어나요.

배운 게 많았던 버그들

클럽의 유일한 소유자가 사라졌어요. 어느 아침 헬스 체크에 owners: 0이 떴어요. 감사 로그를 보니 이유가 있었어요. 회장님이 자기 매장을 본인 전화번호로 등록했는데, "매장 로그인 만들기" 코드가 그 번호로 기존 회원을 찾아서 매장 계정으로 바꾸고 이름도 매장 이름으로 바꿔버린 거예요. 화면에는 "저장됨"이라고 나왔고요. 역할 변경 화면에서는 마지막 소유자를 보호해 뒀지만, 역할을 쓰는 곳이 거기뿐이라고 생각했던 게 문제였어요. 세 번째 곳이 있었어요. 지금은 Member.role을 쓰는 함수가 하나뿐이고, 그 UPDATE의 WHERE 절에 규칙이 들어 있어요. 마지막 소유자는 강등할 수 없어요. 어떤 코드 경로도 이걸 건너뛸 수 없어요.

SQLite에서의 트랜잭션. Prisma 인터랙티브 트랜잭션 안에서 부관리자를 지정하는 기능이, 회원 46명짜리 데이터베이스에서 그냥 실패했어요. 고정된 5,000ms 제한에 6463 ms passed가 찍혔어요. SQLite는 파일 전체에 쓰기 잠금이 하나뿐이라서, 다른 쓰기가 있으면 트랜잭션이 죽을 때까지 멈춰 있어요. 해결은 트랜잭션을 없애는 거였어요. 개수 확인을 안에 넣은 조건부 UPDATE 하나로 바꿨어요. 이건 14~29ms에 끝나고, 동시에 두 번 클릭해도 데이터베이스가 직접 막아줘요.

finish에서 다운로드 세기. 쿠폰 재고를 응답의 finish 이벤트에서 차감하고 있었어요. 실제 휴대폰에서는 클라이언트가 마지막 바이트를 받자마자 소켓을 닫고, node는 finish를 안 부르는 경우가 많아요. 재현해 보니 다운로드 30번이 끝까지 됐는데 finish는 11번이었어요. 쿠폰의 10~60%가 안 세지고 있었던 거예요. 테스트가 통과했던 건 node의 fetch가 keep-alive 연결을 재사용하기 때문인데, 휴대폰은 그러지 않아요. 카운터와 테스트 클라이언트를 둘 다 바꿨어요.

실제 메시지를 보낸 테스트. 스테이징 컨테이너가 운영 환경 변수를 그대로 물려받아서, 테스트용 번호 두 개로 실제 카카오톡 메시지를 보냈어요. 알고 보니 그 번호는 실제 사람들 번호였어요. 지금은 나가는 모든 메시지가 단 하나의 발송 함수 안에 있는 가드 하나를 거쳐요. 테스트 프로세스와 테스트용 번호 접두사를 막고, 로그에 GUARD:blocked를 남기고, 애매하면 안 보내는 쪽으로 실패해요.

그 밖에 들어 있는 것들

  • 명단 엔진: 대기 번호는 절대 다시 매기지 않고, 누가 취소하면 대기 1번이 자동으로 올라가요.
  • 회원 5,240명을 생성해서 부하 테스트를 했어요. 최악의 경우 24ms, 관리자 회원 목록은 36ms예요. 회원 검색은 미리 계산해 둔 컬럼에 LIKE 한 번이고, 회원 내보내기는 모든 행을 메모리에 올리지 않고 스트리밍해요.
  • 파크골프 리그: 리그전 대진 추첨, 경기를 티 시간에 맞춰 넣는 스케줄러, 그리고 설치형 점수 입력 앱이 있어요. 각 휴대폰이 오프라인에서 점수를 쌓아뒀다가 작업 id와 함께 다시 보내서, 같은 점수가 두 번 반영되는 일이 없어요. 스테이지 테스트는 104개 항목을 확인해요.

지금은

운영 중이고, 일주일에 몇 번씩 릴리스하면서 계속 커지고 있어요. 공유 서버에 컨테이너 하나로 돌아가요. 9월에 서버 전체에 메모리 부족이 터져서 다른 서비스들과 함께 내려간 뒤로 메모리를 1GB로 제한했어요. 모든 릴리스에는 롤백 이미지가 있고, 먼저 데이터베이스 백업을 뜨고, 운영 환경에 읽기 전용 확인을 돌린 뒤 흔적을 스스로 정리해요.