flowoodz.
← blog
22분 읽기글 flowoodz

learncave 적금 계산기: 연 5% 적금에 360만 원을 넣어도 이자가 9만 7,500원인 이유

learncave 적금 계산기가 쓰는 단리 공식, 15.4% 세후 계산, 같은 돈을 처음부터 예금했을 때와의 비교를 calc 함수를 직접 돌린 값으로 적었습니다. 먼저 만든 복리 계산기에서 고친 버그 세 가지가 적금 계산기로 어떻게 넘어왔는지도 코드로 확인했습니다.

목차
  1. 적금 이자는 회차마다 남은 개월 수만큼 붙습니다
  2. 세후 이자는 세전 이자에 0.846을 곱합니다
  3. 같은 돈을 처음부터 예금했다면 18만 원입니다
  4. 복리 계산기에서 먼저 고친 버그 세 가지
  5. 적금 계산기는 세 수정을 어떻게 물려받았나
  6. 남은 것

learncave는 제가 주제 하나를 리서치해서 출처 등급, 쉬운 설명, 퀴즈로 묶어 두는 한국어 학습 코스 아카이브입니다.

요즘은 사회초년생 돈 공부 코스를 늘리고 있고, 챕터 안에 숫자를 넣으면 그래프가 바로 바뀌는 계산기를 둡니다.

어제(2026년 10월 17일) 커밋 a5732c5로 적금 계산기를 넣었습니다. 월 납입, 기간, 연 금리, 과세를 넣으면 세전·세후 이자와, 같은 돈을 처음부터 예금했을 때의 이자가 함께 바뀝니다.

이 글을 처음 쓸 때는 이 계산기가 들어 있는 '예금·적금 이자의 진짜 계산' 코스가 _course.yaml에 draft: true가 달린 검수 전 초안이라서, 운영 사이트에서 주소를 열면 404가 나왔습니다.

10월 18일에 이 코스가 자동 검수를 통과해 공개됐습니다(10월 21일 고침). 지금은 적금 이자 계산기 단독 페이지에서 이 글의 숫자를 직접 넣어 볼 수 있고, 복리 계산기도 단독 페이지로 열려 있습니다.

초안을 운영 빌드에서 빼는 구조는 각주 검사 글에 적었습니다.

이 글의 숫자는 오늘 learncave 레포 루트에서 src/scripts/calc.ts의 savings()와 savingsBars()를 bun으로 직접 불러 얻었습니다. 금리 5%는 계산기 기본값으로 쓴 예시 금리이고, 실제 상품 금리가 아닙니다.

적금 회차별 세전 이자가 1만 5,000원에서 1,250원으로 줄어 합계 9만 7,500원, 세후 8만 2,485원이 되는 그림

적금 이자는 회차마다 남은 개월 수만큼 붙습니다#

월 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) ÷ 24

12개월이면 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일 자동 검수를 통과해 공개됐습니다. 아래에서 계산기를 열어 이 글의 숫자를 바로 넣어 볼 수 있습니다.

learncave 적금 이자 계산기

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만 원을 넘을 때의 종합과세도 계산기에 없고, 코스 둘째 챕터가 글로만 설명합니다.