배너

AST로 Outdated 없는 퍼널 문서 만들기

조성륜
2025년 12월 24일

안녕하세요, 토스코어 Business Onboarding Team 프론트엔드 개발자 조성륜입니다.

토스팀에 합류하고 처음 받은 과제는 판매자 입점 퍼널을 이해하는 것이었습니다. 판매자 입점 퍼널은 판매자가 토스페이에 가입하는 과정을 말해요.

"이 페이지 다음에 어디로 가요?" "음... 조건에 따라 달라요. 코드 보시면..."

39개의 페이지 파일을 하나씩 열어보며 며칠을 보냈습니다. 파일을 열 때마다 이런 코드가 나왔어요.

// applicant.tsx (신청자 정보 페이지)
if (신청자 === 대표자 && 사업자형태 === 개인사업자) {
  router.push(결제수단_페이지)
} else if (신청자 === 대리인) {
  router.push(위임정보_페이지)
} else {
  // ... 더 많은 분기들
}

39개 페이지가 82개의 서로 다른 조건으로 연결되어 있었습니다. 한 페이지에서 평균 2개 이상의 분기가 있는 셈이죠. 판매자 본인이 직접 신청하는지(대표자), 위임받은 사람이 신청하는지(대리인), 개인사업자인지 법인사업자인지, 본인인증 결과가 어떤지에 따라 경로가 달라졌습니다.

그리고 생각했어요.

"어차피 코드에 다 있는 정보인데, 자동으로 문서를 만들 수 없을까?"

이 글에서는 코드를 분석해서 '절대 Outdated 되지 않는 퍼널 문서'를 만든 경험을 공유합니다.

"그 문서는 아직 최신화 안되어 있어요"

팀에 합류하고 가장 먼저 한 일은 기존 문서를 찾아보는 것이었습니다. ‘판매자 입점 퍼널 플로우’라는 문서가 있었어요. 페이지 간 연결을 화살표로 그린 다이어그램이었죠. "오, 이거 보면 되겠다!" 싶었습니다.

그런데 막상 코드를 보니 뭔가 이상했습니다.

"문서에는 A 다음에 B로 가는데, 코드에서는 C로 가네요?" "아, 그 문서는 아직 최신화 안되어 있어요. 코드를 보셔야 해요."

알고 보니 3개월 전에 마지막으로 수정된 문서였습니다. 그 사이에 퍼널 구조가 여러 번 바뀌었지만, 문서는 업데이트되지 않은 거예요. 그래서 제가 직접 플로우차트를 다시 그려보기로 했습니다. 코드를 하나씩 읽으면서 문서에 정리했어요.

하지만 금방 한계에 부딪혔습니다.

1. 조건부 분기를 표현하기 어려웠습니다

‘대표자이면서 개인사업자일 때’는 A로, ‘대리인이면서 법인일 때’는 B로 가는 건 그림으로 그리기 복잡했습니다. 화살표에 조건을 다 적으면 다이어그램이 읽을 수 없을 정도로 지저분해졌어요.

2. 제가 그린 문서도 금방 Outdated 됐습니다

열심히 그렸는데, 3일 뒤에 다른 팀원이 퍼널에 새 페이지를 추가했습니다. 제 문서는 이미 현실과 달라져 있었어요. 매번 코드가 바뀔 때마다 문서를 수정하는 건 현실적으로 불가능했습니다.

결국 같은 문제가 반복되는 거였죠. 수기로 작성한 문서는 결국 Outdated 됩니다.

런타임 vs 정적 분석

코드에서 Navigation 정보를 추출하는 방법은 크게 두 가지입니다.

방식
설명
장점
단점
런타임 분석
실제로 코드를 실행하면서 어떤 경로로 가는지 기록
실제 동작 그대로
한 번에 한 경로만, 모든 케이스 실행 필요
정적 분석
코드를 텍스트로 읽어서 AST로 파싱
모든 경로를 한 번에, 빠르고 안전
동적 로직 파악 어려움

저희는 정적 분석을 선택했습니다.

AST(Abstract Syntax Tree)는 코드를 트리 구조로 표현한 것입니다. TypeScript 컴파일러가 코드를 이해하는 방식과 동일해요. ts-morph 라이브러리를 사용해서 소스코드를 AST로 파싱했습니다.

코드에서 퍼널 흐름을 추출하기까지

‘코드를 읽어서 Navigation을 추출한다’는 아이디어는 간단하지만, 실제로 구현하려면 여러 단계가 필요합니다. 하나씩 풀어가 볼게요.

Step 1. 분석할 페이지 파일 찾기

