값을 하는 곳에만 붙였는가 — 7,514세션 로그로 고른 경로 하나를, 망가뜨려서 증명하기

루프팩 9주차 회고. "E2E는 비싸다"는 알겠는데 그래서 어디에 붙일지가 늘 감이었다. 30일치 이벤트 로그에서 노이즈를 거른 7,514세션을 세어 근거를 만들었더니, 빈도 1위(100%)는 버리고 7위(8.6%)를 고르는 결론이 나왔다. 그리고 그 하나가 값을 하는지, 구현을 세 번 망가뜨려 확인했다.

FrontendTestingPlaywrightE2ENext.js

"E2E는 비싸다"는 말은 어디서든 듣는다. 통합 테스트가 200ms일 때 E2E는 20초고, 버튼 문구 하나 바뀌면 고칠 곳이 생긴다. 여기까지는 안다.

문제는 그 다음이었다. 그래서 어디에 붙일 건데?

지난 주까지 내가 E2E를 정하던 방식은 이랬다. "이거 중요해 보이니까 하나 만들자." 근거를 물으면 "중요하니까"라고 답할 수밖에 없는 상태였다. 근거가 없으니 개수도 못 정한다. 늘려도 될 것 같고 줄여도 될 것 같다.

9주차 과제는 그걸 정면으로 찔렀다. 30일치 이벤트 로그(fixtures/events-30d.jsonl, 22,427줄)를 던져주고 이 로그로 E2E 대상을 정할 근거를 만들라고 했다. 그리고 마지막에 구현을 일부러 망가뜨려서 그 E2E가 잡는지 확인하라고 했다.

결론부터 쓰면 이렇게 됐다.

로그 순위경로세션 비율E2E
1목록 진입100%아니오
2목록 조작 (카테고리·정렬·페이지)60.5%아니오
5담기17.4%아니오
7로그인8.6%예

1·2위를 버리고 7위를 골랐다. 그리고 망가뜨리기 실험 3건 중 1건은 일부러 살아남게 뒀다.

이 글은 그 두 결정을 어떤 기준으로 내렸는지에 대한 기록이다.

로그를 근거로 쓰려면, 내 앱 로그도 같은 모양이어야 했다

시작은 계측이었다. 주어진 시드 로그는 30일치 과거 데이터고, 내 앱이 앞으로 쌓을 로그는 내가 스키마를 정해야 한다. 두 구조가 어긋나 있었다.

시드 로그   { sessionId, ts, name, props: { ... }, device, userId? }
스타터 로거 { event, properties: { sessionId, ts, device, ...props } }   // 공통 값을 props에 병합

정보량은 같다. 중첩 위치만 다르다. 그래서 실제 논점은 형식이 아니라 변환을 누가 떠안는가였다.

변환 위치앱이 늘어나면스키마 책임
보내는 쪽 (provider)각 앱이 각자 계약을 지킨다생산자
읽는 쪽 (집계 스크립트)스크립트에 앱별 분기가 쌓인다소비자가 떠안음

처음엔 "이번 주에 이 로그를 읽는 코드가 없으니 만들지 말자"고 판단했다가 뒤집었다. 그 근거는 코드베이스 안만 본 것이었다. 운영을 가정하면 30일 뒤 이 로그를 읽는 사람이 반드시 생긴다. 합의된 스키마를 지키는 건 생산자 책임이라고 보고, 스타터 로거는 그대로 두고 recordProvider를 하나 붙여 그 안에서 시드와 같은 최상위 구조로 조립했다.

세션 정의도 여기서 같이 정했다. 브라우저 탭(컨텍스트) 생존 기간으로 잡고 sessionStorage에 담았다. 무활동 만료(30분 같은)는 임의의 숫자가 필요하고, 브라우저 단위는 여러 번의 시도를 하나로 뭉개 빈도의 분모를 왜곡한다. 무엇보다 E2E가 브라우저 컨텍스트 하나를 열어 처음부터 끝까지 도는 단위라, 세션 정의를 그 단위에 맞추면 로그의 "한 번의 시도"와 E2E의 "한 번의 실행"이 같은 것을 가리키게 된다.

로그를 세는 것부터가 이미 판단이었다

