Vol. 15

개발일지

출퇴근 웹앱 개발일지 (1) — 무엇을 만들었나

직원 출퇴근을 관리하는 웹앱을 직접 짰습니다. 첫 편에서는 어떤 시스템인지, 어떤 스택으로 돌아가는지, 어떤 제약 안에서 만들어졌는지 정리합니다.

2026.05.25 · 발신 우체부J


title: "출퇴근 웹앱 개발일지 (1) — 무엇을 만들었나" date: "2026-05-25" description: "직원 출퇴근을 관리하는 웹앱을 직접 짰습니다. 첫 편에서는 어떤 시스템인지, 어떤 스택으로 돌아가는지, 어떤 제약 안에서 만들어졌는지 정리합니다." tags: ["dev", "attendance-web"] category: "dev"

매장에서 일하는 직원들이 출근/퇴근을 찍고, 관리자가 그 기록으로 월급명세서까지 뽑는 사내용 웹앱을 직접 만들어 1년 가까이 굴리고 있습니다. 이 시리즈에서는 그동안 어떻게 짰는지, 어디서 막혔는지, 무엇을 고쳤는지를 코드와 git 히스토리를 근거로 한 편씩 정리해 두려고 합니다.

첫 편은 시스템 전체 윤곽입니다.

무엇을 하는 앱인가

  • 직원 화면: 로그인 → 🟢출근/🔴퇴근 버튼 → 본인 기록(표·캘린더) 조회 → 누락된 기록 수정 신청 → 본인 급여명세서 확인
  • 관리자 화면: 직원 관리, 전체 기록 CRUD, 수정 신청 승인/반려, 통계, 급여명세서 발행/이메일 발송, 활성 세션 강제 로그아웃, 모든 변경의 감사 로그
  • 자동화: KST 자정마다 "미퇴근" 직원을 cron으로 잡아 관리자에게 푸시 알림

스택

| 영역 | 선택 | |---|---| | 프론트 | Vanilla JS SPA (단일 페이지, 프레임워크 없음) | | 백엔드 | Vercel Functions (Node.js 24) | | DB | Neon Postgres (서버리스) | | 인증 | bcrypt 해싱 + HS256 JWT + DB 세션 테이블 | | 푸시 | Web Push API + web-push 라이브러리 + VAPID 키 | | 메일 | Gmail SMTP via Nodemailer (앱 비밀번호) | | 배포 | Vercel (수동 vercel deploy --prod --yes) |

프레임워크를 일부러 안 썼습니다. 화면이 한 장(SPA)이라 라우터가 필요 없고, 빌드 단계 없이 정적 파일을 그대로 올리면 끝나는 구조가 디버깅이 빠르기 때문입니다.

DB 스키마 — 표 8개

schema.sql에 정의된 테이블입니다.

  • users — 계정 (id·이름·비번 해시·role·이메일·primary_user_id 자기참조 FK)
  • records — 출퇴근 기록 (user_id × date 조합 PK, check_in / check_out / round_count / work_type / cover_for_user_id)
  • sessions — JWT의 jti와 1:1 매핑, 강제 로그아웃용
  • push_subscriptions — Web Push 구독 정보
  • record_requests — 직원의 기록 수정 신청 (pending/approved/rejected/cancelled)
  • audit_log — 모든 mutation 자동 기록 (actor·action·before·after JSONB)
  • payroll_settings — 직원별 급여 기준값 (시급/월급/수당/원천징수 여부)
  • payslips — 월별 명세서 (직원 × 연·월 UNIQUE, draft/finalized)

특이한 점 두 개:

  1. records의 PK가 (user_id, date) — 하루 1행 강제. 같은 날 두 번 출근은 구조적으로 불가능.
  2. users.primary_user_id가 자기참조 FK — 한 사람이 호점별로 아이디를 여러 개 가질 때 대표 계정에 묶어 급여명세서를 한 장으로 통합 발행하기 위한 컬럼입니다. 이게 후속 편에 따로 나옵니다.

제약 — Vercel Hobby 12함수 한도

무료 플랜 한도가 함수 12개입니다. 지금 정확히 12 / 12를 쓰고 있어서, 새 엔드포인트를 추가할 때마다 기존 [action].js 파일에 동작을 합치는 식으로 관리합니다. 예를 들어 /api/auth/[action].js 하나가 login / signup / logout / me / checkin / checkout을 다 처리합니다.

처음에는 함수를 하나씩 따로 만들었다가, 한도에 부딪힌 뒤 통합한 흔적이 코드에 남아 있습니다 (api/audit.jsapi/stats.js?type=audit로 흡수).

기타 운영 제약

  • 모바일 직원 로그인 차단 — User-Agent에서 모바일이 감지되면 직원은 서버가 403을 반환합니다. 관리자 계정만 모바일 허용. 출퇴근은 매장 PC에서만 찍게 한 정책입니다.
  • Preview 환경 변수 없음 — Vercel preview 배포는 동작하지 않습니다. 환경 변수가 production에만 등록돼 있어서, 검증은 production에서 합니다.
  • 자기 회차 변경은 "이번 달만" — KST 기준 이번 달 기록은 직원이 직접 근무 유형(정상/교육/대타)을 바꿀 수 있지만, 지난달 이후는 "수정 신청" 흐름을 거치게 막아놨습니다.

git에 남은 큰 분기점

| 커밋 | 변경 | |---|---| | aefba1c | 초기 웹 버전 (localStorage 기반) | | 11df1f5 | 관리자 대시보드 추가 | | b3c4fb1 | localStorage → Neon Postgres + Vercel Functions로 전환 | | 45d0681 | 출퇴근 시간이 UTC로 저장되던 버그 수정 (KST 9시간 차이) | | b14166b | 5분 무활동 자동 로그아웃 | | f9d9990 | 관리자 "근무중" 탭 (현재 근무 중인 직원 실시간 표시) | | d739fd8 | 직원 본인 근무 유형 체크 (정상/교육/대타) | | 7f4c6f0 | 급여명세서 모듈 추가 | | 634b713 | 다중 호점 직원 통합 (대표 계정 + 하위 계정) |

각 항목이 시리즈 한 편씩 됩니다.

다음 편 예고

Vol. 2 — 인증과 세션. bcrypt + JWT만으로는 "관리자가 직원을 즉시 강제 로그아웃시키기"가 안 됩니다. JWT는 한번 발급되면 만료 전까지 살아 있기 때문입니다. 그래서 sessions 테이블을 추가해 JWT의 jti를 DB에서 검증하는 구조로 바꿨는데, 그 흐름을 정리할 예정입니다.