먼저 39개의 페이지 파일을 찾아야 합니다. Next.js 프로젝트에서 페이지는 src/pages/ 디렉토리에 있고, 각 페이지의 진입점은 index.tsxuniversal.tsx 파일입니다.

// FileScanner: glob 패턴으로 페이지 파일 탐색
const files = await glob('src/pages/funnel/**/*.tsx', {
  ignore: ['**/components/**', '**/hooks/**', '**/_*.tsx'],
});

// index.tsx, universal.tsx만 필터링
return files.filter(file =>
  ['index.tsx', 'universal.tsx'].includes(path.basename(file))
);

components/hooks/ 폴더는 페이지가 아니니까 제외합니다. _app.tsx 같은 특수 파일도 제외하고요. 이렇게 하면 순수하게 페이지 컴포넌트만 남습니다.

Step 2. Navigation Edge 데이터 구조 설계

코드를 분석하기 전에, 어떤 정보를 추출할지 먼저 정해야 했습니다. 처음에는 단순하게 ‘A 페이지에서 B 페이지로 간다’만 저장하려고 했어요.

// 첫 번째 시도: 너무 단순함
{ from: 'applicant', to: 'pay-method' }

그런데 이것만으로는 부족했습니다. 다이어그램을 봐도 왜 이 경로로 가는지, 어떤 조건일 때 가는지 알 수 없었어요. 결국 코드를 다시 찾아가야 했죠.

고민 끝에 Navigation의 맥락 정보까지 함께 저장하기로 했습니다.

interface NavigationEdge {
  from: string;           // 출발 페이지
  to: string;             // 도착지 (상수 또는 URL)
  method: 'push' | 'replace';  // 브라우저 히스토리 처리 방식
  condition?: string;     // 이 Navigation이 실행되는 조건
  queryParams?: Record<string, string>;  // 전달되는 쿼리 파라미터
  lineNumber: number;     // 코드에서의 정확한 위치
  sourceType: 'page' | 'hook';  // 페이지에서 직접 호출 vs 훅에서 호출
  hookName?: string;      // 훅에서 호출된 경우 훅 이름
}

각 필드를 이렇게 설계한 이유가 있습니다:

필드
왜 필요한가
method
push는 뒤로가기 가능, replace는 불가능. 사용자 경험에 영향을 주므로 구분 필요
condition
"대표자일 때만 이 경로로 간다" 같은 분기 조건을 알아야 퍼널 로직 이해 가능
queryParams
다음 페이지에 어떤 상태를 전달하는지 파악 가능
lineNumber
다이어그램에서 코드로 바로 점프할 수 있음
sourceType
페이지 직접 호출인지 훅 내부 호출인지 구분해야 디버깅이 쉬움

이 구조가 이후 모든 분석의 기반이 됩니다.

Step 3. 페이지에서 Navigation 패턴 추출하기

이제 각 페이지 파일을 AST로 파싱해서 router.push(), router.replace() 같은 Navigation 호출을 찾습니다.

// NavigationExtractor: AST에서 Navigation 호출 탐색
extractFromFile(filePath: string, pageName: string): NavigationEdge[] {
  const sourceFile = this.project.addSourceFileAtPath(filePath);
  const edges: NavigationEdge[] = [];

  // 모든 함수 호출을 순회
  const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);

  for (const call of callExpressions) {
    // router.push(), router.replace() 패턴 감지
    if (this.isRouterNavigation(call)) {
      const edge = this.extractEdge(call, pageName);
      if (edge) edges.push(edge);
    }
  }

  return edges;
}

핵심은 getDescendantsOfKind(SyntaxKind.CallExpression)입니다. 파일 전체에서 모든 함수 호출을 찾아온 뒤, 그중에서 router.push()router.replace() 패턴인지 확인합니다.

조건문(condition)은 어떻게 추출할까요? Navigation 노드를 찾으면 AST를 부모 방향으로 거슬러 올라가면서 가장 가까운 if문이나 삼항 연산자를 찾습니다.

// 조건문 추출 로직
private extractCondition(node: Node): string | undefined {
  let current = node.getParent();

  while (current) {
    if (current.isKind(SyntaxKind.IfStatement)) {
      // if문 발견! 조건식 추출
      return current.getExpression().getText();
    }
    current = current.getParent();
  }

  return undefined;  // 조건문 없이 실행되는 Navigation
}

이렇게 하면 어떤 조건일 때 이 경로로 가는지까지 알 수 있어요.

Step 4. 커스텀 훅 안에 숨어있는 Navigation 찾기

