headless
전체 블로그

// 기술 블로그

새 은행 추가가 INSERT 한 줄인 이유 — ProviderType enum 없이 설계하기

한국 금융기관 78곳을 enum 없이 다룹니다. 새 기관 추가가 코드 배포가 아니라 DB 한 줄이 되는 메타데이터 드리븐 설계와, 그 대가로 우리가 치른 비용을 적습니다.

·headless

한국 금융기관을 코드로 다뤄 본 사람은 한 번쯤 enum ProviderType { KB, SHINHAN, WOORI, ... } 를 적습니다. 그리고 기관이 늘 때마다 그 enum과, enum을 받는 when 분기 수십 곳을 같이 고칩니다. headless는 이 enum을 만들지 않기로 했습니다. 새 은행 추가가 코드 배포가 아니라 provider 테이블 INSERT 한 줄이 되는 설계와, 그 대가를 적습니다.

풀려고 한 문제

요구는 단순했습니다. 은행·홈택스·카드 데이터를 하나의 엔드포인트와 표준 스키마로 응답합니다. 문제는 그 뒤가 단순하지 않다는 점이었습니다.

  • 한국 금융기관은 78곳을 넘고, 인증 방식이 제각각입니다. 공동인증서, 아이디·비밀번호, 기관 전용 토큰이 섞입니다.
  • 같은 "거래내역"이라도 기관마다 응답 필드 이름과 구조가 다릅니다.
  • 기관은 계속 늘어납니다. 분기마다 새 은행·새 카드사 요청이 들어옵니다.

첫 단순한 해법은 ProviderType enum과 기관별 핸들러였습니다. 기관이 열 곳일 때는 버팁니다. 마흔 곳을 넘기면 enum 하나를 바꾸는 PR이 컴파일 의존성을 타고 수십 파일로 번집니다. 새 기관 = 코드 변경 = 배포 = 회귀 위험. 이 사슬을 끊는 게 목표였습니다.

우리가 고려한 옵션들

옵션장점단점안 고른 이유
A. ProviderType enum + 기관별 핸들러타입 안전, IDE 자동완성기관 추가가 코드·배포, 분기 폭발운영 비용이 기관 수에 선형으로 늘어남
B. 기관별 플러그인 JAR 동적 로딩코어와 격리빌드·배포·버전 관리가 기관 수만큼운영 표면이 너무 넓어짐
C. 메타데이터 테이블 + 단일 실행기 (선택)기관 추가가 DB 한 줄, 코어 불변컴파일 타임 보장 상실, 런타임 검증 부담

C를 골랐습니다. 타입 안전을 일부 포기하는 대신, 기관 증가가 코어를 건드리지 않는 쪽을 택했습니다.

선택한 설계

enum 자리에 테이블이 들어갑니다. "이 기관이 무엇을 할 수 있는가"가 코드가 아니라 데이터로 적힙니다.

provider                금융기관 레지스트리 (표시명, 기관코드)
credential_schema       기관 × 인증수단별 입력 필드 (동적 폼 생성)
action                  수집 액션 + handler_type
action_service          액션 내부 호출 단위 (실행 순서)
provider_action         기관 ↔ 액션 가용성 (조회 범위 제약)
contract_schema         외부 HTTP 계약 카탈로그 (schema_id)
contract_request_field  스키마별 요청 필드 (key/type/required/...)

흐름은 이렇습니다.

요청(provider, schema, 기간)


provider/action/provider_action 조회 → "이 기관이 이 액션을 지원하나, 조회 범위는"


UnifiedActionHandler — handler_type + action_service 실행순서로 전략 분기
   │  (SIMPLE / PREREQ_ITERATION / MULTI_STEP / BULK_DOWNLOAD)

Parser/Mapper → ContractRecord sealed variant 로 변환


정규화 데이터 저장 (bank_transaction / tax_invoice / cash_receipt ...)

핵심은 UnifiedActionHandler 하나가 모든 액션을 실행한다는 점입니다. 기관별 핸들러가 없습니다. 분기 기준이 코드의 when (providerType)이 아니라 action.handler_typeaction_service.execution_order라는 데이터입니다. 새 기관은 자기가 무엇을 지원하는지를 행으로 선언할 뿐, 실행 경로는 기존 것을 그대로 탑니다.

새 은행 추가가 왜 한 줄인가

기존에 다루는 채널(예: 기업뱅킹 거래내역)을 그대로 쓰는 기관이라면, 추가는 시드 INSERT입니다.

sql
-- 새 은행 등록 (개념 예시 — 실제 컬럼은 마이그레이션 기준)
INSERT INTO provider (id, org_cd, display_name, name_variants,
                      accepted_auth_methods, accepts_cert_login)
VALUES (:id, 'XXX', '○○은행', '["○○","○○은행"]',
        '["CERT","ID_PW"]', true);

INSERT INTO provider_action (provider_id, action_id, chunk_size_days, max_range_days)
VALUES (:id, :bankTxAction, 31, 93);

