leo.dev
backend

연차 자동부여 배치가 못 보던 수동 부여

2026.08.048 min read batchidempotencytypeormmysql

연차 자동 적립 배치는 매일 KST 자정에 한 번 돌고, 부여 조건이 오늘 == 발생일이었다. 워커 배포가 그 시각에 걸리거나, 크래시가 나거나, DB가 잠깐 죽으면 그날이 발생일인 직원들은 그 발생분을 받지 못한다. 다음 날 배치는 정상적으로 도는데 오늘 != 발생일이라 어제 놓친 사람들을 다시 집지 않는다. 한 번의 미실행이 영구 누락이 된다.

이게 단순 데이터 누락이 아닌 건 대상이 법정 휴가라서다. 근로기준법 제60조가 정하는 연차는 못 받으면 미사용수당으로 소급 청구된다. 배치가 한 번 안 돌았다는 사실은 로그 어딘가에만 있고, 잔액에는 그냥 며칠이 덜 붙어 있을 뿐이다.

부여 조건을 하루에서 구간으로

처방은 조건 하나였다. 발생일 == 오늘발생일 ≤ 오늘 && 아직 부여 안 됨으로 바꾼다. 그러면 어제 놓친 발생분이 오늘 실행에서 그대로 대상에 들어온다. 별도 보정 배치를 만드는 게 아니라, 여러 번 돌아도 결과가 한 번 돈 것과 같은(멱등) 백필로 기존 경로 자체를 바꾸는 쪽을 택했다.

‘여러 번 돌아도 안전한가’는 배치만의 질문이 아니다. 프론트에서 이 코드가 언제 다시 도는가를 안 물어 같은 요청이 반복돼 429가 난 것도 결국 같은 질문이었다.

도래 후보(발생일이 오늘까지 온 회차)를 계산하는 부분은 DB를 안 보는 순수 함수로 뽑았다.

leave-accrual.service.ts
export function computeDueOccurrences(rule, joinedDate, today): DueOccurrence[] {
const joinDate = startOfDay(new Date(joinedDate));
const firstAnniversary = addYears(joinDate, 1);
// 월차 — 입사 첫해에만, 오늘까지 도래한 개월분
if (isBefore(today, firstAnniversary) && rule.initialMonthlyGrant > 0) {
for (let n = 1; n <= 11; n++) {
const occ = addMonths(joinDate, n);
if (isBefore(today, occ)) break; // 아직 도래 안 함
if (!isBefore(occ, firstAnniversary)) break; // 1주년 넘으면 월차 아님
dues.push({ occurredAt: occ, occurrenceKey: `${occ.getFullYear()}_MONTH_${n}`, ... });
}
}
...
}

n이 1부터 11까지인 건 입사 첫해에 1개월 개근마다 하루씩 붙는 구간이 1주년 전까지라서다. 이 하루가 법에 그대로 적혀 있다.

사용자는 계속하여 근로한 기간이 1년 미만인 근로자 또는 1년간 80퍼센트 미만 출근한 근로자에게 1개월 개근 시 1일의 유급휴가를 주어야 한다.

근로기준법 제60조 제2항

1주년이 지나면 이 항이 안 걸리고 연 단위 부여로 넘어가므로, 월차 후보를 11에서 끊는 것이 그 경계다. 예전 코드는 여기서 오늘이 정확히 addMonths(joinDate, n)인지를 isSameDay로 물었고, 지금은 오늘보다 앞서 도래했는지만 묻는다. 입사 5개월째에 배치가 처음 도는 직원이면 1~5개월차가 한 번에 계산된다.

중복 부여를 막는 건 occurrence_key다. 연차 변동을 한 행씩 쌓는 원장에 (employee_id, occurrence_key) UNIQUE가 걸려 있고, 부여 직전에 같은 키를 조회해 있으면 건너뛴다. 사전 조회가 정상 경로를, UNIQUE가 동시성 경로를 막는다.

