Published on
· Updated

선납이연 계산기를 만들어 원스토어에 출시하기까지 — 선납노트 개발기

Authors

선납이연 계산기를 만들 때 처음 풀어야 했던 것은 날짜 계산이었습니다. 실제로 쓸 수 있게 만드는 데에는 다른 문제가 더 붙었습니다. 납입을 세 단계로 직접 나누고 싶은 사람, 계획한 금액의 일부만 넣은 사람, 계산한 결과를 자기 휴대폰에 남겨 두고 싶은 사람이 모두 같은 앱을 써야 했습니다.

선납노트는 웹 기술로 화면과 계산을 만들고 Capacitor로 Android 앱에 묶은 개인 프로젝트입니다. 2026년 10월 22일 원스토어에 출시했고, 같은 날 계산 검증에서 발견한 두 오류를 수정한 1.6.1-onestore(versionCode 9)까지 심사 승인과 상용 반영을 확인했습니다.

이 글은 해당 출시본의 구현과 검증 기록입니다. 앱 전체 소스를 공개하는 글이나 은행의 계산을 그대로 재현했다는 주장은 아닙니다. 코드 예시는 설명에 필요한 부분으로 줄였습니다. 앱을 쓰려는 분은 선납노트 사용법부터 읽으면 됩니다.

화면과 계산은 JavaScript, 기기 기능은 Capacitor

웹 화면은 React 없이 JavaScript의 템플릿과 DOM 이벤트로 구성했습니다. 입력 폼, 비교 결과, 적금 상세와 실제 납입 기록이 화면을 담당하고, 날짜·금액 계산은 별도 모듈로 분리했습니다.

역할실제 사용 기술과 구성
화면HTML, CSS, JavaScript 모듈
웹 번들Vite 7 계열
Android 연결Capacitor 8 계열, Java 플러그인
적금 기록 저장@capacitor/preferences, 버전이 있는 JSON
원스토어 결제ONE store IAP SDK 21.04.00
계산 검증Node.js 기본 테스트 러너와 독립 대조 계산
Android 빌드Gradle, 별도 원스토어 빌드 변형

구조는 다음처럼 나뉩니다.

입력 폼·목록·상세 화면
  ├─ 계획 엔진: 약정일, 납입 방식, 단계별 날짜
  ├─ 실제 기록: 부분 납입 배정, 취소, 다음 납입 안내
  ├─ 예상 이자: 정상 납입을 가정한 월 단리
  └─ 저장·구매 경계
       ├─ Capacitor Preferences → 휴대폰 내부 기록
       └─ Java 플러그인 → 원스토어 IAP

계산 모듈은 DOM이나 결제 서비스에 의존하지 않습니다. 덕분에 날짜 계산을 검증할 때 화면을 띄우거나 구매 서비스를 연결할 필요가 없었습니다. 반대로 실제 Android에서의 설치·구매 검증은 계산 테스트와 따로 진행했습니다.

‘세 단계’를 계산 방식이 아닌 입력 방식으로 만들기

6-1-5는 유용한 예시지만 모든 사용자가 같은 방식으로 납입하지는 않습니다. 입력 화면에는 기본 3단계를 보여 주되, 단계의 추가·삭제를 허용했습니다. 각 단계의 납입량은 회분 또는 금액으로 받고 내부에서는 회분으로 정규화합니다. 금액 입력도 월 납입액의 배수로 제한합니다.

자동 계산은 한 단계의 날짜만 맡길 수 있습니다. 나머지 단계의 날짜와 회분을 고정한 뒤 합산 조건을 만족하는 가장 늦은 날짜를 구합니다. 단계 날짜는 앞 단계보다 빠를 수 없고, 가입일부터 약정 만기 사이에 있어야 합니다. 조건에 맞는 날짜가 없으면 오류를 보여 줍니다.

월별 약정일은 직전 회차에 한 달씩 더하는 방식이 아니라 원래 가입일을 기준으로 각 회차를 계산합니다. 해당 월에 같은 일이 없으면 월말로 제한합니다. 따라서 1월 말 가입과 윤년 경계도 따로 검사해야 합니다.

이 앱은 동일 금액의 정액 적금 모델입니다. 상품마다 선납이연 허용 여부와 만기 처리 방식이 다르므로 특정 은행의 실제 만기일을 확정하지 않습니다.

계획과 실제 납입 기록을 분리하기

