출퇴근 웹앱 개발일지 (5) — Cron과 푸시, 명세서 이메일 자동화로 마무리
사람이 누르지 않아도 알아서 도는 자동화 세 가지. 자정에 퇴근 안 찍은 직원을 잡아내는 Cron, 관리자에게 바로 띄우는 웹푸시, 그리고 급여명세서 이메일 발송까지. 5편 시리즈를 마무리합니다.
title: "출퇴근 웹앱 개발일지 (5) — Cron과 푸시, 명세서 이메일 자동화로 마무리" date: "2026-06-04" description: "사람이 누르지 않아도 알아서 도는 자동화 세 가지. 자정에 퇴근 안 찍은 직원을 잡아내는 Cron, 관리자에게 바로 띄우는 웹푸시, 그리고 급여명세서 이메일 발송까지. 5편 시리즈를 마무리합니다." tags: ["dev", "attendance-web", "cron", "push"] category: "dev"
4편까지는 사람이 직접 누르는 기능들이었어요. 출근 찍고, 퇴근 찍고, 명세서 만들고. 이번 마지막 편은 아무도 안 눌러도 알아서 도는 자동화 이야기예요. 세 가지를 붙였습니다 — 자정 미퇴근 잡기(Cron), 관리자 푸시 알림, 그리고 명세서 이메일 발송.
1. 자정에 "퇴근 안 찍은 사람" 자동으로 잡기
제일 흔한 사고가 이거였어요. 출근은 찍었는데 퇴근을 안 찍고 퇴근. 그러면 그날 기록이 반쪽짜리로 남고, 나중에 급여 계산할 때 근무시간이 비어버려요.
그래서 KST 자정 직후에 한 번 도는 Cron을 만들었습니다. "방금 끝난 어제" 기록 중
출근만 있고 퇴근이 빈 행을 찾아 23:59:59로 채워줘요.
// api/cron/auto-checkout.js
const target = yesterdayInKST(); // 자정 막 넘겼으니 대상은 "어제"
const rows = await sql`
SELECT r.user_id, u.name, r.check_in::text AS check_in
FROM records r JOIN users u ON u.id = r.user_id
WHERE r.date = ${target}
AND r.check_in IS NOT NULL
AND r.check_out IS NULL -- 출근만 찍고 퇴근이 빈 행
`;
if (rows.length > 0) {
await sql`
UPDATE records SET check_out = '23:59:59'
WHERE date = ${target} AND check_in IS NOT NULL AND check_out IS NULL
`;
}
KST(UTC+9) 환산은 이렇게 했어요. 서버는 UTC로 도니까, 9시간 더해서 "어제"를 구합니다.
function yesterdayInKST() {
const kst = new Date(Date.now() + 9 * 60 * 60 * 1000);
kst.setUTCDate(kst.getUTCDate() - 1);
return `${kst.getUTCFullYear()}-${pad(kst.getUTCMonth() + 1)}-${pad(kst.getUTCDate())}`;
}
스케줄은 vercel.json의 crons에 등록하고, 아무나 못 부르게 시크릿으로 막았어요.
Cron 요청 헤더에 Authorization: Bearer <CRON_SECRET>이 맞을 때만 동작해요.
const expected = process.env.CRON_SECRET;
if (expected && req.headers["authorization"] !== `Bearer ${expected}`) {
return res.status(401).json({ error: "unauthorized" });
}
그리고 자동으로 손댄 기록은 전부 감사 로그에 "시스템(자동 퇴근)"이 한 걸로 남겨요. 나중에 "이 퇴근시간 누가 넣었지?" 했을 때 추적되게요.
2. 자동 처리하고 끝? 관리자한테 알려줘야죠
자정에 조용히 채워놓기만 하면, 관리자는 누가 퇴근을 안 찍었는지 영영 몰라요. 그래서 처리 결과를 웹 푸시로 관리자한테 바로 띄웁니다.
await sendToAdmins({
title: "🌙 자정 자동 퇴근",
body: `퇴근 미체크 ${rows.length}명을 23:59로 자동 처리했어요: ${names}`,
tag: `auto-checkout-${target}`,
});
푸시는 Web Push + VAPID로 구현했어요. 구독 흐름은 push/[action].js 한 파일에
모아놨고요(4편의 그 12칸 살림 기억나시죠).
// vapid-key → subscribe → (unsubscribe / status)
if (action === "vapid-key") return await vapidKey(req, res);
if (action === "subscribe") return await subscribe(req, res);
if (action === "unsubscribe") return await unsubscribe(req, res);
if (action === "status") return await status(req, res);
브라우저가 공개키(VAPID)를 받아 구독을 만들고, 서버는 그 구독 정보를 DB에 저장해뒀다가
알림 보낼 때 꺼내 씁니다. 출근·퇴근·신청 등록 때도 같은 sendToAdmins를 써요.
⚠️ 푸시는 토큰이 조용히 만료되는 게 함정이에요. 특히 안드로이드는 며칠~몇 주 뒤 구독이 silently 죽는 경우가 있어서, 발송 결과(
sent/dead)를 로깅해두고 죽은 구독은 정리하게 해뒀습니다.
3. 급여명세서를 이메일로
명세서를 확정하면 직원 이메일로 보내는 것도 자동화했어요. Gmail SMTP(Nodemailer)를 썼습니다. 도메인 인증 같은 거 없이, Google 계정의 앱 비밀번호만 있으면 돼서 1인 운영에 딱이었어요.
cachedTransporter = nodemailer.createTransport({
service: "gmail",
auth: { user: GMAIL_USER, pass: GMAIL_APP_PASSWORD }, // 앱 비밀번호(16자)
});
명세서는 화면에서 보던 표 그대로를 인라인 스타일 HTML로 만들어 본문에 실어요.
(이메일 클라이언트들이 외부 CSS를 잘 안 먹어서, 스타일을 태그마다 직접 박아야 했어요.)
발송 기록(sent_at/sent_to)도 남기고, 감사 로그에도 적습니다.
Gmail SMTP는 하루 500통 한도가 있어요. 직원 수십 명 규모에선 차고 넘치지만, 더 커지면 전용 발송 서비스로 옮겨야 합니다. 지금 규모엔 0원이 맞아요.
4. 자동화 세 개를 붙이고 배운 것
- 자정 Cron은 "사람의 실수"를 메우는 안전망이에요. 퇴근 안 찍는 건 못 막지만, 빈 기록은 막아요.
- 자동 처리는 반드시 흔적을 남겨야 합니다. 감사 로그 없이 조용히 데이터를 고치면, 나중에 분쟁날 때 설명할 길이 없어요.
- 무료로 갈 수 있는 데까진 무료로. Cron도, 푸시도, Gmail 발송도 추가 비용 0원이었어요. 작은 매장 앱엔 이게 맞았습니다.
시리즈를 마치며
다섯 편에 걸쳐 출퇴근 앱을 처음부터 끝까지 풀어봤어요. 시스템 개요(1편), 인증과 세션(2편), 급여명세서(3편), 무료 한도 12칸 살림(4편), 그리고 이번 자동화(5편)까지요.
거창한 기술은 없었어요. 작은 매장 하나 굴리는 데 필요한 만큼만, 돈 안 들이고, 안 끊기게. 그게 이 앱의 전부였고, 의외로 그 "필요한 만큼"을 정직하게 맞추는 게 제일 어려웠던 것 같아요. 긴 시리즈 읽어주셔서 고맙습니다.