UNIQUE KEY `uq_leave_ledger_employee_occurrence` (`employee_id`, `occurrence_key`)

이 구조 덕에 직원 등록 시점의 초기 백필과 매일 배치가 같은 함수를 쓰게 됐다. 등록 경로가 먼저 2026_MONTH_3을 넣었으면 배치는 같은 키를 보고 건너뛴다. 재등록해도 이중 부여가 안 난다.

여기까지는 의도대로 됐다.

릴리스 당일에 문의가 왔다

이 변경이 담긴 릴리스는 7월 10일 오전에 나갔고, 같은 날 담당자 문의로 사고를 알았다. 연차가 실제보다 많이 잡혀 있다는 것이었다.

원인은 자동 부여가 담당자가 손으로 넣어둔 부여 위에 한 번 더 들어간 것이었다. 자동 부여를 켜둔 채 담당자도 화면에서 수동으로 연차를 넣는 회사가 있는데, 배치가 보는 occurrence_key는 자동 부여가 자기 자신과 겹치지 않게만 한다. 수동 부여 행에는 그런 키가 없으니 배치 입장에서는 원장에 자기 키가 없는 게 사실이고, 처음 부여하는 것이 맞다고 판단한다.

이 구멍은 발생일 == 오늘이던 시절에도 있었다. 다만 그때는 회차가 도래하는 날에만 하루치씩 샜다.

조건을 구간으로 바꾸자 그동안 쌓여 있던 회차가 한 번에 도래 판정을 받는다. 없던 버그가 생긴 게 아니라 있던 구멍으로 한 번에 나가는 양이 달라진 것이다. 자가치유가 메우라고 만든 결손과, 담당자가 이미 손으로 메워둔 결손을 배치가 구분하지 못했다.

몇 명에게 며칠이 잘못 나갔는지는 정확히 세지 않았다. 부풀려진 잔액은 수기로 정정했다. 어긋난 걸 문의받기 전에 알아채는 장치는 그때 없었고, 원장 합과 잔액을 매일 대조하는 배치는 나중에 붙였다.

껐다 켠 회사부터 막기

코드에 손댈 때 근본부터 가지 않고 재발 경로 하나를 먼저 끊었다. 자동 부여를 껐다가 다시 켜는 경우다.

껐던 기간은 담당자가 연차를 손으로 관리하던 기간이다. 규칙을 다시 켜는 순간 배치는 그 기간의 발생일들을 전부 “도래했고 아직 부여 안 됨”으로 판정한다. 같은 사고가 한 회사분씩 통째로 재현되는 경로라, 총량 계산을 설계하는 것보다 먼저 막을 값어치가 있었다.

규칙이 off에서 on으로 바뀔 때 서버가 그날을 autoGrantEffectiveFrom에 기록하고, 그 날짜 이전에 발생한 회차는 부여 대상에서 뺀다.

leave-accrual.service.ts
const effectiveFrom = rule.autoGrantEffectiveFrom
? startOfDay(new Date(rule.autoGrantEffectiveFrom))
: null;
const isGated = (occ: Date) => effectiveFrom !== null && isBefore(occ, effectiveFrom);
// 월차 루프 안
if (isGated(occ)) continue; // break가 아니다 — 이후 회차는 부여 대상

월차 루프를 break가 아니라 continue로 둔 건, break로 끊으면 체제 시작일 이전 회차 하나 때문에 그 뒤의 정상 회차까지 같이 빠지기 때문이다. 주년 회차 쪽은 반대로 즉시 return인데, 한 시점에 후보가 하나뿐이라 그게 게이트에 걸리면 다음 주년까지 부여할 게 없다.

NULL이면 게이트가 없는 것으로 본다. 컬럼을 새로 넣으면서 기존 행을 백필하지 않았다. 백필했다면 지금까지 정상 운영 중이던 회사들의 배치 결손 복구와 등록 시점 백필이 그 날짜에 잘려나간다. 자가치유를 넣으려고 한 작업이니, 자가치유를 껐다 켜는 회사에만 껐다 켠 만큼의 경계를 준다.

