배너

토스인컴 세금 환급 서비스 : 빠른 속도에서 품질을 지키기 위한 E2E 자동화 여정

정수호
2025년 12월 2일

안녕하세요. 토스인컴 QA Manager 정수호입니다.

토스인컴의 QA는 단순히 ‘테스트를 수행하는 팀’이 아니에요. 우리는 제품팀의 구성원으로서 품질을 함께 설계하고 실행하는 동료입니다. 특히 제가 맡고 있는 세금 환급 서비스는 토스인컴을 대표하는 도메인입니다. 사용자 유형이 다양하고, 세무 로직과 정책, 예외 흐름이 많죠. 변화가 잦고 조건이 복잡한 서비스일수록 품질을 초기에 설계하고 실행을 단순화하는 일이 중요합니다.

메뉴얼 검증으로는 "빠르게 실험하고, 빠르게 배포하는" 토스의 속도를 따라갈 수 없습니다. 버튼 하나, 문구 하나가 바뀌어도 35개 이상의 시나리오를 다시 확인해야 했고, 작은 누락이 전체 배포를 지연시키기도 했습니다.

그래서 우리는 결심했습니다. "누구나 실행할 수 있고, 언제나 신뢰할 수 있는 자동화 시스템"을 만들자고. 그 핵심은 Functional Page Object Model(POM)이었습니다.

토스인컴 QA의 일하는 방식

토스인컴의 QA는 중앙 기능조직(QA 팀)이면서 동시에 각 서비스의 사일로(제품팀) 안에 겸직 형태로 배치됩니다.즉, 제품팀 속 QA로서 프로젝트의 초기 기획·디자인부터 배포·라이브 모니터링까지 전 과정을 함께해요.

이 구조 덕분에 QA는 "끝단에서 문제를 발견하는 팀"이 아니라, 의사결정에 참여해 리스크를 줄이고 실행 속도를 높이는 팀이 됩니다. Functional POM은 이런 일하는 방식과 최적의 궁합을 이뤄요.

*POM: 화면을 어떻게 조작할지에 대한 설명서를 코드로 한곳에 모아둔 것 *Functional: 그 설명서를 작고 명확한 함수들로 나눠 필요한 것만 조립해 쓰는 방식

우리가 마주했던 현실과 전환의 계기

초기에는 우리도 일반적인 클래스 기반 POM을 도입했습니다. 하지만 세금 서비스 특성상 UI 문구와 전환 흐름이 자주 바뀌는 상황에서, this 상태 관리와 상속 구조는 복잡성을 더했고 유지보수 비용이 계속 늘었어요.

우리는 깨달았습니다. 자동화는 라이브러리 선택이 아니라 "운용 구조" 문제라는 것을. 그래서 목표를 다시 세웠습니다. 더 단순하게, 더 조립 가능하게, 더 읽기 쉽게.

우리가 시도한 변화들 (Functional POM을 중심으로)

1️⃣ 클래스 대신 함수형 POM으로

상태를 가지는 클래스 대신, 무상태(Stateless) 함수로 페이지 동작을 설계했습니다. 원칙은 단순합니다. 입력으로 page(그리고 필요 시 context)를 받고, 결과로 page를 돌려준다.

Before — POM 없이 작성된 테스트(중복·취약)

// ❌ 이런 코드가 35개 파일에 복사되어 있었습니다.
async function test1() {
  await page.goto('https://income.toss.im/login');
  await page.click('#branch-selector');
  await page.click('button:has-text("세금환급")');
  await page.click('button:has-text("동의하고 시작하기")'); // 텍스트 바뀌면 전 파일 수정
  await page.fill('[placeholder="핀번호 6자리"]', '123456');
  // ...
}

After — 함수형 POM으로 캡슐화

