주짓수 도장에서 사용할 출결 앱을 처음 구현했다. 목표는 출석부를 디지털로 옮기는 것이 아니라, 입구에서 빠르게 출석하고 결과를 확신하며 관리자가 기록을 바로 처리하는 흐름을 만드는 일이었다.

기록 범위

현재 저장소의 첫 커밋은 2026년 9월 19일 23:58이다. 모든 브랜치와 reflog에도 이보다 앞선 소스 이력은 없었다. 이전에 구상한 내용이 있더라도 자료가 남아 있지 않아 날짜별 구현 기록으로 단정하지 않았다. 이 글은 확인할 수 있는 첫 구현 시점부터 시작한다.

사용 위치를 세 구간으로 나눴다

도장의 출결은 회원·입구 태블릿·관리자가 보는 정보가 달라 한 화면으로 해결하기 어렵다. 회원은 본인의 QR과 오늘 상태만 필요하고, 입구 태블릿은 즉시 스캔할 수 있어야 하며, 관리자는 명단과 수정 기능이 필요하다.

위치 행동 우선순위
회원 휴대폰 PIN 로그인 후 QR을 연다 내 출석 상태 확인
입구 태블릿 QR을 읽거나 PIN을 입력한다 빠른 처리와 명확한 결과
관리자 날짜별 명단을 조회·수정한다 검색과 예외 처리

이 구분 덕분에 태블릿에 회원 이력이나 관리자 기능을 억지로 넣지 않았다. 태블릿은 스캔과 결과만 크게 보여 주고, 관리 기능은 별도 화면으로 분리했다.

QR을 하루짜리 출석 권한으로 만들었다

회원 번호를 QR에 넣으면 캡처 이미지가 계속 재사용될 수 있다. 그래서 회원 QR은 그날 출석에만 쓰는 임시 토큰을 바탕으로 구성했다.

  1. 회원이 PIN으로 본인을 확인한다.
  2. 서버가 32바이트 난수 토큰을 만들고, 원문 대신 HMAC 해시를 저장한다.
  3. QR에는 matos://checkin?token=... 형식의 값만 담는다.
  4. 태블릿이 읽으면 서버가 날짜, 만료, 사용, 폐기 상태를 확인한다.
  5. 검증 뒤에만 출석 레코드와 포인트를 만든다.

새 QR을 발급하면 이전의 미사용 토큰은 폐기하고, 사용한 토큰에는 사용 시각을 남긴다. 토큰을 캡처해 두더라도 다음 날이나 다음 발급 후에는 다시 쓸 수 없다.

데이터베이스에서 중복을 막았다

gyms, members, member_pins, attendance_tokens, attendances를 분리했다. 출석에는 회원, 도장, 날짜, 체크인 시각, QR 또는 수동 처리 방식, 포인트를 남긴다. 같은 회원의 같은 날 출석은 데이터베이스 제약으로 막았다. 버튼을 두 번 누르지 않게 하는 화면 로직만으로는 네트워크 재시도나 동시 요청을 막을 수 없기 때문이다.

PIN과 QR 토큰 원문은 저장하지 않는다. PIN은 해시로 저장하고 QR 토큰도 서버 비밀값을 이용한 해시로 비교한다. 관리자와 회원 세션은 서명된 쿠키로 만들고 유효 시간을 제한했다.

입구 스캐너와 대체 경로

태블릿은 html5-qrcode로 카메라를 열어 QR을 읽는다. 성공하면 이름, 완료 표시, 시간, 짧은 소리와 진동으로 결과를 보여 준다. 처리 중에는 다시 스캔하지 않게 하고, 다음 회원을 위해 짧은 시간 뒤 카메라를 다시 시작한다.

카메라 권한 거부, 장치 미연결, 다른 앱의 카메라 점유도 구분해 안내한다. 스캔을 계속할 수 없으면 네 자리 PIN 출석으로 전환한다. 입구에서 출석 자체가 막히지 않는 것이 더 중요했다.

관리자와 운영 환경

관리자는 날짜 조회, 이름 검색, 전체 / 출석 / 출석 전 필터로 명단을 좁힌다. 출석 전인 회원은 수동 처리하고, 잘못 누른 경우에는 즉시 취소할 수 있게 했다. 월간 화면에서는 횟수와 포인트를 확인한다.

앱은 Next.js와 Supabase로 구성했다. Vercel 자동 배포를 연결하고, 도장 장비에서는 라즈베리 파이가 앱을 실행하도록 설치·업데이트 스크립트를 만들었다. 데이터베이스 키와 세션 비밀값은 배포 환경 변수로만 관리한다.

이 날짜의 검증 기준은 세 가지였다. QR이 읽힌 뒤에만 저장 성공을 보여 주는지, 중복 출석이 데이터베이스에서 막히는지, 관리자와 태블릿이 같은 출석 상태를 보는지다.

다음 글: 태블릿 출석 흐름과 PWA 다듬기