커머스 앱에 로그인을 붙이면서 E2E 테스트를 어디에 쓸지 정해야 했다.
인증 테스트를 짰다보다 어디에 붙일지 규칙을 먼저 세우고, 그 규칙이 맞았는지 나중에 확인한 과정을 소개하는 글이다.
통합 테스트가 200ms면 E2E는 20초입니다. 매우 비싼 테스트이고, 화면 문구가 하나 바뀔 때마다 고칠 곳이 생기는 유지보수 비용 또한 비싼 테스트이다.
이번에는 테스트를 짜기 전에 질문을 먼저 던졌다.
어디까지 E2E로 짜야 할까?
상품 목록, 상세, 장바구니가 있는 앱에 로그인과 보호 페이지를 새로 붙이는 작업이었고, 단위 및 통합 테스트가 이미 238개 돌고 있었다. 그리고 30일치 사용자 이벤트 로그가 22,427건 있는 상황에서 E2E 테스트의 경계를 그어야했다.
이벤트 로그의 표는 아래와 같다.
| 이벤트 | 분포도 |
|---|---|
product_list_view | 8,000 · 100.0% |
product_detail_view | 4,656 · 58.2% |
category_filter_change | 3,056 · 38.2% |
page_change | 1,756 · 21.9% |
cart_add | 1,309 · 16.4% |
sort_change | 943 · 11.8% |
login_start | 644 · 8.1% |
login_success | 586 · 7.3% |
wishlist_add | 480 · 6.0% |
order_start | 419 · 5.2% |
order_complete | 277 · 3.5% |
login_fail | 121 · 1.5% |
봇 행동 추출하기
이벤트 로그 데이터 중에서는 device 필드가 있었는데 해당 필드의 값이 null인 경우도 있었다.
이는 봇 행동 혹은 유의미한 트래픽이 아니라고 판단하여 분모에서 제외하였다. 아래는 예외 처리하기로 한 분모를 제외하여 다시 정리한 표이다.
| 이벤트 | 분포도 |
|---|---|
product_list_view | 7,514 · 100.0% |
product_detail_view | 4,656 · 62.0% |
category_filter_change | 3,056 · 40.7% |
page_change | 1,756 · 23.4% |
cart_add | 1,309 · 17.4% |
sort_change | 943 · 12.5% |
login_start | 644 · 8.6% |
login_success | 586 · 7.8% |
wishlist_add | 480 · 6.4% |
order_start | 419 · 5.6% |
order_complete | 277 · 3.7% |
login_fail | 121 · 1.6% |
사용자 이벤트 로그가 말해주지 않는 것
로그만 보고 정하면 안 되는 이유, 즉 사용자 이벤트 로그가 말해주지 않는 것이 있다. 아래 세 가지는 어느 서비스에서든 똑같이 걸릴 것 같다.
빈도는 실패의 파급을 말해주지 않는다.
로그인은 전체 세션의 8.6%만 지난다.
위 표의 이벤트 12개 중 7위다. 담기(17.4%)나 페이지 이동(23.4%)보다 한참 아래인 이벤트 로그이다.
하지만 사용자는 로그인이 막히면 커머스 앱의 핵심 기능인 주문도 마이페이지도 전부 막힌다.
로그 상으로는 제일 빈도가 낮지만, 한 번 깨지면 제일 큰 손실을 일으킬 수 있다.
빈도는 몇 명이 지나가는가를 말할 뿐, 막혔을 때 몇 개가 같이 죽는가는 말해주지 않았다.
고장 난 경로는 로그에서 오히려 조용해진다.
만약, 로그인 기능이 되지 않는다면, 로그인 시작 이벤트만 남고 성공도 실패도 기록되지 않는다.
사람들이 로그인 화면까지는 왔는데 그 뒤로 아무 행동도 할 수 없어 아무 이벤트도 남지 않는다.
그러면 다음 달 로그에서 그 경로의 빈도는 오히려 낮아 보인다. 고장 났기 때문에 조용해진 건데, 숫자만 보면 덜 중요한 경로로 읽힌다.
로그를 근거로 우선순위를 매기는 일이 위험해지는 지점이 이 부분이라고 생각하게 되었다.
따라서 로그는 살아남은 요청만 기록한다.
즉, 로그에 많이 찍히는 경로가 반드시 중요한 경로인 것도 아니고, 로그에 적게 찍히는 경로가 반드시 덜 중요한 경로인 것도 아니다.
로그에 아예 없는 것이 있다.
//evil.com 같은 오픈 리다이렉트 시도는 정상 사용자 이벤트 로그만으로는 발견하기 어렵다. 이 부분은 AI를 사용하여 사용자 로그를 같이 분석하다가 제안 받았던 부분인데, 오픈 리다이렉트라는 개념을 처음 알게 되었다.
오픈 리다이렉트란?
사용자가 전달한 URL이나 Query String 등의 값을 별도의 검증 없이 리다이렉트에 사용하는 경우, 공격자가 의도한 외부 사이트로 사용자를 이동시킬 수 있는 취약점이다.
예를 들어 다음과 같이
redirect파라미터를 제공하는 페이지가 있다고 하자.
https://example.com/login?redirect=/dashboard서버가
redirect값을 별도의 검증 없이 그대로 리다이렉트에 사용한다면,
https://example.com/login?redirect=//evil.com과 같이 공격자가 URL을 변조할 수 있다.
여기서
//evil.com은 현재 페이지의 프로토콜을 그대로 사용하면서evil.com을 새로운 호스트로 해석하게 만든다. 따라서https://example.com에서 접근했다면 최종적으로https://evil.com으로 이동하게 된다.
위와 같은 상황이 발생하게 된다면 로그인 직후, 사용자 신뢰가 가장 높은 시점에 외부 사이트로 나가버릴 수 있다.
그래서 그은 경계
이 세 가지를 근거로 인증에 5갈래, 인증 외에 1갈래를 골랐다.
| 갈래 | 통합 테스트로는 볼 수 없는 것 |
|---|---|
| 미로그인 → 보호 페이지 → 로그인 → 원래 페이지 복원 | 미들웨어가 주소를 심고 → URL을 타고 → 페이지가 읽어 이동한다 |
| 세션 만료 → 만료 안내가 뜬다 | window.location.href로 일어나는 전체 페이지 이동 |
| 잘못된 비밀번호 → 실패 문구, URL은 그대로 | ”이동하지 않았다”를 URL로 확인하는 것 |
returnTo에 외부 주소 → 우리 도메인을 벗어나지 않음 | 브라우저가 실제로 어디에 있는지 |
| 로그인, 로그아웃 후 헤더의 로그인 상태가 실제로 바뀜 | 서버 재렌더 → 새 로그인 상태 → DOM으로 이어지는 사슬 |
| 상세 진입 → 담기 → 주문 → 주문 완료 | 앱의 매출로 이어지는 유일한 경로 |
경계가 맞았는지 확인하기
먼저 구현을 일부러 망가뜨려보았다.
테스트가 초록이라는 건 테스트가 통과했다는 뜻이지 코드가 맞다는 뜻이 아니다.
그래서 인증 구현을 한 번에 한 곳씩 망가뜨리고, 매번 production 빌드를 다시 만들어 전체 스위트를 돌렸다.
실험 중에 테스트 코드는 건드리지 않았다.
정확히는 자동화된 도구가 코드를 변형하는 전통적인 뮤테이션 테스팅과는 조금 다르지만, 여기서는 테스트가 실제 결함을 얼마나 잘 잡아내는지를 확인하기 위해 의도적으로 구현을 깨뜨려 본 것에 가깝다.
| 무엇이 깨지나 | 단위 / 통합 238개 | E2E |
|---|---|---|
| 로그인 안 한 사람에게도 보호 페이지가 열린다 | 12건 실패 | 실패 |
| 로그인은 되는데 원래 페이지 대신 홈으로 간다 | 5건 실패 | 실패 |
| 세션이 끊겼는데 만료 안내가 없다 | 전원 통과 | 실패 |
| 로그인해도 원래 페이지로 못 돌아간다 | 전원 통과 | 실패 |
| 로그인은 되는데 성공 이벤트가 안 남는다 | 전원 통과 | 전원 통과 |
표의 위의 두 줄은 단위/통합 테스트가 12건, 5건씩 실패로 잡았다. 이미 촘촘하게 덮여 있는 자리라 굳이 브라우저를 띄우지 않아도 알 수 있었다.
문제는 아래 세 줄이다. 세 줄 모두 238개가 전원 통과했다.
인증이 망가진 상태를 238개 테스트가 통째로 지나쳤다는 뜻이다.
로그인해도 원래 페이지로 못 돌아갔던 현상
표의 네번째 줄이 조금 신기했다.
실제로 빌드를 만들어 돌려보니, 미로그인 상태로 헤더의 장바구니를 눌러 주문서로 가면 로그인 화면으로 튕긴다.
거기서 로그인을 다시 진행하게 되면 성공 시에, 헤더는 로그인 상태로 바뀌는데 화면은 로그인 폼에 그대로 남는다.
사용자 눈에는 로그인이 실패한 것처럼 보이기 때문에 다시 로그인을 누르게 됩니다.
원인은 Link의 Prefetch가 남긴 캐시
Next.js는 화면에 보이는 링크를 미리 받아둔다. 사용자가 누르기 전에 미리 요청해두면 클릭했을 때 빠르기 때문이다.
문제는 그 미리 받아둔 결과가 미로그인 시점의 판정이라는 것이었다.
① 미로그인으로 홈 진입 → 헤더의 주문서 링크를 자동 프리페치
② 미들웨어가 그 프리페치에 "로그인 화면으로 가라"고 응답 (307)
③ 라우터 캐시에 "이 경로는 로그인 화면으로 간다"가 남음 ← 미로그인 시점의 판정이 굳음
④ 로그인 성공 (세션 쿠키는 이제 멀쩡함)
⑤ 주문서로 이동 → ③의 캐시를 재사용, 서버에 다시 묻지 않음 → 로그인 화면에 그대로고친 방법
미로그인일 때만 프리페치를 끄도록 설정해주었다. 로그인 상태에서는 리다이렉트가 날 일이 없으니 캐시가 오염될 수 없다고 생각했다.
<Link href="/order" prefetch={isLoggedIn ? undefined : false}>정리하며
**테스트의 초록불은 코드가 맞다가 아니라 이 테스트가 통과했다**라는 것을 알게 되었다.
이 테스트가 맞는지 확인하는 방법은 커버리지 숫자를 올리는 게 아니라 일부러 망가뜨려 보는 것이었다는 것이 새로웠다.
그리고 E2E를 어디에 쓸지는 결국 이 질문이었던 것 같다.
a이 테스트를 브라우저에서 돌려야만 확인할 수 있는 것은 무엇인가?
단위 테스트와 통합 테스트에서 이미 잡을 수 있는 것은 브라우저까지 가져가지 않고, 브라우저, 미들웨어, URL, 세션, a실제 페이지 이동이 연결되는 경계에 E2E를 배치한다.
결국 E2E의 개수를 늘리는 것이 목표가 아니라, 비용이 저렴한 테스트로 잡을 수 없는 실패를 가장 비싼 테스트로 보호하는 것이 내가 이번에 그은 경계였다.