// 기술 블로그
새 은행 추가가 INSERT 한 줄인 이유 — ProviderType enum 없이 설계하기
한국 금융기관 78곳을 enum 없이 다룹니다. 새 기관 추가가 코드 배포가 아니라 DB 한 줄이 되는 메타데이터 드리븐 설계와, 그 대가로 우리가 치른 비용을 적습니다.
한국 금융기관을 코드로 다뤄 본 사람은 한 번쯤 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_type과 action_service.execution_order라는 데이터입니다. 새 기관은 자기가 무엇을 지원하는지를 행으로 선언할 뿐, 실행 경로는 기존 것을 그대로 탑니다.
새 은행 추가가 왜 한 줄인가
기존에 다루는 채널(예: 기업뱅킹 거래내역)을 그대로 쓰는 기관이라면, 추가는 시드 INSERT입니다.
-- 새 은행 등록 (개념 예시 — 실제 컬럼은 마이그레이션 기준)
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 대신, 데이터가 전략을 고릅니다.
// 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로 필드에 바로 닿습니다.
sealed interface ContractRecord { val schemaId: String }
// BankTransactionV1Record, TaxInvoiceSalesV1Record, CashReceiptSalesV1Record ... (7 variants)기관이 늘어도 이 두 조각은 그대로입니다. 늘어나는 건 테이블의 행입니다.
실제로 무엇이 일어나나
- 코어·인프라·앱 모듈은 16개로 고정돼 있고, 기관 수와 무관하게 유지됩니다.
- 기존 채널을 쓰는 신규 기관 온보딩은 시드 마이그레이션 한 건으로 끝납니다. 코어 회귀 테스트 영향 0입니다.
- 데이터 모양은
ContractRecord7 variant로 수렴합니다. 외부 계약 표면이 기관 수가 아니라 데이터 종류 수에 비례합니다 — 이게 가장 큰 절감입니다. - 새 모양이 필요할 때만 코드가 늘어납니다. 그 빈도는 기관 추가보다 훨씬 낮습니다.
우리가 틀렸던 것
- 컴파일 타임 안전을 잃었습니다. provider 코드 오타나 잘못된
handler_type은 컴파일러가 안 잡습니다. 시드 검증과 부트 시점 정합성 체크로 메웁니다. 공짜가 아닙니다. - "INSERT 한 줄"은 절반만 맞습니다. 새 응답 구조가 오면 variant·Parser·Mapper가 따라옵니다. 초기에 이 경계를 흐리게 말해 기대치가 어긋난 적이 있습니다. 그래서 지금은 [한 schema = 한 Record 셰이프] 원칙을 못 박았습니다 — variant가 필요하면 그건 스키마를 쪼개라는 신호입니다.
- 마이그레이션 규율이 빡빡해졌습니다. 데이터가 동작을 정하니, 한 번 적용된 Flyway 파일은 불변이고 변경은 항상 새 forward-only 파일이어야 합니다. 시드 한 줄 실수가 런타임 동작을 바꿉니다. 코드 리뷰만큼 시드 리뷰가 중요해졌습니다.
- JSONB의 무른 부분. 요청 필드 메타를 유연하게 두려고 JSONB를 쓴 자리들이 있고, 이건 스키마 강제력이 약합니다. 도입부에서
?: emptyList()류 방어를 어댑터에 가둬 흡수합니다.
다시 한다면 메타데이터 경계와 코드 경계를 첫 문서에서부터 분명히 적었을 것입니다. 설계 자체보다 그 경계를 말로 흐린 게 실수였습니다.
그래서
비슷한 문제(다수의 이질적 외부 연동을 하나의 표준으로)를 푼다면 먼저 이걸 보면 됩니다.
- 분기의 축이 "연동 대상"인가 "데이터 모양"인가. 대상이 계속 늘고 모양은 수렴한다면 메타데이터 드리븐이 맞습니다.
- 잃는 컴파일 타임 보장을 어디서 메울지(시드 검증·부트 정합성 체크)를 설계와 같이 정합니다. 나중에 붙이면 늦습니다.
- 데이터-주도 설계의 일반 논의(예: 마틴 파울러의 "데이터 주도" 패턴 글, PostgreSQL JSONB 문서)는 트레이드오프를 미리 보는 데 도움이 됩니다.
한계도 분명합니다. 진짜 새로운 데이터 모양은 여전히 코드를 부릅니다. 우리가 자동화한 것은 "기관의 증가"지 "데이터 다양성의 증가"가 아닙니다.
더 읽기
- 이 설계가 사용자에게 어떻게 보이는지: Claude Code에서 5분 만에 거래내역 받아오기
- 표준 스키마 위에서 도는 업무 자동화: 은행 입금과 매출 세금계산서, 매월 사람 손 없이 대사
- 계약 스키마와 응답 형식: API 레퍼런스