qa-recorder 라이브러리 개발기
2026-05-01문제의 시작
사내 어드민은 레거시 코드가 많았습니다. 기능이 복잡하게 얽혀 있는 만큼 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는 쓸 수 없는 선택지였습니다.
해결책: window.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처럼 저수준 접근은 아니지만, 실용적인 관점에서는 충분했습니다. 서버 없이 동작하고, 스크립트 태그 하나로 바로 쓸 수 있고, 모든 브라우저에서 동작합니다.
XHR도 같은 방식으로 XMLHttpRequest.prototype.open과 send를 래핑해서 처리했습니다.
두 번째 시행착오: 순환 버퍼 관리
네트워크 캡처를 계속 쌓으면 장시간 세션에서는 메모리 문제가 생깁니다. 초기엔 배열에 그냥 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개로 충분하다는 결론이었습니다.
세 번째 시행착오: Shadow DOM 격리
플로팅 버튼과 모달 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를 쓰는 페이지에 붙여도 UI가 의도한 대로 나옵니다.
사용된 기술 간략 설명
rrweb — DOM 세션 리플레이
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
}]
}
}
Standalone HTML 리포트
.har 파일을 열려면 별도 뷰어가 필요합니다. 번거로움을 없애기 위해 HAR 데이터를 인라인으로 포함한 단독 실행 HTML 파일을 함께 생성합니다. 서버 없이 브라우저에서 바로 열면 Chrome DevTools 스타일의 네트워크 인스펙터가 동작합니다.
Shadow DOM UI
라이브러리가 주입하는 플로팅 버튼, 확인 모달, 프로그레스 바는 모두 Shadow DOM 안에 격리되어 있습니다. 호스트 페이지의 CSS와 JS가 라이브러리 UI에 영향을 주지 않습니다.
마치며
만들고 나서 실제로 어드민에 붙여봤습니다. QA에서 버그 제보가 오면 이제 HAR 파일 하나로 어느 API가 실패했는지 바로 확인할 수 있었습니다. 재현 단계를 좁히는 시간이 눈에 띄게 줄었습니다.
생각해보면 QA 제보를 받을 때마다 "어떤 상황이었나요?"를 되물어야 했던 게 당연한 일처럼 느껴졌었는데, 그게 꼭 당연한 건 아니었습니다.
라이브러리는 GitHub에 공개되어 있습니다.