Namsang LABS
Deep Dive · #settlement #revenue-recognition #saas #kotlin

코드로 읽는 회계: 돈을 받은 날이 매출일이 아니다

· Sangkyoon Nam

분기 결산 미팅이었다. 회계법인 담당자가 화면을 공유하더니 B사 행에서 멈췄다. “이거 1월 매출이 300만원으로 잡혀 있는데, 맞습니까?” 맞다고 했더니 고개를 저었다. “구독 시작일이 1월 20일이죠. 1월에 실제로 서비스를 제공한 건 12일이니까 그만큼만 매출입니다. 19만원 정도예요.”

B사는 1월 20일에 6개월치 구독료 300만원을 결제했다. 시스템에 결제 알림이 떴고, 대시보드의 1월 매출이 300만원 올라갔다. 결제된 금액만큼 매출이라고 생각했지만, 회계에서는 아니었다. 구독 기간은 1월 20일부터 7월 19일까지, 그중 1월 서비스는 12일이고 그만큼만 1월 매출로 인식된다. 약 19만원. 나머지 281만원은 통장에 들어왔지만 앞으로 5개월간 제공해야 할 서비스의 대가다. 이것은 이연수익(deferred revenue)이라는 부채로 잡힌다.

#현금주의와 발생주의

매출을 언제 인식할지에 대해 회계는 두 가지 방식을 둔다. 현금주의와 발생주의다. 현금주의는 돈이 들어온 시점에 매출을 인식한다. 직관에 가깝고, 코드에 옮기기에도 쉽다. PG사 웹훅이 오면 payment_completed_at에 타임스탬프를 찍고 amount에 금액을 넣는다. 결제 이벤트가 발생하는 즉시 매출로 인식된다.

// 현금주의: 결제 시점에 전액 매출 인식
fun onPaymentCompleted(payment: Payment) {
    revenueRepository.save(
        Revenue(
            amount = payment.amount,         // 3,000,000원 전액
            recognizedAt = payment.completedAt // 결제 시점
        )
    )
}

발생주의는 다르다. 서비스를 제공한 시점에 매출을 인식한다. 돈을 받은 시점이 아니다. 비동기 작업에 비유하면, 요청(request)을 받은 시점이 아니라 실제 처리(processing)가 완료된 시점에 기록하는 방식이다. 결제는 요청 시점, 매출은 완료 시점이다.

K-IFRS 제1115호(한국)와 ASC 606(미국) 모두 이 원칙을 따른다. 법인이라면 매출 처리에 발생주의가 강제된다.

// 발생주의: 서비스 제공 기간에 걸쳐 분할 인식
fun onPaymentCompleted(payment: Payment, subscription: Subscription) {
    // 결제 금액은 이연수익(부채)으로 잡는다
    deferredRevenueRepository.save(
        DeferredRevenue(
            amount = payment.amount,         // 3,000,000원
            serviceStart = subscription.startDate,
            serviceEnd = subscription.endDate
        )
    )
    // 매월 서비스 제공분만큼 이연수익 → 매출로 전환 (아래에서 구체화)
}

#300만원 중 1월은 얼마인가

B사 구독 조건을 다시 정리하면 이렇다.

  • 계약 금액: 3,000,000원 (월 500,000원 x 6개월)
  • 구독 시작: 2026-01-20
  • 구독 종료: 2026-07-19
  • 1월 서비스 일수: 12일 (1/20 ~ 1/31)

구독 시작일이 1월 1일이었다면 단순했다. 1월 매출은 월 구독료 500,000원 그대로다. 그러나 실제 시작일은 1월 20일이고, 1월에 서비스를 제공한 건 12일뿐이다. 한 달을 통째로 인식할 수 없으니 12일분만 떼어내야 한다. 일할 계산(pro-rata)이 필요해지는 지점이다. 이를 처리하는 두 가지 방법이 있다.

1. 월별 일수로 나누기 : 월 구독료를 해당 월의 실제 일수로 나누고, 서비스 제공일을 곱한다. 매월의 구독료가 그 달 안에서만 분배되는 방식이다.

1월 매출 = 500,000 / 31 × 12 = 193,548.38...

2. 계약 기간으로 평탄화하기 : 계약 총액을 전체 서비스 일수로 나눠 일 단가를 먼저 구한 다음, 매달의 서비스 일수에 곱한다. 계약 전체를 하나의 수행의무(performance obligation)로 보는 방식이다.

