Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/Web Development/EN

Year-End Tax Refund Estimator from Card Data
Year-End Tax Refund Estimator from Card Data

카드 내역 기반 연말정산 환급액 계산 웹뷰

연동된 카드 내역만으로 카드 소득공제 예상 환급액을 계산해 주는 앱 안의 연말정산 웹뷰

세 달짜리 시즌과 없는 총급여

연말정산 시즌에 맞춰 앱 안에 탭 하나를 새로 열었습니다. 이미 연동된 카드사와 홈택스 지출 내역으로 카드 소득공제 예상 환급액을 계산해 보여주고, 남은 기간에 어떻게 쓰면 더 돌려받는지까지 안내하는 화면입니다.

제약은 셋이었습니다.

첫째, 기간이 정해져 있습니다. 12월부터 이듬해 2월까지가 전부인데 문구와 계산 기준은 그 사이에도 바뀝니다. 앱 심사와 강제 업데이트 없이 바꿀 수 있어야 했어요. 둘째, 계산에 반드시 필요한 총급여를 앱이 모릅니다. 어디에도 없는 값이라 사용자가 직접 넣어야 했고, 그러려면 숫자 키패드와 저장, 저장 후 화면 갱신까지 웹이 책임져야 했습니다. 셋째, 연동 상태가 사람마다 다릅니다. 카드사만 연동한 사람, 홈택스만 연동한 사람, 아무것도 연동하지 않은 사람이 전부 같은 주소로 들어옵니다. URL은 하나인데 보여야 할 화면은 전부 달랐어요.

이 글에서는 시즌에 맞춘 배포 구조와 없는 과거를 계산으로 되살린 방식, 이 둘만 다룹니다. 번들 캐싱으로 첫 화면을 당긴 이야기, 네이티브 모달과 딥링크로 앱과 주고받은 이야기, 인증 설계 이야기는 여기서 다루지 않습니다.

시스템 경계와 외부 의존
시스템 경계와 외부 의존

화면은 웹으로, 껍데기만 네이티브로

화면 전체를 웹뷰로 그리고, 껍데기와 모달과 인증만 네이티브 브릿지에 맡겼다

세법 문구와 카피가 시즌 한복판에서 바뀌는데, 그때마다 앱 배포를 태울 수는 없었습니다. 웹으로 두면 master 머지가 곧 배포라서 오전에 고친 문구를 오후에 내보낼 수 있었어요. 대신 앱 버전마다 지원하는 브릿지가 달랐습니다. 총급여 저장 뒤 대시보드를 다시 그리는 REFRESH_DASHBOARD 는 Android 3.11.0, iOS 3.12.0 이상에서만 존재해서, semver 의 gte 로 버전을 재고 아니면 웹에서 직접 refetch 하도록 갈랐습니다.

정적 배포 대신 Express와 pug 로 HTML 한 겹을 서버에서 그렸다

네이티브가 넘겨주는 토큰을 받을 자리가 필요했기 때문입니다. 순수 정적 페이지는 요청 헤더를 읽을 수 없어요. supportedWebView 미들웨어가 banksalad-access-token 헤더를 읽어 템플릿에 넣고, 그 값이 sessionStorage 로 들어갑니다. 이 한 겹 때문에 Docker 이미지와 k8s Deployment 가 필요해졌고, HTML 은 Cache-Control: private, no-cache, no-store 로 못 박아야 했습니다. 사용자별 토큰이 박힌 HTML 이 캐시되면 그대로 사고니까요.

화면 분기를 라우트가 아니라 서버가 준 refund_status 여섯 값과 총급여 유무로 결정했다

같은 /tax 인데 사람마다 다른 화면이 떠야 했습니다. 라우트를 늘리면 네이티브 딥링크와 진입 지점을 그만큼 늘려야 하죠. 대신 Entry 가 총급여를 먼저 물어보고 Main 과 Onboarding 을 고르게 했습니다. 조합이 금방 불어납니다. 그래서 프로덕션이 아닐 때만 붙는 가짜데이터 편집기를 만들어 여섯 상태를 손으로 갈아끼우며 확인했어요.