완료 체크 하나로는 ‘계획한 180만원 중 오늘 60만원만 납입’한 상태를 표현할 수 없습니다. 회차마다 약정일·계획일과 별개로 실제 납입 조각을 저장했습니다.

아래는 실제 구조를 줄인 예시입니다. 날짜와 금액은 가상 데이터입니다.

const installment = {
  n: 1,
  due: "2026-10-22",
  date: "2026-10-22",
  paid: false,
  receipts: [{ id: "example-entry", date: "2026-10-22", amount: 200000 }],
};
// 월 납입액 300000원이라면 이 회차에는 100000원이 남는다.

기록 입력은 날짜·시작 회차·마지막 회차·실제 금액을 받습니다. 선택한 범위에서 아직 덜 낸 앞 회차부터 금액을 배정합니다. 예를 들어 월 30만원 적금의 1–6회차를 선택하고 60만원을 넣으면 1–2회차가 완료되고 3–6회차 120만원은 남습니다.

한 번의 기록이 여러 회차로 나뉘어도 같은 기록 ID를 사용합니다. 기록 취소는 그 ID의 조각들을 함께 제거한 뒤 회차 완료 여부를 다시 계산합니다. 은행에서 어느 회차로 처리했는지는 앱이 알 수 없으므로 입력 화면에도 실제 은행 기록과 맞추도록 안내합니다.

합산 상태는 다음과 같이 계산합니다.

실제 납입분: 납입 금액 × (약정일 − 실제 납입일)
남은 납입분: 남은 금액 × (약정일 − 계산에 사용할 예정일)

합산 표시 일수 = 위 두 항목의 전체 합 ÷ 월 납입액

내부의 금액×일수 합은 BigInt 정수로 누적합니다. 표시 일수는 금액 비중으로 합친 값이며 은행의 만기 연장 일수가 아닙니다. 다음 납입 안내는 다음 미납 묶음 하나를 조정하고, 다른 남은 묶음의 예정일과 실제 기록은 유지하는 계산입니다.

첫 번째 오류: 지나간 예정일이 선납 여유에 남았다

기존 다음 납입일 계산은 이미 지난 미납 날짜를 오늘보다 앞에 둘 수 없도록 처리했습니다. 그런데 홈·상세의 요약은 원래 예정일을 그대로 사용했습니다. 같은 적금인데 한쪽은 일정 조정이 필요하다고 하고 다른 쪽은 과거에 계획한 선납 여유를 보여 줄 수 있었습니다.

수정에서는 요약에 기준 날짜를 전달하도록 했습니다.

const effectiveDate = (row, asOf) => (row.date < asOf ? asOf : row.date);

이 보정은 남은 미납 금액에만 적용합니다. 이미 납입한 금액은 실제 날짜로 계산합니다. 사용자가 저장한 예정일이나 실제 기록을 덮어쓰지는 않습니다.

또한 계획 비교에서는 여전히 입력한 날짜 그대로 계산합니다. 아직 선택하지 않은 계획의 수학적 비교와, 시간이 흐른 뒤 현재 기록의 상태 확인은 기준이 다르기 때문입니다. 화면에는 ‘예정일이 지난 미납액은 오늘 넣는 것으로 계산했다’는 설명을 추가했습니다.

검증용 전액 선납 계획을 만들고 실제 납입을 기록하지 않은 채 만기 이후 날짜를 기준으로 계산했을 때, 원래 계획이 유효하더라도 현재 요약에서는 이연 초과와 조정 필요가 나오는지를 검사했습니다.

두 번째 오류: 소수 금리에서 이자가 1원 작아졌다

예상 이자는 모든 회차를 정상 납입하고 입력 금리가 유지된다는 가정의 월 단리입니다. 월 납입액을 A, 기간을 n개월, 연 금리의 퍼센트 숫자를 r이라고 두면 세전 이자 추정식은 다음과 같습니다.

세전 이자 = floor(A × r × n × (n + 1) ÷ 2400)
예상 세금 = floor(세전 이자 × 세율 ÷ 100)

이 식을 이진 부동소수점으로 계산한 뒤 원 미만을 버리면, 정수여야 할 경계가 아주 조금 작아져 1원이 줄 수 있었습니다. 회귀 테스트에는 다음 극단값 사례를 남겼습니다. 상품 조건이나 추천 수익률이 아니라 입력 허용 범위를 검사한 경계 사례입니다.

