개발 slecs

송금 URL로 은행 자동 판별하는 구조 짜기

목차

스마트폰 연락처 송금 흐름은 생각보다 진입점이 좁다. 사용자가 앱에서 송금 버튼을 누르는 순간 OS가 은행 앱을 직접 띄우거나, 공유 인텐트로 URL을 던지거나 하는 식이라 중간에 끼어들 자리가 거의 없다. 우리가 목표한 건 그 "거의 없는" 자리 중 하나였다. 사용자가 송금 직전 URL을 캡처해서 넘겨주면, 그 문자열을 받아 어느 은행인지 판별하고 이후 흐름을 이어가는 구조.

처음 요건을 들었을 때는 URL 파싱이라 금방 끝날 거라 봤다. 틀렸다.

은행마다 URL이 다르다는 게 얼마나 다른 건지

URL 포맷이 제각각이라는 말은 알고 있었는데, 실제 캡처 샘플을 모아서 펼쳐놓으니 결이 완전히 달랐다. 어떤 은행은 https://pay.bank-a.co.kr/transfer?to=... 같은 표준 HTTP URL이고, 어떤 건 bankb://transfer/... 같은 앱 전용 커스텀 스킴이고, 또 어떤 건 universallink 도메인 뒤에 query에 은행 코드를 따로 박아놓은 형태다. 같은 은행이어도 앱 버전에 따라 path 구조가 바뀐 케이스도 있었다.

결국 식별 포인트를 세 갈래로 정리했다.

시그니처 식별 위치 신뢰도
호스트 도메인 scheme + host 높음
path 접두 path 첫 segment 중간
쿼리 파라미터 키 query string 낮음

host 기반이 가장 신뢰도가 높다. 도메인은 은행이 바꾸기 어렵고, 바꾸면 어차피 레거시 URL이 죽으니까 탐지 실패가 명확하게 드러난다. path prefix는 host가 CDN이나 universallink 공용 도메인으로 추상화된 경우를 커버한다. query 시그니처는 마지막 보루인데, 실제로 파라미터 이름이 같은데 의미가 다른 은행이 둘 있어서 이것만 믿었으면 오판할 뻔했다. host 우선순위를 먼저 깔아두길 잘했다.

매칭 순서는 이 사다리 구조를 그대로 코드로 옮겼다.

1) host 매칭    → 가장 신뢰도 높음, 여기서 확정되면 이후 체크 없음
2) path prefix  → host가 공용 도메인이거나 변경된 경우 대비
3) query 시그니처 → 위 두 개 모두 미스일 때 마지막 시도
4) 모두 미스    → UNKNOWN 반환, 상위 레이어에서 처리

각 은행은 enum으로 묶고, 각 enum 값에 매칭 규칙 객체를 붙였다. 새 은행이 추가되면 enum 값 하나 추가하고 규칙 객체 채우면 끝나는 구조. 기존 코드는 안 건드린다.

컨트롤러는 얇게, 유틸에 다 때려넣기

URL 수신 엔드포인트 자체는 문자열 하나 받아서 유틸 함수 하나 호출하고, 판별 결과와 정규화된 식별자만 돌려주게 했다. 컨트롤러 안에 if (url.includes("bank-a")) 같은 분기는 한 줄도 없다.

  • 컨트롤러: 입출력 직렬화, 에러 포맷팅만
  • 유틸: URL 정규화, 은행 매칭, 결과 객체 생성까지 전담
  • 테스트: 유틸만 단위 테스트로 케이스 쌓으면 됨

이렇게 갈라두면 나중에 결제대행사에서 새 URL 패턴을 던져줘도 컨트롤러는 손 안 대고 유틸 안에서만 작업이 끝난다. 컨트롤러가 무거워지는 순간 테스트 작성도 번거로워지고, 나중에 다른 사람이 읽을 때 "왜 여기 비즈니스 로직이?" 하는 의문도 생긴다. 분리 비용은 처음에만 발생하고, 유지보수 이득은 계속 쌓인다.

구조를 개략적으로 그리면 이런 형태다.

POST /transfer/detect
  └─ TransferController.detect(rawUrl: string)
       └─ BankDetectorUtil.detect(rawUrl)
            ├─ normalizeUrl(rawUrl)   // decode, 커스텀 스킴 핸들링
            ├─ matchByHost(parsed)
            ├─ matchByPath(parsed)
            ├─ matchByQuery(parsed)
            └─ DetectResult { bank: BankEnum, normalizedId: string }

삽질 포인트 셋

커스텀 스킴 파싱. 일부 URL이 https://가 아니라 앱 전용 스킴으로 들어왔다. 표준 URI 파서에 넣으면 throw하는 케이스가 있어서 try-catch로 감싸고, 파싱 실패 시 raw string 기반 fallback 매칭을 따로 뒀다. fallback은 단순 startsWith 수준인데, 커스텀 스킴 URL은 host 구조가 표준과 달라서 어차피 정규화가 불가능한 경우가 많다. 억지로 파싱하려 하기보다 fallback 경로를 명확히 두는 게 나았다.

URL 인코딩 혼재. 동일 은행인데 한 쪽은 query value가 인코딩된 채로, 다른 쪽은 디코딩된 채로 들어왔다. 매칭 전에 decodeURIComponent 한 번 거치고 재매칭하도록 추가했다. 처음에 이 단계를 빠뜨렸더니 샘플의 일부가 UNKNOWN으로 떨어졌는데, 같은 은행인 게 육안으로 보여서 뭔가 이상하다 싶었다. 인코딩 차이를 확인하는 데 시간이 좀 걸렸다.

파라미터 충돌. 앞서 말한 것처럼 쿼리 파라미터 이름이 같은데 다른 은행인 케이스가 있었다. query 시그니처만 봤으면 오판. host 우선순위로 먼저 분기해놓은 덕분에 이 케이스는 host 단계에서 이미 걸러졌다. 매칭 우선순위 설계가 단순한 것 같아도 엣지 케이스를 얼마나 흡수하느냐를 결정한다는 게 실감됐다.

회고

작업 시간의 절반 이상이 실데이터 수집이었다. "캡처 샘플 모아주세요"라고 요청하고 기다리는 시간, 들어온 샘플 분류하는 시간. 코드 자체는 그다음 단계 정리 작업이었다. 외부 입력을 다루는 작업은 항상 이 순서다. 코드부터 짜면 샘플 받고 나서 갈아엎게 된다.

판별 로직을 처음엔 정규식 하나로 퉁치려 했다. 샘플이 쌓이면서 포기했다. 대신 host → path → query로 내려가는 단조로운 사다리가 됐는데, 이게 디버깅할 때 훨씬 편했다. "이 URL이 왜 UNKNOWN이야?"라는 질문에 사다리 구조는 "1번에서 미스, 2번에서 미스, 3번에서 미스"로 추적이 선형이지만, 정규식 한 방은 왜 안 걸렸는지 역추적이 지저분해진다. 복잡성을 구조 안에 숨기는 것보다 단계를 명시적으로 드러내는 게 나을 때가 있다.

새 은행 대응은 이제 enum 값 하나에 규칙 객체 채우고 테스트 케이스 몇 줄 추가하면 끝난다. 처음에 구조 잡는 데 조금 더 쓴 시간이 앞으로 계속 이자로 돌아올 것 같다.

댓글 0

첫 댓글 달아줘.