// 전체 서비스 일수: 2026-01-20 ~ 2026-07-19 = 181일
1월 매출 = 3,000,000 / 181 × 12 = 198,895.02...

193,548원과 198,895원. 같은 계약에서 5,000원 넘게 차이가 난다. 어느 쪽이 맞는가는 코드가 결정하지 않는다. 회계 정책이 결정한다.

#며칠로 나누는가

월 구독료가 청구·세금계산서의 기본 단위인 한국 실무에서는 1번 방식이 더 흔한 편이다. 1번을 택하면 바로 다음 질문이 이어진다. 1월은 31일이고 2월은 28일이다. 월마다 일수가 달라 같은 월 구독료인데도 하루 단가가 달라진다.

val dailyJanuary = BigDecimal("500000")
    .divide(BigDecimal("31"), 10, RoundingMode.HALF_UP)
// 16,129.03...

val dailyFebruary = BigDecimal("500000")
    .divide(BigDecimal("28"), 10, RoundingMode.HALF_UP)
// 17,857.14...

같은 요금제인데 2월의 하루 단가가 1,700원 이상 높다. 이 차이를 다루는 세 가지 정책이 있다.

  • 실제 일수 기준 : 해당 월의 실제 일수(28, 29, 30, 31)로 나눈다. 가장 정밀하고, 감사 실무에서 일반적으로 선호되는 방식이다. 다만 이미 이야기한 대로 월마다 하루 단가가 달라진다.
  • 30일 고정 기준 : 모든 달을 30일로 간주한다. 금융권에서 자주 쓴다. 하루 단가가 일정해서 운영이 편하다. 다만 1월에는 1일 적게, 2월에는 2일 많게 인식한다.
  • 365일 기준 : 연간 구독료를 365로 나눠서 일 단가를 구한다. 월 구독료 개념 자체를 쓰지 않는다. 1일 = 연간/365. 윤년이면 366. 다만 연간 구독에만 적용되어 이번 사례에는 해당되지 않는다.
enum class DayCountPolicy {
    ACTUAL,      // 해당 월 실제 일수
    FIXED_30,    // 모든 달을 30일로 간주
    ANNUAL_365   // 연간 365일 기준
}

fun daysInMonth(yearMonth: YearMonth, policy: DayCountPolicy): BigDecimal =
    when (policy) {
        DayCountPolicy.ACTUAL -> BigDecimal(yearMonth.lengthOfMonth())
        DayCountPolicy.FIXED_30 -> BigDecimal("30")
        DayCountPolicy.ANNUAL_365 ->
            BigDecimal("365").divide(BigDecimal("12"), 10, RoundingMode.HALF_UP)
    }

연간/다년 구독이 표준인 글로벌 SaaS에서는 2번 방식을 더 선호하는 경향이 있다. 계약 전체를 하나의 수행의무로 보고 기간에 걸쳐 평탄화하는 게 ASC 606·IFRS 15가 가장 자연스럽게 받아들이는 형태이기도 하다. 2번을 선택해도 또 다른 질문이 이어진다. 평탄화 기준 일수를 무엇으로 잡을지. 같은 계약이라도 기준이 바뀌면 일 단가가 달라진다.

  • 계약 일수 그대로 : 2026-01-20 ~ 2026-07-19 = 181일. 실제 서비스 기간을 그대로 분모로 쓴다.
  • 30일 고정 환산 : 6개월 × 30 = 180일. 평탄화 안에서도 월 단위 추상화를 끌어들이는 방식이다.
// 계약 일수 그대로
1월 매출 = 3,000,000 / 181 × 12 = 198,895.02...

// 30일 고정 환산
1월 매출 = 3,000,000 / 180 × 12 = 200,000.00

결국 어느 방식을 택해도 정책 결정은 피할 수 없다.

#발생 시점은 어디서 끊어야 하나

앞 두 섹션이 매월 매출의 금액을 정하는 정책이었다면, 이번엔 그 매출을 어느 달에 귀속시킬지의 시점에 대한 정책이 필요하다. 발생주의는 서비스를 제공한 시점에 매출을 인식하는 것인데 문제는 ‘시점’의 정밀도가 언어마다 다르다는 것이다.