수동 부여 위에 자동이 얹히는 경로

게이트는 규칙을 껐다 켠 회사만 본다. 계속 켜둔 채로 수동 부여도 하는 회사에서는 회차가 도래할 때마다 같은 중복 부여가 그대로 일어난다. 담당자가 3월에 15일을 손으로 넣어뒀어도 주년 회차가 오면 배치는 15일을 또 넣는다. 전수조사에서 그런 회사가 실제로 나왔고, 게이트로는 한 건도 못 막는다.

멱등 판정이 occurrence_key 하나에 걸려 있는 한 이 방향은 안 막힌다. 수동 부여에 키를 붙이는 방법도 있지만, 담당자가 넣는 부여는 회차에 대응하는 게 아니라 사유가 제각각이라 붙일 키가 없다. 그래서 판정 대상을 키에서 금액으로 옮겼다.

자동 부여의 뜻을 바꿨다. “회차마다 N일 추가”가 아니라 “그 시점까지의 도래 기준 총량을 채우는 것까지만” 부여한다.

부여량 = min(회차 기준량, max(0, 도래 기준 누계 − 이미 받은 부여 합))
받은 것으로 세는 것 자동 부여 전액 · 수동 부여(grant_manual > 0) · 상향 보정(repair > 0)
세지 않는 것 차감(deduct_manual) · 하향 보정(repair < 0) · 사용 · 만료

차감은 안 세기로 했다. 담당자가 징계로 연차를 깎았는데 그걸 “덜 받은 것”으로 세면 다음 회차에 배치가 깎인 만큼을 도로 채워주고, 그건 담당자가 내린 결정을 배치가 취소하는 방향이다.

이렇게 잘려서 0일이 된 회차도 원장에 행을 남긴다. 그 행이 멱등 마커이자 “왜 안 나갔는가”의 메모다.

leave-accrual.service.ts
const memoReason =
days <= 0
? `${reason} — 수동 부여·보정으로 기준 기충족(부여 0일)`
: days < fullDays
? `${reason} — 기준 ${fullDays}일 중 ${days}일 부여(수동 부여·보정 반영)`
: reason;

0일 행은 잔액을 안 건드리므로 잔액 행을 만들지도, 잠그지도 않는다. 만료 배치가 집으러 오지 않도록 isProcessed = true로 만들어 둔다. 소멸시킬 것이 없는 행이라서다.

0일로 잘린 건수는 배치 요약에서 suppressed로 따로 센다. 부여·보정과 섞어 세면 “오늘 부여 0건”이 정상인지 배치가 안 돈 건지 구분이 안 된다.

총량을 비교할 통을 정하는 기준

총량을 비교하려면 무엇과 무엇을 같은 통에 넣을지부터 정해야 했다. 처음엔 원장의 귀속연도를 그대로 썼다가 리뷰에서 막혔다.

월차 원장의 귀속연도는 발생일의 달력 연도다. 12월 입사자의 1~11개월차는 발생일이 전부 이듬해라 이듬해 귀속으로 들어간다. 그런데 등록 시점의 초기 부여는 등록 연도로 기록된다.

통이 다르니 서로를 못 보고, 12월에 11일을 초기 부여받은 직원이 이듬해에 월차 11일을 또 받는다. 막으려던 중복 부여가 그대로 재현된다.

월차는 달력 연도가 아니라 첫해(입사~1주년) 단일 버킷으로 접었다. 원장을 읽는 창도 입사 전년도부터 이듬해까지 세 해로 넓혔다. 미래 입사자를 미리 등록해두면서 담당자가 초기 부여를 입사 전년도로 기록하는 경우가 있어서다.

leave-accrual.service.ts
private topUpBucketKey(p: DueOccurrence): string {
return p.occurrenceKey.includes('_MONTH_') ? 'FIRST_YEAR' : String(p.occurredAt.getFullYear());
}

