💡 엔지니어링 노트 시리즈는 토스페이먼츠 개발자들이 제품을 개발하면서 겪은 기술적 문제와 해결 방법을 직접 다룹니다. 첫 번째는 프론트엔드 이야기인데요. 웹에서 퍼널을 손쉽게 관리할 수 있도록 만든 모듈을 소개합니다. 이 포스트는 토스 테크 블로그에서도 읽을 수 있습니다.
토스 제품 디자인 원칙(PP: Product Principle)엔 “One thing for One Page”라는 원칙이 있어요. 화면 하나에는 명확한 목표 하나만 있어야 한다는 건데요. 이 원칙에 따라 제품을 만들다 보면 ‘퍼널’이 많이 생깁니다. 토스페이먼츠의 제품도 예외는 아니에요. 그래서 저희 프론트엔드 개발자들은 퍼널의 흐름을 잘 관리해야 하죠. 퍼널이란 사용자가 웹사이트를나 애플리케이션을 방문해서 최종 목표까지 달성하는데 거치는 단계를 뜻합니다.
오늘은 토스페이먼츠의 프론트엔드 개발자들이 이 흐름을 어떻게 관리하고 있는지 소개하고, 이 방법의 좋은 점과 아쉬운 지점까지 공유해 볼게요.
결제를 떠올리면 보통 어떤 흐름이 생각나시나요? 주문서에서 신용∙체크카드와 같은 결제 수단을 선택한 뒤(퍼널 1) 결제하기 버튼을 누르고(퍼널 2), 카드 결제창에서 내 카드사를 선택한 뒤(퍼널 3) 앱카드 등으로 인증한 뒤(퍼널 4) 다시 주문하던 페이지로 돌아오는(퍼널 5) 과정이 익숙한 흐름입니다.
그런데 결제 제품에는 이렇게 최종 사용자가 경험하는 단순한 흐름 말고도 개발자가 다뤄야 하는 다른 여러 퍼널들이 있습니다.
쉬운 계좌이체를 도와주는 퀵계좌결제를 처음 사용하는 시나리오에서 내 계좌를 연결하는 과정은 다음과 같아요.
이때 각 단계에서 유저가 ‘뒤로가기’를 한다고 생각해볼게요. 2, 3, 4단계에서 뒤로가기를 하면 바로 전 단계로 이동하면 됩니다. 그런데 5, 6단계에서는 뒤로가기를 하면 어떨까요? 5단계에서 4단계로 이동한다면, 이미 인증된 ARS 페이지에 다시 접근하는 것이기 때문에 유저에게는 비정상적인 흐름이 됩니다. 6단계에서도 5단계로 이동하기보다는, 직관적으로 1단계에서 뒤로가기를 했을 때와 똑같이 작동하는게 자연스러워 보여요.
이렇게 퍼널이 많아지면서 히스토리를 관리하기가 까다로워졌어요. 모바일 애플리케이션이라면 회원가입 내비게이션 스택이나 ARS 인증 내비게이션 스택을 통째로 없애면 될 텐데, 토스페이먼츠의 결제 제품들은 웹 기술로 만들고 있어요. 웹 브라우저의 히스토리는 단일 스택으로만 관리하기 때문에 같은 방식으로 구현할 수가 없었어요.
이 문제를 해결하기 위해 검토했던 두 가지 방법과 최종 해결 방법을 소개할게요.
첫 번째 해결 방법은 ‘각 퍼널을 싱글 페이지 앱(SPA)처럼 만들기’예요. 작은 퍼널 안에선 히스토리를 쌓지 않고 싱글 페이지로 만드는 방법이에요. 그런데 이 방법을 사용하면 퍼널 중간에 브라우저 뒤로가기를 대응할 수 없다는 문제가 있어요. 예를 들어 계좌 등록 퍼널에서 은행 선택 후 계좌번호 입력 퍼널에서 뒤로가기를 선택하면 은행 선택 페이지로 돌아갈 수 없어요. 또, 사용자가 새로고침을 하면 해당 페이지를 유지하지 못하고 시작점으로 돌아갈 수 밖에 없는 문제도 있었죠.
다음으로 쿼리 파라미터로 관리하는 해결 방법을 고민했어요. 각 단계를 /user-register?step=1
과 같은 URL 쿼리 파라미터로 표현하는 방식이에요. 그런데 이 방식을 사용하면 ‘작은’ 퍼널을 재사용하고 싶을 때 까다로워지더라고요. 퍼널이 끝난 뒤 어디로 돌아가야 하는지에 대한 정보를 계속 URL에 들고 다녀야 하기 때문이에요. 예를 들어 비밀번호 등록이라는 작은 퍼널이 끝났을 때, 회원가입 퍼널로 돌아가야 하는지, 계좌등록 퍼널로 돌아가야 하는지 알아야 하니까요.
그래서 저희는 ‘Flow’라는 이름의 퍼널 관리 모듈을 만들었어요. 이 모듈을 사용하면 여러 개의 페이지를 오가며 퍼널에서 해야 할 일을 수행하고, 할 일을 마치면 히스토리 스택을 비울 수 있어요. 실제 모듈 코드보다 단순화한 코드로 먼저 살펴볼게요.
class SimpleFlow {
private pageCount: number;
private unsubscribeRouteChange: null | (() => void) = null;
private static router = Router;
async start(url: string | UrlObject) {
// pageCount를 초기화해요.
this.pageCount = 0;
// listener를 붙여요. 반환받은 반환한 클린업 함수는 end 안에서 호출해요.
this.unsubscribeRouteChange = SimpleFlow.router.listen(event => {
if (event.action === Action.Push) {
this.pageCount++;
}
});
await SimpleFlow.router.push(url);
}
async end() {
if (this.pageCount > 0) {
await SimpleFlow.router.back(this.pageCount);
}
this.unsubscribeRouteChange?.();
}
}
여기서 핵심은 pageCount
입니다. 이 모듈은 결국 시작한 지점에서 페이지를 몇 개 지나왔는지를 추적하는 객체인 셈이죠.
이번에는 사용하는 쪽 코드도 살펴볼게요. 퀵계좌결제에서 에스크로 관련 정보를 등록하는 흐름이에요. 먼저 전체 흐름을 이미지로 살펴볼게요.
// useExcrowInputFlow.ts
// 훅을 호출하는 곳마다 flow를 새로 만들지 않고 flow를 유지하려 싱글턴으로 관리합니다.
let flow: Flow;
export function useEscrowInputFlow() {
const startEscrowInputFlow = () => {
flow = new Flow();
return flow.start(라우트.결제_에스크로);
};
const endEscrowInputFlow = flow.end;
return {
startEscrowInputFlow,
endEscrowInputFlow,
};
}
// PayScreen.tsx
const { startEscrowInputFlow } = useEscrowInputFlow();
// ... 다른 flow 훅들
return <PayForm
onSubmit={() => {
if (escrowConfig.isAvailable) {
await startEscrowInputFlow(); // 에스크로 흐름 시작하기
}
if (isSmsAuthRequired) {
await startSMSFlow(); // SMS 인증 흐름 시작하기
}
if (isPINRequired) {
await startPaymentPasswordFlow(); // 결제 비밀번호 입력 흐름 시작하기
}
await confirm();
}}
/>;
// EscrowScreen.tsx
const { endEscrowInputFlow } = useEscrowInputFlow();
return <EscrowForm
onSubmit={() => {
...
endEscrowInputFlow(); // 에스크로 흐름 끝내기
})
/>
위와 같이 모듈을 사용해 봤어요.
PayScreen
에서 에스크로 흐름을 시작하고, EscrowScreen
에선 에스크로 로직을 모두 끝낸 뒤 에스크로 흐름을 끝내면 돼요.
모듈을 통해 히스토리 관리가 힘들었던 문제도 풀어냈어요. 훅에서 반환하는 end
함수만 호출하면 모바일 애플리케이션에서 내비게이션 스택을 없애는 것과 같은 효과를 얻었습니다.
만약 Flow가 없다면 코드가 분산되어 유스케이스 읽듯이 자연스럽게 읽을 수 없어요.
// PayScreen.tsx
...
if (escrowConfig.isAvailable) {
router.push('/에스크로-입력');
return;
}
if (isPINRequired) {
router.push('/비밀번호-입력');
return;
}
if (isSmsAuthRequired) {
router.push('/SMS-2차인증');
return;
}
await confirm();
...
// 에스크로페이지
<Button
onClick={() => {
if (isPINRequired) {
router.push('/비밀번호-입력');
return;
}
if (isSmsAuthRequired) {
router.push('/SMS-2차인증');
return;
}
await confirm();
}}
>
입력완료
</Button>
// 비밀번호입력페이지
<Button
onClick={() => {
if (isSmsAuthRequired) {
router.push('/SMS-2차인증');
return;
}
await confirm();
}}
>
입력완료
</Button>
Flow를 사용하면 히스토리 관리를 편하게 할 수 있을 뿐 아니라 선언적으로 코드를 작성할 수 있다는 점이 정말 매력적이에요. 간단한 예를 들어볼게요.
<Button
onClick={() => {
// 이 코드보다
router.push('/select-bank');
// 다음 코드가 더 의도를 잘 드러내요!
startAccountAddFlow();
});
>
+ 계좌 추가
</Button>
최근 토스페이먼츠 프론트엔드 챕터는 다음 이미지에 있는 것처럼 개발을 시작하기 전에 전체적인 시나리오(개발 명세)를 먼저 작성한 뒤 개발하기 위해 노력하고 있는데요. Flow를 이용하니 이렇게 작성한 시나리오 그대로 구현하기 편리했어요.
// PayScreen.tsx
const { startEscrowInputFlow } = useEscrowInputFlow();
// ... 다른 flow 훅들
return <PayForm
onSubmit={() => {
if (escrowConfig.is가능) {
await startEscrowInputFlow(); // 에스크로 흐름 시작하기
}
if (isSmsAuthRequired) {
await startSMSFlow(); // SMS 인증 흐름 시작하기
}
if (isPINRequired) {
await startPaymentPasswordFlow(); // 결제 비밀번호 입력 흐름 시작하기
}
await confirm();
}}
/>;
위에서 살펴본 코드 예제 중 위 부분은 사실 다음 시나리오를 그대로 코드로 표현한 결과물이에요.
또 흐름의 시작과 끝을 독립적으로 관리할 수 있다는 장점도 있어요. 앞서 고려했던 URL 쿼리 파라미터를 사용할 때처럼 이전 퍼널과 다음 퍼널에 대한 정보를 들고 있을 필요 없이 시작과 끝을 모두 독립적으로 처리할 수 있어요. Flow.end()
만 호출하면 되니까요.
물론 Flow도 완벽하진 않아요. Flow 를 여러 개 이어 붙이는 경우를 생각해 볼게요. 유저에겐 A → B → C → D 순서대로 흘러가는 것처럼 보이지만, Flow를 사용하면 실제 화면 이동 흐름은 A → B → A(잠깐) → C → A(잠깐) → D가 돼요. 그래서 이동할 때 잠깐씩 이전 화면인 A가 보여 깜빡이는 것 같은 문제가 생겼어요.
또, 시작과 끝이 독립적인 대신 start
를 부르는 곳과 end
를 호출하는 곳이 멀어져서 코드를 읽기가 어려워지는 문제도 있어요.
복잡하고 반복되는 퍼널 관리, 토스페이먼츠에서는 이렇게 해결하고 있어요. 다른 분들은 퍼널을 어떻게 관리하고 계신지 궁금해요. 참고로 토스 모바일에서는 useFunnel
을 사용하고 있는데요. 토스페이먼츠에서는 히스토리 관리가 필요해서 useFunnel
을 사용하는 대신 Flow 모듈을 새로 만들었어요. Flow 모듈이나 문제 해결 방법에 대한 피드백, 혹은 우리 팀에서 사용하고 있는 더 나은 방법이 있다면 자유롭게 의견을 남겨주세요!
Writer 임재후, 최수민 Edit 한주연 Graphic 이은호, 이나눔
토스페이먼츠 Twitter를 팔로우하시면 더욱 빠르게 블로그 업데이트 소식을 만나보실 수 있어요.