집계를 시작하자마자 막혔다. 로그에는 "이건 봇이다" 같은 표시가 없다. 세는 방법을 내가 정해야 했고, 그 정의에 따라 순위가 달라진다.

세 가지를 정했다.

첫째, 세션 기준으로 센다. 이벤트 수로 세면 정렬을 다섯 번 바꾼 한 사람이 주문을 완료한 사람보다 위로 올라온다.

둘째, 노이즈는 좁게 거른다. 넓게 거르면 잡음이 아니라 실제 행동이 같이 날아간다.

축거른 기준규모넓게 걸었으면 잃는 것
봇device === null486세션 (6.1%)"이벤트 1개 = 봇"으로 세면 목록만 보고 나간 사람 세션 1,144개가 같이 사라진다
중복 전송sessionId·ts·name·props·device가 전부 같은 줄77줄"같은 세션의 같은 이벤트"로 세면 재시도 행동까지 지워진다
오류client_error를 경로 집계에서만 제외, 세션은 유지103건세션을 통째로 버리면 오류를 겪은 사람의 정상 행동까지 잃는다

device가 null인 것 자체는 봇의 증거가 아니다. 다만 그 486세션에는 네 특징이 함께 겹쳤다.

  • 전부 이벤트가 1개
  • 전부 기본값 진입 (category:all, sort:latest, page:1)
  • ts의 밀리초가 전부 .000Z — 이 486줄을 빼고 나면 로그 전체에 .000Z로 끝나는 줄이 27줄밖에 안 남는다
  • 발생 시각이 전부 UTC 01시대이고 30일 매일 반복

네 조건이 모두 겹치는 집합이 device === null 집합과 정확히 일치해서, 조건을 더 붙여도 걸리는 집합이 안 변했다. 그래서 제일 단순한 조건 하나만 남겼다.

포기한 것도 같이 적어뒀다. 네 특징 중 셋만 가진 봇은 못 잡는다. 이 시드에는 없었을 뿐이다.

셋째, 이탈률의 정의를 골랐다. "그 이벤트가 세션의 마지막이었던 비율"(A)과 "다음 단계로 못 넘어간 비율"(B)은 다른 값이 나온다. B를 골랐는데, 이 표의 목적이 UX 개선이 아니라 회귀가 숨을 수 있는 구간 찾기였기 때문이다. 담기에서 로그인으로 못 넘어가는 게 원래 50.8%라면, 버그로 55%가 돼도 알아채기 어렵다.

세는 규칙이 곧 결과라서, 스크립트를 문서에 통째로 남겼다. 필터 세 개가 맨 위에 있다.

// 집계 스크립트 — 레포 루트에 저장해 node로 실행한다
const raw = fs.readFileSync('fixtures/events-30d.jsonl', 'utf8').trim().split('\n').map(JSON.parse);

// 노이즈 필터 3종
const isBot = (e) => e.device === null;                       // 1) 봇
const seen = new Set(); const dedup = [];                     // 2) 중복: 완전 동일 줄 하나만 남긴다
for (const e of raw) { const k = JSON.stringify(e); if (!seen.has(k)) { seen.add(k); dedup.push(e); } }
const clean = dedup.filter((e) => !isBot(e));
const events = clean.filter((e) => e.name !== 'client_error'); // 3) 오류: 경로 집계에서만 제외

// 세션 기준 순위 — 한 세션 안의 같은 이벤트는 Set으로 1회
const sess = group(events);
const rank = count(sess.flatMap((s) => [...new Set(s.map((e) => e.name))]), (x) => x);

// 이탈률 A(세션의 마지막이었나) / B(다음 단계에 도달 못 했나)를 나란히 뽑아 비교한 뒤 B를 채택
steps.forEach((st, i) => {
  const reached = sess.filter((s) => has(s, st)); const next = steps[i + 1];
  const lastHere = reached.filter((s) => s.at(-1).name === st).length;
  const noNext = next ? reached.filter((s) => !has(s, next)).length : 0;
  console.log(`${st}\t도달 ${reached.length}\tA ${pct(lastHere, reached.length)}\tB ${pct(noNext, reached.length)}`);
});

그런데 걸러내도 순위가 안 바뀌었다