페이지 파일만 분석하면 놓치는 Navigation이 있습니다. 로직이 커스텀 훅으로 분리된 경우예요.

// applicant.tsx - 페이지 컴포넌트
function ApplicantPage() {
  useBusinessLogin() // 이 훅 안에서 router.push()를 호출하면?
}

// hooks/useBusinessLogin.ts - 커스텀 훅
function useBusinessLogin() {
  const router = useRouter()

  useEffect(() => {
    router.push('/business-login-bridge') // 여기!
  }, [])
}

페이지 컴포넌트만 분석하면 이 Navigation을 놓치게 됩니다. 그래서 페이지가 import하는 훅을 역추적하는 방식을 사용했습니다.

// HookAnalyzer: 페이지의 import 구문에서 훅 추출
extractHookImports(pageFilePath: string): HookImport[] {
  const sourceFile = this.project.addSourceFileAtPath(pageFilePath);
  const hookImports: HookImport[] = [];

  for (const importDecl of sourceFile.getImportDeclarations()) {
    const moduleSpecifier = importDecl.getModuleSpecifierValue();

    // '/hooks/' 경로에서 import하는 것만 추적
    if (moduleSpecifier.includes('/hooks/')) {
      const namedImports = importDecl.getNamedImports();

      for (const namedImport of namedImports) {
        const hookName = namedImport.getName();
        // use로 시작하는 것만 훅으로 판단
        if (hookName.startsWith('use')) {
          hookImports.push({ hookName, hookFilePath: this.resolveHookFilePath(...) });
        }
      }
    }
  }

  return hookImports;
}

이렇게 찾은 훅 파일을 다시 AST로 파싱해서 Navigation 호출이 있는지 확인합니다.

// 훅 내부에 Navigation이 있는지 체크
hasNavigationCalls(hookFilePath: string): boolean {
  const sourceFile = this.project.addSourceFileAtPath(hookFilePath);

  const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
  for (const call of callExpressions) {
    if (NavigationDetector.isRouterNavigation(call)) {
      return true;
    }
  }

  return false;
}

훅에서 Navigation을 찾으면, 해당 훅을 사용하는 페이지에서 나가는 edge로 기록합니다. 출처도 함께 기록해서 나중에 ‘이 Navigation은 훅에서 온 것’임을 알 수 있게 했어요.

// 추출 결과에는 출처가 함께 기록됩니다
{
  from: 'applicant',
  to: '/business-login-bridge',
  sourceType: 'hook',        // 'page'가 아닌 'hook'
  hookName: 'useBusinessLogin',
  lineNumber: 42
}

Step 5. URL 상수를 실제 경로로 변환하기

여기까지 하면 Navigation edge들이 추출되는데, 문제가 있습니다. 도착지가 URLS.FUNNEL.PAY_METHOD 같은 상수 형태예요.

// 코드에는 이렇게 쓰여 있지만
router.push(URLS.FUNNEL.PAY_METHOD)

// 그래프에는 실제 경로가 필요합니다
'/funnel/pay-method'

그래서 URL 상수 파일을 AST로 파싱해서 상수 → 실제 경로 매핑 테이블을 만듭니다.

// URLResolver: URL 상수 파일 파싱
async loadUrlConstants(): Promise<void> {
  const urlsFile = project.addSourceFileAtPath('src/constants/urls.ts');

  // URLS 객체 찾기
  const urlsObject = urlsFile.getVariableDeclaration('URLS');

  // 중첩 객체를 재귀적으로 순회하며 매핑 생성
  this.extractNestedObject(urlsObject, 'FUNNEL');
}

// 결과: Map<상수명, 실제경로>
// 'FUNNEL.PAY_METHOD' → '/funnel/pay-method'
// 'FUNNEL.DELEGATION_INFO' → '/funnel/delegation-info'

Step 6. 그래프 구축하기

이제 모든 edge를 모아서 그래프를 만들 차례입니다. 이 과정에서 진입점이탈점도 자동으로 분류합니다.

// 진입점: 들어오는 edge가 없고, 나가는 edge만 있는 페이지
// 이탈점: 나가는 edge가 없고, 들어오는 edge만 있는 페이지

for (const [pageName, node] of nodes) {
  if (node.incomingEdges.length === 0 && node.outgoingEdges.length > 0) {
    entryPoints.push(pageName);  // 퍼널의 시작점
  }

  if (node.outgoingEdges.length === 0 && node.incomingEdges.length > 0) {
    exitPoints.push(pageName);   // 퍼널의 끝점 (신청 완료 등)
  }
}

Step 7. Mermaid 다이어그램 생성하기

마지막으로 그래프를 Mermaid 문법의 플로우차트로 변환합니다.