// page1_loginToAgreeTerms.ts
export async function gotoLoginPage(page: Page, userNo: number, context: BrowserContext) {
  await page.goto(`https://service.tossincome.com/intro?userNo=${userNo}`);
  await waitForNetworkIdleSafely(page);
  await clickButton(page, '시작하기'); // 문구가 바뀌면 여기 "한 곳"만 수정
  return page;
}

테스트는 "사람이 읽는 시나리오"처럼

// Test Case
await gotoLoginPage(initialPage, userNo, context);
await clickInitialConsent(currentPage);
await enterPhonePin(currentPage);
await enterPassword(currentPage);

네이밍 컨벤션(가독성 강화)

접두사
의미
예시
goto
페이지 이동
gotoLoginPage()
click
클릭
clickReConsent()
enter
입력
enterPhonePin()
answer
질문 응답
answerNoMarriageQuestion()
add/skip/update
데이터 조작
addMedicalDeduction()
verify/check
검증
verifyRefundAmount()
waitFor
대기
waitForRefundPageReady()
complete
복합 플로우
completeLogin()

2️⃣ "페이지=화면"이 아니라 "여정의 단계"로 분리

세금 환급의 전체 여정을 4단계로 나눴습니다.

각 단계는 하나의 페이지 오브젝트 파일이 담당합니다.

src/modules/test/cases/page/
├── page1_loginToAgreeTerms.ts
├── page2_checkDeduction.ts
├── page3_refundInfoToPay.ts
└── page4_report.ts

이렇게 나누면 페이지 전환이 잦아도 책임 경계가 명확하고, 각 단계를 독립적으로 수정·재사용할 수 있습니다.

3️⃣ Robust Click Strategy — 클릭 실패에 흔들리지 않게

React 렌더링 타이밍으로 생기는 간헐 실패(플래키)를 줄이기 위해, 클릭 유틸에 4단계 폴백을 넣었습니다.

export async function clickButton(page, buttonName, options = {}) {
  const button = page.getByRole('button', { name: buttonName });
  await button.waitFor({ state: 'visible' });

  try {
    // 1) Enter 키 (가장 안정적)
    await button.focus();
    await page.keyboard.press('Enter');
  } catch {
    try {
      // 2) 기본 클릭
      await button.click();
    } catch {
      try {
        // 3) Force 클릭
        await button.click({ force: true });
      } catch {
        // 4) JS 직접 실행
        await page.evaluate((name) => {
          const btn = [...document.querySelectorAll('button')]
            .find(b => b.textContent?.includes(name));
          btn?.click();
        }, buttonName);
      }
    }
  }

  await waitForNetworkIdleSafely(page); // 안전한 네트워크 대기
}

과거엔 "클릭이 안 돼요"가 매일 한 번씩 들렸다면, 지금은 거의 들리지 않습니다.

4️⃣ 페이지 전환 자동 감지 — 새 창/리다이렉트에서도 안전하게

로그인 → 스크래핑 → 결제 등에서 새 창이 열리거나 리다이렉트가 일어나면 기존 page가 닫히곤 합니다. 항상 최신 유효 페이지를 다시 얻어 쓰는 패턴을 유틸로 통일했어요.

export async function getLatestNonScrapePage(context: BrowserContext) {
  const pages = context.pages();
  for (let i = pages.length - 1; i >= 0; i--) {
    const p = pages[i];
    if (!p.isClosed() && !p.url().includes('scrape')) return p;
  }
  throw new Error('유효한 페이지를 찾을 수 없습니다');
}

테스트 본문에서는 전환마다 currentPage = ...를 명시적으로 교체해 항상 올바른 탭에서 동작하도록 합니다.

5️⃣ 공제별 독립 함수 — 시나리오를 "레고처럼 조립"

의료비, 신용카드, 주담대, 인적공제 등 모든 공제 동작을 완전히 독립된 함수로 만들었습니다. 조합만 바꾸면 새로운 테스트를 빠르게 만들 수 있어요.