며칠 붙잡고 노이즈 기준을 세웠는데, 걸러내기 전후 순위가 하나도 안 바뀌었다.

순위이벤트전 (8,000세션)후 (7,514세션)
1product_list_view8,000 (100%)7,514 (100%)
2product_detail_view4,656 (58.2%)4,656 (62.0%)
3category_filter_change3,056 (38.2%)3,056 (40.7%)
5cart_add1,309 (16.4%)1,309 (17.4%)
7login_start644 (8.1%)644 (8.6%)
10order_start419 (5.2%)419 (5.6%)

허탈했지만, 왜 안 바뀌었는지를 설명할 수 있으면 그것도 결과다. 봇 486세션이 전부 product_list_view 하나짜리라서, 이미 1위이고 모든 세션에 있는 이벤트만 부풀렸다. 다른 이벤트의 세션 수는 1도 안 변했고 분모만 줄어서 2위 이하의 비율이 전부 올라갔다.

여기서 하나 더 정직하게 적었다. 이 시드는 한 세션 안에서 같은 이벤트가 두 번 나오는 경우가 없어서, 이벤트 수로 세도 순위가 같다. 세션 기준을 쓰는 이유는 여전히 유효하지만, 이 데이터로는 두 기준의 차이를 보여줄 수 없다.

한 축으로 순위를 매기면 정확히 거꾸로 된 결론이 나온다

집계가 끝나고 나온 표다.

경로세션 수비율이탈률 (다음 단계 미도달)E2E
목록 진입7,514100%82.6%아니오
목록 조작4,54460.5%—아니오
담기1,30917.4%50.8%아니오
로그인 (시작 → 성공)644 → 5868.6% → 7.8%9.0%예
주문 (주문서 → 완료)419 → 2775.6% → 3.7%33.9%아니오

축을 세 개로 나눠 보니 서로 반대 방향을 가리키고 있었다.

축인증(로그인)의 값순위
빈도8.6% (644세션)하위권
실패 비용login_fail 18.8%. 로그인 성공 없이 order_start가 발생한 세션은 0건 — 막히면 주문 전환 전부가 막힌다최상위
재현 난이도미들웨어 리다이렉트(307) · next 복원 · 쿠키 속성 · 라우터 캐시. 전부 jsdom에 없다최상위

세 번째 축인 재현 난이도가 이번 주에 새로 얻은 기준이다. 질문을 "중요한가"에서 **"브라우저가 있어야만 성립하는가"**로 바꾸니 판단이 기계적으로 떨어졌다. "중요한가"로 물으면 전부 중요해서 답이 안 나온다.

빈도 1·2위를 안 붙인 근거도 이 축에서 나왔다. "통합이 다 덮었으니까"가 아니다.

목록 화면의 검증 대상현재 배치브라우저 필요
필터·정렬·페이지 변경 → 목록 반영통합 (MSW)아니오
변경 조건이 URL에 실림통합 (onUrlUpdate)아니오
조건이 담긴 주소로 진입통합 (searchParams 주입)아니오
파서 기본값·경계값단위아니오
새로고침 후 필터·목록 유지8주차 E2E예

브라우저가 필요한 부분은 8주차에 이미 만들어져 있었다. jsdom은 location.reload()를 구현하지 않아(호출하면 notImplemented 경고 후 no-op) 새로고침은 재현 대상 자체가 없다. 그러니 이번 주에 목록 경로로 E2E를 늘리면 있는 걸 다시 만드는 것이다.

빈도 축을 아예 못 쓰는 화면도 있었다. /cart·/orders·/mypage는 시드 로그에 진입 이벤트가 없다. 로그가 없다고 "안 중요하다"가 되진 않아서, 나머지 두 축으로만 판단했다. 셋 다 안 붙였는데 근거는 같다 — 가드는 proxy.ts의 matcher(/orders/:path*, /mypage) 하나라 인증 E2E가 한 번 통과하면 화면마다 반복할 이유가 없고, 화면 내용은 서버 데이터 렌더라 MSW로 재현된다.

