Vol. 17

개발일지

출퇴근 웹앱 개발일지 (3) — 급여명세서 모듈

시급제·월급제·주휴수당이 섞여 있고, 한 사람이 여러 매장에서 일하면 명세서를 한 장으로 통합해야 합니다. 출퇴근 기록을 급여로 환산하는 계산 구조를 정리합니다.

2026.05.31 · 발신 우체부J


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 한 파일로 여러 엔드포인트를 합치는 패턴을 정리합니다.