
분기 결산 미팅이었다. 회계법인 담당자가 화면을 공유하더니 B사 행에서 멈췄다. “이거 1월 매출이 300만원으로 잡혀 있는데, 맞습니까?” 맞다고 했더니 고개를 저었다. “구독 시작일이 1월 20일이죠. 1월에 실제로 서비스를 제공한 건 12일이니까 그만큼만 매출입니다. 19만원 정도예요.”
B사는 1월 20일에 6개월치 구독료 300만원을 결제했다. 시스템에 결제 알림이 떴고 대시보드의 1월 매출이 300만원 올라갔다. 결제된 금액만큼 매출이라고 생각했지만 회계에서는 아니었다. 구독 기간은 1월 20일부터 7월 19일까지, 그중 1월 서비스는 12일이고 그만큼만 1월 매출로 인식된다. 약 19만원이다.
나머지 281만원은 통장에 들어왔지만 남은 구독 기간에 제공해야 할 서비스의 대가다. 이것은 이연수익(deferred revenue)이라는 부채로 잡힌다.
#현금주의와 발생주의
매출을 언제 인식할지 회계는 두 방식을 둔다. 현금주의와 발생주의다. 현금주의는 돈이 들어온 시점에 매출을 인식한다. 직관에 가깝고 코드에 옮기기도 쉽다. PG사 웹훅이 오면 payment_completed_at에 타임스탬프를 찍고 amount에 금액을 넣는다. 결제 이벤트가 나는 즉시 매출로 인식된다.
// 현금주의: 결제 시점에 전액 매출 인식
fun onPaymentCompleted(payment: Payment) {
revenueRepository.save(
Revenue(
amount = payment.amount, // 3,000,000원 전액
recognizedAt = payment.completedAt // 결제 시점
)
)
}
발생주의는 다르다. 서비스를 제공한 시점에 매출을 인식한다. 돈을 받은 시점이 아니다. 비동기 작업에 비유하면 요청을 받은 시점이 아니라 처리가 진행된 만큼 기록하는 방식이다. 결제는 요청 시점, 매출은 진행 시점이다.
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원 × 6개월)
- 구독 시작: 2026-01-20
- 구독 종료: 2026-07-19
- 1월 서비스 일수: 12일, 1월 20일부터 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월은 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월에는 하루 적게, 2월에는 이틀 많게 인식한다.
- 365일 기준. 연간 구독료를 365로 나눠 일 단가를 구한다. 월 구독료 개념을 쓰지 않는다. 윤년이면 366이다. 이 글의 6개월 계약에는 쓰지 않는다.
enum class DayCountPolicy {
ACTUAL, // 해당 월 실제 일수
FIXED_30 // 모든 달을 30일로 간주
}
fun daysInMonth(yearMonth: YearMonth, policy: DayCountPolicy): BigDecimal =
when (policy) {
DayCountPolicy.ACTUAL -> BigDecimal(yearMonth.lengthOfMonth())
DayCountPolicy.FIXED_30 -> BigDecimal("30")
}
연간이나 다년 구독이 표준인 글로벌 SaaS에서는 2번 방식을 더 선호하는 경향이 있다. 계약 전체를 하나의 수행의무로 보고 기간에 걸쳐 평탄화하는 형태를 ASC 606과 IFRS 15가 자연스럽게 받아들이기 때문이다. 계약 기간 전체에 배분하더라도 분모를 무엇으로 잡을지는 따로 정해야 하고, 같은 계약이라도 기준이 바뀌면 일 단가가 달라진다.
- 계약 일수 그대로. 2026-01-20부터 2026-07-19까지 181일. 실제 서비스 기간을 그대로 분모로 쓴다.
- 30일 고정 환산. 6개월 × 30 = 180일. 평탄화 안에서도 월 단위 추상화를 끌어들인다.
// 계약 일수 그대로
1월 매출 = 3,000,000 / 181 × 12 = 198,895.02...
분모를 180일로 바꾸는 것만으로는 30일 환산 방식이 완성되지 않는다. 1월 20일부터 31일까지를 며칠로 볼지, 월말을 어떻게 환산할지까지 정해야 하고, 그 규칙 없이 실제 일수 181일을 그대로 곱하면 6개월 합계가 300만원을 넘는다. 그래서 여기서는 계약 일수 181일을 쓰는 계산만 완결된 예제로 둔다. 같은 계약, 같은 12일인데 분모 하나로 1월 숫자가 바뀐다는 점은 그대로다.
#발생 시점은 어디서 끊어야 하나
매출 금액을 정했으면 그 매출을 어느 달에 귀속시킬지가 남는다. 경계는 자정 한 점처럼 보이지만, 그 점을 코드로 어떻게 닫느냐에 따라 어느 달에도 속하지 않는 건이 생긴다.
1월 31일 23:59:59에 발생한 매출은 1월이다. 1초 뒤 2월 1일 00:00:00이면 2월이다. 당연해 보이지만 코드에서 이 경계를 잡는 방식이 까다롭다. Java와 Kotlin의 LocalDateTime은 나노초, Python datetime은 마이크로초, JavaScript Date는 밀리초까지 표현한다. 23:59:59 뒤에 자릿수가 더 있다는 뜻이고, 경계를 초 단위로 닫아 버리면 그 자릿수에 놓인 건이 어느 달에도 들어가지 않는다. 그 자릿수가 실제로 저장되는지는 언어가 아니라 컬럼이 정한다. MySQL DATETIME은 기본 정밀도가 0이라 소수 초를 기본으로 반올림해 저장하고, 설정에 따라 절사한다. 반올림이면 23:59:59.5가 다음 달 자정으로 넘어갈 수도 있다. DATETIME(6)까지 지정해야 마이크로초가 남고, PostgreSQL timestamp는 기본이 마이크로초다.
// 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
시작은 포함, 끝은 미포함. 이렇게 잡으면 경계에서 빠지거나 겹치는 건이 없다. 회계에서는 이 경계 처리를 컷오프라고 부른다.
타임존도 같이 봐야 한다. 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건의 레코드가 만들어진다. 월별 일수 방식이라면 1월 193,548원, 2월부터 6월까지 각 500,000원, 7월에 남은 306,452원이다. 매달 말에 해당 월의 인식 매출을 합산하면 그 달의 매출이 되고, 7건의 합이 계약 금액과 맞는지 검산한다.
반올림에서 0.5의 행방이 그랬듯, 결제 금액 가운데 이번 달 매출이 얼마인지도 코드가 아니라 정책이 정한다. 일할 계산의 분모, 매출 귀속의 컷오프, 그 결과를 담을 테이블까지 모든 단계의 결정을 회계 정책이 했다. 매출 인식은 정책의 누적이다.
남은 질문이 둘 있다. 매달 소수점을 절사해 6개월치 인식 매출의 합계가 300만원에 못 미치면 어떻게 맞출 것인가. 그리고 3개월차에 B사가 해지를 요청하면, 인식하지 않은 이연수익은 어떻게 되는가.
#참고 자료
회계 표준 (1차 출처)
- FASB ASC 606, Revenue from Contracts with Customers: 미국 GAAP의 매출 인식 표준이다.
- IFRS 15, Revenue from Contracts with Customers: 국제회계기준의 매출 인식 표준이고 K-IFRS 제1115호의 모태다.
- K-IFRS 제1115호, 고객과의 계약에서 생기는 수익: 한국채택국제회계기준의 수익 인식 기준서다.
실무 가이드
- PwC, Revenue Recognition: An IFRS 15 Guide for Software and SaaS Sellers 2022: SaaS 매출 인식에 IFRS 15를 적용하는 방법을 안내한다.
- The SaaS CFO, Guide to SaaS Revenue Recognition and Deferred Revenue: SaaS 이연수익 구조와 일할 인식과 평탄화 인식의 차이를 설명한다.
- Chargebee, Ultimate Guide to SaaS Revenue Recognition: ASC 606의 5단계 수익 인식과 일할 계산 예시를 보여준다.