// MermaidGenerator: 그래프를 Mermaid 문법으로 변환
generate(graph: NavigationGraph): string {
  const lines: string[] = [];

  lines.push('```mermaid');
  lines.push('flowchart TD');

  // 노드 정의 (진입점은 둥근 모양, 이탈점은 기울어진 모양)
  for (const [pageName] of graph.nodes) {
    const shape = this.getNodeShape(pageName, graph);
    lines.push(`    ${nodeId}${shape}`);
  }

  // 엣지 정의 (조건, 쿼리 파라미터를 라벨로)
  for (const edge of graph.edges) {
    const label = this.getEdgeLabel(edge);  // 조건문, ?param=value 등
    const style = edge.method === 'replace' ? '==>' : '-->';
    lines.push(`    ${fromId} ${style}|"${label}"| ${toId}`);
  }

  lines.push('```');
  return lines.join('\\n');
}

push는 단일 화살표(-->), replace는 이중 화살표(==>)로 구분합니다. 브라우저 히스토리에 남는지 여부가 다르니까, 시각적으로도 구분해주는 거예요.

완성된 다이어그램, 그리고 효과

최종적으로 생성되는 다이어그램 예시입니다.

flowchart TD
    entry([진입 페이지])
    applicant[신청자 정보]
    pay_method[결제 수단]
    delegation[위임 정보]
    review[검토]
    complete[/신청 완료/]

    entry -->|"?from=home"| applicant
    applicant -->|"신청자 === 대표자 && 사업자형태 === 개인사업자"| pay_method
    applicant -->|"신청자 === 대리인"| delegation
    pay_method --> review
    delegation --> review
    review ==>|"[replace]"| complete

    classDef entryNode fill:#e1f5ff,stroke:#0066cc,stroke-width:2px
    classDef exitNode fill:#ffe1e1,stroke:#cc0000,stroke-width:2px
    class entry entryNode
    class complete exitNode

무엇이 바뀌었나

1. 신규 입사자 온보딩 시간 단축

‘판매자 입점 퍼널이 어떻게 돌아가는지’를 설명할 때, 이제 다이어그램 하나면 충분합니다. 39개 파일을 읽을 필요 없이, 전체 흐름을 한눈에 보여줄 수 있어요.

2. 영향 범위 파악이 쉬워짐

"이 페이지를 수정하면 어디에 영향을 주지?"라는 질문에 바로 답할 수 있습니다. 다이어그램에서 해당 노드에서 뻗어나가는 화살표만 보면 되니까요. 예를 들어 신청자 정보 페이지에서는 6개의 서로 다른 경로가 있고, 각각 어떤 조건일 때 실행되는지 한눈에 파악할 수 있어요.

3. 데드엔드 발견

이탈점이 아닌데 나가는 edge가 없는 페이지(데드엔드)를 자동으로 찾을 수 있습니다. 실제로 개발 중에 실수로 Navigation을 빼먹은 페이지를 발견하기도 했어요.

4. 코드 변경과 문서 동기화

코드를 변경하면, 스크립트를 다시 실행하는 것만으로 문서가 자동으로 업데이트됩니다. 더 이상 "문서가 Outdated 됐는지" 걱정할 필요가 없어요.

마치며

신규 입사자로서 처음 마주한 39개의 페이지 파일들. "이 페이지는 어디로 연결되지?"를 파악하는 데만 며칠이 걸렸습니다. 하지만 그 경험이 이 도구를 만드는 출발점이 되었어요.

신규 입사자의 시선이 오히려 도움이 됐습니다. 팀원들에게는 익숙한 것들이, 처음 보는 사람에게는 명확한 Pain Point로 보이거든요. 그 불편함을 ‘원래 그런 거니까’로 넘기지 않고, 실제 코드베이스를 활용해 해결책을 만들 수 있었습니다.

문서화는 항상 "중요하지만 급하지 않은" 일로 밀려나기 쉽습니다. AST를 활용하면 코드가 변경될 때마다 자동으로 동기화되는, 절대 Outdated 되지 않는 문서를 만들 수 있어요.

비슷한 고민이 있으시다면, 꼭 퍼널이 아니더라도 정적 분석으로 해결할 수 있는 문제가 많습니다. 라우팅 구조, 컴포넌트 의존성, API 호출 패턴 등 코드에 있는 정보를 자동으로 추출하고 시각화 해보세요.

이 글을 읽고 궁금한 점이나 비슷한 경험이 있으시다면 댓글로 공유해주세요!

댓글 0댓글 관련 문의: toss-tech@toss.im
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.