leo.dev
backend

연차 잔액을 컬럼 하나로 들지 않은 이유

연차 잔액을 remaining_days 컬럼 하나로 들고 있으면 조회는 빠르다. 문제는 그 숫자가 틀렸을 때, 또는 틀렸다고 누가 주장할 때 아무것도 증명하지 못한다는 것이다. 누가 언제 며칠을 부여했고, 어떤 신청으로 차감됐고, 취소로 얼마가 돌아왔는지가 컬럼 하나에는 남지 않는다. 연차는 돈이고 노무 분쟁의 단골 소재다. 그래서 잔액을 값이 아니라 이력으로 모델링했다.

잔액은 집계 캐시, 원장이 원본이다

테이블을 둘로 나눴다. employee_leave_balances는 “지금 며칠”을 빠르게 읽는 집계값이고, 진실의 원천은 모든 변동을 한 행씩 쌓는 employee_leave_ledgers다. 잔액이 의심되면 원장의 변동량을 다 더해 재계산할 수 있다. 회계의 시산표와 분개장 관계와 같다.

원장 한 행은 한 번의 변동이다.

employee-leave-ledger.entity.ts
@Entity('employee_leave_ledgers')
export class EmployeeLeaveLedger {
ledgerType: LeaveLedgerType; // grant_auto | usage | cancel_restore | expiration ...
changeAmount: Decimal; // +15, -1, +1 — 부호로 증감
balanceAfter: Decimal; // 변동 후 잔액 스냅샷
reason: string | null; // 사유
occurrenceKey: string | null; // 중복 부여 방지 키 (예: 2026_MONTH_3)
requestId: string | null; // 어떤 휴가 신청에서 나왔는지
}

changeAmount를 부호로 둔 덕에 부여·사용·만료·조정이 한 테이블에 같은 모양으로 쌓인다. 반차·반반차가 있어 타입은 decimal이다. 0.5, 0.25를 부동소수점으로 다루면 합산이 어긋나기 때문이다.

취소는 행을 지우지 않는다 — 역분개

여기서 결정이 하나 갈린다. 승인했던 휴가를 취소하면, 차감 기록을 지울 것인가?

지우지 않는다. 회계의 역분개처럼 반대 부호의 보정 행을 새로 쌓는다.

USAGE -1 balanceAfter 9 (3/2 사용 승인)
CANCEL_RESTORE +1 balanceAfter 10 (3/3 취소 — 위 USAGE 행은 그대로)

연차 원장 구조 — append-only 원장과 역분개 보정, 그리고 집계 캐시

차감 행을 지우면 “사용했다가 취소함”과 “처음부터 안 씀”이 똑같아진다. 둘은 다른 역사다. 분쟁에서 “3월에 썼다가 돌려받은 게 맞냐”를 데이터로 답하려면 두 행이 다 남아 있어야 한다. 한 번 일어난 일은 일어난 일로 둔다.

변동의 이유마다 행 타입이 있다

ledgerType은 닫힌 어휘다. 잔액이 바뀌는 모든 경로가 각자 타입을 갖는다.

GRANT_AUTO 정기 부여 (회계연도·입사일 기준 자동)
GRANT_MANUAL 관리자 수동 부여 (포상·조정)
CARRY_OVER 전년도 이월
USAGE 사용 차감
CANCEL_RESTORE 사용 취소 복구 (역분개)
DEDUCT_MANUAL 관리자 수동 차감
EXPIRATION 유효기간 만료 소멸
PENALTY 징계 차감
REPAIR 잔액 직접 보정 (데이터 수정)

“왜 줄었나”가 항상 한 단어로 남는다. 특히 만료(EXPIRATION)도 행이다. 연차는 유효기간이 지나면 소멸하는데, 그냥 잔액에서 빼버리면 “사라진 연차”는 흔적이 없다. 만료를 음수 변동 행으로 쌓으면 소멸분도 언제 얼마가 왜 사라졌는지 원장에 남는다. 만료 배치가 회계연도·입사일 기준으로 돌며 소멸분을 기록한다. 보정(REPAIR)조차 잔액을 직접 덮어쓰지 않고 보정 행으로 남겨, “누가 손으로 고쳤다”가 이력에 보이게 한다. 원장의 힘은 정상 변동이 아니라 이런 예외(만료·징계·수기 보정)를 같은 자리에 기록하는 데서 나온다.

balanceAfter를 매 행에 박은 이유

변동량을 다 더하면 잔액이 나오는데 왜 행마다 스냅샷을 또 저장하나. 검증용이다. 합산값과 어긋나는 행을 만나면 거기가 버그 지점이다. “어? 왜 잔액이 안 맞지”가 “몇 번째 변동부터 틀어졌나”로 좁혀진다. 이력 시스템에서 디버깅은 결국 “언제부터”를 찾는 일이라, 각 시점의 결과를 박아두는 값이 싸게 먹힌다.

부여는 한 번만 — occurrenceKey

정기 부여(매월, 입사일 기준)는 배치로 돈다. 배치는 재시도되고, 재시도는 같은 부여를 두 번 넣을 수 있다. occurrenceKey2026_MONTH_3 같은 키를 박고 유니크를 걸면, 같은 키는 두 번 들어가지 않는다. 멱등성을 원장 레벨에서 보장한다.

동시성은 원장이 아니라 락이 막는다

원장은 감사 추적을 줄 뿐 동시성을 주지 않는다. 이걸 헷갈리면 안 된다. 관리자 둘이 같은 신청을 동시에 승인하면 잔액이 두 번 깎인다. 그건 별도 장치로 막는다. 잔액 행을 비관적 쓰기 잠금으로 잠그고(SELECT ... FOR UPDATE), 트랜잭션 안에서 신청 상태를 다시 읽어 이미 처리됐으면 거부한다.

await manager.findOne(EmployeeLeaveRequest, {
where: { id },
lock: { mode: 'pessimistic_write' }, // 먼저 들어온 쪽이 끝날 때까지 대기
});
// 잠근 뒤 status 재확인 → PENDING 아니면 ConflictException

먼저 커밋한 쪽이 상태를 바꿔놓으면, 두 번째는 재확인에서 막힌다. 잠금 구간은 짧게 잡고, 알림·캘린더 같은 외부 호출은 트랜잭션 밖으로 뺀다.

그래서

원장은 공짜가 아니다. 행이 계속 쌓이고, 잔액을 따로 집계 캐시로 유지해야 하고, 모든 변동 경로가 원장을 빠짐없이 기록하도록 강제해야 한다. 그 비용을 치르는 기준은 단순하다. 돈이나 법이 걸린 숫자는 “현재값”이 아니라 “어떻게 그 값이 됐는지”로 들고 있어야 한다. 연차가 그렇고, 결제·포인트·재고도 같은 줄에 선다.

↑↓ 이동 열기esc 닫기