- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 이자 계산의 함정 — 곱하기 한 번이 아닌 이유
- 단리와 복리 — 수식과 구현
- 상품별 이자 산식
- 우대금리 조건 엔진
- 세금 처리 — 원천징수의 기본 구조
- 금리 변경 이력 관리
- 이자 지급 배치 — 결산의 풍경
- 검증 — 이자 엔진을 어떻게 믿을 것인가
- 반올림과 절사 — 1원의 정치학
- 실수 사례 시나리오 — 어디서 터지는가
- 체크리스트
- 마치며
- 참고 자료
들어가며
"이자 계산쯤이야 곱하기 한 번이지"라고 생각했다면, 코어뱅킹에서 가장 위험한 착각을 하고 있는 것입니다. 이자 계산은 수신 시스템에서 버그가 가장 자주, 가장 비싸게 터지는 영역입니다. 1원의 오차도 고객 민원과 감독 당국 보고로 이어질 수 있고, 수백만 계좌에 일괄 적용되는 결산 배치에서의 오류는 곧바로 대형 사고가 됩니다.
이 글에서는 예금 이자 계산 엔진을 처음부터 설계한다는 관점으로, 일할 계산의 함정, 단리/복리 구현, 상품별 산식, 우대금리 룰 엔진, 세금 처리, 금리 이력 관리, 그리고 반올림 정책까지 훑어보겠습니다. 코드는 Java와 Python으로 제시합니다. 본 글은 기술 해설이며 세무·투자 자문이 아니고, 구체적인 세율·규정은 반드시 최신 법령과 기관 공식 자료로 확인하시기 바랍니다.
이자 계산의 함정 — 곱하기 한 번이 아닌 이유
일할과 월할, 그리고 일수 관행
연 3.0 퍼센트 금리로 100만 원을 73일간 예치하면 이자는 얼마일까요? 답은 "어떤 일수 관행(day count convention)을 쓰느냐에 따라 다르다"입니다.
ACT/365 (실제일수/365): 1,000,000 x 0.03 x 73/365 = 6,000.00원
ACT/360 (실제일수/360): 1,000,000 x 0.03 x 73/360 = 6,083.33원
30/360 (월30일 가정) : 일수를 30일 단위로 환산하여 계산
한국 원화 예금은 통상 "실제 경과일수 / 365" 방식(윤년에도 365로 나누는 관행이 일반적이나, 기관·상품별로 ACT/ACT를 쓰는 경우도 있습니다)을 사용합니다. 반면 국제 단기금융시장에는 ACT/360 관행이 흔하고, 채권에는 30/360 계열이 있습니다. 어떤 관행이 "맞다"가 아니라, 상품 약관에 명시된 관행을 정확히 구현하는 것이 요구사항입니다.
| 관행 | 분자(일수) | 분모 | 주 사용처 |
|---|---|---|---|
| ACT/365 Fixed | 실제 경과일수 | 항상 365 | 원화 예금·대출 다수 |
| ACT/360 | 실제 경과일수 | 항상 360 | 머니마켓, 일부 외화 |
| ACT/ACT | 실제 경과일수 | 해당 연도의 실제 일수 | 일부 채권·국제 거래 |
| 30/360 | 월을 30일로 환산 | 360 | 채권 쿠폰 계산 |
윤년이라는 복병
윤년에는 1년이 366일입니다. ACT/365 Fixed 관행이라면 366일 예치 시 "연 금리보다 약간 많은" 이자가 붙습니다(366/365). ACT/ACT라면 윤년 구간과 평년 구간을 나눠 계산해야 합니다. 2월 29일에 신규한 정기예금의 1년 만기일은 언제인가(2월 28일? 3월 1일?) 같은 만기일 산정 규칙도 약관과 시스템이 일치해야 합니다. 이런 경계 케이스는 반드시 테스트 케이스로 고정해 두어야 합니다.
기산일과 지급일 — 첫날과 마지막 날
경과일수를 셀 때 "예입일은 포함하고 지급일은 제외"(initial date inclusive, final date exclusive)가 일반적인 관행입니다. 6월 1일에 넣고 6월 2일에 빼면 1일치 이자입니다. 이 규칙이 시스템 곳곳(온라인 해지 계산, 일적수 배치, 미지급이자 계상)에서 단 하나라도 어긋나면 1일치 이자 오차가 발생합니다. 사소해 보이지만 수백만 계좌면 사소하지 않습니다.
단리와 복리 — 수식과 구현
수식
[단리 Simple Interest]
이자 = 원금 x 연이율 x (경과일수 / 365)
I = P x r x (d / 365)
[복리 Compound Interest - 연 m회 복리, n년]
원리금 = 원금 x (1 + r/m)^(m x n)
S = P x (1 + r/m)^(m*n)
[월복리 정기예금 예시 - 연 3.0%, 12개월, 월복리]
S = P x (1 + 0.03/12)^12
= P x 1.030416... (실효수익률 약 3.0416%)
Java 구현 — BigDecimal이 기본기
금액 계산에 double을 쓰는 순간 실격입니다. 이진 부동소수점은 0.1조차 정확히 표현하지 못합니다.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
public class InterestCalculator {
private static final BigDecimal DAYS_IN_YEAR = new BigDecimal("365");
private static final int CALC_SCALE = 10; // 중간 계산 정밀도
private static final int KRW_SCALE = 0; // 원화는 소수점 없음
/** 단리: 원금 x 연이율 x 경과일수/365, 원 미만 절사 */
public BigDecimal simpleInterest(BigDecimal principal,
BigDecimal annualRate, // 0.03 = 연 3%
LocalDate from, // 예입일(포함)
LocalDate to) { // 지급일(제외)
long days = ChronoUnit.DAYS.between(from, to);
if (days < 0) {
throw new IllegalArgumentException("지급일이 예입일보다 빠릅니다");
}
return principal
.multiply(annualRate)
.multiply(new BigDecimal(days))
.divide(DAYS_IN_YEAR, CALC_SCALE, RoundingMode.HALF_UP)
.setScale(KRW_SCALE, RoundingMode.DOWN); // 원 미만 절사
}
/** 월복리: 매월 이자를 원금에 전입하며 적립 */
public BigDecimal monthlyCompound(BigDecimal principal,
BigDecimal annualRate,
int months) {
BigDecimal monthlyRate = annualRate.divide(
new BigDecimal("12"), CALC_SCALE, RoundingMode.HALF_UP);
BigDecimal balance = principal;
for (int i = 0; i < months; i++) {
BigDecimal interest = balance.multiply(monthlyRate)
.setScale(KRW_SCALE, RoundingMode.DOWN); // 회차별 절사
balance = balance.add(interest);
}
return balance;
}
}
중요한 디테일이 두 가지 숨어 있습니다.
- 중간 계산은 충분한 정밀도(scale 10 등)로 하고, 최종 금액 확정 시점에만 절사/반올림합니다. 매 단계에서 절사하면 오차가 누적됩니다.
- 복리를 수학 공식 거듭제곱으로 한 번에 계산한 값과, 회차별로 절사하며 누적한 값은 다릅니다. 약관이 "매월 이자를 원금에 전입한다"고 되어 있으면 후자(루프 방식)가 정답입니다. 공식이 아니라 약관이 스펙입니다.
Python 구현 — decimal 모듈
from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP, getcontext
from datetime import date
getcontext().prec = 28 # 충분한 전역 정밀도
DAYS_IN_YEAR = Decimal("365")
def simple_interest(principal: Decimal, annual_rate: Decimal,
start: date, end: date) -> Decimal:
"""단리 이자. 예입일 포함, 지급일 제외. 원 미만 절사."""
days = (end - start).days
if days < 0:
raise ValueError("지급일이 예입일보다 빠릅니다")
raw = principal * annual_rate * Decimal(days) / DAYS_IN_YEAR
return raw.quantize(Decimal("1"), rounding=ROUND_DOWN)
def installment_maturity_interest(monthly_amount: Decimal,
annual_rate: Decimal,
months: int) -> Decimal:
"""정액적금 만기이자(단리): 회차별 예치월수에 비례."""
total = Decimal("0")
for k in range(1, months + 1):
remaining_months = months - k + 1
portion = (monthly_amount * annual_rate
* Decimal(remaining_months) / Decimal("12"))
total += portion
return total.quantize(Decimal("1"), rounding=ROUND_DOWN)
float가 왜 안 되는지는 한 줄이면 증명됩니다.
>>> 0.1 + 0.2
0.30000000000000004
>>> Decimal("0.1") + Decimal("0.2")
Decimal('0.3')
상품별 이자 산식
정기예금 — 만기, 중도해지, 분할인출
[만기 지급]
이자 = 원금 x 약정금리 x 예치일수/365
[중도해지]
이자 = 원금 x 중도해지금리(예치기간 구간별) x 실제예치일수/365
- 중도해지금리는 보통 "기본금리 x 비율" 또는 구간별 고정금리 테이블
- 우대금리는 통상 중도해지 시 적용 제외
[분할인출(일부해지)]
- 인출분: 인출 시점까지 중도해지 산식으로 정산
- 잔존분: 원래 약정 조건 유지
- 인출 횟수 제한, 최저 유지 잔액 등 상품 조건 검증 필요
중도해지 금리 구간 테이블의 예시는 다음과 같습니다(상품마다 다릅니다).
| 예치 기간 | 적용 금리 |
|---|---|
| 1개월 미만 | 연 0.1 퍼센트 |
| 1개월 이상 3개월 미만 | 연 0.5 퍼센트 |
| 3개월 이상 6개월 미만 | 기본금리의 30 퍼센트 |
| 6개월 이상 만기 전 | 기본금리의 50 퍼센트 |
정기적금 — 회차의 세계
적금은 "각 회차 납입금이 만기까지 남은 기간만큼 이자를 받는다"가 본질입니다.
[정액적금 만기이자(단리, 약정 납입일 기준)]
k회차 납입금의 이자 = 월부금 x 연이율 x (만기까지 남은 월수)/12
총이자 = 월부금 x 연이율 x (n + (n-1) + ... + 1)/12
= 월부금 x 연이율 x n(n+1)/(2 x 12)
예: 월 10만원, 연 3%, 12개월
총이자 = 100,000 x 0.03 x 12x13/(2x12) = 100,000 x 0.03 x 6.5 = 19,500원
함정은 실제 납입일입니다. 약정일보다 늦게 내면(미납 후 납입) 이자가 줄고, 일찍 내면(선납) 늘어나는 것이 일할 계산의 자연스러운 귀결인데, 상품 약관에 따라 "선납·미납 일수를 상계하여 만기일을 조정"하는 방식(선납일수/미납일수 정산)을 쓰기도 합니다. 이 정산 로직은 적금 도메인에서 가장 버그가 많이 나오는 곳이므로, 약관 문구와 계산 결과를 회차 단위로 대조하는 테스트가 필수입니다.
우대금리 조건 엔진
요구사항
요즘 예금 상품은 "급여이체 시 0.2 퍼센트포인트, 카드 실적 30만 원 이상 시 0.3 퍼센트포인트, 마케팅 동의 시 0.1 퍼센트포인트, 최대 0.5 퍼센트포인트 우대" 같은 조건이 붙습니다. 이걸 if문으로 하드코딩하면 상품이 늘 때마다 배포가 필요하고, 조건 판정 시점(신규 시? 만기 시? 매일?)도 상품마다 달라 금방 스파게티가 됩니다. 그래서 우대금리는 데이터(룰)로 선언하고 엔진이 평가하는 구조가 정석입니다.
룰 정의 테이블
CREATE TABLE preferential_rate_rule (
product_code VARCHAR(10) NOT NULL,
rule_id VARCHAR(8) NOT NULL,
rule_name VARCHAR(100) NOT NULL, -- 예: 급여이체 우대
condition_type VARCHAR(20) NOT NULL, -- SALARY_TRANSFER, CARD_SPEND ...
condition_param VARCHAR(200), -- JSON 파라미터(임계값 등)
bonus_rate NUMERIC(7,4) NOT NULL, -- 우대폭(연리 %p)
eval_timing CHAR(1) NOT NULL, -- O:신규시 M:만기시 D:매일
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
CONSTRAINT pk_prr PRIMARY KEY (product_code, rule_id, valid_from)
);
평가 엔진 스케치
public interface PreferentialCondition {
/** 조건 충족 여부를 평가한다. 평가 시점의 컨텍스트를 받는다. */
boolean evaluate(EvaluationContext ctx);
}
public class SalaryTransferCondition implements PreferentialCondition {
private final int requiredMonths; // 최근 N개월 연속 급여이체
public SalaryTransferCondition(int requiredMonths) {
this.requiredMonths = requiredMonths;
}
@Override
public boolean evaluate(EvaluationContext ctx) {
return ctx.salaryTransferMonths() >= requiredMonths;
}
}
public class PreferentialRateEngine {
public BigDecimal totalBonusRate(String productCode,
EvaluationContext ctx,
LocalDate evalDate) {
List ruleRows = ruleRepository.findActive(productCode, evalDate);
BigDecimal sum = BigDecimal.ZERO;
for (Object row : ruleRows) {
RuleRow rule = (RuleRow) row;
PreferentialCondition cond = conditionFactory.create(rule);
if (cond.evaluate(ctx)) {
sum = sum.add(rule.bonusRate());
auditLogger.record(ctx.accountNo(), rule.ruleId(), evalDate);
}
}
return sum.min(maxBonusRate(productCode)); // 상한 적용
}
}
설계 포인트는 네 가지입니다.
- 판정 시점의 분리: "만기 시 일괄 판정" 상품과 "매일 판정해 일별 적용금리에 반영" 상품은 엔진 호출 위치가 다릅니다. 룰에 평가 타이밍을 메타데이터로 넣어야 합니다.
- 판정 근거의 보존: 우대금리 분쟁은 "내가 조건을 채웠는데 왜 우대가 빠졌냐"는 형태로 옵니다. 어떤 룰이 어떤 데이터로 충족/미충족 판정됐는지 감사 로그를 반드시 남깁니다.
- 룰의 유효기간: 상품 개정으로 조건이 바뀌면 기존 가입자는 가입 시점 룰, 신규 가입자는 새 룰을 타야 할 수 있습니다. 룰 테이블에 유효기간을 두고 가입일 기준으로 선택합니다.
- 외부 데이터 의존성: 카드 실적, 급여이체 여부는 타 시스템 데이터입니다. 평가 시점에 데이터가 아직 집계 전이면 어떻게 할지(보류? 재평가 큐?)를 명시적으로 설계해야 합니다.
세금 처리 — 원천징수의 기본 구조
이자를 지급할 때 은행은 이자소득세를 원천징수합니다. 개념 구조는 다음과 같습니다(세율 등 구체 수치는 법령 개정으로 변할 수 있으므로 반드시 국세청 등 공식 자료로 확인해야 합니다).
세전이자 = 이자 계산 엔진의 산출액
소득세 = 세전이자 x 소득세율 (원 단위 처리 규칙 적용)
지방소득세 = 소득세 x 지방소득세율 (원 단위 처리 규칙 적용)
세후이자 = 세전이자 - 소득세 - 지방소득세
시스템 관점의 포인트는 다음과 같습니다.
- 과세 구분 코드: 일반과세, 비과세(법정 비과세 상품), 세금우대 등 계좌별 과세 유형이 마스터에 있어야 하고, 지급 시점의 유형으로 계산합니다.
- 절사 규칙: 세금 계산에서 10원 미만 절사 같은 원 단위 처리 규칙이 적용되는 경우가 있어, 세전이자에 세율을 곱한 뒤의 단수 처리 순서까지 약관·세법 기준으로 고정해야 합니다.
- 분리 기장: 세전이자, 소득세, 지방소득세, 세후이자를 각각 별도 분개로 기록해야 원천징수 신고 집계가 가능합니다.
- 소득 귀속 시기: 이자소득의 귀속은 "지급 시점" 기준이 원칙이라, 미지급이자(발생주의 계상)와 원천징수(지급 시점) 사이의 시점 차이를 회계와 세무 양쪽에서 다뤄야 합니다.
금리 변경 이력 관리
변동금리 상품의 이자는 "기간별로 다른 금리"를 적용해 구간 합산합니다. 금리 이력 테이블이 핵심입니다.
CREATE TABLE product_rate_history (
product_code VARCHAR(10) NOT NULL,
rate_type CHAR(2) NOT NULL, -- 01:기본 02:중도해지 03:만기후
effective_from DATE NOT NULL, -- 적용 시작일(포함)
effective_to DATE NOT NULL, -- 적용 종료일(제외), 9999-12-31 허용
annual_rate NUMERIC(7,4) NOT NULL,
approved_by VARCHAR(20) NOT NULL, -- 결재 정보
created_at TIMESTAMP NOT NULL,
CONSTRAINT pk_pr PRIMARY KEY (product_code, rate_type, effective_from)
);
구간 합산 계산은 다음과 같이 표현됩니다.
총이자 = SUM( 원금(또는 일별잔액) x 구간금리 x 구간일수/365 )
for 각 금리 구간 in [예입일, 지급일)
예: 100만원, 4/1 예입, 7/1 지급
4/1 - 5/15 (44일): 연 3.0% -> 1,000,000 x 0.030 x 44/365 = 3,616원
5/16 - 7/1 (47일): 연 3.5% -> 1,000,000 x 0.035 x 47/365 = 4,506원
합계(원 미만 절사 정책에 따라 구간별/총액별 처리) = 8,122원
여기서도 절사를 구간별로 할지 총액에서 한 번만 할지가 약관 사항입니다. 그리고 이력 테이블 설계의 불문율이 있습니다. 금리 이력은 절대 UPDATE로 덮어쓰지 않습니다. 잘못 등록된 금리도 정정 이력으로 남기고, 소급 재계산이 필요하면 재계산 배치와 보정 분개로 처리합니다. 과거 이자 계산을 언제든 재현할 수 있어야 감사에 대응할 수 있기 때문입니다.
이자 지급 배치 — 결산의 풍경
보통예금류는 분기 또는 반기 결산으로 이자를 일괄 지급하는 것이 일반적입니다. 결산 배치의 골격은 다음과 같습니다.
[이자 결산 배치 흐름]
1. 대상 추출: 결산 대상 상품/계좌 (상태, 과세유형 포함)
2. 일적수 집계: 결산 기간의 일별잔액 합계 (저널 기반 재구성 가능해야 함)
3. 세전이자 계산: 일적수 x 일이율(연이율/365), 구간 금리 반영
4. 세금 계산: 과세유형별 원천징수액 산출
5. 지급 처리: 세후이자 입금 거래 생성 (이자 거래 유형)
6. 분개 기록: 이자비용 / 미지급이자 / 예수금(세금) 정리
7. 검증: 총액 대사 (계좌별 합계 = 분개 총액), 전년/전기 대비 이상치 점검
8. 리포트: 결산 명세, 원천징수 집계 산출
미지급이자 충당이라는 개념도 알아둘 필요가 있습니다. 회계는 발생주의이므로, 아직 지급하지 않았어도 일 단위로 발생한 이자를 미지급이자(부채)로 계상합니다. 월말마다 "발생 이자 누계"를 계상하고, 실제 지급 시점에 미지급이자를 상계하는 구조입니다. 이자 엔진은 "지급용 계산"과 "계상용 계산"을 모두 지원해야 하고, 둘의 산식이 일치해야 결산 대사가 맞습니다.
검증 — 이자 엔진을 어떻게 믿을 것인가
고정 케이스와 황금 파일
약관 예시, 상품 설명서의 계산 예, 과거 실제 지급 내역에서 추출한 케이스를 황금 파일(golden file)로 고정합니다. 엔진을 수정할 때마다 전 케이스를 회귀 검증합니다.
Property-based 테스트 — 이자 엔진과 찰떡궁합
이자 계산은 수학적 성질이 명확해서 property-based testing이 잘 맞습니다.
from hypothesis import given, strategies as st
from decimal import Decimal
from datetime import date, timedelta
amounts = st.integers(min_value=1, max_value=10_000_000_000)
days = st.integers(min_value=0, max_value=3650)
rates = st.decimals(min_value="0.0001", max_value="0.20", places=4)
@given(p=amounts, d=days, r=rates)
def test_interest_is_monotonic_in_days(p, d, r):
"""예치일수가 늘면 이자는 줄지 않는다."""
start = date(2026, 1, 1)
i1 = simple_interest(Decimal(p), r, start, start + timedelta(days=d))
i2 = simple_interest(Decimal(p), r, start, start + timedelta(days=d + 1))
assert i2 >= i1
@given(p=amounts, d=days, r=rates)
def test_interest_never_negative(p, d, r):
start = date(2026, 1, 1)
i = simple_interest(Decimal(p), r, start, start + timedelta(days=d))
assert i >= 0
@given(p1=amounts, p2=amounts, d=days, r=rates)
def test_splitting_principal_loses_at_most_rounding(p1, p2, d, r):
"""원금을 쪼개 계산한 합은 합산 계산과 절사 오차 이내로 같다."""
start = date(2026, 1, 1)
end = start + timedelta(days=d)
whole = simple_interest(Decimal(p1 + p2), r, start, end)
split = (simple_interest(Decimal(p1), r, start, end)
+ simple_interest(Decimal(p2), r, start, end))
assert 0 <= whole - split <= 1 # 절사 1원 이내
마지막 테스트가 보여주듯, 절사 정책 때문에 분할 계산과 합산 계산은 정확히 같지 않습니다. "오차 허용 범위가 정확히 얼마인가"를 성질로 못 박아 두면, 분할인출·재예치 로직의 정합성을 기계적으로 보장할 수 있습니다. Java 진영에서는 jqwik으로 같은 패턴을 구현할 수 있습니다.
총액 검증 배치
운영 환경에서는 결산 때마다 독립 구현(가능하면 다른 팀이 만든 검증용 계산기)으로 전체 또는 표본 계좌를 재계산해 총액을 대사하는 안전망을 둡니다. 엔진과 검증기가 같은 버그를 공유하지 않도록 산식 문서로부터 따로 구현하는 것이 요령입니다.
반올림과 절사 — 1원의 정치학
정책이 먼저, 코드는 나중
반올림은 수학 문제가 아니라 약관·규정 문제입니다. 시스템이 정해야 할 것은 다음과 같습니다.
| 결정 항목 | 선택지 예시 |
|---|---|
| 최종 이자 단수 처리 | 원 미만 절사 / 반올림 / 올림 |
| 처리 시점 | 구간별 / 회차별 / 최종 총액에서 1회 |
| 세금 단수 처리 | 10원 미만 절사 등 별도 규칙 |
| 중간 계산 정밀도 | 소수점 이하 10자리 유지 등 |
| 금리 표시 자릿수 | 소수점 둘째/넷째 자리 |
같은 산식이라도 절사 시점이 다르면 결과가 달라지고, 고객에게 불리한 방향의 일관성 없는 처리(어떤 화면은 절사, 어떤 배치는 반올림)는 민원과 감독 지적의 단골 소재입니다.
부동소수점 금지의 재확인
// 절대 금지
double interest = 1000000 * 0.03 * 73 / 365.0; // 이진 오차 내재
// 기본기
BigDecimal interest = new BigDecimal("1000000")
.multiply(new BigDecimal("0.03"))
.multiply(new BigDecimal("73"))
.divide(new BigDecimal("365"), 10, RoundingMode.HALF_UP)
.setScale(0, RoundingMode.DOWN);
BigDecimal 사용 시에도 함정이 있습니다. new BigDecimal(0.03)처럼 double 생성자를 쓰면 이미 오염된 값이 들어옵니다. 반드시 문자열 생성자 또는 valueOf를 쓰고, divide에는 scale과 RoundingMode를 명시해야 합니다(무한소수에서 ArithmeticException이 나는 것은 오히려 친절한 실패입니다).
실수 사례 시나리오 — 어디서 터지는가
실제 현장에서 반복되는 사고 패턴을 익명화·일반화한 시나리오입니다.
- 일수 계산 off-by-one: 해지 화면은 지급일 제외, 결산 배치는 지급일 포함으로 구현되어 같은 계좌의 이자가 화면과 배치에서 1일치 다르게 산출. 고객 안내 금액과 실제 지급액 불일치 민원 발생.
- 윤년 미고려: 2월 29일 신규 계좌의 만기일 산정이 모듈마다 달라(2/28 vs 3/1) 만기 안내와 실제 만기 처리가 하루 어긋남. 만기 후 금리 적용 구간도 연쇄로 틀어짐.
- 금리 이력 소급 수정: 잘못 등록한 금리를 UPDATE로 덮어써서, 이미 그 금리로 지급된 계좌들의 재현 계산이 불가능해짐. 감사 대응을 위해 전체 거래 로그에서 역산하는 대공사 발생.
- 우대금리 판정 데이터 지연: 카드 실적 집계가 D+2인데 만기일 당일 판정 배치가 D+1 데이터로 평가하여 우대 미적용. 충족 고객 일괄 보정 지급 및 사과 안내.
- double 혼입: 신규 마이크로서비스가 조회용 예상 이자를 double로 계산. 원장 확정액과 1원 차이가 간헐 발생해 "앱이랑 통장이랑 다르다"는 민원으로 발견.
- 회차별 절사 vs 총액 절사: 적금 만기이자를 리팩터링하면서 절사 시점이 바뀌어 구버전 대비 수원 단위 차이. 병행 검증이 없었다면 그대로 지급될 뻔한 사례.
공통 교훈은 하나입니다. 이자 로직의 변경은 반드시 신구 병행 계산으로 전 계좌 차이 리포트를 뽑고 나서 배포합니다.
체크리스트
이자 엔진 설계·리뷰 시 점검 목록입니다.
- 일수 관행(ACT/365 등)이 상품 약관과 일치하는가, 상품별로 설정 가능한가
- 기산일 포함/지급일 제외 규칙이 전 모듈에서 동일한가
- 윤년, 2월 29일 신규, 만기일이 휴일인 경우의 규칙이 정의·테스트되어 있는가
- 모든 금액 계산이 BigDecimal/Decimal 기반인가, double 혼입 정적 검사 장치가 있는가
- 절사/반올림 정책(대상, 시점, 방향)이 문서화되고 코드와 일치하는가
- 금리 이력이 불변(append-only)이고 과거 계산을 재현할 수 있는가
- 우대금리 판정 시점, 근거 데이터, 감사 로그가 설계되어 있는가
- 과세 유형별 세금 계산과 분개 분리가 되어 있는가
- 미지급이자 계상 산식과 지급 산식이 일치하는가
- 황금 파일 회귀 + property-based 테스트 + 독립 검증 계산기가 갖춰져 있는가
- 이자 로직 변경 시 신구 병행 계산 차이 리포트 절차가 있는가
마치며
이자 계산 엔진은 "작은 숫자를 다루는 큰 시스템"입니다. 산식 자체는 중학교 수학이지만, 일수 관행·절사 정책·금리 이력·세금·우대 조건이 곱해지면서 조합 폭발이 일어나고, 그 모든 조합에서 1원까지 맞아야 한다는 것이 이 도메인의 난이도입니다. 핵심을 다시 요약하면 이렇습니다. 약관이 스펙이다, 부동소수점은 금지다, 이력은 불변이다, 검증은 독립적으로 한다. 이 네 가지만 지켜도 이자 사고의 대부분은 예방할 수 있습니다.
참고 자료
- ISDA - 일수 관행 정의(2006 ISDA Definitions 관련 자료): https://www.isda.org
- ICMA - 채권시장 관행: https://www.icmagroup.org
- Java BigDecimal 공식 문서: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/math/BigDecimal.html
- Python decimal 모듈 공식 문서: https://docs.python.org/3/library/decimal.html
- IEEE 754 부동소수점 표준 개요: https://standards.ieee.org/ieee/754/6210/
- Hypothesis (Python property-based testing): https://hypothesis.readthedocs.io
- jqwik (Java property-based testing): https://jqwik.net
- 국세청 (이자소득 원천징수 안내): https://www.nts.go.kr
- 전국은행연합회 (금리 비교·상품 공시): https://www.kfb.or.kr
- 한국은행 기준금리: https://www.bok.or.kr/portal/main/contents.do?menuNo=200643