창을 넓히면 이 버킷과 상관없는 행도 같이 들어온다. 그래도 계산이 안 틀어지는 건, 자동 부여 행은 “이미 받은 합”과 “도래 기준 누계” 양쪽에 같은 값으로 더해져 빼면 0이 되기 때문이다. 월차와 주년 회차는 애초에 상호 배타라 같은 실행에서 섞이지도 않는다. 1주년 전이면 월차만, 후면 주년만 후보로 잡힌다.

이 구조가 못 막는 방향

판정을 키에서 금액으로 옮겨도 안 막히는 방향이 세 가지 남는다.

수동 부여가 회차보다 먼저 기록돼 있어야 잘라낼 수 있다. 자동 부여가 먼저 나간 회차 위에 담당자가 수동을 얹으면 그건 못 막는다. 담당자가 앞으로 무엇을 넣을지는 계산할 수 없다.

반대 방향 방어는 수동 부여 API에서 기준 초과를 경고하는 쪽이다. 자동이 먼저 나간 회차 위에 수동이 얹힌 건이 한 건이라도 나오면 그때 넣는다.

수동 부여가 삭제되면 주년 회차만 되살아난다. 삭제로 받을 몫이 다시 생겨도 한 회차가 주는 양은 그 회차 기준량이 상한이고, 이미 0일로 잘린 월차 회차는 키가 이미 쓰여 멱등 skip 대상이다. 그 몫은 담당자가 수동으로 보충한다. 과소 부여 방향이라 감수했다.

배치가 과거를 회수하지는 않는다. 7월에 부풀려진 잔액은 수기로 정정했고, 이미 나간 연차를 배치가 스스로 걷어가는 동작은 만들지 않았다. 잔액을 줄이는 자동 동작은 잘못 돌면 아무도 알아채기 전에 조용히 연차를 없앤다. 앞으로 중복이 안 나가게 하는 것까지가 이 변경의 범위다.

세 가지 다 과소 부여 쪽으로 기울어 있다. 연차는 못 받으면 담당자가 채워줄 수 있지만, 잘못 나간 건 담당자가 직원에게 직접 설명하고 되돌려야 한다.

자가치유로 바꾼 뒤의 무음 실패

원래 코드의 회사·직원 단위 catchlogger.error만 하고 배치를 계속 진행했다. 한 직원의 부여가 실패해도 배치는 성공으로 끝나고 아무 알림이 없었다.

자가치유로 바꾸고 나면 이 무음이 성격을 바꾼다. 실패한 직원은 다음 날 다시 대상에 들어오니 일시적 실패는 알아서 복구된다. 그런데 데이터가 구조적으로 잘못돼 매일 같은 데서 터지는 실패도 똑같이 조용하다. 매일 재시도되고 매일 실패하는 상태가 로그 말고는 아무 데도 안 나타난다.

그래서 두 단계 catch에 reportError를 붙이고, 배치 요약이 실패와 보정을 각각 세게 했다.

leave-accrual.service.ts
reportError(err, {
tags: { worker_job: 'leave-accrual', companyId: rule.companyId, employeeId: emp.id },
});

보정 건수(late)가 알림 조건에 들어간 건 그 숫자가 “이전에 배치가 안 돌았다”의 유일한 신호라서다. 자가치유는 결손을 조용히 메우기 때문에, 메운 사실을 세지 않으면 배치가 며칠 안 돌았어도 잔액만 보고는 알 수 없다.

이 알림은 7월 사고와 같은 릴리스에 들어 있었다. 그런데 사고를 알려준 건 알림이 아니라 담당자 문의였다.

late발생일 < 오늘이면 붙는 값이라, 정상적인 결손 복구와 있어서는 안 될 소급 부여가 같은 숫자로 올라온다. 신호는 나갔는데 그 신호가 둘 중 어느 쪽인지를 말해주지 않았다. 억제 건수를 suppressed로 따로 세기 시작한 것도 같은 이유다.

↑↓ 이동 열기esc 닫기