export async function addMedicalDeduction(page, amount = 3_000_000) {
  await page.getByText('의료비를 추가하시겠어요?').waitFor();
  await clickButton(page, '네');
  await page.fill('[data-testid="medical-amount"]', String(amount));
  await clickButton(page, '저장');
}

export async function skipCreditCardDeduction(page) {
  await page.getByText('신용카드 사용액을 추가하시겠어요?').waitFor();
  await clickButton(page, '아니요, 그대로 할게요');
}
// 새로운 시나리오: 결혼공제 No + 의료비 Yes + 신용카드 Skip
await answerNoMarriageQuestion(page);
await addMedicalDeduction(page, 5_000_000);
await skipCreditCardDeduction(page);
await processPaymentAndReport(page);

6️⃣ 협업 중심 운영 — 스펙 변경이 POM 한 곳으로 흘러가게

저희는 각 사일로의 Slack 스레드에서 기획 변경·디자인 업데이트·릴리즈 공지를 한 줄로 이어 관리합니다. 문구나 버튼이 바뀌면, 테스트 본문은 그대로 두고 POM/유틸 한 곳만 수정해요.

  • 기획: "버튼 '확인' → '시작하기'로 변경"
  • QA: "clickButton("시작하기")로 POM 반영 완료. 전체 자동화에 즉시 적용"

소통 채널과 변경 반영 위치를 한 곳으로 모으니 속도와 안정성이 동시에 올라갔습니다.


시스템 개요(스택/구조)

아키텍처 개념

실행 패턴 & 결과 확인

결과물

우리가 얻은 결과(숫자 & 체감)

지표
Before (Manual/Legacy)
After (Functional POM)
개선율
검증 시간
4시간+
55분 (병렬 20분)
77% ↓
커버리지
주요 5개
35개 전체 시나리오
+600%
성공률
불안정
100% (최근 2주)
코드 중복률
85%
20%
76% ↓
신규 테스트 작성
2시간
20분
83% ↓
UI 변경 대응
35개 파일 수정
POM 1곳 수정
98% ↓
온보딩
1주
1일
86% ↓


트러블슈팅 플레이북

타임아웃 문제 해결하기 세금환급 서비스에는 폴링이 있는 화면이 많아요. 네트워크가 완전히 쉬지 않기 때문에 Playwright의 기본 networkidle 대기 방식은 자주 실패했습니다. 그래서 "실패로 간주하지 않는 안전 대기"를 기본값으로 두었어요. waitForNetworkIdleSafely는 네트워크가 잠잠해질 때까지 최대한 기다리되, 타임아웃이 나더라도 테스트 자체는 중단하지 않습니다. 화면이 준비됐는지는 텍스트나 role 같은 UI 앵커로 판단하죠. 이렇게 하니 네트워크는 움직이지만 실제 화면은 이미 준비된 상황에서도 테스트가 안정적으로 진행됐어요.

페이지 전환 실패 극복하기 로그인이나 결제 단계처럼 새 창이 열리거나 리다이렉트가 자주 발생하는 구간에서는 기존 page 객체를 그대로 쓰면 "Page is closed" 에러가 터져요. 이제는 모든 의미 있는 동작이 끝날 때마다 "최신 페이지를 다시 가져오는" 습관을 들였습니다. getLatestNonScrapePage로 스크래핑 탭을 제외한 유효한 최신 탭을 찾아 currentPage를 항상 새로 할당해요. 이 단순한 패턴 하나로 전환 관련 에러가 거의 사라졌습니다.

클릭 미적중 대응 전략 클릭 미적중은 한 번에 해결하려 하지 않았어요. 대신 실패에 내성을 넣는 방식으로 접근했습니다. 버튼 클릭은 Enter 키 → 일반 클릭 → Force 클릭 → JS 직접 실행 순으로 네 단계 시도합니다. 어떤 단계에서 어떤 버튼을 눌렀다가 실패했는지, 실패 시점의 URL, 사용한 대기 전략까지 모두 에러 메시지에 남겨요. 다음에 같은 에러가 발생하더라도 원인 파악이 훨씬 빠르고 재현도 간단해졌습니다.