테스트 입력수정 전십진 분수 기준값
월 580,529,350원·24개월·연 68.32%9,915,441,297원9,915,441,298원

수정은 epsilon을 더하는 방식 대신 금리와 세율의 십진 표현을 정수 분수로 바꾸는 방법을 택했습니다. 예를 들어 4.125%는 분자 4125와 분모 1000으로 다룹니다. 계산의 중간 값은 정수로 유지하고 마지막에만 나눕니다.

// 입력 검증과 십진수 파싱을 생략한 계산 핵심 예시.
// r은 연 금리의 퍼센트 숫자: 4.125 → 4125n / 1000n.
const gross =
  (BigInt(amount) * BigInt(months) * BigInt(months + 1) * r.numerator) /
  (2400n * r.denominator);

const tax = (gross * t.numerator) / (100n * t.denominator);

양수의 BigInt 나눗셈은 소수 부분을 버리므로 이 모델의 원 미만 절사와 맞습니다. MDN BigInt 문서에도 정수 연산과 나눗셈 동작이 설명되어 있습니다. 실제 구현은 지수 표기의 작은 금리도 처리하며, 숫자 타입으로 돌아올 때는 앱이 검증한 금액·기간 범위 안에서 사용합니다.

이 수정이 은행별 세목 분리나 세금 절사 관행까지 재현한다는 의미는 아닙니다. 실제 지연 납입 이자, 중도해지, 복리와 우대금리 조건도 이 추정식의 범위 밖입니다.

기록은 로컬에, 구매 권한은 별도로

앱 자체의 회원가입과 적금 기록용 서버를 두지 않았습니다. @capacitor/preferences에 버전이 있는 JSON을 저장하고, 읽을 때 형식과 회차·날짜·납입 금액을 검증합니다. 기록을 읽지 못하면 새 빈 기록으로 덮어쓰지 않고 변경을 막습니다.

Capacitor Preferences 공식 문서는 Android에서 SharedPreferences를 사용하며 가벼운 키·값 저장 용도라고 설명합니다. 선납노트도 전체 기록을 JSON 한 값으로 저장하므로 관계형 데이터베이스처럼 대량 쓰기나 복잡한 검색을 처리하는 구조는 아닙니다. 기록 규모가 커지면 저장 방식을 다시 검토해야 합니다.

현재 클라우드 동기화와 기록 내보내기·가져오기는 없습니다. Android 자동 백업도 비활성화한 구성입니다. 기록의 서버 전송을 줄인 선택에는 휴대폰 변경이나 앱 삭제 시 복구 수단이 부족하다는 대가가 있습니다. 이 한계는 사용법과 앱 안내에 함께 적었습니다.

무료 저장 한도는 현재 저장된 적금 5개입니다. 원스토어 빌드에는 광고 SDK와 광고 ID 권한을 포함하지 않았고, 한도를 넘겨 저장하려는 경우 일회성 저장 제한 해제 구매를 안내합니다.

Java의 Capacitor 플러그인이 원스토어 결제 연결·상품 조회·구매·구매 확인·복원을 맡습니다. 구매 상태와 패키지·상품을 확인하고, 구매 확인 성공 후 로컬 권한 캐시를 갱신합니다. 원스토어용 캐시는 다른 스토어용 캐시와 분리했습니다. 적금 기록 자체를 결제 서비스로 보내지는 않습니다.

복원은 스토어 구매 내역으로 이용 권한을 다시 확인하는 기능입니다. 적금 기록의 복원과는 다릅니다. 이 구현에는 자체 서버의 실시간 환불 알림 처리나 별도 결제 원장도 없습니다. 로컬 캐시를 서버 검증과 동등한 보안으로 설명해서는 안 됩니다.

연동 시에는 원스토어 IAP SDK 공식 문서의 서비스 조회용 manifest queries 설정도 필요했습니다. 샌드박스에서는 실제 돈이 나가지 않는 구매 성공과 구매 복원 흐름을 확인했습니다. 실제 유료 거래의 모든 취소·환불 상황을 검증했다는 뜻은 아닙니다.

테스트가 증명한 범위를 남기기

2026년 10월 22일 수정 후 기존 43개와 신규 8개, 총 51개 테스트가 통과했습니다. 대량 사례는 각 테스트 안에서 반복 대조한 입력 개수이며, 별도 테스트 개수가 아닙니다.

