기묘한 버그를 경험했다.
우리는 React로 어드민 서비스를 하나 개발해서 쓰고 있는데, 이 프로젝트가 CRA를 기반으로 생성되었고 각종 빌드 설정을 커스터마이징하기 위해 CRA가 eject된 상태였다.
이런저런 복잡한 설정들이 덧붙여져있고, 기본적으로 webpack은 느리기 때문에 패키지를 처음 내려받고 빌드를 하면 약 50초정도가 걸린다, 심지어는 dev 환경 실행도 그 정도 시간이 걸릴 때가 있다.
이러한 고질적인 문제를 해결하고자 번들러를 Vite로 변경하기로 하고, 다른 동료분과 함께 패키지를 정리하고, webpack에 덕지덕지 붙은 플러그인 설정들을 옮기는 등의 작업을 했다.
번들러 교체 작업을 마친 후에 기능들이 다 정상적으로 작동한다고 생각하고, 최근에 이를 운영서버에 반영했다.
운영 반영 후에 이상한 버그가 리포트됐다.
방송 상세 페이지에서 "방송 콘솔" 버튼을 눌러 새 창으로 콘솔 페이지를 띄웠다가, 로딩 UI가 나타나는 시점에 콘솔 창을 닫으면 방송 상세 페이지가 먹통이 되는 현상이었다.
Chrome 브라우저에서만 재현되는 문제였고, 상시 재현이 가능했기 때문에 이 케이스를 중점으로 디버깅을 진행했다.
우리 서비스에는 특정 파트너사 전용 환경이 있는데, 이 환경에서는 우리 콘솔 페이지를 iframe으로 띄워서 사용한다.
이 환경에서 API 호출 시 에러가 발생하면 로그를 남기기 위해 로깅 함수를 만들어서 사용하고 있었다.
export const postCallbackHistoryInIframe = (...) => {
// ...
if (window.self !== window.top) {
return defaultFetch(`${API_ENDPOINT}/some/logging/api`, 'POST', data);
}
return;
}
window.self !== window.top 조건은 현재 페이지가 iframe 안에서 실행되고 있는지 확인하는 코드다.
iframe이 아니면 window.self와 window.top이 동일하니까.
이 로깅 함수는 defaultFetch 함수 내부에서 다음 세 가지 케이스에 실행되도록 되어있었다.
방송 상세에서 "방송 콘솔" 페이지를 열 때 window.open(url, '_blank') 코드를 사용하고 있었다.
문제는 여기서 noopener 옵션을 전달하지 않았다는 것이다.
noopener 옵션 없이 window.open을 호출하면 부모 창과 자식 창이 서로 참조를 갖게 된다.
자식 창에서는 window.opener로 부모를 참조할 수 있고, 부모 창은 자식의 실행 컨텍스트에 영향을 받게 된다.
문제의 시나리오는 이랬다.
window.top이 null이 됨window.self !== window.top 조건을 만족해버림 (self는 존재하고 top은 null이니까)defaultFetch가 호출됨이렇게 무한 루프가 돌면서 Heap Size가 끝없이 증가하고, 결국 부모 페이지가 crash되는 것이었다.
두 가지 수정을 적용했다.
첫 번째로, 로깅 함수 시작 부분에서 창이 닫힌 상태이거나 window.top이 null인 경우 early return 처리를 추가했다.
export const postCallbackHistoryInIframe = (...) => {
// ...
if (window.closed || window.top === null) {
return;
}
if (window.self !== window.top) {
return defaultFetch(`${API_ENDPOINT}/some/logging/api`, 'POST', data);
}
return;
}
두 번째로, 로깅 함수 내에서 defaultFetch를 사용하지 않고 일반 fetch를 사용하도록 변경했다.
로깅 함수 → defaultFetch → 로깅 함수 → defaultFetch → ... 순환 구조 자체를 끊어내기 위함이다.
if (window.self !== window.top) {
fetch(`${API_ENDPOINT}/some/logging/api`, {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
...getToken(),
},
body: JSON.stringify(data),
});
}
window.open을 사용할 때는 보안상의 이유로도, 이런 예상치 못한 버그를 방지하기 위해서도 noopener 옵션을 붙이는 게 좋다.
window.open(url, '_blank', 'noopener');
그리고 함수 간의 순환 참조 구조는 언제나 위험하다. 특히 네트워크 요청과 에러 핸들링이 엮여있을 때는 더더욱.
문제의 원인 자체가 애당초 복잡한 시스템에서 기인한 것이라, 해결 방법이라고 적용한 것들도 눈물나게 어지럽다.
이 글은 Cursor CLI에서 Claude 에이전트를 통해 초안이 작성되었습니다.