qa-recorder 라이브러리 개발기
"이 버튼 눌렀더니 에러 났어요"
사내 어드민은 레거시 코드가 많았습니다. 기능이 복잡하게 얽혀 있다 보니 QA에서 버그가 발견돼도 재현이 쉽지 않았죠.
어드민을 주로 쓰는 커머스팀에서 버그 제보가 오면 대부분 이런 식이었습니다.
"이 버튼 눌렀더니 에러 났어요."
"이거 잠깐 확인해 주실래요?"
어떤 네트워크 요청이 실패했는지, 어떤 상태에서 버튼을 눌렀는지, 콘솔에는 무엇이 찍혔는지 알아야 했습니다. 이 정보 없이는 재현조차 어려웠습니다. 스크린샷과 그 순간의 화면, 네트워크 흔적만으로 디버깅을 시작하는 일이 반복됐습니다.
당시의 개발자 도구를 다시 열 수 있다면
그러다 우아한형제들 기술블로그에서 '우아한 디버깅 툴' 글을 읽었습니다.
핵심은 테스트 당시의 네트워크 요청, 콘솔 로그, DOM 화면을 저장해두는 것이었습니다. 나중에 개발자가 그 기록을 열어 당시 상황을 확인할 수 있습니다. "당시의 개발자 도구를 다시 열 수 있다면"이라는 질문에서 출발한 도구였죠.
읽으면서 바로 이거다 싶었습니다. 우리 어드민에도 이런 게 있었으면 QA 재현에 쓰는 시간을 절반은 줄일 수 있었으니까요.
다만 저 툴은 사내 전용이고 공개된 라이브러리도 없었습니다. 비슷한 오픈소스도 마땅한 게 없어서, 결국 직접 만들기로 했습니다.
남의 서버 구조를 알 수 없다는 제약
오픈소스 라이브러리를 목표로 잡으니 제약이 생겼습니다.
라이브러리를 쓰는 사람의 서버 구조가 어떤지 알 수가 없습니다. 백엔드가 Python이나 Go로 만들어졌을 수도 있고, 아예 없을 수도 있습니다. 그래서 서버 없이, 스크립트 태그 하나만 추가하면 바로 동작하는 구조로 가기로 했습니다.
<script src="https://unpkg.com/qa-recorder/dist/qa-recorder.umd.js"></script>
이 한 줄만으로 시작할 수 있어야 했습니다.
첫 번째 시행착오: CDP는 쓸 수 없었다
우아한형제들 글을 읽으면서 처음엔 CDP(Chrome DevTools Protocol)로 네트워크를 캡처하려고 했습니다.
CDP는 브라우저와 DevTools가 통신하는 프로토콜입니다. Network 도메인을 활성화하면 요청/응답 데이터를 낮은 수준에서 정확하게 받아올 수 있죠.
// CDP로 네트워크 캡처 — 우아한형제들 방식
ws.send({ id: 1, method: "Network.enable" });
ws.on("message", (msg) => {
const data = JSON.parse(msg.toString());
if (data.method === "Network.requestWillBeSent") { ... }
if (data.method === "Network.responseReceived") { ... }
});
문제는 CDP에 접근하는 방법 자체에 있었습니다.
CDP는 브라우저를 --remote-debugging-port 플래그와 함께 실행해야 쓸 수 있습니다. 브라우저 확장 프로그램의 chrome.debugger API를 통해 접근할 수도 있습니다. 일반 웹페이지의 자바스크립트에서는 CDP에 접근할 방법이 없습니다.
결국 "스크립트 태그 하나"라는 목표와 CDP는 양립할 수 없었습니다. 클라이언트에서만 동작하는 라이브러리에는 CDP를 쓸 수 없었습니다.
그렇다면 fetch를 직접 갈아끼우자
대안은 window.fetch와 XMLHttpRequest를 직접 패치하는 방식이었습니다.
// 원본 fetch 저장
const originalFetch = window.fetch;
// window.fetch를 래핑해서 교체
window.fetch = async function (input, init) {
const startTime = Date.now();
const request = new Request(input, init);
const response = await originalFetch(input, init);
// 요청/응답 정보를 버퍼에 기록
captureRequest({
url: request.url,
method: request.method,
status: response.status,
duration: Date.now() - startTime,
requestHeaders: Object.fromEntries(request.headers),
responseHeaders: Object.fromEntries(response.headers),
});
return response;
};
CDP만큼 낮은 수준에서 접근하지는 못해도 필요한 기록을 수집하기에는 충분했습니다. 서버 수집기를 두지 않고 페이지 안에서 동작하게 만들 수 있었습니다. 다만 패치가 설치된 실행 환경의 fetch·XHR만 관찰하며, 브라우저와 WebView별 검증은 필요합니다.
XHR 처리는 fetch보다 훨씬 복잡했습니다. 단순히 open과 send만 래핑하면 안 됐기 때문입니다.
XHR은 하나의 객체 인스턴스가 open, setRequestHeader, send, onreadystatechange 등 수십 개의 메서드와 프로퍼티를 갖고 있습니다. 이걸 개별적으로 래핑하려면 누락 없이 전부 처리해야 하고, 유지보수도 어려워지죠.
해결책은 Proxy였습니다. XMLHttpRequest 생성자 자체를 Proxy로 감싸서, 메서드 호출과 프로퍼티 접근을 중간에서 가로채되 나머지는 원본 XHR에 투명하게 위임하는 구조입니다.
const OriginalXHR = window.XMLHttpRequest;
window.XMLHttpRequest = new Proxy(OriginalXHR, {
construct(Target, args) {
const xhr = new Target(...args);
const meta: RequestMeta = {};
return new Proxy(xhr, {
get(target, prop) {
// 후킹할 메서드만 가로채고, 나머지는 원본으로 위임
if (prop === 'open') {
return (method: string, url: string) => {
meta.method = method;
meta.url = url;
meta.startTime = Date.now();
return target.open(method, url);
};
}
if (prop === 'send') {
return (body?: BodyInit) => {
target.addEventListener('loadend', () => {
captureRequest({
...meta,
status: target.status,
duration: Date.now() - meta.startTime!,
});
});
return target.send(body);
};
}
// 그 외 모든 프로퍼티는 원본으로 위임
const value = Reflect.get(target, prop, target);
return typeof value === 'function' ? value.bind(target) : value;
},
set(target, prop, value) {
return Reflect.set(target, prop, value, target);
},
});
},
}) as any;
Reflect.get으로 원본 XHR에 위임하면, 직접 구현하지 않은 모든 메서드와 프로퍼티가 자동으로 원본 동작을 그대로 씁니다. onreadystatechange, responseType, timeout 같은 속성들을 일일이 처리하지 않아도 되죠.
네트워크는 멀쩡한데 화면이 이상한 경우
fetch와 XHR 캡처를 붙이고 나서 한 가지 더 부족함을 느꼈습니다. QA 제보 중에 네트워크 요청은 정상인데 화면이 이상하게 동작하는 경우가 있었기 때문입니다. 이때는 콘솔 오류가 실마리인 경우가 많았습니다.
그래서 콘솔 캡처도 추가했습니다.
// console.error, console.warn 패칭
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
captureConsole({ level: 'error', args: serializeArgs(args), timestamp: Date.now() });
originalConsoleError(...args); // 원본 동작 유지
};
// 미처리 예외 감지
window.addEventListener('error', (event) => {
captureConsole({
level: 'error',
args: [event.message, event.filename, `line ${event.lineno}`],
timestamp: Date.now(),
});
});
// 미처리 Promise 거부 감지
window.addEventListener('unhandledrejection', (event) => {
captureConsole({
level: 'error',
args: [String(event.reason)],
timestamp: Date.now(),
});
});
window.onerror만으로는 잡히지 않는 비동기 에러가 있습니다. await로 감싸지 않은 Promise나 .catch() 없는 Promise chain에서 발생하는 에러는 unhandledrejection 이벤트로만 잡힙니다. 두 가지를 함께 등록하면 수집 범위를 넓힐 수 있습니다. 그래도 애플리케이션이 이미 처리한 예외, 다른 프레임과 워커 안의 오류까지 전부 수집하는 것은 아닙니다.
기록 코드가 원래 요청을 바꾸면 관찰이 실패한다
fetch를 감싸는 코드는 페이지의 모든 요청 경로에 들어갑니다. 수집기의 예외 때문에 정상 요청이 실패하거나, 응답 본문을 먼저 읽어서 앱이 읽을 수 없게 된다면 디버깅 도구가 새로운 버그를 만드는 셈입니다. 앞의 코드는 흐름을 보여주는 축약 예제입니다. 실제 래퍼에는 원래 요청의 성공·실패와 기록 실패를 분리하는 처리가 필요합니다.
fetch의 Promise가 resolve되는 시점은 보통 응답 헤더를 받은 시점입니다. 앞에서 잰 duration을 전체 본문 다운로드 시간이라고 부르면 안 됩니다. 본문을 기록하려면 복제 응답을 읽어야 하지만, 대용량이나 스트리밍 응답을 무제한 읽는 것도 메모리 문제를 만듭니다. 먼저 메서드·URL·상태·시간만 수집하고 본문은 허용한 형식과 크기에서만 기록하는 식으로 범위를 정할 수 있습니다.
취소 요청의 AbortError나 네트워크 오류는 원래 호출자에게 그대로 전달해야 합니다. 수집 코드만 별도의 예외 경계에 두고, 초기화를 여러 번 해도 래퍼가 겹치지 않도록 해야 합니다. 종료할 때도 현재 fetch가 자신이 설치한 래퍼인 경우에만 원본으로 복구해야 다른 도구의 패치를 덮어쓰지 않습니다.
두 번째 시행착오: 쌓기만 하면 메모리가 터진다
캡처한 네트워크 요청을 계속 쌓으면 장시간 세션에서 메모리 문제가 생깁니다. 초기엔 배열에 그냥 push했는데, 요청이 수백 개를 넘어가면서 저장 파일 크기도 함께 커지더라고요.
그래서 최근 100개만 유지하는 순환 버퍼(circular buffer) 구조로 바꿨습니다.
class CircularBuffer<T> {
private buffer: T[] = [];
private maxSize: number;
constructor(maxSize: number) {
this.maxSize = maxSize;
}
push(item: T) {
if (this.buffer.length >= this.maxSize) {
this.buffer.shift(); // 가장 오래된 항목 제거
}
this.buffer.push(item);
}
snapshot(): T[] {
return [...this.buffer];
}
}
버그 재현에 필요한 건 "버그 직전의 요청들"이지, 세션 전체 요청이 아닙니다. 최근 100개면 충분하다는 결론이었습니다.
다만 100개라는 개수 제한만으로 메모리 상한이 생기지는 않습니다. 한 응답이 수십 MB일 수도 있기 때문입니다. 항목별 본문 크기와 버퍼 전체의 바이트 예산을 함께 두고, 넘친 기록에는 잘렸다는 표시를 남겨야 합니다. 위 배열 구현의 shift()는 항목을 이동시키므로, 더 큰 버퍼가 필요하면 고정 배열과 쓰기 인덱스를 사용하는 방식도 검토할 수 있습니다.
세 번째 시행착오: 남의 페이지 CSS가 흘러들어왔다
플로팅 버튼과 모달 UI를 처음엔 그냥 document.body에 append했습니다. 그랬더니 대상 페이지의 CSS가 라이브러리 UI에 그대로 흘러들어왔습니다. 버튼 모양이 페이지마다 달라졌습니다.
해결책은 Shadow DOM이었습니다.
const container = document.createElement("div");
const shadow = container.attachShadow({ mode: "closed" });
document.body.appendChild(container);
// shadow 내부에서만 스타일이 적용됨
const style = document.createElement("style");
style.textContent = `button { ... }`;
shadow.appendChild(style);
shadow.appendChild(floatingButton);
Shadow DOM은 외부 선택자가 내부 요소에 직접 적용되는 것을 막아줍니다. 다만 상속 속성과 CSS 커스텀 프로퍼티는 경계를 넘을 수 있으므로 호스트에 기본 글꼴·색상 등을 명시하고 실제 페이지에서 확인해야 합니다.
기록부터 공유까지 필요한 기능을 모았다
rrweb으로 당시 화면을 다시 재생한다
DOM의 변화를 MutationObserver 기반으로 직렬화해서 기록하는 라이브러리입니다. getDisplayMedia(화면 녹화 권한)를 요청하지 않아서 모바일, WebView, Safari에서도 권한 팝업 없이 동작합니다. 재생 시에는 기록된 DOM 이벤트를 시간순으로 replay해서 당시 화면을 그대로 보여줍니다.
네트워크 기록은 HAR 1.2 형식으로 저장한다
네트워크 로그를 저장하는 표준 포맷입니다. Chrome DevTools에서 "Save all as HAR" 하면 나오는 바로 그 포맷이죠. window.fetch로 캡처한 요청/응답을 이 스펙에 맞게 직렬화해서 .har 파일로 내보냅니다.
{
"log": {
"version": "1.2",
"entries": [{
"request": { "method": "GET", "url": "...", "headers": [...] },
"response": { "status": 200, "headers": [...], "content": {...} },
"time": 142
}]
}
}
HTML 파일 하나로 리포트를 연다
.har 파일을 열려면 별도 뷰어가 필요합니다. 번거로움을 없애려고 HAR 데이터를 인라인으로 포함한 단독 실행 HTML 파일을 함께 생성합니다. 서버 없이 브라우저에서 바로 열면 Chrome DevTools 스타일의 네트워크 인스펙터가 동작하죠.
라이브러리 UI는 Shadow DOM 안에 둔다
라이브러리가 주입하는 플로팅 버튼, 확인 모달, 프로그레스 바는 모두 Shadow DOM 안에 격리돼 있습니다. 외부 CSS 선택자의 직접 간섭을 줄입니다. closed는 외부에서 shadowRoot로 접근하기 어렵게 하는 선택일 뿐, 같은 페이지의 자바스크립트에 대한 보안 경계는 아닙니다.
저장 전에 민감한 헤더를 가린다
네트워크 요청을 그대로 기록하면 Authorization, Cookie 같은 인증 정보도 함께 저장됩니다. QA 담당자나 외부 팀에 파일을 넘길 때 토큰이 노출되는 문제가 생기죠.
const DEFAULT_MASK_HEADERS = new Set(['authorization', 'cookie', 'set-cookie']);
function maskHeaders(headers: Record<string, string>): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
result[key] = DEFAULT_MASK_HEADERS.has(key.toLowerCase()) ? '[MASKED]' : value;
}
return result;
}
헤더명을 소문자로 정규화해서 비교하니까, Authorization, AUTHORIZATION, authorization 어떤 형태로 들어와도 마스킹됩니다. 마스킹 대상은 설정에서 변경할 수 있습니다.
관련 기록을 ZIP 파일로 묶는다
버튼을 누르면 단순히 HAR 파일 하나가 아니라, 관련 데이터를 묶은 ZIP 파일이 생성됩니다.
qa-report-2026-05-01T14:30:00.zip
├── session.rr.json ← rrweb DOM 이벤트 시퀀스
├── network.har ← HAR 1.2 네트워크 로그
└── report.html ← 세 파일을 인라인으로 품은 통합 뷰어
report.html 하나만 브라우저에서 열면, DOM 재생·네트워크 인스펙터·콘솔 로그가 시간 축으로 동기화된 통합 뷰어로 동작합니다. HAR 뷰어를 따로 설치하거나 JSON 파일을 직접 열 필요가 없습니다.
ZIP 압축은 fflate 라이브러리의 동기 압축(zipSync)으로 처리합니다. 용량이 큰 rrweb 세션 데이터도 압축하면 파일 크기가 크게 줄어들어서 공유하기 편해지기 때문입니다.
파일을 내보내기 전까지가 수집기의 책임이다
인증 헤더만 가려도 URL의 쿼리, JSON 본문, 콘솔 객체, DOM 입력값에 정보가 남을 수 있습니다. 브라우저가 자바스크립트에 공개하지 않는 HttpOnly 쿠키와 Set-Cookie 응답 헤더도 있어 네트워크 패널과 수집 결과가 완전히 같지는 않습니다. 보이는 것을 전부 저장하기보다 허용한 필드를 저장하는 기준이 필요합니다.
DOM 재생 역시 동영상 녹화와 같지 않습니다. 당시의 DOM 변경을 재구성하므로 canvas·비디오·외부 리소스 상태까지 그대로 남는다고 보장할 수 없습니다. 리포트에는 수집 시작 시각과 제외된 항목을 표시하는 편이 좋습니다. 화면 일부가 비어 있어도 당시 사용자 화면이 비어 있었다고 오해하지 않게 하기 위해서입니다.
검증할 때는 성공 요청만 보지 않습니다. 요청 취소, 오프라인 실패, 큰 응답, 비밀값이 들어간 쿼리와 입력 폼을 테스트 데이터로 만들어, 원래 앱 동작이 유지되는지와 내보낸 파일에서 제외 대상이 사라졌는지를 함께 확인합니다. 재현에 도움이 되는 기록과 안전하게 공유할 수 있는 기록이 겹치는 범위를 정하는 것도 라이브러리 설계였습니다.
제보와 함께 당시의 기록을 받게 됐다
만들고 나서 실제로 어드민에 붙여봤습니다. QA에서 버그 제보가 오면 이제 HAR 파일 하나로 어느 API가 실패했는지 바로 확인할 수 있었습니다. 재현 단계를 좁히는 시간이 눈에 띄게 줄었습니다.
그동안은 제보를 받을 때마다 "어떤 상황이었나요?"라고 되묻는 게 당연하다고 생각했습니다. 하지만 당시의 기록을 함께 받으니, 같은 질문을 반복할 필요가 줄었습니다.
라이브러리는 GitHub에 공개해 뒀습니다.
더 확인할 자료
- MDN: Fetch 사용 — 응답·본문 스트림과 복제
- rrweb 기록 설정 — 입력 마스킹 및 기록 범위