Skip to content

Interested in AI, automation, blockchain, web and apps

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

Work/App Development/EN

Subscription Discovery App from Card and Bank Data

카드·계좌를 연결해 구독 결제를 찾아 주는 앱

카드와 계좌를 연결하면 구독 결제를 대신 찾아 주는 앱

매주 바뀌는 화면을 심사 없이 고치기

카드나 계좌를 연결하면 지금 무엇을 구독 중인지 찾아 주는 앱입니다. 스크래핑과 서비스 카탈로그는 서버가 맡고, 저는 그 결과를 화면으로 보여 주는 앱 전체를 맡았습니다.

화면은 스무 개가 넘었습니다. 지원 금융기관 목록, 결과 화면 문구, 탐색 탭의 카테고리는 앱 심사를 다시 받지 않고도 바꿀 수 있어야 했어요.

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

웹뷰 한 장과 브리지 네 개

네이티브 셸은 WKWebView 한 장만 띄우고, 화면 스물두 개를 전부 CRA 기반 웹 SPA로 만들었습니다

S3에 빌드 산출물을 덮어쓰면 그날 바로 반영됩니다. 심사 대기가 사라지니 문구 하나 고치는 데 사흘을 쓰지 않아도 됐어요. 웹 개발자가 앱의 전 화면을 혼자 책임질 수 있다는 점도 컸습니다 카카오·Apple 로그인, 푸시 토큰, 하드웨어 뒤로가기처럼 웹이 못 하는 일은 남습니다. 그래서 브리지 함수를 네 개로 못 박고 그 이상 늘리지 않았습니다

Apollo Client의 split 링크로 HTTP와 WebSocket을 한 클라이언트 안에 묶었습니다

금융기관 스크래핑은 수십 초가 걸립니다. 폴링으로 isCompanySynced를 두드리면 요청 수는 늘고 응답은 여전히 늦어요. 서버가 끝났을 때 밀어주는 편이 정확하고 쌉니다 토큰이 두 경로 모두에 실려야 해서 authLink는 헤더로, wsLink는 connectionParams로 각각 localStorage의 accessToken을 읽습니다

모든 치수를 px가 아니라 MIXIN.autoSize(px)로 통과시켜 vw로 환산했습니다

(px * 4) / 15는 375pt 시안을 화면 폭 100%에 그대로 대응시키는 식입니다. 시안의 숫자를 그대로 옮겨 적으면 320pt 기기에서도 428pt 기기에서도 비율이 무너지지 않습니다 미디어 쿼리를 한 줄도 쓰지 않는 대신, 태블릿처럼 넓은 화면에서는 글자가 그대로 커집니다. 웹뷰 전용이라 감수했습니다

화면마다 index.js(container) / presenter.js / gql.js / style.js 네 파일로 쪼갰습니다

쿼리와 뷰가 같은 폴더에 붙어 있으면 화면 하나를 통째로 들어내거나 갈아 끼우기가 쉽습니다. 요구사항이 매주 바뀌는 프로젝트에서 이 경계가 제일 많이 값을 했어요 파일 수는 네 배가 됩니다. 대신 src/services에는 로그인 뮤테이션만 남기고 나머지는 화면 옆에 뒀습니다

태그를 밀면 GitHub Actions가 yarn build:stg 후 S3 버킷에 aws s3 cp --recursive 하는 것으로 배포를 끝냈습니다

정적 산출물뿐이라 서버가 필요 없습니다. 버전 태그가 곧 배포 트리거이고, 롤백은 이전 태그를 다시 미는 것으로 끝납니다 운영할 사람이 없었습니다. CI=false를 붙여 경고를 오류로 승격시키지 않는 것까지가 그때의 현실적인 타협이었어요

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

스크래핑 진행률을 따라 움직이는 화면

앱을 켜면 네이티브 셸은 웹 번들 URL을 열고 그 뒤로는 거의 아무 일도 하지 않습니다. 웹에서 네이티브로 가는 길은 window.webkit.messageHandlers.mygudokView.postMessage 하나뿐이고, 반대 방향은 셸이 window.onKakaoLoginComplete, window.onAppleLoginComplete, window.setPushToken 같은 전역 함수를 호출하는 방식입니다. 로그인이 끝나면 accessToken이 localStorage에 들어가고, 이후 모든 GraphQL 요청은 authLink가 Authorization 헤더를 붙여 보냅니다.

