레시피 목록

qa-recorder 라이브러리 개발기

2026년 5월 1일 8분 읽기
오픈소스

"이 버튼 눌렀더니 에러 났어요"

사내 어드민은 레거시 코드가 많았습니다. 기능이 복잡하게 얽혀 있다 보니 QA에서 버그가 발견돼도 재현이 쉽지 않았죠.

어드민을 주로 쓰는 커머스팀에서 버그 제보가 오면 대부분 이런 식이었습니다.

"이 버튼 눌렀더니 에러 났어요."

"이거 잠깐 확인해 주실래요?"

어떤 네트워크 요청이 실패했는지, 어떤 상태에서 버튼을 눌렀는지, 콘솔에 뭐가 찍혔는지. 이 정보 없이는 재현조차 힘들었습니다. 스크린샷과 그 순간의 화면, 네트워크 흔적만으로 디버깅을 시작하는 일이 반복됐습니다.

당시의 개발자 도구를 다시 열 수 있다면

그러다 우아한형제들 기술블로그에서 '우아한 디버깅 툴' 글을 읽었습니다.

핵심 아이디어는 간단했습니다. 테스트 당시의 네트워크 요청, 콘솔 로그, DOM 화면을 그대로 저장해두고, 나중에 개발자가 그 환경을 그대로 열어볼 수 있게 해주는 겁니다. "당시의 개발자 도구를 다시 열 수 있다면"이라는 질문에서 출발한 도구였죠.

읽으면서 바로 이거다 싶었습니다. 우리 어드민에도 이런 게 있었으면 QA 재현에 쓰는 시간을 절반은 줄일 수 있었으니까요.

다만 저 툴은 사내 전용이고 공개된 라이브러리도 없었습니다. 비슷한 오픈소스도 마땅한 게 없어서, 결국 직접 만들기로 했습니다.

남의 서버 구조를 알 수 없다는 제약

오픈소스 라이브러리를 목표로 잡으니 제약이 생겼습니다.

라이브러리를 쓰는 사람의 서버 구조가 어떤지 알 수가 없습니다. 백엔드가 Python일 수도, Go일 수도, 아예 없을 수도 있고요. 그래서 서버 없이, 스크립트 태그 하나만 추가하면 바로 동작하는 구조로 가기로 했습니다.

html
<script src="https://unpkg.com/qa-recorder/dist/qa-recorder.umd.js"></script>

이 한 줄로 시작되어야 했습니다.


첫 번째 시행착오: CDP는 쓸 수 없었다

우아한형제들 글을 읽으면서 처음엔 CDP(Chrome DevTools Protocol)로 네트워크를 캡처하려고 했습니다.

CDP는 브라우저와 DevTools가 통신하는 프로토콜입니다. Network 도메인을 활성화하면 요청/응답 데이터를 낮은 수준에서 정확하게 받아올 수 있죠.

js
// 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.fetchXMLHttpRequest를 직접 패치하는 방식이었습니다.

ts
// 원본 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 처리는 fetch보다 훨씬 복잡했습니다. 단순히 opensend만 래핑하면 안 됐기 때문입니다.

XHR은 하나의 객체 인스턴스가 open, setRequestHeader, send, onreadystatechange 등 수십 개의 메서드와 프로퍼티를 갖고 있습니다. 이걸 개별적으로 래핑하려면 누락 없이 전부 처리해야 하고, 유지보수도 어려워지죠.

해결책은 Proxy였습니다. XMLHttpRequest 생성자 자체를 Proxy로 감싸서, 메서드 호출과 프로퍼티 접근을 중간에서 가로채되 나머지는 원본 XHR에 투명하게 위임하는 구조입니다.

ts
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 제보 중에 네트워크 요청은 정상인데 화면이 이상하게 동작하는 경우가 있었기 때문입니다. 이때는 콘솔 오류가 실마리인 경우가 많았습니다.

그래서 콘솔 캡처도 추가했습니다.

ts
// 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 이벤트로만 잡힙니다. 두 가지를 함께 등록해야 실제 운영 환경에서 빠져나가는 에러 없이 전부 기록할 수 있습니다.

두 번째 시행착오: 쌓기만 하면 메모리가 터진다

네트워크 캡처를 계속 쌓으면 장시간 세션에서 메모리 문제가 생깁니다. 초기엔 배열에 그냥 push했는데, 요청이 수백 개를 넘어가면서 저장 파일 크기도 함께 커지더라고요.

그래서 최근 100개만 유지하는 순환 버퍼(circular buffer) 구조로 바꿨습니다.

ts
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개면 충분하다는 결론이었습니다.

세 번째 시행착오: 남의 페이지 CSS가 흘러들어왔다

플로팅 버튼과 모달 UI를 처음엔 그냥 document.body에 append했습니다. 그랬더니 대상 페이지의 CSS가 라이브러리 UI에 그대로 흘러들어왔습니다. 버튼이 붙는 페이지마다 제각각 다르게 보이는 겁니다.

해결책은 Shadow DOM이었습니다.

ts
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 파일로 내보냅니다.

json
{
  "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에 영향을 주지 않습니다.

민감한 헤더 마스킹

네트워크 요청을 그대로 기록하면 Authorization, Cookie 같은 인증 정보도 함께 저장됩니다. QA 담당자나 외부 팀에 파일을 넘길 때 토큰이 노출되는 문제가 생기죠.

ts
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 파일이 생성됩니다.

code
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 세션 데이터도 압축하면 파일 크기가 크게 줄어들어서 공유하기 편해지기 때문입니다.

마치며

만들고 나서 실제로 어드민에 붙여봤습니다. QA에서 버그 제보가 오면 이제 HAR 파일 하나로 어느 API가 실패했는지 바로 확인할 수 있었습니다. 재현 단계를 좁히는 시간이 눈에 띄게 줄었습니다.

생각해보면 QA 제보를 받을 때마다 "어떤 상황이었나요?"를 되물어야 했던 게 당연한 일처럼 느껴졌었는데, 그게 꼭 당연한 건 아니었던 것 같습니다.

라이브러리는 GitHub에 공개해 뒀습니다.