guksulog
포스트 목록
#오픈소스

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는 쓸 수 없는 선택지였죠.

해결책: window.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개면 충분하다는 결론이었어요.

세 번째 시행착오: Shadow DOM 격리

플로팅 버튼과 모달 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에 공개해 뒀어요.