출퇴근 웹앱 개발일지 (3) — 급여명세서 모듈
시급제·월급제·주휴수당이 섞여 있고, 한 사람이 여러 매장에서 일하면 명세서를 한 장으로 통합해야 합니다. 출퇴근 기록을 급여로 환산하는 계산 구조를 정리합니다.
title: "출퇴근 웹앱 개발일지 (3) — 급여명세서 모듈" date: "2026-05-31" description: "시급제·월급제·주휴수당이 섞여 있고, 한 사람이 여러 매장에서 일하면 명세서를 한 장으로 통합해야 합니다. 출퇴근 기록을 급여로 환산하는 계산 구조를 정리합니다." tags: ["dev", "attendance-web", "payroll"] category: "dev"
2편까지가 "누가 언제 일했나"를 기록하는 부분이었습니다. 이번 편은 그 기록을 돈으로 환산하는 급여명세서 모듈입니다. 출퇴근 앱에서 가장 손이 많이 간 부분이기도 합니다.
요구가 단순하지 않았습니다. 시급으로 받는 직원과 월급으로 받는 직원이 섞여 있고, 주휴수당을 자동으로 얹어야 하고, 한 사람이 매장 두세 곳에서 일하면 명세서를 한 장으로 합쳐서 줘야 했습니다. 하나씩 봅니다.
1. 설정과 명세서를 분리했다
급여는 테이블 두 개로 나눴습니다.
payroll_settings— 직원별 고정 설정 (시급/월급 여부, 시급 단가, 각종 수당, 원천징수 대상 여부). 한 사람당 한 행.payslips— 그 달 명세서 (user_id + year + month가 PK). 매달 한 행씩 쌓입니다.
설정은 잘 안 바뀌고, 명세서는 매달 새로 찍힙니다. 명세서를 만들 때 그 시점의 설정값을 복사해 박아두기 때문에, 나중에 시급을 올려도 과거 명세서 금액은 그대로 보존됩니다.
-- 단순화
CREATE TABLE payroll_settings (
user_id TEXT PRIMARY KEY REFERENCES users(id),
pay_type TEXT DEFAULT 'hourly', -- 'hourly' | 'monthly'
hourly_rate INTEGER DEFAULT 0,
monthly_base INTEGER DEFAULT 0,
round_unit_rate INTEGER DEFAULT 5000, -- 게임 진행 1회당 수당
position_allowance INTEGER DEFAULT 0, -- 직책수당
vehicle_allowance INTEGER DEFAULT 0, -- 차량수당
education_allowance INTEGER DEFAULT 0, -- 교육수당
meal_allowance INTEGER DEFAULT 0, -- 식대
withholding_target BOOLEAN DEFAULT FALSE, -- 3.3% 원천징수 대상
updated_at TIMESTAMPTZ
);
round_unit_rate(게임 진행 1회당 수당)는 일반 출퇴근 앱에는 없는 칸입니다.
방탈출 매장이라 직원이 그 달에 진행한 게임 회차를 출퇴근 기록에 같이 적고,
회차 × 단가를 특별수당으로 얹습니다. 출퇴근 기록(records)에 round_count
칸이 하나 더 있는 이유입니다.
2. 시급제 계산 — 그 달 records를 통째로 합산
핵심은 computeMonth() 한 함수입니다. 그 달 1일부터 다음 달 1일 직전까지의
records를 긁어서, 출근·퇴근이 둘 다 찍힌 행만 근무시간으로 환산합니다.
SELECT COALESCE(SUM(
CASE WHEN check_in IS NOT NULL AND check_out IS NOT NULL
THEN EXTRACT(EPOCH FROM (check_out - check_in))
ELSE 0 END
), 0) AS work_seconds,
COALESCE(SUM(round_count), 0) AS round_total
FROM records
WHERE user_id = ANY(...) AND date >= :monthStart AND date < :nextMonth;
초 단위로 합산해서 3600으로 나눠 시간으로 만들고, 소수 2자리로 반올림합니다. 기본급은 (근무시간 + 주휴시간) × 시급, 월급제면 그냥 월급 고정값입니다.
const basePay = isHourly
? Math.round((workHours + weeklyPaidHours) * hourly)
: monthly;
const specialAllowance = roundTotal * roundUnitRate; // 회차 수당
3. 주휴수당 — 주 단위로 다시 묶어 판정
여기가 제일 까다로웠습니다. 주휴수당은 "주 15시간 이상 일하면 하루치 유급휴일을 얹어준다"는 근로기준법 규칙인데, 월 단위가 아니라 주 단위로 판정해야 합니다. 그래서 같은 records를 주(week) 기준으로 한 번 더 묶습니다.
SELECT date_trunc('week', date)::date AS wk,
SUM(...) AS sec,
COUNT(*) FILTER (WHERE check_in IS NOT NULL) AS days
FROM records WHERE ... GROUP BY wk;
각 주에 대해, 그 주 근무가 15시간 이상이고 출근일이 1일 이상이면 **1주 평균 근무시간(하루 최대 8시간 상한)**을 그 주의 주휴시간으로 더합니다.
for (const w of weekRows) {
const hrs = w.sec / 3600;
if (hrs >= 15 && w.days > 0) {
const avg = hrs / w.days;
weeklyPaidSeconds += Math.min(avg, 8) * 3600; // 8시간 상한
}
}
date_trunc('week', ...)는 ISO 기준(월요일 시작)이라, 한국 주휴 계산과도
맞아떨어집니다. 상한 8시간을 둔 건 하루 8시간을 초과한 만큼까지 주휴로
얹어주지 않기 위해서입니다.
4. 직원 통합 — 한 사람이 여러 매장에서 일할 때
매장이 여러 곳이고, 한 직원이 매장을 옮겨 다니며 일하는 경우가 있었습니다. 이 사람은 매장마다 별도 계정(아이디)을 갖고 출퇴근을 찍습니다. 그런데 급여는 사람 단위로 한 장이 나가야 합니다.
그래서 users 테이블에 자기참조 FK 하나를 더 뒀습니다.
ALTER TABLE users
ADD COLUMN primary_user_id TEXT REFERENCES users(id) ON DELETE SET NULL;
대표 계정 하나를 정하고, 나머지 계정의 primary_user_id를 그 대표로 채웁니다.
명세서를 만들 때는 대표 + 하위 계정 전체(getGroupUserIds)의 records를 한꺼번에
합산합니다.
async function getGroupUserIds(userId) {
const [u] = await sql`SELECT id, primary_user_id FROM users WHERE id = ${userId}`;
const primaryId = u.primary_user_id || u.id;
const all = await sql`
SELECT id FROM users
WHERE id = ${primaryId} OR primary_user_id = ${primaryId}`;
return all.map(r => r.id);
}
두 가지를 신경 썼습니다.
- 주휴 판정도 그룹 합산 — 1호점에서 10시간, 2호점에서 8시간 일했으면 그 주는 18시간이라 주휴 대상입니다. 계정별로 따로 보면 각각 15시간 미만이라 주휴를 놓칩니다. 그래서 주별 합산을 그룹 전체 records로 돌립니다.
- 호점별 내역은 따로 보여주기 — 합산은 하되, 명세서에는 "1호점 32시간 /
2호점 28시간" 식으로
byBranch배열을 같이 실어 보냅니다. 직원이 자기 명세서를 봤을 때 어느 매장에서 얼마나 일했는지 확인할 수 있게.
월별 명세서 목록(payslips-month)에서는 하위 계정을 숨깁니다
(WHERE u.primary_user_id IS NULL). 한 사람이 목록에 세 번 뜨면 안 되니까요.
5. 소속 자동 추론 — 작은 편의
호점 정보를 매번 입력하기 귀찮아서, 아이디 끝자리로 추론하는 헬퍼를 하나 뒀습니다. 끝자리가 1/2/3이면 1호점/2호점/3호점으로 채웁니다 (DB에 저장하진 않고 표시용).
function inferBranchByIdSuffix(userId) {
const last = userId.slice(-1);
if (last === "1") return "1호점";
if (last === "2") return "2호점";
if (last === "3") return "3호점";
return null;
}
작은 매장 운영용 앱이라, 이런 "관례 기반 기본값"이 입력 수고를 꽤 줄여줍니다.
6. draft → finalized → 발송
명세서는 상태가 두 개입니다.
draft— 작업 중. 자유롭게 수정·삭제 가능.finalized— 확정. 직원이 자기 명세서를 조회할 수 있는 건 finalized부터. 삭제하려면 먼저 상태를 내려야 합니다(실수 방지).
저장할 때 지급 항목 합계에서 공제 항목 합계를 빼 실지급액을 서버에서 계산해 박아둡니다. 프론트가 보낸 합계를 믿지 않습니다.
const totalPayment = basePay + overtimePay + positionAllowance + bonus
+ specialAllowance + vehicleAllowance + educationAllowance
+ mealAllowance + educationHoursPay;
const totalDeduction = incomeTax + residentTax + socialInsurance + withholdingTax;
const netPay = totalPayment - totalDeduction;
확정된 명세서는 직원 이메일로 발송할 수 있고(한 명씩 또는 그 달 전체 일괄),
발송 시각·수신처가 명세서 행에 기록됩니다. 모든 저장·확정·발송은
audit_log에 남습니다(payroll.payslip_finalize, payroll.payslip_send 등).
이메일 쪽 이야기는 5편에서 따로 다룹니다.
정리 — 무엇이 핵심이었나
- 설정/명세서 분리로 과거 금액을 보존했습니다. 시급을 올려도 지난 명세서는 안 바뀝니다.
- 주휴는 무조건 주 단위로 다시 묶어 판정해야 합니다. 월 단위로 뭉뚱그리면 틀립니다.
- 사람 단위 통합은 자기참조 FK 하나로 풀리지만, 주휴 판정과 목록 중복을 같이 손봐야 제대로 동작합니다.
- 실지급액은 서버에서 다시 계산합니다. 클라이언트 합계는 표시용일 뿐입니다.
다음 편 예고
Vol. 4 — Vercel 함수 12개 한도. 무료 플랜의 서버리스 함수 12개 제한 안에서
이 모든 기능을 어떻게 욱여넣었는지, [action].js 한 파일로 여러 엔드포인트를
합치는 패턴을 정리합니다.