무슨 업무를 했나?
- 결제 화면에 결제 수단별 캠페인 카피를 띄우고, 캠페인이 끝나면 담당자가 직접 내릴 수 있게 만들기
- 결제사를 나이스페이먼츠에서 토스페이먼츠로 옮기는 작업에 맞춰 결제 흐름을 새로 끼우기
- 새 결제 수단으로 토스페이 추가
- 결제 수단마다 따로 만들어져 있던 결제 화면을 하나로 합치기
세 가지가 한 분기에 다 들어왔다.
업무별 정리
1) 결제 수단별 프로모션 시스템 만들기
배경
화주 스쿼드가 결제사를 나이스페이먼츠에서 토스페이먼츠로 옮기고 있었다. 옮기는 김에 토스페이를 새 결제 수단으로 붙이고, 토스 비용으로 토스페이 첫 결제 적립 캠페인도 같이 띄우기로 했다. 결제 화면을 어차피 손대니까 센디 비용으로 네이버페이 적립도 묶었고, 빌링카드 등록을 늘리고 싶다는 이유로 빌링카드 첫 결제 할인까지 같은 시점에 들어왔다.
회사가 결제 프로모션을 돌려본 적이 처음이라 운영 방법도, 시스템도 없었다. 백엔드 일감을 새로 받기도 어려웠다.
릴리즈 타겟은 2025-01-06이었고, 그 시점에 띄울 캠페인은 세 건이었다.
- 토스페이 — 생애 첫 결제 시 3천 원 적립 (최소 결제 1만 원, 토스페이먼츠 산하 다른 간편결제는 안 됨)
- 네이버페이 — 10만 원 이상 결제 시 5천 원 적립 (아이디당 횟수 제한 없음, 2월 14일 일괄 적립)
- 빌링카드 — 등록 후 첫 결제 시 5천 원 즉시 할인 (기존 등록 고객은 5천 원 쿠폰)
화면에 문구를 다는 건 어렵지 않다. 마음에 걸린 건 캠페인이 끝나는 날 같은 화면을 다시 열어서 문구를 지워야 한다는 점이었다. 결제 화면은 자주 건드릴수록 사고 가능성이 올라가는 화면이고, 캠페인이 시작될 때 한 번, 끝날 때 또 한 번 배포하는 일이 캠페인 수만큼 늘어나는 게 싫었다.
고민
- 하드코딩 + 기간 분기: 추가 → 배포 / 회수 → 배포 / 확장 → 배포
- 백엔드 신규 API: 추가 → API 작업 / 회수 → 어드민 / 확장 → 어드민
- Firebase Remote Config: 추가 → 담당자 직접 / 회수 → 담당자 직접 / 확장 → 담당자 직접
백엔드 API는 일정상 받기 어려웠다. 하드코딩은 끝날 때 또 배포해야 해서 가장 위험했다. 남는 건 Remote Config였다.
잠깐, Firebase Remote Config가 뭐냐면
Firebase가 제공하는 원격 키-값 저장소다. 콘솔에서 등록한 값(string·number·boolean·JSON)을 SDK가 받아 와서 앱이 그 값으로 화면을 그린다. 사용자 세그먼트별·플랫폼별로 다른 값을 내려보낼 수도 있다.
쓰는 흐름은 세 단계다.
- 디폴트 등록 — 앱 번들 안 plist(또는 코드)로 기본값을 박아 둔다. 첫 실행이거나 fetch가 실패한 경우 이 값이 쓰인다.
- fetch — 원격에서 최신 값을 가져온다. fetch 주기(기본 12시간) 안에서는 캐시된 값을 그대로 쓴다.
- activate — 받아 온 값을 앱이 실제로 쓰도록 적용한다.
코드로는 이런 모양이다.
let config = RemoteConfig.remoteConfig()
// 1. 디폴트 값 등록
config.setDefaults(["payment_method_promotion": "[]" as NSString])
// 2 + 3. 원격 값 받아 와서 적용
config.fetchAndActivate { _, _ in
let raw = config["payment_method_promotion"].stringValue ?? "[]"
let data = Data(raw.utf8)
let promotions = (try? JSONDecoder().decode([Promotion].self, from: data)) ?? []
self.apply(promotions)
}값 하나가 단일 문자열이나 플래그가 아니라 JSON 문자열을 들고 있을 수 있다는 점이 이번에 도움이 됐다. 결제 수단별 프로모션 N개를 배열 하나에 모아서 내려보내고, 클라이언트는 그 배열을 디코딩해서 화면에 그리면 됐다.
결정
Remote Config를 원격 값 저장소가 아니라, 노출 여부와 노출 내용을 한 곳에서 같이 다루는 도구로 쓰기로 했다. 키 하나에 결제 수단별 프로모션 정보를 배열로 묶어서 내려보내고, 배열이 비면 알아서 사라진다.
[
{ "payMethod": "TOSS_PAY", "message": "생애 첫 결제 3,000원 적립", "url": "https://..." },
{ "payMethod": "NAVER_PAY", "message": "10만원 이상 결제 시 5,000원 적립", "url": "https://m-campaign.naver.com/..." },
{ "payMethod": "EASY_PAY", "message": "빌링카드 첫 결제 5,000원 즉시 할인", "url": null }
]담당자가 만질 수 있는 건 카피, 랜딩 URL, 노출 여부까지로 한정했다. 클라이언트의 결제 수단 enum은 닫힌 집합으로 두고, JSON에 정의되지 않은 값이 들어오면 그 항목만 조용히 빠지도록 했다. 결제 수단 자체를 늘리는 건 PG 연동·결제 흐름이 같이 움직여야 하는 일이라, 그건 클라이언트 배포가 필요한 작업으로 남겼다.
enum PayMethod: String, Decodable {
case NAVER_PAY, KAKAO_PAY, CREDIT, VBANK, APPLE_PAY, TOSS_PAY, EASY_PAY
}빌링카드 카피만 별도 키로 분리한 이유는 캠페인 주기가 달라서다. 한 키에 묶어 두면 두 캠페인을 따로 관리하는 사람이 서로의 변경을 덮어 버릴 수 있었다.
공식 문서 유스케이스랑 거의 똑같았다
작업 끝나고 Firebase 공식 문서를 다시 보니, 이번에 만든 구조가 Remote Config가 권장하는 유스케이스 세 가지에 정확히 맞물려 있었다. 도구가 처음부터 이런 모양의 문제를 풀라고 만들어진 셈이다.
- JSON으로 복잡한 엔터티 구성하기 — 결제 수단별 프로모션 N개를 단일 키 안의 JSON 배열로 묶은 부분.
- 플랫폼·로케일별 프로모션 배너 정의 — 결제 화면 카피와 랜딩을 코드 배포 없이 갈아 끼울 수 있게 만든 부분.
- 첫 사용 사용자에게 맞춤 경험 제공 — 토스페이 생애 첫 결제, 빌링카드 등록 후 첫 결제처럼 "처음" 조건에 묶인 캠페인.
회고
릴리즈하고 다음 캠페인을 담당자가 직접 올리기 시작했을 때부터 부딪힌 일들이 있다.
- Remote Config는 fetch 주기(기본 12시간) 안에서는 이전 값을 들고 있다. 담당자가 새 카피를 올려도 사용자 단말이 받기까지 시간이 걸린다. 캠페인 시작 시점이 분 단위로 중요한 경우, 담당자 입장에서는 "올렸는데 왜 안 보이지"가 사고처럼 느껴졌다.
- 담당자가 결제 수단 식별자를 한 글자라도 잘못 적으면 그 항목은 크래시 없이 그냥 빠졌다. 동작은 안전한데 발견이 늦었다. 결국 담당자가 결과를 미리 볼 수 있는 화면이 같이 있어야 풀리는 문제였다.
- 첫 실행 사용자나 fetch 실패 케이스에는 디폴트 plist 값이 노출된다. 디폴트를 가장 보수적인 상태(빈 배열)로 잡아 둔 게 그나마 다행이었다.
권한을 담당자한테 넘긴다는 결정 자체는 가벼웠는데, 그 권한이 잘못 쓰였을 때 알아챌 방법까지 같이 만들어 두는 일은 처음 작업할 때 거의 그려 보지 않았다는 걸, 캠페인이 굴러가기 시작하면서 알았다.
2) PG 마이그레이션 대응 + 토스페이 추가
배경
결제는 클라이언트가 띄운 웹뷰 안에서 PG가 처리하는 구조라, FE 쪽은 호출할 때 넘기는 가맹점 키와 응답 처리만 새 PG에 맞춰 다시 끼우면 됐다. 토스페이는 일반 웹 결제와 달리 외부 토스 앱으로 빠졌다 돌아오는 흐름이 있어서, 브릿지에 딥링크 한 갈래를 더 얹어야 했다.
문제
딥링크 콜백을 받아 결제 결과를 처리하는 부분 자체는 어렵지 않았다. 출시 직후 부딪힌 건 다른 쪽이었다. 결제 결과 화면에서 영영 로딩만 돈다는 사용자 리포트가 들어왔다.
처음엔 콜백 파싱이 잘못된 줄 알고 그쪽을 들여다봤는데 멀쩡했다. 다시 보니 문제는 콜백 파싱이 아니라 콜백이 아예 안 돌아오는 경우였다.
- 사용자가 토스 앱에서 결제하다가 앱을 강제 종료한 경우
- 시스템이 백그라운드에 있던 센디 앱을 메모리에서 정리한 경우
- 그냥 홈 버튼을 누르고 다른 일을 한 경우
이 경우 브릿지 입장에서는 결제가 진행 중인지, 실패인지, 사용자가 그만둔 건지 판단할 근거가 없었다. 브릿지가 결제 결과를 딥링크 콜백 한 가지에만 의존하고 있던 게 원인이었다. 정상 경로 — 결제하고 토스 앱이 다시 우리 앱으로 돌려보내 주는 흐름 — 만 생각하고 코드를 짠 셈이다.
해결
결제 시작할 때 발급된 주문 ID로 PG 서버에 결과를 직접 물어보는 폴링을 안전망으로 더했다. 외부 앱으로 빠진 뒤 일정 시간이 지나도 콜백이 안 돌아오면 폴링이 결제 상태를 가져와서 결과 화면을 마무리한다.
회고
이 일이 의외로 오래 머릿속에 남았다. 콜백을 받는 코드를 다 짜고 나서 "콜백이 안 오면?"을 한 번도 떠올리지 않았다는 사실 자체가 부끄러웠다. 외부 앱·외부 서비스에 의존하는 모든 흐름은 정상 경로뿐 아니라 그 흐름이 끊어지는 모든 지점을 같이 그려 봐야 한다는 걸 그때 배웠다. 그 뒤로는 외부 의존 흐름을 설계할 때 "콜백이 영영 안 오면 어떻게 되는가"부터 먼저 적어 두는 습관이 생겼다.
3) 흩어져 있던 결제 웹뷰를 하나로 합치기
배경
PG 마이그레이션 작업을 하려고 결제 웹뷰들을 들여다봤다가, 결제 수단마다 별도의 웹뷰 컴포넌트가 따로 있다는 걸 알게 됐다. 카카오페이, 네이버페이, 신용카드가 만들어진 시점이 달라서 모양도 조금씩 달랐다. 그런데 인터페이스를 보면 사실상 같았다. 입력은 주문 ID, 금액, 인증 토큰. 출력은 성공·실패·취소. 차이는 콜백 URL 패턴과 토스페이의 외부 앱 딥링크 한 갈래 정도였다.
고민
분리된 채로 두면 토스페이 추가는 또 새 컴포넌트를 만드는 일이 됐다. 합치면 작업 범위가 PG 마이그레이션 자체보다 커지지만, 이후 결제 수단을 추가할 때마다 케이스 한 갈래만 더하면 끝난다. PG 마이그레이션이 이미 모든 결제 수단 웹뷰를 한 번씩 다 건드리는 작업이라, 합치는 비용이 가장 적게 드는 시점이 정확히 지금이었다.
결정
합치는 쪽으로 갔다. 인터페이스가 같으면 합치는 게 자연스럽다고 봤다. 브릿지를 하나로 두고, 결제 수단은 호출할 때 인자로 받는 형태로 정리했다.
struct PaymentRequest {
let payMethod: PayMethod
let orderId: String
let amount: Int
let authToken: String
}
protocol PaymentBridge {
func start(_ request: PaymentRequest)
func handleRedirect(_ url: URL) -> PaymentResult?
func handleDeepLink(_ url: URL) -> PaymentResult?
}다른 결제 수단은 handleDeepLink를 안 쓰고 handleRedirect만으로 끝난다. 토스페이만 두 갈래를 다 쓴다.
회고
이 결정의 한 면만 봤다는 걸 한참 뒤에 깨달았다. 합치면 좋다는 건 분명한데, 합치고 나면 브릿지 한 곳에서 사고가 나는 순간 모든 결제 수단이 같이 멈춘다는 점은 그때 거의 생각하지 않았다. 분리돼 있을 때는 카카오페이가 깨져도 신용카드는 살아 있었다.
운 좋게도 큰 사고는 없었지만, 만약 첫 분기에 브릿지에서 회귀가 났다면 결제 전체가 영향을 받았을 것이다. 합치는 이득만큼 합친 뒤의 실패 모드 — 한 번에 어디까지 깨지는지, 부분 회귀가 왜 어려워지는지 — 도 같이 그려 봐야 한다는 걸 그 가능성을 떠올리고서야 알았다.
릴리즈 이후 보인 것들
릴리즈는 1월 6일에 나갔다. 그 뒤에 처음 본 변화는 수치가 아니라 동작이었다.
다음 캠페인을 띄울 때 코드를 건드리지 않았다. 담당자가 콘솔에서 JSON을 갈아 끼우면 그게 곧 노출됐다. 토스페이 다음으로 새 결제 수단을 추가할 때도 새 웹뷰 컴포넌트를 만들지 않았다. PaymentBridge에 케이스 한 줄 더하고 끝났다. 결제 화면 배포 자체가 캠페인 일정에서 풀려난 게, 작업 마무리하고 한 분기쯤 지나서야 실감이 났다.
그러면서 본 다른 변화도 있다. 첫 캠페인 끝나고 빌링카드 등록 비율이 플랫폼에 따라 1.6~1.9배로 늘었다(Mixpanel 퍼널 기준). 신호로는 분명 컸지만, 들여다보니 같은 비율로 등록 화면에서 막히는 사람도 늘었다. 카드번호·유효기간·CVC·생년월일·비밀번호 두 자리까지 입력 항목이 많은 화면에서, 키패드 전환과 검증이 반복되면서 일정 비율의 사용자가 빠져나갔다. 프로모션으로 사람을 더 끌어왔어도 그 사람들이 등록을 끝내지 못하면 의미가 줄었다. 이 관찰이 다음 분기 작업으로 자연스럽게 넘어갔다.
정리하면서 깨달은 것
이 작업이 끝난 시점에 정리해 보면, 코드보다는 사람과 시스템 사이의 경계에 대한 배움이 더 컸다.
권한을 담당자한테 넘기는 결정 자체는 가벼웠다. 하지만 그 권한이 잘못 쓰였을 때 어떻게 알아챌지, 어떻게 되돌릴지, 어떻게 미리 확인할지는 거의 그려 보지 않았다. 캐시 갱신이 늦는다거나 식별자 오타로 항목이 조용히 빠진다거나 하는 일들은, 다 권한을 넘긴 다음 캠페인부터 하나씩 보였다. 권한을 푸는 일과 그 권한의 안전망을 만드는 일은 같이 가야 했다.
외부 앱에 의존하는 흐름을 처음 짤 때 정상 경로만 그렸다는 사실도 한참 뒤에 부끄러웠다. "콜백이 안 오면 어떻게 되는가"라는 질문이 그 자체로 안전망의 출발점이었는데, 그걸 사후에야 떠올렸다. 외부에 맡긴 흐름은 끊어지는 모든 지점이 곧 설계의 출발점이라는 걸 그때 알았다.
합치는 결정도 같은 결의 일이었다. 합치는 이득은 분명히 봤지만, 합친 뒤의 실패 모드 — 한 번에 어디까지 깨지는지 — 는 거의 그려 보지 않았다. 운이 좋아 사고가 안 났을 뿐이다.
마지막으로, 신입 때는 코드부터 보는 게 본능이었다. 그런데 이 작업의 결과를 결정한 건 결제사 담당자, PG 마이그레이션을 진행하던 화주 스쿼드, 외부 본부의 캠페인 담당자 같은 개발팀 밖 사람들이었다. 그들과 같이 정한 JSON 키 이름, 디폴트 값을 어디로 둘지, 캠페인 일정의 마진은 얼마로 둘지가 결국 작업의 모양을 결정했다. 자리에 앉아 코드 보는 시간 일부를 그 사람들과 이야기하는 데 더 썼어야 했다는 걸, 정리하면서 가장 분명히 알게 됐다.
기술 스택
- iOS, Swift, MVI
- Firebase Remote Config
- WKWebView, URL Scheme 딥링크 브릿지
- 토스페이먼츠 (PG), 토스페이
- Mixpanel