
카드 내역으로 또래 연봉·소비를 비교하는 웹뷰
앱 심사 없이 붙였다 뗄 수 있는 또래 연봉·소비 비교 웹뷰
심사 없이 붙였다 떼는 화면
이미 운영 중이던 모바일 앱에 화면 하나를 새로 붙이는 작업이었습니다. 앱은 사용자의 카드 청구서 데이터를 이미 가지고 있었고, 새 화면은 그 데이터를 또래 집단과 비교해 얼마나 벌고 얼마나 쓰는지 보여주는 통계 화면입니다.
요구사항은 세 가지였습니다. 첫째, 앱에 붙였다 뗄 수 있는 이벤트성 화면일 것. 둘째, 그 화면에서 산업군과 직종을 한 번 입력받을 것(/v2/users/occupations에 patch합니다). 셋째, "직업별 연봉 비교" 후속 기능의 오픈 알림 수신 동의를 함께 받을 것.
그래서 이 화면은 통계 콘텐츠인 동시에 직종 데이터를 받는 자리이자 알림 동의를 받는 자리입니다. 화면은 하나뿐이지만 그 하나에 들어가는 요청은 일곱 개입니다.

토큰만 네이티브, 나머지는 웹
네이티브 화면 대신 웹뷰에 얹는 CRA 단일 페이지 앱
이벤트 문구나 기준연도를 바꿀 때마다 앱 심사를 다시 받을 수는 없었습니다. 정적 번들을 S3에 올리는 것만으로 iOS와 Android에 동시에 반영되는 쪽을 골랐어요. 대신 웹에는 로그인 세션이 없습니다. 인증을 어떻게 넘겨받을지가 곧바로 다음 문제가 됐습니다.
평균·표준편차와 실수령액 계산만 별도 Cloud Functions(/stat, /take-home-pay)로 분리
2019년 집계 스냅샷을 서빙하고 4대보험·소득세를 역산하는 일은 본 API 서버가 할 일이 아니었습니다. 앱 백엔드의 릴리스 주기에 얹기엔 이벤트 일정이 너무 짧았고요. 본 API(REACT_APP_API_HOST)와 통계 함수(REACT_APP_FUNCTION_HOST)가 서로 다른 오리진이라, 프런트가 두 호스트를 동시에 들고 있어야 했습니다.
백분위는 서버가 아니라 클라이언트에서 jStat 정규분포 CDF로 계산
서버는 성별·연령대별 평균과 표준편차만 주면 됩니다. 사용자가 나이나 성별 셀렉트를 만지작거릴 때마다 백분위를 다시 받아오는 왕복이 사라져서, 값이 즉시 따라 움직여요. 사용자가 입력한 연봉 원본을 통계 서버로 보내고 싶지 않았습니다. 민감한 값은 브라우저 밖으로 나가지 않습니다.
styled-components와 MIXIN.autoSize(px)로 모든 치수를 vw로 환산
375pt 시안 위에서 그려진 디자인을, 폭이 제각각인 웹뷰에서 비율 그대로 늘리고 싶었습니다. (px * 4) / 15로 나눈 vw 한 줄이 미디어쿼리 뭉치를 대체했어요. 웹뷰 높이는 네이티브가 정하고 폭만 기기를 따라가는 구조였습니다.
GitHub Actions에서 env-cmd로 환경을 갈라 빌드하고 S3에 그대로 복사
release 생성은 스테이징 버킷으로, production 브랜치 push는 프로덕션 버킷으로 갑니다. 배포 파이프라인이 aws s3 cp --recursive 한 줄이면 끝나는 게 이 규모에 맞았습니다. Adjust 이벤트 토큰처럼 환경마다 값이 다른 것들은 getEventToken에서 REACT_APP_ENV로 갈라 줬습니다.

정적 번들 하나에 백엔드 세 갈래
구조는 단순합니다. S3에 올라간 정적 번들 하나가 네이티브 웹뷰 안에서 열리고, 그 안에서 세 갈래로 요청을 뿌립니다.
먼저 인증입니다. 웹은 토큰을 모르는 상태로 뜨기 때문에, 부팅하자마자 window.ee(EventEmitter)와 window.setToken을 전역에 심어두고 네이티브에 토큰을 달라고 요청합니다. 토큰이 도착하기 전까지 화면은 <Loading /> 하나뿐이에요.
토큰이 들어오면 세 종류의 서버와 이야기합니다. 사용자 프로필 API(ddr)에서 생년월일과 성별을 받아 연령대를 만들고, 통계용 Cloud Functions에서 그 연령대·성별의 평균과 표준편차를 받아옵니다. 카드 청구 API(athena)에서는 청구서 목록과 카드사별 상세를 받아 최근 2개월 평균 소비를 만듭니다. 여기까지가 화면 왼쪽 열(평균)과 오른쪽 열(나)입니다.
비교 자체는 서버로 가지 않습니다. getPercentile이 jStat의 정규분포 CDF로 상위 몇 퍼센트인지를 브라우저에서 계산하고, getScaleText가 "상위 23%, 평균보다 카드소비금액이 12만원 낮습니다" 같은 문장을 만들어 붙입니다.
제출 시점에는 반대 방향으로도 흐릅니다. 산업군·직종은 본 API에 patch되고, Braze 이벤트와 Adjust 이벤트 토큰은 웹이 직접 쏘는 게 아니라 브리지를 통해 네이티브 SDK로 넘어갑니다. 웹에서 터진 예외는 ErrorBoundary가 잡아 Sentry로 보내고, 사용자에게는 "다시 시작하기" 버튼 하나만 보여줍니다.