1월 31일 23:59:59에 발생한 매출은 1월이다. 1초 뒤 2월 1일 00:00:00이면 2월이다. 당연해 보이지만 코드에서 이 경계를 잡는 방식이 까다롭다. Java/Kotlin의 LocalDateTime은 나노초, Python datetime은 마이크로초, JavaScript Date는 밀리초까지 표현한다. 같은 ‘23:59:59’라도 언어마다 그 뒤 자릿수가 다르고, 이 차이가 경계에서 매출의 월 귀속을 바꾼다.

// 1월 매출 집계
val start = LocalDateTime.of(2026, 1, 1, 0, 0, 0)
val end = LocalDateTime.of(2026, 1, 31, 23, 59, 59)

val januaryRecords = revenueRepository
    .findByRecognizedAtBetween(start, end)

23:59:59.500에 발생한 건은 어디로 가는가. 23:59:59까지만 조회하면 그 이후 밀리초 단위 건이 빠진다. 2월 1일 00:00:00부터 조회해도 이 건은 잡히지 않는다. 어느 달에도 속하지 않는 매출이 생긴다.

// 반개방 구간(half-open interval)
val start = LocalDateTime.of(2026, 1, 1, 0, 0, 0)
val end = LocalDateTime.of(2026, 2, 1, 0, 0, 0)

val januaryRecords = revenueRepository
    .findByRecognizedAtGreaterThanEqualAndRecognizedAtLessThan(start, end)
// >= 1월 1일 00:00:00, < 2월 1일 00:00:00

시작은 포함, 끝은 미포함. 이렇게 잡으면 경계에서 누락되거나 중복되는 건이 없다. 회계에서는 이 경계 처리를 컷오프(cutoff)라고 부른다.

타임존도 같이 고려해야 한다. DB는 보통 UTC지만 세금계산서는 사업장 타임존 기준이다. 한국 사업자라면 KST. 1월 31일 오후 11시 30분(KST)에 발생한 건은 UTC로 1월 31일 14:30이라 어느 쪽으로 봐도 1월이다. 그러나 2월 1일 오전 3시(KST)는 UTC로 1월 31일 18:00이다. UTC로 집계하면 이 건이 1월에 잡히고, 장부와 어긋난다. 컷오프는 KST 기준으로 끊어야 한다.

컷오프가 반드시 자정일 필요는 없다. 영업이 자정을 넘기는 업종(식당, 편의점 등)에서는 마감 시점을 새벽으로 미루는 게 자연스럽다. 서비스에 따라 정산일 마감을 새벽까지 늦추는 옵션을 제공하기도 한다.

#결제 테이블 하나로는 부족하다

여기까지의 정책 — 분모 일수와 컷오프 — 은 매월 인식 매출을 산출하기 위한 것이었다. 결제 테이블에는 “누가, 언제, 얼마를 냈는가”만 있다. “이 금액 중 이번 달 인식 금액은 얼마인가”에 답하려면 별도 테이블이 필요하다.

// 결제 — 현금 흐름
data class Payment(
    val id: Long,
    val customerId: Long,
    val amount: BigDecimal,       // 결제 금액: 3,000,000
    val completedAt: Instant      // 결제 시점
)

// 매출 인식 스케줄 — 발생주의 기준
data class RevenueSchedule(
    val paymentId: Long,
    val yearMonth: YearMonth,        // 귀속 월: 2026-01
    val recognizedAmount: BigDecimal, // 인식 매출: 193,548
    val deferredAmount: BigDecimal,  // 이연 잔액: 2,806,452
    val serviceDays: Int,            // 서비스 일수: 12
    val totalDaysInMonth: Int        // 해당 월 일수: 31
)

결제가 발생하면 구독 기간 전체에 걸쳐 인식 스케줄을 생성한다. B사의 경우 1월부터 7월까지 7건의 레코드가 만들어진다. 매달 말에 해당 월의 인식 매출을 합산하면 월 매출로 인식된다.

반올림이 정책이었던 것처럼, 결제 금액과 월별 인식 매출도 다른 숫자다. 일할 계산의 분모, 매출 귀속의 컷오프, 그리고 그 결과를 담을 테이블 — 모든 단계의 결정은 회계 정책이 했다. 매출 인식은 정책의 누적이다.

다음 글에서 다룰 두 질문. 6개월치 인식 매출을 전부 합산하면 300만원이어야 하는데, 매달 소수점을 절사하면서 합계가 부족해진다. 그리고 3개월차에 B사가 해지를 요청하면, 인식하지 않은 이연수익은 어떻게 되는가.

#참고 자료

회계 표준 (1차 출처)

실무 가이드

Share this post