미국 경비관리 SaaS(ClaimBook)를 만들며 남기는 연재의 한 편이다. 시작 이야기는 친구의 전화에서 시작된 창업기에 있다.
경비 SaaS는 결국 돈을 다룬다. 누가 얼마를 썼고, 누가 승인했고, 언제 마감됐는지가 나중에 감사(audit)의 대상이 된다. 특히 미국에서는 경비·영수증 기록 보관이 IRS 요건이고, 고객사에 따라선 내부통제(SOX식) 증빙이 계약 조건이 된다.
그래서 이 제품은 기능보다 통제를 먼저 넣었다. 이유는 하나다 — 통제는 사후에 못 붙인다. “누가 언제 뭘 바꿨나”를 나중에 기록하기 시작하면, 그 이전 기록은 영원히 비어 있다. 감사에서 그 공백은 “통제가 없었다”와 같다.
1. 감사 로그는 append-only여야 한다
가장 먼저 만든 건 수정·삭제가 불가능한 감사 로그다. 로그를 지울 수 있으면 감사 로그가 아니다.
class AuditLog < ApplicationRecord
acts_as_tenant :organization
belongs_to :auditable, polymorphic: true
# Append-only: 수정/삭제 불가 (SOX)
def readonly?
persisted?
end
def destroy; raise ActiveRecord::ReadOnlyRecord, "AuditLog records cannot be deleted"; end
def delete; raise ActiveRecord::ReadOnlyRecord, "AuditLog records cannot be deleted"; end
end
readonly?가 저장 후 항상 true라 한 번 쓰인 로그는 UPDATE가 막히고, destroy/delete는 예외를 던진다. 애플리케이션 어디서 실수로 건드려도 과거 기록이 바뀌지 않는다. 이걸 코드로 강제해두면 “정책상 안 지운다”가 아니라 “지울 수 없다”가 된다 — 감사에서 이 차이가 크다.
2. 승인선은 규칙으로, 승인 중 수정도 로그로
두 번째는 누가 승인하는가를 사람 기억이 아니라 규칙으로 만든 것이다. 승인선(ApprovalLine)과 단계(Step)를 두고, 제출된 경비명세서가 금액·조직 규칙에 따라 결재자에게 라우팅된다.
여기서 한 번 구멍을 냈다. 처음엔 감사 로그를 생성·제출 시점에만 남겼다. 그런데 실제 운영에서 승인자가 검토 중에 회계 계정(GL category)을 바로잡는 일이 잦았고, 그 수정이 아무 데도 안 남았다. 통제의 핵심 구간인 “승인 단계의 변경”이 공백이었던 것이다.
그래서 승인 단계의 수정을 별도로 열되(검토 중 상태에서 현재 승인자만), 그 수정은 자동으로 감사 로그에 기록되도록 막았다. “고칠 수 있게” 하는 것과 “고친 걸 남기는” 것은 세트여야 한다.
3. 마감된 달은 잠근다 — 단, 현실과 타협해서
세 번째는 월 마감(period lock)이다. 제출·승인이 끝난 회계월은 다시 못 고쳐야 한다. 안 그러면 마감 후 숫자가 바뀌어 리포트가 조용히 틀어진다.
처음엔 단순하게 “달이 바뀌면 직전 달은 즉시 잠금”으로 했다. 이게 현실과 부딪혔다. 4월 영수증이 5월 초에 들어오는 일이 당연히 있다. 6월이 됐다고 5월이 즉시 잠기면, 5월 초에 4월 마감 작업을 하던 담당자의 길이 막힌다.
그래서 마감 판정을 한 곳(period_open?)으로 모으고 유예 기간(grace window)을 넣었다.
def self.period_open?(user:, period:)
today = Date.current
return true if period == today.strftime("%Y-%m") # 현재월은 열림
existing = where(user: user, period: period)
return false if existing.where.not(status: "draft").exists? # 제출·승인된 월은 잠금(SOX)
return true if existing.where(status: "draft").exists? # 살아있는 draft는 열림
prev = (today.beginning_of_month << 1).strftime("%Y-%m") # 직전월 + 유예일 내면 열림
period == prev && today.day <= user.organization.statement_grace_days
end
제출·승인으로 넘어간 달은 유예와 무관하게 잠긴다(통제) — 하지만 아직 손대지 않은 직전 달은 유예일까지 열어둔다(현실). 통제와 업무흐름의 타협점을 이 한 함수에 박았다.
배운 것
- 통제는 사후 부착이 안 된다. append-only 감사 로그를 나중에 켜면 그 이전이 영원히 빈다. 돈을 다루는 제품이면 1일차에 넣는다.
- “안 지운다”가 아니라 “못 지운다”로 만든다. 정책이 아니라 코드(
readonly?·예외)로 강제해야 감사에서 인정된다. - 수정 허용과 로그 기록은 세트다. 고칠 수 있게 열었으면, 그 변경은 반드시 남긴다. 특히 승인 단계처럼 통제가 걸린 구간은.
- 마감(잠금)은 현실 업무와 타협해야 한다. 즉시 잠금은 늦게 오는 영수증을 못 받는다. 잠금 규칙에 유예창을 두되, 이미 제출된 건 예외 없이 잠근다.
한계 · 다음 계획
- 감사 로그의 보존·아카이빙 정책(장기 보관, 조회 성능)은 아직 최소 수준이다. 데이터가 쌓이면 파티셔닝/아카이브를 다뤄야 한다.
- 현재 통제는 경비·명세서·승인 위주다. 예산·통합(회계 연동) 쪽 변경 이력도 같은 append-only 원칙으로 넓히는 중이다.
- 다음 편에서는 이 제품의 AI가 영수증·카드를 틀리게 매칭했을 때 정확도를 어떻게 끌어올렸는지 쓴다.
FAQ
Q. 감사 로그를 굳이 append-only로 코드에서 막아야 하나요? 권한으로 막으면 안 되나요?
권한만으로는 “권한 있는 사람이 지웠는가”라는 질문이 남는다. 모델 수준에서 UPDATE/DELETE 자체를 막으면 “누구도 못 바꾼다”가 되어 증빙이 단순해진다. readonly?와 destroy 예외는 그 마지노선이다.
Q. 월을 즉시 잠그지 않으면 마감 후 조작 위험이 있지 않나요?
잠금은 상태 기반이다. 제출·승인으로 넘어간 명세서가 있으면 유예와 무관하게 잠긴다. 유예창은 “아직 아무도 손대지 않은 직전 달”에만 적용된다. 즉 통제가 필요한 대상은 이미 잠겨 있다.
Q. 이런 통제가 소규모 팀에도 과한 것 아닌가요?
기능으로 보면 과해 보이지만, 감사 로그는 “쌓이기 시작한 시점”이 가치라 나중에 켜면 소급이 안 된다. 승인선·마감은 조직이 작을 땐 단계 수를 줄여 가볍게 쓰고, 커지면 규칙만 확장하면 된다.
Q. 승인 단계에서 값을 고치게 하면 통제가 약해지지 않나요?
고치는 것 자체보다 “고친 걸 남기지 않는 것”이 위험하다. 현재 승인자만, 검토 상태에서만 열고, 모든 수정을 자동으로 감사 로그에 남기면 오히려 현실적인 오류 정정 경로가 생기면서 추적성은 유지된다.