은행·카드·투자를 한 화면에 모으는 자산관리 앱
인증서 스크래핑으로 은행·카드·투자를 한 화면에 모은 자산관리 앱
잔액 하나에 앱 네 개
개인자산관리(PFM) 서비스입니다. 인증서로 한 번에 스크래핑해 은행, 카드, 대출, 투자를 한 화면에 모아 보여 주는 것이 핵심이었어요. 이 조회 화면은 자사 앱뿐 아니라 제휴사 앱 안에서도 동일하게 떠야 했습니다. 2020년에는 맞춤 대출 비교를 붙였습니다. 여러 금융기관의 상품을 한자리에서 비교하고, 회원의 금융정보를 분석해 가심사가 아닌 확정 한도와 금리를 받아, 영업점에 가지 않고 신청까지 끝내는 흐름입니다.

인증서는 네이티브, 화면은 웹
조회 화면 전체를 네이티브가 아니라 웹뷰로 짜고, 빌드 결과를 S3에 올려 배포했습니다
제휴사 앱은 스토어에 올릴 수 없습니다. 화면이 네이티브였다면 문구 한 줄을 고칠 때마다 제휴사의 앱 심사 일정을 기다려야 했어요. .github/workflows/production.yml이 production 브랜치 푸시를 받아 s3://broccoli-inapp으로 바로 복사하도록 두어, 배포 주기를 직접 쥐었습니다 인증서 처리와 스크래핑 엔진은 네이티브에만 있어서, 웹은 그 기능을 브릿지로 호출만 할 수 있었습니다
대출 서비스의 서버는 Apollo Server(GraphQL)로 세우고, 진행 상태만 Subscription으로 밀었습니다
대출 조회는 사용자가 버튼을 누른 뒤 수십 초 동안 여러 금융사의 응답을 기다리는 일입니다. 화면이 초당 한 번씩 GET /status를 두드리는 대신, Context 문서가 바뀔 때만 WebSocket으로 밀어 주는 편이 서버에도 배터리에도 낫다고 봤어요 MongoDB change stream을 쓰려면 단일 노드라도 레플리카셋이어야 해서, 접속 URL에 ?replicaSet=rs를 박아 두고 운영했습니다
금융사 연동을 Agent 추상 클래스와 회사별 parser/maps/transforms 세 파일로 쪼갰습니다
금융사마다 전문 규격이 전부 달랐습니다. 어떤 곳은 AES CBC, 어떤 곳은 ECB, 어떤 곳은 OAuth 토큰을 먼저 받아야 했고요. 회사별 if 문을 서비스 로직에 흘리면 다섯 번째 회사에서 손을 못 댈 게 뻔했습니다 제휴사 목록이 계속 늘어나는 중이었습니다. 출시 시점 3곳에 추가 10곳 계약이 이미 잡혀 있었어요
금융사로 나가는 모든 호출을 사내 프록시(proxyUrl) 뒤로 보냈습니다
금융기관은 대개 출발지 IP를 화이트리스트로 잡습니다. 서비스 인스턴스를 늘릴 때마다 각 금융사에 IP 등록을 요청하는 건 배포를 막는 일이 됩니다. 나가는 IP를 프록시 한 곳으로 고정해 두면 스케일 아웃이 금융사 협의와 분리돼요
대출 상품 약관 화면을 코드가 아니라 어드민에서 편집하게 만들었습니다
상품 상세에 들어가는 한도·금리·중도상환수수료·고지사항은 준법감시인 심의를 통과한 문구 그대로여야 합니다. 이걸 프론트 상수로 두면 문구 한 줄 바뀔 때마다 배포가 필요했습니다 어드민은 사내용이라 최소한으로, Express + Mongoose CRUD 하나에 Heroku 배포로 끝냈습니다

한 번의 조회가 흩어지고 모이는 길
세 덩어리가 물려 돕니다.
첫째는 자산 조회입니다. 네이티브 앱이 웹뷰를 띄우고, 웹은 window.broccoliView.getToken()으로 토큰을 달라고 요청합니다. 네이티브가 window.setToken(token)을 호출해 돌려주면 그때부터 웹이 /asset/main, /expense/main 같은 자산 API를 직접 부릅니다. 스크래핑 자체는 웹이 못 하니 requestAllScraping(type)으로 부탁하고, 진행 상황은 네이티브가 window.onLoading('on')과 window.setCompaniesStatus(json)으로 되돌려 줍니다. 데이터는 서버에서, 명령과 상태는 브릿지로 오가는 구조예요.
둘째는 대출 비교입니다. 사용자가 직장·소득·자동차 정보를 넣고 조회를 누르면 scan 뮤테이션이 떨어지고, LoanManager가 활성화된 금융사 수만큼 Agent를 만들어 동시에 던집니다. 같은 시점에 최근 두 달 입출금 내역을 모아 스코어링 API로 보내고, 그 점수를 조회 요청에 함께 실어 승인 확률이 높은 사용자를 앞세웁니다.
셋째는 상태입니다. 사용자별로 Context 문서 하나가 idle → entering → scraping → scanned → presenting → applying → accepted 중 어디에 있는지를 들고 있고, 이 문서가 바뀔 때마다 change stream이 Subscription으로 화면을 밀어 줍니다. 앱을 껐다 켜도 /로 들어오면 ContextStatusMapper가 상태를 읽어 있던 화면으로 되돌려 놓습니다.