데이터의 축은 두 갈래입니다. 하나는 사용자의 실제 결제입니다. registerCompany로 금융기관 자격증명을 넘기면 서버가 스크래핑으로 거래 내역을 긁어 오고, 그 결과가 BankAccount/CardAccount 아래 UserService로 붙습니다. 카탈로그에 매칭된 것은 로고와 요금제가 달린 구독으로, 매칭 안 된 것은 "기타 정기 결제"로 갈라져 홈에 나옵니다. 다른 하나는 서비스 카탈로그입니다. allServices가 카테고리별 순위를, service(id)가 요금제와 의견을 담당하고, 검색은 서버를 다시 타지 않고 받아 둔 목록 위에서 hangul-js로 초성까지 걸러 냅니다.

그리고 이 둘을 가로지르는 세 번째 축이 있습니다. 스크래핑이 지금 돌고 있는지를 알려 주는 backgroundState입니다. 이건 화면의 상태가 아니라 앱의 상태여서, 라우터 최상단에 올려 두었습니다.

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

스크래핑 진행 상태를 화면이 아니라 라우터가 들고 있게 한 이유

연동 로딩 화면 안에서 setInterval로 isCompanySynced(code)를 몇 초마다 두드리는 방법이 제일 먼저 떠오릅니다. 실제로 동작도 합니다. 그런데 사용자가 로딩 중에 뒤로 가거나 홈으로 빠져나가는 순간 그 타이머는 컴포넌트와 함께 사라집니다. 스크래핑은 계속 돌고 있는데 앱은 그걸 모르는 상태가 되고, 다시 들어와도 진행률을 복원할 방법이 없어요. 어떻게 하면 사용자가 어느 화면에 있든 "끝났다"는 신호를 놓치지 않게 할 수 있을까요?

저는 진행 상태를 화면에서 걷어 내 라우터로 올렸습니다. BrowserRouter 바로 아래에 BackgroundStateMapper라는, 아무것도 그리지 않고 <></>만 반환하는 컴포넌트를 두고 여기서 backgroundState 쿼리를 건 다음 subscribeToMore로 같은 이름의 subscription을 이어 붙였습니다. 서버가 syncing: 'COMPANY'를 밀어 주면 지금 어느 경로에 있든 history.replace('/mysub/track/loading/...')로 로딩 화면을 덮어씌우고, syncing이 null로 돌아오면 로딩 화면이 스스로 결과 화면으로 넘어갑니다. 은행인지 카드인지는 BANKS 상수에서 companyCode를 찾아 판별합니다.

진행 상태의 소유자가 화면이 아니라 앱이 되기 때문입니다. 화면은 언마운트돼도 라우터는 살아 있으니 신호를 놓칠 자리가 없어요. 덕분에 로딩 화면은 Lottie 애니메이션과 문구만 가진 순수한 프레젠터가 되고, 전환 조건은 value?.backgroundState?.syncing === null 한 줄로 끝납니다. 폴링을 지운 덕에 스크래핑이 오래 걸릴수록 요청이 늘어나는 문제도 같이 사라졌고요.

뒤로가기의 진실을 아는 쪽은 네이티브가 아니라 라우터였습니다

웹뷰 앱에서 하드웨어 뒤로가기나 스와이프 백은 보통 네이티브가 알아서 처리합니다. 자기 네비게이션 스택을 보고 판단하니까요. 그런데 화면이 전부 웹이면 그 스택에는 웹뷰 한 장밖에 없습니다. 그래서 웹 쪽에서 window.history.length를 보고 판단하려는 시도가 따라 나오는데, 이 값은 replace로 갈아 끼운 이동을 세지 않아서 온보딩이나 탭 화면에서도 그럴듯한 숫자가 나옵니다. 결과는 홈에서 뒤로 갔더니 앱이 로그인 화면으로 튕기거나, 아예 종료되는 동작이었어요.

판단을 웹으로 완전히 가져오고, 네이티브에는 결론만 통보하도록 뒤집었습니다. 라우터 안 BottomNav가 location.pathname을 구독하다가, 하단 탭 네 경로(/mysub/list, /service/list, /box/list, /more/list)와 /onboard에 있을 때만 setGoBackEnable(false)를, 나머지 모든 경로에서는 true를 브리지로 밀어 줍니다. 브리지 쪽에는 process.env.REACT_APP_ENV !== 'development' 가드를 걸어, 브라우저에서 개발할 때 window.webkit이 없어 터지는 일이 없게 했습니다.