UI 변경에 유연하게 대응하기 UI 문구가 바뀌는 건 거의 매번 일어나요. 이럴 땐 테스트 본문을 건드리지 않고 POM과 유틸만 수정합니다. 테스트는 그대로 "결혼공제 No, 의료비 Yes…" 같은 비즈니스 문장으로 남겨두고, 버튼, 셀렉터, 대기 전략은 모두 POM이 책임지죠. 데이터 의존이 큰 단계에서는 금액, 종류, 날짜 같은 입력값을 기본값이 있는 파라미터로 바꿔 노출했어요. 이렇게 하니 케이스 생성 속도가 훨씬 빨라졌고, 같은 흐름에서도 숫자만 바꾸면 새로운 시나리오가 만들어집니다.


트러블슈팅 이후의 변화

이 구조가 자리를 잡자, 팀 안의 일상이 완전히 달라졌습니다.

개발 후 즉시 자동 검증 이제는 개발이 끝나면 바로 기본 기능 자동 점검이 진행돼요. 핵심 시나리오를 빠르게 훑는 기능 테스트가 계속 돌아가고, 문제 없이 통과되면 바로 배포할 수 있습니다. QA 요청을 따로 넣을 필요가 없죠.

24시간 자동화 시스템 현재 자동화는 24시간 가동 중이에요. 누가 요청하지 않아도 결과가 자동으로 사내 메신저의 QA Automation 증적 채널로 공유됩니다. 모든 테스트가 통과했는지, 어떤 스텝이 오래 걸렸는지, 새 코드 배포 이후 속도가 얼마나 개선됐는지까지 한눈에 볼 수 있어요.

금액 정합성 자동 검증 세금환급 서비스는 금액이 핵심 데이터입니다. 이제는 엔진(플랫폼)에서 계산 로직이 바뀌어도 금액 정합성을 자동으로 검증해요. 과거에는 QA가 직접 여러 케이스를 돌려 눈으로 비교해야 했지만, 지금은 자동화가 모든 금액을 비교하고 오차를 리포트합니다.

성능 개선 지표로 활용 자동화 리포트에는 각 스텝별 실행 시간이 표시돼요. 기능 개선 전후를 비교하면 "어떤 부분에서 얼마나 빨라졌는지"를 수치로 바로 확인할 수 있습니다. QA 리포트는 이제 단순한 통과 여부를 넘어, 성능과 속도 개선을 시각적으로 보여주는 지표로 자리 잡았어요. 이 모든 과정은 누가 일일이 요청하거나 기다릴 필요 없이 자동으로 돌아가요. 그 결과, 팀은 "검증을 기다리는 시간" 대신 "다음 개선을 준비하는 시간"에 집중할 수 있게 됐습니다.


실전 팁 & 베스트 프랙티스

1️⃣ 가장 아픈 곳부터 시작하기 우리는 늘 가장 아픈 곳부터 시작했어요. 가장 자주 깨지던 로그인·약관 구간을 먼저 함수로 뽑았고, 그다음 공제, 결제, 신고 순으로 확장했습니다.

2️⃣ 사용자 시나리오 기준으로 파일 나누기 파일을 나눌 때는 화면이 아니라 사용자 시나리오를 기준으로 경계를 나눴어요. 이 기준이 명확해야 책임이 선명해지고, 수정도 빠르거든요.

3️⃣ 페이지 전환 시 원칙 지키기 전환을 다룰 때는 원칙 하나를 정했어요. "전환 후에는 반드시 currentPage를 새로 할당한다." 이 짧은 한 줄이 수많은 전환 문제를 미리 막아줬습니다.

4️⃣ 과한 추상화 피하기 추상화는 과하게 하지 않았어요. 클래스를 여러 겹으로 쌓는 대신, 작은 함수와 명시적 인자로 명료함을 택했습니다. 누구나 유지보수를 할 수 있도록 말이에요

