위 화면은 토스의 송금 화면 중 일부입니다.
계좌번호를 입력하면 해당 계좌의 은행 선택을 추천해주는 기능으로, 이체가 발생하는 기능에서
사용자가 간편하게 서비스를 이용할 수 있도록 돕는 유용한 기능입니다.
해당 기능을 서버나 머신러닝의 의존성 없이, 단순 자바스크립트 코드만으로 구현한 경험에 대해 공유합니다.
이 글에서는...
- "계좌번호로 은행을 유추하는 게 그렇게 어려운 일일까?"라는 질문을 생각해봅니다
- ML이나 서버 의존성 없이, 단순한 구현 코드만으로 해당 기능을 구현한 경험을 공유합니다
과목 코드: 각 은행에서 기관을 식별할 때 사용하는 코드로, 계좌번호에 2자리 또는 3자리로 포함됩니다.
e.g. 기업은행 XXX-YY-ZZZZZZ-C와 같은 형식인 경우, YY가 과목번호이며, 이는 01 또는 02이다.
처음에는 각 금융기관마다 계좌번호 발급시 사용할 패턴이 있을 것이기에, 구현하는 데 난이도가 높진 않을 것이라고 생각했습니다.
그런데 다음과 같은 문제점이 있었습니다.
제일 큰 블로커는, 공개 자료가 없기 때문에 14자리까지 올 수 있는 계좌번호에 들어가는 과목코드 달랑 2~3자리만으로 은행을 유추해야 한다는 점이었습니다.
또한 과목코드만으로 은행들을 식별하기에는 은행마다 위치가 다를 때도 있으며 같은 과목코드를 사용하는 곳 또한 존재합니다.
그런데, 이를 단점이 아닌 특징으로 바라보아 해결할 수는 없을까요?
먼저 금융결제원에서 제공하는 ‘참가기관별 CMS 계좌번호체계’ 자료를 조사했습니다.
해당 데이터는 각 은행이 어떤 과목코드를 사용하고 있는지를 유추시켜주는 자료입니다.

해당 자료에는 각 은행이 어떻게 과목 코드를 구성하는지, 각 과목 코드는 어떤 값인지에 대해 기록되어있습니다.
같은 은행에서도 계좌 유형이 어떻냐에 따라서 과목 코드가 변경되기 때문에, 다른 은행과 겹치거나 은행 안에서 과목 코드 위치가 다른 등의 이슈가 있습니다.
그런데 한 가지 좋은 소식은, 일부 은행의 계좌번호에는 과목코드를 제외하고도 특정한 규칙이 공개된 경우도 기재되어있다는 겁니다.
e.g. 토스뱅크는 일련번호의 첫자리가 토스머니는 8, 나머지는 0으로 고정된다.
확실히 이를 로직으로 구현하더라도 정확하게 계좌번호를 확인했을 때 '이 계좌번호는 이 은행거야!'라기엔 겹치는 은행이 많을 것 같네요.
그렇다면 해당 계좌번호체계를 정리해서 각 은행별 검증기를 만들고, 입력된 계좌번호를 각 검증기들이 확인하여 점수를 부여한다면, 높은 점수를 반환한 검증기의 은행이 맞을 확률이 높지 않을까요?
해당 논리를 기반으로 다이어그램을 간단히 그려보겠습니다.

해당 알고리즘을 토대로, 서버나 ML의 의존성 없이 단순 코드만으로 기능을 구현해보겠습니다.