토큰이 늦게 오는 화면을, 폴링 없이 깨우기
웹뷰에 토큰을 넘기는 가장 손쉬운 방법은 쿼리스트링입니다. ?token=...으로 열어주면 프런트는 아무것도 안 해도 되죠. 그다음으로 흔한 건 네이티브가 localStorage에 값을 심어줄 때까지 setInterval로 들여다보는 폴링입니다. 전자는 토큰이 URL과 웹뷰 히스토리, 리퍼러에 그대로 남고, 후자는 언제 깨어날지 모르는 화면을 만듭니다.
방향을 뒤집었습니다. 앱이 뜨자마자 Bridge.initEventEmitter()와 Bridge.initSetToken()으로 window.ee와 window.setToken을 먼저 심어두고, 그다음에 Bridge.getToken()으로 네이티브를 호출합니다. 네이티브는 준비되는 즉시 window.setToken(token)을 부르고, 그 안에서 localStorage에 저장한 뒤 emitEvent('setToken')이 터집니다. React는 이 이벤트를 리스너로 받아 state를 갱신하고, 그때 비로소 본 화면이 렌더됩니다. 토큰이 없는 동안은 if (!token) return <Loading /> 한 줄이 전부고, 언마운트될 때는 리스너와 localStorage의 토큰을 같이 지웁니다. Android는 window.broccoliWebview.*, iOS는 window.webkit.messageHandlers.broccoliWebview.postMessage로 갈리는데 이 분기는 getAgent() 안에만 있습니다.
핵심은 "등록이 요청보다 먼저"라는 순서입니다. 네이티브가 얼마나 빨리 응답하든(iOS 목 브리지는 일부러 4초를 기다리게 해뒀어요) 콜백은 이미 전역에 있으니 이벤트를 놓치지 않습니다. 폴링 간격만큼의 지연도, 토큰이 URL에 남는 문제도 사라지고요. 개발 환경에서는 같은 인터페이스를 initBridge()가 통째로 목으로 갈아끼우기 때문에, 앱을 빌드하지 않고 브라우저에서 그대로 화면을 만들 수 있었습니다.
직전 한 달만으로는 소비를 말할 수 없어서
청구서 목록 API(/asset/card/bill/list)는 oldList를 돌려줍니다. 여기 담긴 payAmount를 전부 더하면 "내 카드소비"가 나오니, 그걸 그대로 평균과 비교하고 싶어집니다. 그런데 이 목록은 직전 달 한 번의 청구일 뿐이라, 명절이나 여행이 낀 달이면 사용자는 자기 소비가 부풀려진 숫자를 보게 됩니다. 반대로 이전 달까지 챙기려고 카드사별 상세를 for 루프 안에서 순차로 await 하면, 카드 네 장인 사용자는 네 배의 대기를 그대로 떠안습니다.
getMonthlyCardBill에서 목록의 각 항목마다 companyCode와 payDate를 꺼내, YYYYMMDD 문자열을 $1-$2-$3으로 되돌린 뒤 moment로 한 달을 빼서 상세 조회 바디를 만듭니다. 그렇게 만든 요청들을 map으로 펼쳐 Promise.all에 통째로 넘기고, 돌아온 sum들을 접어 이전 달 합계를 얻습니다. 마지막으로 직전 달 합계와 더해 2로 나눠 최근 2개월 평균을 냅니다. 화면에는 그 값 아래에 "최근 2개월 청구금액 평균"이라는 근거 문구를 같이 박아뒀습니다.
목록 API가 직전 달만 주기 때문에 이전 달은 카드사별 상세로만 얻을 수 있는데, 이 상세 호출들은 서로 의존이 없습니다. 그래서 병렬로 던지면 카드가 몇 장이든 체감 대기는 가장 느린 한 번으로 수렴합니다. 두 달을 평균 내는 것만으로 한 달짜리 변동이 절반으로 눌리고, 사용자가 "이번 달은 원래 이 정도 안 써요"라고 의심할 여지도 줄어듭니다. 값이 도착하기 전까지 오른쪽 열은 스켈레톤을 유지하니, 0원이 잠깐 보였다가 바뀌는 일도 없습니다.
유저가 할 수 있는 일
두 달 평균의 대가, 없는 테스트
솔직하게 말하면, 이 화면은 시점에 묶여 있습니다. getMonthlyCardBill 첫 줄의 /** 현재 2월 */이라는 주석과 "oldList는 언제나 직전 달"이라는 가정이 그 증거예요. 기준 통계도 2019년 스냅샷이라 해가 바뀌면 문구부터 손봐야 합니다.
에러 처리는 더 아쉽습니다. Main에서 일곱 개 요청의 에러를 demoErr || notiIErr || ...로 한데 묶어 throw 하기 때문에, 카드 상세 하나가 실패해도 ErrorBoundary가 화면 전체를 "앗! @_@"로 덮습니다. Promise.all도 마찬가지라 카드사 한 곳이 죽으면 두 달 평균이 통째로 사라지죠. 다시 만든다면 Promise.allSettled로 부분 합을 살리고, 요청별로 실패 범위를 화면 조각 단위로 가두겠습니다. 카드 소비 열만 조용히 비워두고 나머지는 보여주는 편이 사용자에게 훨씬 낫습니다.
토큰을 localStorage에 두는 것도 언마운트 때 지우는 것으로 덮었을 뿐, 지금이라면 메모리 안에만 두겠습니다. 테스트는 한 줄도 없습니다. 화면이 하나고 일정이 짧다는 이유였지만, getPercentile이나 getMonthlyPay처럼 순수 함수로 떼어둔 계산들은 테스트를 붙이기 가장 쉬운 자리였는데도 그러지 않았어요. 라우터가 없어 화면 상태가 전부 submitted 같은 로컬스토리지 플래그에 얹혀 있는 것도, QA가 특정 상태를 재현하기 어렵게 만들었습니다.