검증방법과 범위
계획과 달력2,500개 입력 세트에서 월말·윤년 경계, 2–120개월과 매월·두 묶음·6-1-5 계산 대조
단계 자동 날짜200개 사례에서 가능한 날짜를 하루씩 열거해 마지막 유효 날짜와 비교
실제·부분 납입160개 사례에서 다음 날짜를 하루씩 대조하고 원본 기록이 바뀌지 않는지 확인
예상 이자·세금200,000개 입력을 십진 정수 분수 기준 계산과 대조
회귀 사례1원 절사 경계, 아주 작은 금리, 미납 경과와 부분 납입 요약

달력 기준 계산은 제품 코드의 월 더하기 함수를 그대로 재사용하지 않고 별도로 작성했습니다. 다음 납입 날짜도 제품의 해석식을 한 번 더 구현하는 대신 날짜를 하루씩 움직이며 조건을 확인했습니다. 같은 오류를 공유하는 테스트를 줄이려는 선택입니다.

브라우저에서는 미납액 안내·이연 초과·예상 이자 표시를 확인했습니다. Android에서는 assembleOnestore와 lintOnestore가 통과했고 lint 오류는 0개였습니다. 이전 출시 APK와 새 APK의 서명 지문이 같은지 확인한 뒤 삭제 없이 업데이트 설치하고 앱 실행을 확인했습니다.

이는 앱에 정의한 모델과 설치 경로의 검증입니다. 모든 은행 상품에 대해 만기나 실제 지급 이자가 일치한다는 보장은 아닙니다. 기준 공식과 해석을 확인할 때는 HSBC 적립식 예금 약관, KB 정액적립식 상품 안내, KB 적금 계산기도 참고했습니다. 각 문서는 해당 상품과 가정의 자료이며 모든 은행에 공통인 약관은 아닙니다.

빌드 모드와 실제 출시 파일도 검증하기

웹 자산은 vite build --mode onestore로 빌드하고 Capacitor로 Android 프로젝트에 복사했습니다. Vite의 모드 문서가 설명하는 import.meta.env.MODE를 화면의 광고 안내 분기에 사용합니다. 모드는 비밀을 숨기는 보안 장치가 아니며, 화면 분기만으로 광고 SDK가 APK에서 사라지는 것도 아닙니다.

따라서 원스토어용 Android 변형의 의존성과 manifest도 따로 구성했습니다. 최종 APK에서는 광고 SDK·광고 ID 권한이 빠졌는지, 내부 테스트 플래그가 꺼졌는지, 패키지와 버전 코드가 맞는지를 검사했습니다. 수정 전 소스의 번들과 기존 출시 APK 안의 JavaScript가 동일한지도 SHA-256으로 비교해, 검증하는 코드가 실제 출시 코드와 이어지는지 확인했습니다.

서명키와 서명 비밀번호는 프로젝트 밖에서 빌드에만 공급하며 글이나 저장소에 넣지 않습니다. 1.6.1은 기존 원스토어 앱의 서명을 유지해 업데이트했습니다. Android 개발자 인증에는 원스토어에서 사용하는 공개 인증서 지문을 추가했고 Google과 원스토어 양쪽의 ‘확인됨’ 상태까지 확인했습니다. 앱 서명을 바꾼 것이 아니라 기존 인증서의 등록을 맞춘 작업입니다.

최종 완료 기준은 APK 파일 생성이 아니라 원스토어 심사 승인과 상용 바이너리의 버전 9 / 1.6.1-onestore 반영이었습니다. 공개 검색 결과에 앱이 나타나고 다운로드 페이지가 열리는 것도 확인했습니다.

다음에 개선할 부분

사용자 입장에서는 납입 결과를 빠뜨리지 않고 기록하는 흐름과 휴대폰 변경 시 데이터 보관이 중요합니다. 다음 개선 후보는 기록 내보내기·가져오기, 납입 알림과 입력 흐름 단순화입니다. 현재 제공한다고 소개하지 않고, 실제 사용 의견을 받아 우선순위를 정할 계획입니다.

선납노트를 만들며 가장 도움이 된 구분은 계획한 돈·실제로 납입한 돈·정상 납입을 가정한 예상 이자를 따로 다루는 것이었습니다. 같은 화면에 보여 주더라도 계산의 기준과 수정 가능한 범위는 서로 달랐습니다.