그리고 하나 더 — 로그는 앱에 있는 결함을 보여주지 않는다. 시드의 로그인 성공률은 91.0%로 양호했다. 그런데 로컬에 띄워놓고 직접 눌러보니 소프트 내비게이션 경로에서 로그인 복원이 아예 안 되고 있었다. 로그는 과거 행동의 기록이지 현재 구현의 상태가 아니다.

테스트 개수가 아니라 단언 단위로 지키는 범위가 갈린다

로그 근거로 직접 고른 시나리오는 1개다. (과제가 인증 필수 갈래로 만료·잘못된 자격 증명 둘을 더 고정해 줘서, 실제 신규 테스트는 3개가 됐다. 인증 외에 내가 추가로 고른 건 0개다.)

미로그인 상태에서 담고 주문서로 가면, 로그인 후 그 화면으로 돌아오고 담은 상품이 그대로 있다

단언은 세 개를 넣었다.

  1. 가드가 /login?next=...로 보내고 next에 원래 경로가 담긴다
  2. 로그인 후 /orders/new로 돌아온다
  3. 복원된 주문서에 담은 상품이 남아 있다

세 번째가 이 시나리오의 값을 정한다. 같은 테스트라도 URL만 단언하면 장바구니 소실을 못 잡고, 상품까지 단언하면 잡는다. 테스트 개수는 같은데 지키는 범위가 다르다. 이건 나중에 실험에서 실측으로 확인됐다.

// e2e/auth.spec.ts
test.describe('로그인 검증 자체는 로그인 상태로 시작하지 않는다', () => {
  test.use({ storageState: { cookies: [], origins: [] } });

  test('미로그인으로 담고 주문서로 가면, 로그인 후 그 화면으로 돌아오고 담은 상품이 그대로 있다', async ({ page }) => {
    await page.goto('/');

    const cartButton = firstUncartedButton(page);
    const ariaLabel = await cartButton.getAttribute('aria-label');
    if (ariaLabel === null) throw new Error('담기 버튼에 aria-label이 없다');
    const productLabel = ariaLabel.replace(/ 장바구니$/, '');
    await cartButton.click();

    await page.getByRole('link', { name: '장바구니' }).click();
    await page.getByRole('link', { name: '주문하기' }).click();

    // ① 가드가 로그인으로 보내고 next에 원래 경로를 싣는다.
    //    proxy.ts가 next를 절대 URL로 만들므로 인코딩 형태를 단언하지 않고 파싱해서 본다
    await expect(page).toHaveURL(/\/login\?next=/);
    expect(nextPathname(new URL(page.url()))).toBe('/orders/new');

    await page.getByLabel('이메일').fill('looper1@loopers.dev');
    await page.getByLabel('비밀번호').fill('looper1234');
    await page.getByRole('button', { name: '로그인' }).click();

    // ② 원래 가려던 경로로 돌아온다
    await expect(page).toHaveURL('/orders/new');
    // ③ 복원된 주문서에 담은 상품이 남아 있다
    await expect(page.getByText(productLabel)).toBeVisible();
  });
});

storageState를 쓰는 테스트와 안 쓰는 테스트의 경계

test.use({ storageState: { cookies: [], origins: [] } }) — 이 한 줄이 이 테스트에서 제일 중요하다. 로그인 흐름 자체가 검증 대상인 테스트를 로그인된 상태로 시작하면, 가드가 안 걸려서 그 테스트는 아무것도 지키지 않는다.

테스트storageState근거
복원안 씀로그인 흐름 자체가 검증 대상
잘못된 자격 증명안 씀같음
세션 만료씀검증 대상이 만료 판정이지 로그인 폼이 아니다. 로그인된 상태가 전제

만료 테스트에는 한 가지가 더 걸려 있었다. /api/auth/me는 "로그인 안 함"과 "세션 만료"를 같은 401로 돌려준다. 그래서 401을 전역에 그대로 올리지 않고 각 API 모듈이 의미로 번역하도록 했다. getMe는 401을 null(미로그인)로 바꿔 전역에 도달시키지 않고, 주문 API만 SessionExpiredError를 던져 전역 QueryCache·MutationCache의 onError 한 곳에서 로그인 화면으로 보낸다.

이 결정이 테스트에 그대로 드러난다. 만료 테스트가 /mypage가 아니라 /orders로 들어가는 이유가 그것이다.