코어 코드는 컴파일되지 않습니다. when 분기도 늘지 않습니다. 그 기관의 응답 구조가 기존 채널과 같은 한, Parser/Mapper도 재사용합니다.

응답 구조가 새로우면 INSERT 한 줄로 안 끝납니다. 그때는 ContractRecord variant 하나와 그에 맞는 Parser/Mapper를 코드로 추가하고, contract_schema 시드를 넣습니다. 메타데이터가 지운 것은 "기관마다의 분기"지, "새로운 데이터 모양마다의 코드"가 아닙니다. 그 경계는 뒤에서 다시 짚습니다.

핵심 코드 발췌

분기의 단일 지점입니다. providerType을 받는 거대한 when 대신, 데이터가 전략을 고릅니다.

kotlin
// UnifiedActionHandler — 개념 발췌
fun handle(action: Action, services: List<ActionService>): List<ContractRecord> {
    val ordered = services.sortedBy { it.executionOrder }
    return when (action.handlerType) {
        SIMPLE           -> runSimple(ordered)
        PREREQ_ITERATION -> runWithPrerequisite(ordered) // 선행 호출 결과로 반복
        MULTI_STEP       -> runMultiStep(ordered)
        BULK_DOWNLOAD    -> runBulkDownload(ordered)
    }
}

응답은 sealed 계층 한 곳으로 모입니다. 호출자는 schemaId로 분기하고 smart-cast로 필드에 바로 닿습니다.

kotlin
sealed interface ContractRecord { val schemaId: String }
// BankTransactionV1Record, TaxInvoiceSalesV1Record, CashReceiptSalesV1Record ... (7 variants)

기관이 늘어도 이 두 조각은 그대로입니다. 늘어나는 건 테이블의 행입니다.

실제로 무엇이 일어나나

  • 코어·인프라·앱 모듈은 16개로 고정돼 있고, 기관 수와 무관하게 유지됩니다.
  • 기존 채널을 쓰는 신규 기관 온보딩은 시드 마이그레이션 한 건으로 끝납니다. 코어 회귀 테스트 영향 0입니다.
  • 데이터 모양은 ContractRecord 7 variant로 수렴합니다. 외부 계약 표면이 기관 수가 아니라 데이터 종류 수에 비례합니다 — 이게 가장 큰 절감입니다.
  • 새 모양이 필요할 때만 코드가 늘어납니다. 그 빈도는 기관 추가보다 훨씬 낮습니다.

우리가 틀렸던 것

  • 컴파일 타임 안전을 잃었습니다. provider 코드 오타나 잘못된 handler_type은 컴파일러가 안 잡습니다. 시드 검증과 부트 시점 정합성 체크로 메웁니다. 공짜가 아닙니다.
  • "INSERT 한 줄"은 절반만 맞습니다. 새 응답 구조가 오면 variant·Parser·Mapper가 따라옵니다. 초기에 이 경계를 흐리게 말해 기대치가 어긋난 적이 있습니다. 그래서 지금은 [한 schema = 한 Record 셰이프] 원칙을 못 박았습니다 — variant가 필요하면 그건 스키마를 쪼개라는 신호입니다.
  • 마이그레이션 규율이 빡빡해졌습니다. 데이터가 동작을 정하니, 한 번 적용된 Flyway 파일은 불변이고 변경은 항상 새 forward-only 파일이어야 합니다. 시드 한 줄 실수가 런타임 동작을 바꿉니다. 코드 리뷰만큼 시드 리뷰가 중요해졌습니다.
  • JSONB의 무른 부분. 요청 필드 메타를 유연하게 두려고 JSONB를 쓴 자리들이 있고, 이건 스키마 강제력이 약합니다. 도입부에서 ?: emptyList() 류 방어를 어댑터에 가둬 흡수합니다.

다시 한다면 메타데이터 경계와 코드 경계를 첫 문서에서부터 분명히 적었을 것입니다. 설계 자체보다 그 경계를 말로 흐린 게 실수였습니다.

그래서

비슷한 문제(다수의 이질적 외부 연동을 하나의 표준으로)를 푼다면 먼저 이걸 보면 됩니다.

  • 분기의 축이 "연동 대상"인가 "데이터 모양"인가. 대상이 계속 늘고 모양은 수렴한다면 메타데이터 드리븐이 맞습니다.
  • 잃는 컴파일 타임 보장을 어디서 메울지(시드 검증·부트 정합성 체크)를 설계와 같이 정합니다. 나중에 붙이면 늦습니다.
  • 데이터-주도 설계의 일반 논의(예: 마틴 파울러의 "데이터 주도" 패턴 글, PostgreSQL JSONB 문서)는 트레이드오프를 미리 보는 데 도움이 됩니다.

한계도 분명합니다. 진짜 새로운 데이터 모양은 여전히 코드를 부릅니다. 우리가 자동화한 것은 "기관의 증가"지 "데이터 다양성의 증가"가 아닙니다.

더 읽기