배포와 인프라 구성
배포와 인프라 구성

헤더로 들어온 토큰이 도는 길

요청 하나가 어떻게 도는지 따라가 보면 구조가 한눈에 들어옵니다. 사용자가 앱에서 연말정산 탭을 누르면, 네이티브가 webview.banksalad.com/tax 를 열면서 banksalad-access-token, banksalad-application-version, banksalad-application-name 세 헤더를 얹습니다. 앞단의 nginx 가 요청을 사이드카로 넘기고, 사이드카 뒤의 Express 가 supportedWebView 하나로 모든 /tax/* 를 받아 static/index.pug 를 렌더합니다. 이때 헤더 값이 인라인 스크립트로 들어가 sessionStorage 에 앉고, window.apiHost 와 window.namespaceEnv 도 함께 심깁니다.

핵심 데이터 모델
핵심 데이터 모델

2019년 세법을 클라이언트에 다시 심어 두 해를 나란히 세우기

리포트의 핵심 문장은 "2019년보다 32만원 더 환급받겠어요" 입니다. 이 숫자를 얻는 가장 쉬운 길은 서버에 2019년 환급액 API 를 하나 더 요청하는 것이죠. 아니면 작년에 계산해 둔 값을 어딘가 저장해 뒀다가 읽어 오거나요. 둘 다 막혔습니다. 2019년에는 이 기능 자체가 없어서 저장된 값이 없고, 2019년 카드 사용액도 스크래핑되어 있지 않았습니다. 없는 과거를 서버에 달라고 조를 수는 없었어요.

비교의 축을 바꿨습니다. 비교해야 하는 건 2019년의 나와 2020년의 나가 아니라, 같은 지출이 두 세법을 통과했을 때의 차이입니다. 2020년은 코로나 대응으로 카드 소득공제율이 한시적으로 올랐으니까요. 그래서 2019년 세법을 클라이언트에 그대로 이식했습니다. 근로소득공제와 기본인적공제, 국민연금, 건강보험, 장기요양보험, 고용보험을 각각 구간 함수로 만들고, 과세표준 속산표로 산출세액을 뽑은 뒤, 2019년 공제율(신용 15%, 체크와 현금 30%)과 총급여별 한도(300만, 250만, 200만)를 먹여 카드 소득공제 전후의 세액 차이를 냅니다. 전부 Ramda 의 R.cond 와 R.pipe 로 짜서 구간표가 코드에 그대로 드러나게 했고요.

2020년 실제 사용액을 2019년 규칙에 넣기 때문에 "공제율이 올라서 얼마나 더 받는가" 라는 문장이 정확히 성립합니다. 사용자가 두 해에 다르게 썼는지는 변수에서 빠지고, 세법 차이만 남죠. 표시할 때는 두 값을 모두 만원 단위로 내린 뒤 빼기 때문에(floorByTenThousand) 막대 그래프의 높이와 제목의 숫자가 어긋나지 않고, 차이가 1만원 미만이면 아예 "2019년과 환급액이 동일해요" 로 문구를 바꿉니다. 반올림 때문에 "0원 더 환급받겠어요" 같은 문장이 나오는 사고를 막는 장치예요.

로그인 화면 없이 웹뷰를 인증시키기

URL 쿼리스트링에 토큰을 붙이는 흔한 방법은 히스토리와 리퍼러, 액세스 로그에 금융 토큰을 그대로 흘립니다.

토큰을 URL 이 아니라 요청 헤더로 받아 인라인 스크립트로 sessionStorage 에만 앉히고, 같은 헤더로 받은 앱 버전으로 브릿지 지원 여부를 갈랐습니다.

주소창에 없으니 로그 어디에도 남지 않고, HTML 은 no-store 라 캐시되지 않으며, 웹뷰를 닫으면 토큰도 함께 사라집니다. 이 설계는 따로 자리를 잡아 풀겠습니다.

유저가 할 수 있는 일

앱에서 연말정산 탭을 열어 내 상태를 확인한다
앱에서 연말정산 탭을 열어 내 상태를 확인한다

연동한 게 없을 때 또래 평균 환급액을 보고 연동으로 넘어간다
연동한 게 없을 때 또래 평균 환급액을 보고 연동으로 넘어간다

숫자 키패드로 총급여를 입력해 저장한다
숫자 키패드로 총급여를 입력해 저장한다

해가 바뀌면 작년 총급여를 그대로 쓸지 고른다
해가 바뀌면 작년 총급여를 그대로 쓸지 고른다

홈에서 내 소비 상태에 맞는 안내 문구를 읽는다
홈에서 내 소비 상태에 맞는 안내 문구를 읽는다

대시보드에서 사용액과 카드 소득공제액을 뜯어본다
대시보드에서 사용액과 카드 소득공제액을 뜯어본다

물음표를 눌러 사용액과 공제액 설명을 읽는다
물음표를 눌러 사용액과 공제액 설명을 읽는다

2020 환급 리포트를 열어 작년과 올해를 비교한다
2020 환급 리포트를 열어 작년과 올해를 비교한다

연동하기를 눌러 앱의 자산 연동 화면으로 넘어간다
연동하기를 눌러 앱의 자산 연동 화면으로 넘어간다

연말정산 Tip 배너에서 카드와 연금 추천으로 넘어간다
연말정산 Tip 배너에서 카드와 연금 추천으로 넘어간다

화면이 실패했을 때 재시도하거나 오류 페이지로 떨어진다
화면이 실패했을 때 재시도하거나 오류 페이지로 떨어진다

1 / 1

코드에 박아 둔 세법과 뭉친 로딩

아쉬운 곳부터 말하면, 2019년 세법 상수가 코드에 그대로 박혀 있습니다. 2019-refund.ts 안에는 과세표준 구간과 보험료율, 공제 한도가 리터럴로 앉아 있어요. 그해 리포트를 띄우는 데는 충분했지만 이건 세법이 바뀌면 웹 배포를 해야 한다는 뜻이고, 다시 만든다면 이 계산은 서버로 올리고 웹은 결과만 그리게 했을 겁니다. 계산 자체가 서비스의 자산인데 클라이언트 번들 안에 있는 것도 이상하고요.

시간을 다루는 방식도 위태로웠습니다. currentTimestamp 가 모듈이 로드되는 순간의 Date.now() 로 고정되고, 2020년을 가리키는 값은 1609372800000 같은 상수로 박혀 있습니다. 앱을 켜 둔 채 자정을 넘기면 조회 연도가 어긋날 수 있어요. 연도를 도메인 값으로 다루지 않고 밀리초 숫자로 흘려보낸 대가입니다.

useFetch 는 화면에 필요한 호출을 Promise.all 로 묶습니다. 덕분에 로딩 스피너를 한 번만 그리면 되지만, 넷 중 하나만 실패해도 화면 전체가 에러로 넘어갑니다. 배너 하나가 죽었다고 환급액까지 못 보게 되는 건 과한 처사죠. 지금이라면 화면을 조각내고 실패한 조각만 에러를 띄우게 했을 겁니다.

테스트는 컨트롤러와 순수 함수에 몰려 있습니다. 세금 계산과 차트 변환은 msw 로 응답까지 물려 검증했지만, 뷰 테스트는 두 개뿐이라 상태별 카피가 제대로 붙는지는 결국 가짜데이터 스위치로 사람이 눈으로 확인했습니다. 시즌이 짧다는 이유로 미룬 것이고, 실제로 배포 직전 몇 건은 문구 수정 커밋으로 남아 있습니다.

Read next

카드 내역으로 또래 연봉·소비를 비교하는 웹뷰

Broccoli — 2020