앱 심사를 기다리지 않고 제휴사 앱 안의 화면을 고치기
웹뷰에 URL만 띄우고 토큰은 쿼리스트링으로 넘기는 방법을 먼저 떠올리기 쉽습니다. 붙기는 붙어요. 그런데 토큰이 브라우저 히스토리와 서버 액세스 로그에 그대로 남고, 무엇보다 웹이 네이티브에게 무언가 시킬 방법이 없습니다. 인증서 연동, 스크래핑 재시도, 화면 종료 같은 동작은 결국 네이티브 화면으로 다시 만들게 되고, 그 순간 제휴사 앱 심사에 다시 묶입니다.
양방향 브릿지를 규약으로 못 박았습니다. 웹이 네이티브를 부를 때는 Android는 window.broccoliView.*, iOS는 window.webkit.messageHandlers.broccoliView.postMessage({ method, param })로 통일하고, 네이티브가 웹을 부를 때는 window.setToken, window.onRefresh, window.onLoading, window.setCompaniesStatus 네 개를 웹이 마운트 시점에 심어 둡니다. 그리고 개발 환경용 가짜 브릿지를 따로 뒀습니다. REACT_APP_ENV=development면 window.broccoliWebview와 window.webkit을 직접 만들어 토큰을 3초 뒤에 흘려 주고, close()는 /_native로, startScraping()은 /scan/status로 라우팅합니다.
화면이 전부 웹이므로 문구 수정이든 레이아웃 변경이든 S3에 올리면 끝입니다. 제휴사 앱은 다시 심사받지 않아요. 브릿지 표면이 함수 이름 스무 개 남짓으로 고정돼 있으니 네이티브 팀과의 계약도 명확했고, 가짜 브릿지 덕분에 시뮬레이터 없이 브라우저에서 개발할 수 있었습니다.
다섯 금융사에 동시에 물었을 때, 언제 '조회가 끝났다'고 말할 것인가
Promise.all로 모든 금융사 응답을 모아 한 번에 화면에 뿌리는 방법이 가장 자연스러워 보입니다. 하지만 이건 두 군데서 무너집니다. 금융사 한 곳이 느리면 전체가 그 속도에 묶이고, 한 곳이 죽으면 나머지 네 곳의 멀쩡한 결과까지 사라집니다. 게다가 어떤 금융사는 요청에 곧바로 답하지 않고 나중에 우리 서버의 콜백 URL로 결과를 되쏩니다. 이 방식은 애초에 하나의 Promise 체인에 들어오지도 않아요.
응답을 기다리는 대신 이벤트로 받았습니다. LoanManager.scan()은 활성 금융사 수를 ScanRequest.requestCount에 먼저 적어 두고, 각 Agent를 await 없이 던집니다. 동기로 답하는 곳은 자기 응답을 파싱해서, 콜백으로 답하는 곳은 POST /api/v1/loans/event가 들어온 시점에, 똑같이 loans/manager/scan 이벤트를 emit 합니다. 결과가 하나 도착할 때마다 ScannedCompany를 한 건 쌓고, 그 개수가 requestCount에 닿으면 그때 Context.status를 scanned로 올립니다. 동시에 scan() 안에서 30초짜리 SCAN_TIMEOUT 타이머를 걸어 두어, 끝내 답이 없는 곳이 있어도 상태는 반드시 앞으로 갑니다.
빠른 금융사의 결과가 느린 곳에 발목 잡히지 않고, 실패한 곳은 status: failed인 한 건으로 계수되어 완료 판정을 막지 않습니다. 완료 조건이 '전부 도착' 하나가 아니라 '카운트 충족 또는 타임아웃' 둘이라, 어느 쪽으로든 화면은 멈추지 않아요. 상태가 scanned로 올라가면 change stream이 Subscription으로 화면을 밀고, 사용자가 이미 화면을 숨겼다면 같은 자리에서 푸시를 보냅니다. setScanned가 status !== 'scanning'이면 조용히 빠져나가기 때문에 카운트와 타임아웃이 겹쳐 들어와도 두 번 처리되지 않습니다.
유저가 할 수 있는 일
테스트 대신 로그로 버틴 1년
솔직히 말하면 테스트 코드가 한 줄도 없습니다. 금융사 규격이 협의 중에 계속 바뀌었고, 샌드박스 응답과 운영 응답이 달랐고, 그래서 붙잡은 건 테스트가 아니라 로그였어요. 요청과 응답을 AgentLog에 남기되 주민등록번호·이름·CI·전화번호는 '*'로 가려서 저장하는 식으로요. 덕분에 장애 원인은 대체로 찾았지만, 규격 변경을 배포 전에 잡아 주는 안전망은 끝까지 없었습니다. 다시 만든다면 금융사별 parser에 대한 계약 테스트부터 깔겠습니다. 응답 픽스처만 있으면 되는 일이었는데 미뤘어요.
둘째로, 완료 판정을 ScannedCompany 개수로 세는 방식은 같은 금융사가 결과를 두 번 보내면 어긋납니다. 지금은 도착 건마다 한 행을 쌓고 scanId 기준으로 세기 때문에, 중복이 오면 실제보다 빨리 scanned가 됩니다. scanId + companyName에 유니크 인덱스를 걸었어야 했습니다.
셋째로, Context 하나에 스크래핑·조회·신청 상태를 전부 얹은 탓에 사용자당 대출 신청을 동시에 하나밖에 못 합니다. 당시 기획상 그랬으니 맞는 선택이었지만, 여러 상품을 병렬로 신청하는 그림으로 가려면 Application을 상태의 주인으로 올리는 재설계가 필요합니다.