// 만료를 시간으로 재현하지 않는다. 항상 401을 주는 시나리오 쿠키를 심는다.
// 쿼리로는 못 붙인다 — 앱 내부 호출이라 URL을 만들 자리가 없다
await context.addCookies([{ name: 'scenario', value: 'expired', url: 'http://localhost:3000' }]);

await page.goto('/orders');

await expect(page.getByRole('status')).toHaveText('세션이 끊어졌어요. 다시 로그인해 주세요.');
const loginUrl = new URL(page.url());
expect(loginUrl.pathname).toBe('/login');
expect(loginUrl.searchParams.get('reason')).toBe('expired');
expect(nextPathname(loginUrl)).toBe('/orders');

여기도 정직하게 남긴 게 있다. 이 레포에서 storageState의 실제 소비자는 1개뿐이다. "테스트 20개가 각각 로그인하면 400초" 같은 절약 효과는 여기서 성립하지 않는다. "매 테스트에서 로그인 폼을 다시 채우지 않는다"는 요구는 만족하지만, 시간을 아꼈다고 쓰지는 않았다.

격리에서 가른 것은 데이터가 아니라 세션이었다

과제가 계정 8개를 준 이유를 처음엔 "워커별로 주문 데이터가 섞이지 않게"로 이해했다. 그런데 이번 테스트 3개 중 주문을 생성하는 건 하나도 없다. 복원 시나리오는 주문서 도달까지다. 그럼 왜 갈라야 하나.

돌려보니 답이 나왔다. 로그아웃과 세션 쿠키가 계정 단위라, 워커가 같은 계정을 쓰면 한쪽의 로그아웃이 다른 쪽 세션을 끊는다. 데이터 충돌이 아니라 세션 충돌이었다.

// e2e/fixtures.ts — playwright.dev/docs/auth "Authenticate in a worker fixture" 레시피
export const test = base.extend<object, { account: Account; workerStorageState: string }>({
  // 워커별 계정: looper1 ~ looper8
  account: [({}, provide, { parallelIndex }) =>
    provide({ email: `looper${(parallelIndex % 8) + 1}@loopers.dev`, password: 'looper1234' }),
    { scope: 'worker' }],

  workerStorageState: [async ({ browser, account }, provide) => {
    // 워커별 파일. outputDir 아래라 .gitignore를 안 건드리고, 실행마다 정리되는 자리라
    // 세션 TTL 1시간이 지난 파일을 물고 시작할 일이 없다
    const fileName = path.resolve(test.info().project.outputDir, `.auth/${test.info().parallelIndex}.json`);
    if (fs.existsSync(fileName)) return provide(fileName);

    // API 직접 호출로 로그인 상태를 위조하지 않는다. UI 폼을 채운다
    const page = await browser.newPage({ baseURL: APP_ORIGIN, storageState: undefined });
    await page.goto('/login');
    await page.getByLabel('이메일').fill(account.email);
    await page.getByLabel('비밀번호').fill(account.password);
    await page.getByRole('button', { name: '로그인' }).click();
    await expect(page.getByRole('button', { name: '로그아웃' })).toBeVisible();
    await page.context().storageState({ path: fileName });
    await page.close();
    await provide(fileName);
  }, { scope: 'worker' }],

  storageState: ({ workerStorageState }, provide) => provide(workerStorageState)
});

셀렉터는 역할·이름 기반으로 갔고 getByTestId는 한 번도 안 썼다. 예외가 하나 있는데, "아직 담기지 않은 상품"을 골라야 하는 담기 버튼이다. 그 조건이 aria-pressed="false"라 role+name만으로는 구분되지 않아 button[aria-label$="장바구니"][aria-pressed="false"]를 썼다. testid가 아니라 실제 접근성 속성 기준이고, 8주차 테스트가 같은 관례를 쓰고 있어 그대로 재사용했다.

E2E가 값을 한 자리 — 통합으로는 절대 안 잡히던 결함

테스트를 먼저 쓰고 빨간불을 확인한 뒤에 원인을 파기로 했다. 먼저 고쳐버리면 그 테스트가 결함을 잡는지 확인할 방법이 없어진다.

