learncave 적금 계산기: 연 5% 적금에 360만 원을 넣어도 이자가 9만 7,500원인 이유
learncave 적금 계산기가 쓰는 단리 공식, 15.4% 세후 계산, 같은 돈을 처음부터 예금했을 때와의 비교를 calc 함수를 직접 돌린 값으로 적었습니다. 먼저 만든 복리 계산기에서 고친 버그 세 가지가 적금 계산기로 어떻게 넘어왔는지도 코드로 확인했습니다.
목차
learncave는 제가 주제 하나를 리서치해서 출처 등급, 쉬운 설명, 퀴즈로 묶어 두는 한국어 학습 코스 아카이브입니다.
요즘은 사회초년생 돈 공부 코스를 늘리고 있고, 챕터 안에 숫자를 넣으면 그래프가 바로 바뀌는 계산기를 둡니다.
어제(2026년 10월 17일) 커밋 a5732c5로 적금 계산기를 넣었습니다. 월 납입, 기간, 연 금리, 과세를 넣으면 세전·세후 이자와, 같은 돈을 처음부터 예금했을 때의 이자가 함께 바뀝니다.
이 글을 처음 쓸 때는 이 계산기가 들어 있는 '예금·적금 이자의 진짜 계산' 코스가 _course.yaml에 draft: true가 달린 검수 전 초안이라서, 운영 사이트에서 주소를 열면 404가 나왔습니다.
10월 18일에 이 코스가 자동 검수를 통과해 공개됐습니다(10월 21일 고침). 지금은 적금 이자 계산기 단독 페이지에서 이 글의 숫자를 직접 넣어 볼 수 있고, 복리 계산기도 단독 페이지로 열려 있습니다.
초안을 운영 빌드에서 빼는 구조는 각주 검사 글에 적었습니다.
이 글의 숫자는 오늘 learncave 레포 루트에서 src/scripts/calc.ts의 savings()와 savingsBars()를 bun으로 직접 불러 얻었습니다. 금리 5%는 계산기 기본값으로 쓴 예시 금리이고, 실제 상품 금리가 아닙니다.
적금 이자는 회차마다 남은 개월 수만큼 붙습니다#
월 30만 원을 12개월 동안 넣는 연 5% 적금을 보겠습니다. 넣은 돈은 360만 원이고, 360만 원의 5%는 18만 원입니다. 그런데 만기에 붙는 세전 이자는 9만 7,500원입니다.
넣은 돈마다 은행에 머문 기간이 달라서 그렇습니다. 코스가 출처로 삼은 적립식예금약관은 저축금마다 입금일부터 만기일 전날까지 이자를 셈한다고 정합니다. 첫 회차 30만 원은 12개월, 마지막 회차 30만 원은 1개월만 이자를 받습니다.
식으로 쓰면 이렇습니다. n은 기간(개월), k는 회차입니다.
회차 k의 이자 = 월 납입액 × 연 금리 × (n - k + 1) ÷ 12
세전 이자 합계 = 월 납입액 × 연 금리 × (n + (n - 1) + … + 1) ÷ 12
= 월 납입액 × 연 금리 × n(n + 1) ÷ 2412개월이면 12 + 11 + … + 1 = 78이라서 월 납입액 × 연 금리 × 78 ÷ 12가 됩니다. 계산기의 savings()는 이 식을 그대로 한 줄로 씁니다.
const r = rate / 100;
const paid = monthly * months;
const gross = (monthly * r * months * (months + 1)) / 24;회차별 이자가 정말 계단으로 줄어드는지는 막대 그래프를 그리는 savingsBars()로 확인했습니다. 이 함수는 금액 대신 SVG 경로를 돌려줍니다. 그래서 막대 높이를 축 최댓값 2만 원 기준으로 다시 금액으로 바꿨습니다.
$ bun -e '
import { savingsBars, CHART } from "./src/scripts/calc.ts";
const b = savingsBars({ monthly: 300000, months: 12, rate: 5 });
const plotH = CHART.height - CHART.top - CHART.bottom; // 막대 높이 plotH = 축 최댓값 2만 원
const won = [...b.steps.matchAll(/v-([\d.]+)/g)].map((m) => Math.round((Number(m[1]) / plotH) * 20000));
console.log(b.yTicks.map((t) => t.label).join(" | "));
console.log(won.join(", "), "합계", won.reduce((a, c) => a + c));
console.log(JSON.stringify(savingsBars({ monthly: 300000, months: 1, rate: 5 }).xTicks.map((t) => [t.label, t.hidden])));
'
0 | 5,000 | 1만 | 1만 5,000 | 2만
15000, 13750, 12500, 11250, 10000, 8750, 7500, 6250, 5000, 3750, 2500, 1250 합계 97500
[["1회차",false],["1회차",true]]첫 줄은 세로축 눈금이고, 둘째 줄이 1회차부터 12회차까지의 세전 이자입니다. 1회차가 1만 5,000원, 12회차가 1,250원이고 회차마다 1,250원씩 줄어듭니다. 합계 9만 7,500원은 savings()가 낸 세전 이자와 같습니다.
셋째 줄은 아래 버그 이야기에서 다시 씁니다.
세후 이자는 세전 이자에 0.846을 곱합니다#
일반 예금·적금 이자에는 소득세 14%와, 그 소득세의 10%인 지방소득세 1.4%가 원천징수됩니다. 합쳐서 15.4%입니다. 코스 둘째 챕터는 국세청 원천징수 세율표와 지방세법 조문을 출처로 이 숫자를 설명합니다.
계산기는 세율을 % 단위로 받아 한 줄로 뺍니다.
const net = gross * (1 - tax / 100);과세 선택지는 일반 과세 15.4%와 비과세 0% 두 개뿐입니다. 입력 세 가지를 넣고 돌린 결과입니다.
$ bun -e '
import { savings, wonFull } from "./src/scripts/calc.ts";
for (const i of [
{ monthly: 300000, months: 12, rate: 5, tax: 15.4 },
{ monthly: 300000, months: 12, rate: 5, tax: 0 },
{ monthly: 100000, months: 12, rate: 5.7, tax: 15.4 },
]) {
const s = savings(i);
console.log(JSON.stringify(i), JSON.stringify(s), wonFull(s.net), ((s.net / s.paid) * 100).toFixed(2) + "%");
}'
{"monthly":300000,"months":12,"rate":5,"tax":15.4} {"paid":3600000,"gross":97500,"net":82485,"deposit":180000} 8만 2,485원 2.29%
{"monthly":300000,"months":12,"rate":5,"tax":0} {"paid":3600000,"gross":97500,"net":97500,"deposit":180000} 9만 7,500원 2.71%
{"monthly":100000,"months":12,"rate":5.7,"tax":15.4} {"paid":1200000,"gross":37050,"net":31344.3,"deposit":68400} 3만 1,344원 2.61%일반 과세면 세후 이자가 8만 2,485원이고 납입 총액의 2.29%입니다. 비과세를 고르면 세후가 세전과 같은 9만 7,500원, 2.71%입니다.
손으로 다시 계산해도 같습니다. 소득세가 9만 7,500 × 14% = 1만 3,650원, 지방소득세가 1만 3,650 × 10% = 1,365원입니다. 둘을 합한 1만 5,015원을 빼면 8만 2,485원이 남습니다.
셋째 줄은 금융당국이 2023년 보도자료에 든 예시 조건(월 10만 원, 12개월, 연 5.7%)입니다. 함수가 낸 세후 이자는 31344.3이고 화면에는 3만 1,344원으로 찍힙니다. 보도자료가 적은 세후 이자 3만 1,344원과 같습니다.
0.3원이 남는 이유는 세금을 원 단위로 자르지 않고 세율을 그대로 곱해서입니다. 손으로 나눠 보면 소득세 5,187원에 지방소득세가 518.7원입니다. 계산기는 화면에 찍는 마지막 순간에만 wonFull()의 Math.round로 원 단위 반올림을 합니다.
계산기 아래 설명에도 "세금은 원 단위 절사 없이 이자에 세율을 곱했습니다"라고 적어 두었습니다. 은행이 실제로 어느 자리에서 끝수를 처리하는지는 제가 확인하지 않았습니다. 은행은 날짜로 이자를 세므로 실제 통장 금액은 계산기와 수백 원쯤 다를 수 있습니다.
같은 돈을 처음부터 예금했다면 18만 원입니다#
결과 칸의 마지막 줄은 같은 360만 원을 처음부터 12개월 동안 정기예금에 넣었을 때의 세전 이자입니다. 코드는 deposit: (paid * r * months) / 12이고, 위 실행 결과의 "deposit":180000이 그 값입니다.
그래프에서는 점선 직사각형이 예금이고, 그 안의 계단이 적금입니다. 점선 안의 빈 자리는 아직 넣지 않은 돈의 몫이라 이자가 붙지 않습니다.
$ bun -e '
import { savings, wonFull } from "./src/scripts/calc.ts";
const a = savings({ monthly: 300000, months: 12, rate: 5, tax: 15.4 });
console.log((a.gross / a.deposit * 100).toFixed(1) + "%", (a.deposit / a.gross).toFixed(3));
console.log(wonFull(savings({ monthly: 300000, months: 12, rate: 9.23, tax: 15.4 }).gross));
'
54.2% 1.846
17만 9,985원적금 세전 이자는 예금의 54.2%입니다. 거꾸로 보면 같은 이자를 받으려면 적금 금리가 예금 금리의 1.846배여야 합니다. 예금이 연 5%라면 적금은 연 9.23% 근처가 되어야 비깁니다.
다만 9.23%를 그대로 넣으면 17만 9,985원이 나와 18만 원에 15원 모자랍니다. 1.846이 반올림한 값이기 때문입니다. 코스 본문은 이 비율을 12 ÷ 6.5로 적습니다.
이 비교에는 전제가 둘 있습니다. 아직 적금에 넣지 않은 돈이 그동안 다른 곳에서 버는 이자를 0으로 봅니다. 그리고 360만 원을 처음부터 가진 사람이어야 비교가 성립합니다. 코스 본문도 첫 월급부터 모으는 사람에게는 애초에 그 목돈이 없다고 짚습니다.
복리 계산기에서 먼저 고친 버그 세 가지#
적금 계산기는 빈 화면에서 시작하지 않았습니다. learncave에는 10월 14일 커밋 308b671로 넣은 복리 계산기 GrowthCalc가 먼저 있었습니다. 복리 코스 첫 챕터와 인플레이션 코스 둘째 챕터가 이 계산기를 씁니다.
커밋 5c389ee(10월 17일 19시 35분)는 이 복리 계산기의 버그 세 가지를 고친다는 메시지를 달고 있습니다. 겹치는 축 눈금, iOS에서 음수를 넣지 못하는 칸, 범위 밖 값이 말없이 잘리는 문제입니다. 고친 곳은 src/scripts/calc.ts의 plot()과 src/components/GrowthCalc.astro이고, 테스트와 CSS도 함께 바뀌었습니다.
그런데 diff를 읽어 보니 세 가지가 모두 이 커밋에서 처음 고쳐진 것은 아니었습니다. 메시지 자체가 세로축 1만 원 최소 간격과 음수 칸 처리는 10월 15일 커밋 c248e3e에 이미 들어가 있어 테스트로만 확인한다고 적고 있습니다.
1. 1년짜리 그래프에서 눈금이 겹쳤습니다#
원금 1만 원, 기간 1년을 넣으면 가로축이 0년 | 0년 | 1년 | 1년으로 찍혔습니다. 눈금 네 칸을 기간을 3등분한 자리마다 놓고 그 연도를 반올림했기 때문입니다. 1년의 3분의 1은 0년으로, 3분의 2는 1년으로 반올림됩니다.
- const year = Math.round((span * i) / 3);
- return { x: x(year), label: `${year}년` };
+ const year = xStep * i;
+ return { x: x(Math.min(year, span)), label: `${year}년`, hidden: year > span };xStep은 Math.ceil(span / 3)입니다. 1년이면 간격이 1년이라 0·1·2·3년 눈금이 생기고, 기간을 넘는 2년과 3년은 숨깁니다. 10년이면 0·4·8년만 보입니다.
옛 코드를 파일로 꺼내지 않고 git show 출력을 그대로 bun에 넘겨 세 시점을 돌려 봤습니다.
$ for rev in c248e3e^ c248e3e 5c389ee; do echo "$rev"; bun -e "$(git show "${rev}:src/scripts/calc.ts" | sed 's/^export //')
const p = plot(grow({ principal: 10000, monthly: 0, years: 1, rate: 0 }), ['nominal', 'invested']);
console.log(' y', p.yTicks.map((t) => t.label).join(' | '), ' x', p.xTicks.filter((t) => !t.hidden).map((t) => t.label).join(' | '));
"; done
c248e3e^
y 0 | 0 | 1만 | 1만 | 1만 x 0년 | 0년 | 1년 | 1년
c248e3e
y 0 | 1만 | 2만 | 3만 | 4만 x 0년 | 0년 | 1년 | 1년
5c389ee
y 0 | 1만 | 2만 | 3만 | 4만 x 0년 | 1년세로축 겹침은 c248e3e에서 이미 사라졌고, 가로축 겹침이 5c389ee에서 사라졌습니다. 처음 zsh에서 $rev:src/...로 썼다가 zsh가 :s를 치환 수식어로 읽어 버려 ${rev}로 고쳐 다시 돌렸습니다.
세로축이 겹친 이유는 복리 계산기의 won()이 금액을 만 원 단위로 반올림하기 때문입니다. 축 최댓값이 1만 원이면 한 칸이 2,500원인데, 2,500원은 '0'으로, 5,000원과 7,500원은 '1만'으로 찍혔습니다.
c248e3e는 눈금 한 칸을 최소 1만 원으로 올려 이 문제를 고쳤습니다.
2. iOS에서 수익률 칸에 음수를 넣지 못했습니다#
복리 계산기의 연 수익률 칸은 -20%까지, 물가 칸은 -5%까지 받습니다. 그런데 처음에는 모든 칸에 inputmode="decimal"을 달았습니다. 코드 주석에 따르면 iOS의 decimal 자판에는 '−' 키가 없습니다. c248e3e가 음수를 받는 칸에서만 이 속성을 뺐습니다.
inputmode={f.min < 0 ? undefined : 'decimal'}이 수정에는 테스트가 없습니다. calc.test.ts는 DOM이 없는 순수 계산만 검사하고, 레포에서 inputmode를 찾으면 컴포넌트 두 파일만 나옵니다. 저도 이번에 iOS 기기로 다시 확인하지는 않았습니다.
3. 범위 밖 값이 말없이 잘렸습니다#
처음 복리 계산기는 범위를 벗어난 값을 그 칸의 최솟값이나 최댓값으로 잘라 계산하면서 아무것도 알리지 않았습니다. 기간 칸에 100을 치면 60년으로 계산하는데, 칸에는 100이 그대로 남았습니다.
c248e3e가 안내 문구를 붙였지만 절반만 고쳐졌습니다. 입력을 마치면(change) 칸에 잘린 값을 다시 쓰는데, 그 순간 입력값과 잘린 값이 같아져 안내가 사라졌습니다. 칸을 벗어나고 나면 무엇이 잘렸는지 볼 수 없었습니다.
5c389ee는 칸마다 aria-live 안내 자리를 두고, 안내 문구를 input 이벤트에서만 바꿉니다. change에서 값을 다시 써도 안내는 남고, 범위 안 값을 새로 쳐야 지워집니다. 지금 calc-inputs.ts에 있는 모습은 이렇습니다.
const hint = (i: HTMLInputElement) => {
const el = i.closest('label')?.querySelector('[data-hint]');
if (!el) return;
const v = i.valueAsNumber;
const n = (x: string) => Number(x).toLocaleString('ko-KR') + i.dataset.unit;
el.textContent = v > Number(i.max) ? `최대 ${n(i.max)}까지 계산합니다.` : v < Number(i.min) ? `최소 ${n(i.min)}부터 계산합니다.` : '';
};적금 계산기는 세 수정을 어떻게 물려받았나#
a5732c5(19시 42분)는 5c389ee보다 7분 늦게 들어왔습니다. 적금 계산기는 고친 코드 위에서 시작했지만, 세 수정을 물려받은 방식은 저마다 다릅니다.
범위 밖 안내는 같은 코드를 나눠 씁니다. a5732c5는 복리 계산기 안에 있던 자르기와 안내 코드를 src/scripts/calc-inputs.ts의 bindInputs()로 옮겼습니다. 두 계산기가 이 함수 하나를 부르고, 위의 hint도 그 안에 있습니다.
적금 계산기에는 과세를 고르는 <select>가 하나 더 있습니다. 그래서 bindInputs()는 숫자 칸만 범위로 자르고, 선택 칸은 값을 숫자로 읽기만 합니다.
if (!(f instanceof HTMLInputElement)) return [f.name, Number(f.value)];축 눈금은 같은 생각을 다른 함수로 다시 썼습니다. savingsBars()는 plot()을 쓰지 않고, 눈금 간격을 1·2·5 단위로 올리는 niceStep()은 같이 씁니다. 최소 간격은 1만 원이 아니라 1원입니다.
눈금 글자를 원 단위까지 찍는 wonFull()을 쓰니 5,000이나 1만 5,000도 정확히 찍히고, 앞의 실행 결과 첫 줄이 그 모습입니다.
가로축 눈금은 1회차와 마지막 회차 두 개뿐입니다. 기간이 1개월이면 두 눈금이 같은 자리에 겹치므로 둘째를 숨깁니다. 앞 실행 결과의 셋째 줄 [["1회차",false],["1회차",true]]가 그 경우이고, 5c389ee가 기간을 넘는 눈금을 hidden으로 숨긴 방식과 같습니다.
음수 칸 문제는 적금 계산기에 생기지 않습니다. 월 납입과 금리의 최솟값은 0, 기간은 1이라 음수를 받는 칸이 없습니다. 그래서 SavingsCalc.astro는 분기 없이 모든 칸에 inputmode="decimal"을 고정했습니다.
남은 것#
적금 계산기와 그 코스는 10월 18일 자동 검수를 통과해 공개됐습니다. 아래에서 계산기를 열어 이 글의 숫자를 바로 넣어 볼 수 있습니다.
bindInputs()에는 자동 검사가 없습니다. bun test를 돌리니 테스트 4개(expect 25번)가 모두 통과했지만, 전부 calc.ts의 순수 계산을 봅니다.
운영 중인 복리 챕터 HTML에서 숫자 칸 4개마다 data-hint 자리가 있는 것까지는 확인했습니다. 안내가 change 뒤에도 남는지는 브라우저에서 사람이 봐야 하고, 이번에는 보지 않았습니다.
SavingsCalc.astro의 inputmode="decimal" 고정은 지금은 문제가 없습니다. 하지만 나중에 음수를 받는 칸을 더하면 복리 계산기의 iOS 문제가 그대로 돌아옵니다. 그때는 f.min < 0 분기도 같이 옮겨야 합니다.
예금 비교 칸이 세전만 보여 주는 것은 미결입니다. 맨 위 결과는 세후라서, 세후 적금 이자 옆에 세전 예금 이자가 놓입니다.
적금과 예금 모두 15.4%를 떼니 54.2%라는 비율은 세후에도 그대로지만, 화면만 보면 헷갈릴 수 있습니다. 예금 칸도 세후로 바꿀지, 라벨의 '(세전)'으로 충분한지는 아직 정하지 못했습니다.
과세 선택지도 일반 과세와 비과세 둘뿐이고, 그 밖의 세율은 넣지 않았습니다. 한 해 금융소득이 2,000만 원을 넘을 때의 종합과세도 계산기에 없고, 코스 둘째 챕터가 글로만 설명합니다.