5️⃣ 변경에 유연한 구조 만들기 자주 바뀌는 문구, 셀렉터, 대기 로직은 전부 POM과 유틸의 영역으로 두었어요. 테스트 본문에는 최대한 비즈니스 문장만 남겨, 누가 봐도 읽히는 테스트를 만들었습니다.

6️⃣ 실패 로그 상세히 남기기 실패가 발생하면 "무엇을 하다 실패했는지(버튼/URL/대기)"를 그대로 로그에 남겨요. 재현이 빠르고, 학습이 쉬워집니다.

7️⃣ 코드를 문서처럼 작성하기 모든 함수에는 JSDoc으로 사용법과 주의사항, 간단한 예시를 적어두었어요. 새로 합류한 팀원도 코드를 열자마자 이해할 수 있게 하기 위해서죠. 우리에게 코드는 곧 문서입니다.


읽히는 테스트를 만드는 법

1️⃣ 서술형 제목으로 시작하기 테스트는 제목부터 서술형 문장으로 써요. 예를 들어 "결혼공제 No + 의료비 Yes → 환급 확인 후 결제 완료"처럼요.

2️⃣ 사전조건 명확히 하기 사전조건에는 "로그인 가능한 유저", "세금환급 서비스 진입 가능"처럼 최소한의 맥락만 남깁니다.

3️⃣ Given-When-Then 구조로 작성하기 시나리오는 Given–When–Then의 리듬으로 써요. "로그인과 약관 동의가 끝난 상태에서, 결혼공제는 아니요로 답하고, 의료비 공제를 받고, 신용카드 공제는 건너뛴 뒤, 예상 환급액 300,000원을 확인하고 결제·신고까지 완료한다."

4️⃣ 운영 방식 문서화하기 운영 방식도 자연어로 덧붙여요. "이 케이스는 단일·반복·격리 실행이 가능하며, 워커 병렬을 켜면 HTML 리포트가 병합되고, 비디오와 Slack 알림으로 결과가 공유된다."

5️⃣ 자연어를 코드로 변환하기 이렇게 자연어로 먼저 작성한 시나리오를 그대로 코드로 옮기면 다음과 같아요.

currentPage = await completeLogin(currentPage, userNo, context);
await answerNoMarriageQuestion(currentPage);
await addMedicalDeduction(currentPage);
await skipCreditCardDeduction(currentPage);
currentPage = await openRefundPage(context);
await verifyRefundAmount(currentPage, 300000);
await processPaymentAndReport(currentPage);

핵심은 하나예요. 테스트가 먼저 읽히고, 코드가 그 뒤를 따라간다. 이 순서를 지키면 비개발자도 테스트를 이해할 수 있고, 개발자는 즉시 실행 가능한 스크립트를 얻습니다.

우리가 배운 것들

우리가 이 과정을 통해 배운 건 단순합니다.

처음부터 함께 설계하고, 누구나 반복 가능한 형태로 실행을 정리할 때, 팀은 더 빠르고 안정적으로 나아갈 수 있습니다.

마치며

Functional POM은 어떤 유행을 쫓기 위한 도구가 아니에요. 우리에겐 일을 단순하게 만드는 습관이었습니다. 클래스 대신 함수를 선택했고, 과한 추상화 대신 가독성과 조립성을 택했습니다. 그 결과, UI가 바뀌어도 두렵지 않은 테스트를 갖게 됐어요. 지금도 35개의 시나리오가 모두 살아 있는 자동화를 유지하고 있습니다.

이제 QA는 끝에서 검증만 하는 팀이 아니에요. 처음부터 함께 설계하고, 함께 개선하는 팀으로 자리 잡았습니다. 앞으로도 토스인컴 QA팀은 Simplicity를 무기로 품질과 속도를 동시에 높이는 실험을 이어갈 예정이에요.

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