예상대로 복원 테스트가 실패했다. 로그인은 200으로 성공하고 헤더도 로그인 상태로 바뀌는데 화면이 /login에 그대로 남았다.

원인은 내 코드가 아니었다. 미로그인 상태에서 주문하기 링크를 프리페치하면 Next 클라이언트 라우터 캐시가 그때의 리다이렉트 결과를 들고 있다가, 로그인 직후 router.replace('/orders/new')에서 그대로 재생한다(vercel/next.js#88937, 미해결).

- <Link className="primary-action" href="/orders/new">
+ <Link className="primary-action" href="/orders/new" prefetch={false}>

prefetch={false}는 이 링크에 대한 사전 요청을 막아, 라우터 캐시에 미로그인 시점의 리다이렉트 결과가 남지 않게 한다.

이게 jsdom + MSW로는 절대 안 잡히는 종류다. 라우터 캐시라는 재현 대상 자체가 없다. "브라우저가 있어야만 성립하는가"라는 축을 세워둔 게 여기서 값을 했다.

trace를 열어보는 것과 옵션만 켜두는 것은 다르다

설정에 trace 옵션을 넣어둔 적은 있어도, 열어서 읽어본 적은 없었다. 통과하는 테스트의 단언을 일부러 틀리게 바꾸고(/orders/new → /orders/history) 실행했다.

pnpm build   # playwright.config.ts가 reuseExistingServer라 이걸 빼먹으면 옛 빌드로 통과한다
pnpm exec playwright test --trace=on -g "미로그인으로 담고"
pnpm exec playwright show-trace test-results/<경로>/trace.zip

Playwright Trace Viewer — 왼쪽 액션 목록에서 마지막 Expect toHaveURL만 빨간색 5.0s로 표시되고, 가운데 스냅샷은 주소창이 /orders/new이며 주문서에 담은 상품이 그려져 있다. 아래 Call 패널의 expectedText는 /orders/history다

이 한 장에서 세 가지가 동시에 읽힌다.

  • 왼쪽 액션 목록에서 로그인 버튼 클릭(21ms)까지는 전부 정상 완료되고, 마지막 Expect "toHaveURL"만 빨간색에 5.0s다. 5초는 기본 타임아웃이니 실패가 아니라 기다리다 만료된 것이다.
  • 가운데 스냅샷의 주소창이 **http://localhost:3000/orders/new**이고, 주문서에 담은 상품(메이커스 투명케이스 · 1개)까지 그려져 있다. 앱은 정확히 원하는 상태에 도달해 있었다.
  • 아래 Call 패널의 expectedText: [{"string":"http://localhost:3000/orders/history"}] — 틀린 건 앱이 아니라 기댓값 쪽이다.

터미널에도 unexpected value "http://localhost:3000/orders/new"는 찍힌다. 다만 trace에는 그 앞뒤가 같이 남아 있다. 호출 로그에는 5 × .../login?next=... → 9 × .../orders/new로, 복원이 진행되는 중간 상태까지 폴링 횟수와 함께 기록돼 있었다. "언제부터 이 값이었나"를 되짚을 수 있는지가 터미널 출력과의 차이다.

망가뜨려서 증명하기 — 3건 중 1건은 일부러 살려뒀다

E2E가 비싸니까, 그 값을 하는지 확인해야 한다. 구현을 한 번에 한 곳만 바꾸고 전체 스위트를 돌렸다. 테스트 코드는 한 줄도 건드리지 않았다.

후보 5개 중 3개를 골랐는데, 고르는 기준은 "이 실험이 무엇을 말해주는가"였다. 잡히는 것만 모으면 "E2E가 잘 돈다"는 말밖에 못 한다. 살아남는 게 하나 있어야 지키는 범위의 경계가 드러난다.

#망가뜨린 곳어떻게결과
1proxy.ts의 config.matcher['/orders/:path*', '/mypage'] → ['/mypage']잡힘 (7 passed / 1 failed)
2CartSection.tsx의 주문하기 링크prefetch={false} 제거잡힘 (7 passed / 1 failed)
3resolveLoginDestination.tsisSafeRedirect() 검사 제거살아남음 (8 passed)

같은 테스트가 깨져도, 멈춘 단언이 다르면 다른 결함이다

1번과 2번은 같은 테스트를 실패시켰다. 그런데 메시지가 달랐다.

# 실험 1 — 첫 번째 단언에서 멈춤
Expected pattern: /\/login\?next=/
Received string:  "http://localhost:3000/orders/new"

가드가 안 걸려서 주문서에 그냥 도착했다. 다른 해석이 없다.

# 실험 2 — 세 번째 단언에서 멈춤
Expected: "http://localhost:3000/orders/new"
Received: "http://localhost:3000/login?next=http%3A%2F%2Flocalhost%3A3000%2Forders%2Fnew"

Received가 /login?next=...니까 가드는 정상이고 next도 잘 실렸다. 로그인 후 복원 단계만 실패한 것이 메시지 안에서 구분된다.

앞에서 "테스트 개수는 같은데 단언에 따라 지키는 범위가 다르다"고 쓴 것의 실측 근거가 이거다. 테스트 하나가 지키는 범위는 테스트 단위가 아니라 단언 단위로 갈린다.

실험 1에서 부수적으로 확인된 것도 있다. 그 변경으로 /orders(주문 내역)도 함께 보호에서 빠졌는데 만료 테스트는 통과했다. 그 테스트가 보는 건 가드가 아니라 401 → SessionExpiredError 번역 → 전역 onError 경로이기 때문이다. 두 테스트가 서로 다른 것을 지키고 있다는 확인이다.

살아남은 3번을 어떻게 할 것인가

E2E 8개가 전부 통과했다. 우리 E2E 3개 중 next에 외부 주소를 실어 보내는 테스트가 없기 때문이다.

여기서 반사적으로 "E2E를 하나 더 만들어야겠다"가 나왔는데, 손대기 전에 망가뜨린 상태 그대로 단위 테스트를 돌려봤다.

pnpm exec vitest run --project unit resolveLoginDestination

FAIL  resolveLoginDestination > 외부 도메인은 기본 경로다
  AssertionError: expected '/steal' to be '/'
Tests  1 failed | 6 passed (7)

잡고 있었다. 그래서 E2E를 추가하지 않기로 했다. 외부 주소 차단은 문자열 입력 → 문자열 출력의 순수 함수 판정이라 브라우저가 있어야 성립하는 부분이 없다. 검사 자체도 startsWith('/') 같은 문자열 매칭이 아니라 URL 파싱 후 origin 비교로 뒀다 — //evil.com(프로토콜 상대 URL)이나 /\evil.com(브라우저가 백슬래시를 슬래시로 정규화)처럼 브라우저 파싱 규칙에 기대는 우회를 접두사 검사로는 막을 수 없기 때문이다. new URL()은 브라우저와 같은 파서를 쓰므로 그 정규화가 검증 시점에 똑같이 반영된다. 앞에서 세운 "브라우저가 필요한가" 기준을 그대로 적용하면 단위 계층이 맞는 자리다.

이 항목이 살아남은 건 커버 공백이 아니라 계층 배분이 의도대로 동작한 결과다. 실험이 드러낸 건 "E2E에 구멍이 있다"가 아니라 "이 검증은 E2E의 몫이 아니다"였다.

다만 하나는 정직하게 적어둬야 한다. 이 결론은 단위 테스트가 실제로 존재하고 잡는다는 걸 돌려서 확인했기 때문에 성립한다. 확인 안 했으면 "어딘가에서 지키고 있겠지"라는 추정이었을 거고, 그건 근거가 아니다. 살아남은 실험을 만났을 때 할 일은 다른 계층이 정말 잡는지 돌려보는 것이다.

실험이 끝난 뒤 원복하고 다시 확인했다.

git diff proxy.ts resolveLoginDestination.ts CartSection.tsx   # (빈 출력)

pnpm exec playwright test --workers=4                 # 8 passed (13.9s)
pnpm exec playwright test --workers=1 --repeat-each=3 # 24 passed (1.3m)
pnpm exec vitest run --exclude '**/.claude/**'        # 175 tests passed

비싸다는 걸 숫자로 적어보기

"E2E는 비싸다"를 말로만 하지 말고 이 시나리오의 값을 세어봤다. UI 문구나 구조가 바뀌면 고칠 곳은 7군데다.

#지점무엇이 바뀌면 깨지나
1상품 카드 담기 버튼aria-label 형식 변경
2장바구니의 주문 링크문구 변경
3로그인 이메일 입력라벨 변경
4로그인 비밀번호 입력라벨 변경
5로그인 버튼문구 변경
6복원 URL경로 변경
7주문서의 주문 상품마크업 구조 변경

그리고 Page Object는 만들지 않기로 했다. 신규 E2E가 1개라 7군데가 한 파일 안에 있어서, UI가 바뀌어도 고칠 파일 수가 이미 1이다. 8주차 E2E에는 로그인 흐름이 없어 뽑아낼 중복도 없다. 만들 시점은 로그인을 지나가는 E2E가 2개가 될 때로 적어뒀다.

이번 주에 내린 결정 한 장

질문이번 주의 답근거
E2E 대상을 무엇으로 고르나빈도 · 실패 비용 · 재현 난이도 세 축빈도 하나만 보면 1위를 붙이고 7위를 버리는, 정확히 거꾸로 된 결론이 나온다
계층은 무엇으로 가르나"브라우저가 있어야만 성립하는가"미들웨어 리다이렉트(307) · 라우터 캐시 · 문서 재로드는 jsdom에 재현 대상이 없다
빈도 1위(100%)에 왜 안 붙였나브라우저가 필요한 부분은 8주차 E2E가 이미 덮고 있다늘리면 있는 걸 다시 만드는 것
테스트 1개면 지키는 범위도 1개인가아니다. 단언 단위로 갈린다같은 테스트가 실험 1은 첫 단언, 실험 2는 세 번째 단언에서 멈췄다
망가뜨렸는데 살아남으면먼저 다른 계층이 잡는지 돌려본다잡고 있으면 커버 공백이 아니라 계층 배분이 맞은 것
storageState는 어디까지로그인 흐름 자체가 검증 대상인 테스트는 안 쓴다로그인된 채 시작하면 가드가 안 걸려 아무것도 안 지킨다
병렬에서 무엇을 가르나계정과 storageState 파일데이터가 아니라 세션이 충돌한다

배운 점, 그리고 남은 숙제

가장 크게 바뀐 건 질문의 모양이다. "이거 E2E로 해야 하나?"는 답이 안 나오는 질문이었다. "중요한가"로 물으면 전부 중요하기 때문이다. "브라우저가 있어야만 성립하는가"로 바꾸니 판단이 취향이 아니라 사실 확인이 됐다.

두 번째는 실험 설계에 살아남을 것을 일부러 넣은 것이다. 잡히는 것만 모았으면 "E2E가 잘 돈다"는 만족만 남았을 텐데, 살아남은 하나가 "여기까지가 E2E의 몫"이라는 경계를 그려줬다. 실험의 값은 성공률이 아니라 드러나는 경계에 있었다.

세 번째는 로그가 만능이 아니라는 것이다. 시드 로그인 성공률은 91%로 멀쩡했는데, 실제 앱은 복원이 아예 안 되고 있었다. 로그 순위를 그대로 E2E 목록으로 옮겼다면 정작 고장 나 있던 곳은 지나쳤을 것이다.

숙제도 남았다.

  • 초기 HTML에 로그인 상태를 반영한 대가. 루트 레이아웃에서 쿠키를 읽자 정적 라우트 8개가 전부 동적으로 바뀌고 TTFB +494ms, FCP 중앙값 +406ms가 회귀했다(LCP·CLS는 유지). 요구사항과 7주차 성능 기준이 정면으로 부딪히는 자리인데, 이번엔 요구사항을 택하고 대가를 기록만 해뒀다.
  • 실패 메시지가 원인을 안 알려준 사례. 만료 테스트가 계속 타임아웃 나서 봤더니 서버 상태 라이브러리가 401도 세 번 재시도하고 있었다. 그런데 메시지는 element(s) not found뿐이었다. 401은 재시도해도 답이 안 바뀌는 상태 코드라 재시도 정책 쪽을 고쳤지만, "어떤 메시지가 원인을 알려주는가"는 아직 설계 대상으로 못 다뤘다.