각 은행사의 패턴을 생성해주는 클래스를 통해 플러그인을 생성하고, 이를 detectAccountNumber라는 감지기에 여럿 넣어주어 작동하는 식으로 설계했습니다.
해당 아키텍처를 기반으로 한 플로우는 다음과 같습니다.
이제 해당 알고리즘과 아키텍처를 기반으로 함수를 구현해보겠습니다.
interface Detector {
/**
* 지원할 은행 종류입니다.
* ex) kdb, kakao, toss
*/
bank: string;
basicRuleList: Array<BasicRule>;
}
interface BasicRule {
/**
* 계좌 번호 패턴입니다. 과목코드의 위치를 기준으로 판별합니다.
* ex) YYYZZZZZZZZC인 경우, YYY를 기반으로 판단
*/
patternList: Array<string>;
/**
* 과목 코드입니다.
* from ~ to (YCodeRange) 입력 시 이에 해당하는 범위의 과목 코드를 허용합니다.
* ex) { from: 100, to: 104 } => 100 ~ 104
*/
yCodeList?: Array<string | YCodeRange>;
/**
* 각 은행 별로 계좌번호 형식에 따른 규칙을 설정합니다.
* ex) 토스뱅크는 일련번호의 첫자리가 토스머니는 8, 나머지는 0으로 고정된다.
* additionalRules: [(accountNumber) => accountNumber[3] === '8' || accountNumber[3] === '0'],
*/
exceptionalRuleList?: Array<(accountNumber: string) => boolean)>;
}
해당 코드를 기반으로 각 은행의 계좌 패턴을 입력받아 플러그인을 만들어주는 generateDetector 함수를 작성합니다.
// generateDetector.ts
export function generateDetector({
bank,
basicRuleList,
globalCustomRuleList,
}: Detector): (props: DetectorProps) => ScoreResult {
// 설계한 알고리즘 기반으로 점수 계산하는 로직
...
return { bank, bankCode, score };
}
generateDetector를 기반으로 아키텍처 설계에 따라 각 은행별로 플러그인을 생성합니다.
// 부산은행.ts
import { generateDetector } from '../src/generateDetector';
export const BNKDetectorPlugin = generateDetector({
bank: '부산은행',
basicRules: [
{
patterns: ['XXXYYYZZZZZC', 'ZYYYZZZZZZZZZC', 'YYYZZZZZZZZZC'],
yCodes: ['107', '108', '109', '121', '123', '124', '122', '103', '101', '127', '716', '112'],
},
],
});
이제 최종적으로, detectAccountNumber 함수에 해당 플러그인들을 주입하고, batch를 통해 플러그인 실행 후 높은 점수가 나온 은행을 반환하는 함수를 구현합니다.
const detectorList = Object.values(detectors);
export function detectAccountNumber({
accountNumber,
length,
additionalRuleScore,
}: DetectAccountNumberProps): Array<{ bank: string; code: string }> {
const results: Array<ScoreResult> = detectorList.map((detector) =>
detector({ accountNumber, additionalRuleScore }),
);
const sorted = [...results].sort((a, b) => b.score - a.score);
const banks = sorted.filter((result) => result.score !== 0);
return banks.map(({ bank, code }) => ({ bank, code })).slice(0, length);
}
마지막으로 해당 함수를 호출하여 사용해주면 복잡한 로직 없이 계좌번호를 기반으로 유추된 은행을 얻을 수 있습니다!
import { detectAccountNumber } from 'detectAccountNumber';
const result = detectAccountNumber({ accountNumber: '7777015828112' });
// [ { bank: '카카오뱅크', bankCode: '090' } ]
jest 테스트 코드를 기반으로 해당 함수의 예외 처리와, 은행 유추 정확도가 높은지 테스트해보겠습니다.
import detectAccountNumber from './detectAccountNumber';
const testAccountList = [
{ name: "suhyup", accountList: ... },
...
];
describe("detectAccountNumber", () => {
testAccountList.forEach(({name, accountList}) => {
test(`계좌번호를 입력했을 때 해당 은행을 포함한 배열을 반환한다 : ${name} (${accountList.length})`, () => {
accountList.forEach((accountNumber) => {
const result = detectAccountNumber(accountNumber, 2);
expect(result.includes(name)).toBe(true);
})
})
})
test("두 번째 인자에 값이 전달되었을 때 해당 값을 초과하지 않는 1개 이상의 배열을 반환한다", () => {
expect(detectAccountNumber("3021822612521", 1).length).toBeLessThanOrEqual(1);
expect(detectAccountNumber("100216268330", 2).length).toBeLessThanOrEqual(2);
expect(detectAccountNumber("98206928101011", 3).length).toBeLessThanOrEqual(3);
})
test("해당하는 은행을 찾지 못했을 때에는 빈 배열을 반환한다", () => {
expect(detectAccountNumber("475475475475").length).toBe(0)
})
test("잘못된 인자를 전달받았을 경우 빈 배열을 반환한다", () => {
expect(detectAccountNumber("1234").length).toBe(0);
expect(detectAccountNumber("ABCDEFGHIJKLMN").length).toBe(0);
expect(detectAccountNumber("ㄱㄴㄷㄹㅁㅂㅅㅇㅈㅊㅋㅍ").length).toBe(0);
expect(detectAccountNumber("999999999999999999").length).toBe(0);
expect(detectAccountNumber("!@#$%^&*()_+").length).toBe(0);
})
})

입력한 계좌번호의 은행에 맞게 결과가 반환됩니다.
그리 간단하지는 않지만, 무거운 서버나 머신러닝 등의 의존성 없이 간단 구현 코드만으로 유틸리티한 함수를 만들 수 있었습니다. 혹시나 저가의 비용으로 비슷한 기능을 구현해야 하는 요구사항이 생긴다면 금융결제원 CMS 계좌번호체계 기반 데이터로 시도해보시는 것을 추천드립니다.
개발에는 쉬운게 없네요ㅜㅜ 토스로 송금보낼 때는 간단해보였던 것이 막상 구현해보려하니...