"여기서 뒤로 갈 수 있는가"를 아는 유일한 주체가 라우터이기 때문입니다. 경로 목록이 곧 정책이라 새 화면을 추가해도 기본값이 true라 안전하고, 뒤로가기를 막고 싶으면 배열에 경로 한 줄을 더하면 끝납니다. 같은 목록으로 하단 탭 노출 여부와 선택된 탭 인덱스까지 결정하니, 탭 화면의 정의가 코드 안에서 한 군데로 모입니다.

유저가 할 수 있는 일

카카오나 Apple 계정으로 앱에 처음 들어온다
카카오나 Apple 계정으로 앱에 처음 들어온다

은행이나 카드사를 연결해 정기결제를 찾아낸다
은행이나 카드사를 연결해 정기결제를 찾아낸다

홈에서 다가오는 결제와 지금까지 써본 서비스를 확인한다
홈에서 다가오는 결제와 지금까지 써본 서비스를 확인한다

구독 하나를 열어 결제 이력과 출금처를 본다
구독 하나를 열어 결제 이력과 출금처를 본다

결제 며칠 전에 알림을 받을지 정한다
결제 며칠 전에 알림을 받을지 정한다

연결한 금융기관을 해제하고 기록을 지운다
연결한 금융기관을 해제하고 기록을 지운다

구독 순위를 카테고리별로 보고 추천을 누른다
구독 순위를 카테고리별로 보고 추천을 누른다

초성만 쳐서 구독 서비스를 찾는다
초성만 쳐서 구독 서비스를 찾는다

서비스의 요금제와 다른 사람 의견을 살펴본다
서비스의 요금제와 다른 사람 의견을 살펴본다

서비스에 의견을 남긴다
서비스에 의견을 남긴다

다른 사람 의견에 공감을 누른다
다른 사람 의견에 공감을 누른다

큐레이션된 정기 배송 박스를 훑어본다
큐레이션된 정기 배송 박스를 훑어본다

더보기에서 문의 수단과 약관을 연다
더보기에서 문의 수단과 약관을 연다

로그아웃하고 온보딩 화면으로 되돌아간다
로그아웃하고 온보딩 화면으로 되돌아간다

알림 권한을 허용하면 푸시 토큰이 서버에 등록된다
알림 권한을 허용하면 푸시 토큰이 서버에 등록된다

1 / 1

목업으로 남은 박스 탭

솔직히 말하면 이 앱은 절반쯤에서 멈춰 있습니다. 하단 탭의 박스 구독 화면은 API를 한 번도 부르지 않는 정적 목업입니다. 카테고리 탭을 눌러도 actions.getServices(category) 호출이 주석 처리된 채로 남아 있고, 카드 네 장은 소스에 하드코딩돼 있어요. 약관 세 화면과 내 정보 화면도 문자열 한 줄짜리 자리표시자입니다. /mysub/disconnected 라우트는 Pages.Disconnected를 가리키는데 src/pages/index.js가 그런 이름을 내보낸 적이 없어서, 그 주소로 들어가면 렌더가 깨집니다. 테스트는 한 줄도 없고요.

다시 만든다면 두 가지를 바꾸겠습니다. 첫째, 금융기관 아이디를 ?username=으로 다음 화면에 넘긴 부분입니다. 비밀번호는 뮤테이션 변수로만 흐르게 잘 막아 뒀지만, 아이디가 URL과 히스토리에 남을 이유는 없었습니다. 아이디와 비밀번호를 한 라우트 안의 단계 상태로 묶었어야 했어요. 둘째, accessToken을 localStorage에 둔 것입니다. 웹뷰라 편했지만 토큰은 네이티브 keychain에 두고 브리지로 주고받는 편이 맞았습니다. 프로덕션 환경 파일에 API 주소가 아예 비어 있고 스테이징 배포 워크플로만 있는 것도, 이 프로젝트가 어디까지 갔었는지를 그대로 보여 줍니다.

Read next

보일러 교체 신청과 공유 응모를 받는 캠페인 사이트

Navien — 2019