이론이 실무를 만났을 때: 트러블슈팅 3가지 사례
앞선 세 편에서 프로토타입 체인, 프로토타입 오염, 디바운싱과 쓰로틀링을 다뤘습니다. 이번 편은 조금 다릅니다. 이 개념들이 실제 프로젝트에서 어떤 문제를 만났고, 어떻게 해결로 이어졌는지 사례 중심으로 정리해보려 합니다. 개념을 안다는 것과, 문제 상황에서 그 개념을 떠올려 적용할 수 있다는 것은 다른 차원의 이야기라고 생각합니다.
사례 1: 검색 자동완성이 서버를 압박하던 문제
증상
검색 자동완성 기능을 붙이고 나서, 백엔드 팀에서 특정 엔드포인트의 요청 수가 비정상적으로 많다는 이야기가 나왔습니다. 실제 검색 전환율에 비해 요청 수가 지나치게 많았습니다.
원인 파악
input 이벤트에 바로 fetch를 연결해둔 상태였습니다.
searchInput.addEventListener("input", (e) => {
fetchAutocomplete(e.target.value);
});
한 글자씩 입력할 때마다 요청이 나가고 있었고, 사용자가 평균 6~8글자를 입력한다고 가정하면 검색 한 번에 6~8개의 요청이 발생하는 구조였습니다. 게다가 네트워크 상황에 따라 이전 요청의 응답이 다음 요청보다 늦게 도착하는 경우도 있어서, 자동완성 목록이 순간적으로 엉뚱한 결과로 깜빡이는 부수적인 문제도 있었습니다.
해결
디바운싱을 적용해 "타이핑이 멈춘 뒤"에만 요청이 나가도록 바꿨습니다.
const debouncedFetch = debounce((value) => {
fetchAutocomplete(value);
}, 250);
searchInput.addEventListener("input", (e) => {
debouncedFetch(e.target.value);
});
여기에 더해, 응답이 늦게 도착해 순서가 뒤바뀌는 문제를 막기 위해 요청 시점의 검색어와 현재 인풋 값이 일치하는지 확인하는 가드를 하나 추가했습니다.
async function fetchAutocomplete(value) {
const results = await api.get(`/search?q=${value}`);
if (searchInput.value !== value) return; // 그 사이 사용자가 계속 타이핑한 경우 무시
renderResults(results);
}
결과와 배운 점
요청 수가 눈에 띄게 줄었고, 자동완성 목록이 깜빡이던 문제도 함께 사라졌습니다. 이 사례에서 배운 건, 디바운싱이 단순히 "요청 수를 줄이는" 최적화 이상의 역할을 한다는 점이었습니다. 비동기 요청이 뒤섞여서 생기는 레이스 컨디션까지 자연스럽게 줄여주는 효과가 있었습니다. 다만 디바운싱만으로 레이스 컨디션을 완전히 막을 수는 없기 때문에, 응답을 받는 시점에 최신 상태인지 확인하는 방어 코드는 별도로 필요했습니다.
사례 2: 무한 스크롤에서 스크롤이 버벅이던 문제
증상
리스트 페이지에 무한 스크롤을 붙였는데, 특히 저사양 기기에서 스크롤할 때 프레임이 뚝뚝 끊긴다는 QA 리포트가 올라왔습니다.
원인 파악
스크롤 위치를 감지해서 다음 페이지를 불러오는 로직이 이렇게 되어 있었습니다.
window.addEventListener("scroll", () => {
const scrollBottom = window.innerHeight + window.scrollY;
const documentHeight = document.documentElement.scrollHeight;
if (scrollBottom >= documentHeight - 100) {
loadNextPage();
}
});
getBoundingClientRect나 scrollHeight 같은 값을 읽는 연산은 브라우저의 레이아웃 계산(reflow)을 강제로 트리거하는 경우가 많습니다. 이 코드가 스크롤 이벤트가 발생할 때마다, 그러니까 초당 수십 번씩 실행되면서 레이아웃 계산을 계속 유발하고 있었습니다.
왜 디바운싱이 아니라 쓰로틀링이었나
처음엔 디바운싱으로 고치려고 했습니다. 그런데 디바운싱을 적용하면 사용자가 스크롤을 멈춰야만 다음 페이지 로드 여부를 판단하게 되어, 빠르게 쭉 스크롤을 내리는 사용자에게는 로딩이 한참 늦게 트리거되는 문제가 있었습니다. 스크롤이 진행되는 동안에도 주기적으로 위치를 체크해야 하는 상황이었기 때문에 쓰로틀링이 맞는 선택이었습니다.
const throttledCheck = throttle(() => {
const scrollBottom = window.innerHeight + window.scrollY;
const documentHeight = document.documentElement.scrollHeight;
if (scrollBottom >= documentHeight - 100) {
loadNextPage();
}
}, 150);
window.addEventListener("scroll", throttledCheck, { passive: true });
{ passive: true } 옵션도 함께 추가했습니다. 스크롤 이벤트 리스너 내부에서 preventDefault()를 호출하지 않는다는 걸 브라우저에 미리 알려주면, 브라우저가 스크롤 처리를 이벤트 핸들러 실행과 별개로 최적화할 수 있어 체감 성능에 도움이 됩니다.
결과와 배운 점
레이아웃 계산 호출 빈도가 확 줄면서 저사양 기기에서의 프레임 드랍이 개선됐습니다. 이 사례를 겪으며 디바운싱과 쓰로틀링을 고를 때 "이벤트가 멈춘 뒤의 결과가 중요한가, 진행되는 동안의 반응성이 중요한가"라는 질문이 실제로 얼마나 명확한 기준이 되는지 체감했습니다.
사례 3: 설정 병합 로직에서 발견된 프로토타입 오염 가능성
증상
사용자별 커스텀 설정(테마, 알림 옵션 등)을 서버 기본값과 병합하는 유틸 함수가 있었습니다. 기능 자체는 문제없이 동작하고 있었지만, 보안 점검 과정에서 이 병합 함수가 지적됐습니다.
원인 파악
문제의 함수는 이런 형태였습니다.
function mergeSettings(defaults, userSettings) {
for (const key in userSettings) {
if (typeof userSettings[key] === "object" && userSettings[key] !== null) {
defaults[key] = mergeSettings(defaults[key] || {}, userSettings[key]);
} else {
defaults[key] = userSettings[key];
}
}
return defaults;
}
userSettings는 서버에서 내려온 JSON을 파싱한 객체였는데, 만약 이 값이 사용자가 직접 조작할 수 있는 경로(예: 프로필 설정을 저장하는 API 요청 바디)를 거쳐온다면, {"__proto__": {"isAdmin": true}} 같은 페이로드가 들어왔을 때 defaults[key], 즉 defaults.__proto__에 접근하면서 전역 Object.prototype을 직접 조작하게 될 위험이 있었습니다.
실제로 이 경로가 외부 공격에 노출되어 있었던 건 아니었지만, 병합 유틸 함수 자체가 프로젝트 곳곳에서 재사용되고 있었기 때문에 앞으로 사용자 입력을 다루는 다른 곳에 이 함수가 쓰일 가능성을 열어두고 있었습니다. 이전 글에서 다룬 프로토타입 오염 개념을 알고 있었기에, 코드 리뷰에서 이 위험을 먼저 짚어낼 수 있었습니다.
해결
위험한 키를 걸러내는 방어 코드를 추가했습니다.
const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function mergeSettings(defaults, userSettings) {
for (const key of Object.keys(userSettings)) {
if (BLOCKED_KEYS.has(key)) continue;
if (typeof userSettings[key] === "object" && userSettings[key] !== null) {
defaults[key] = mergeSettings(defaults[key] || {}, userSettings[key]);
} else {
defaults[key] = userSettings[key];
}
}
return defaults;
}
for...in을 Object.keys()로 바꾼 것도 같은 맥락입니다. 객체 자신의 키만 순회하도록 범위를 좁혀서, 상속된 프로퍼티까지 순회 대상에 들어가는 상황 자체를 줄였습니다.
결과와 배운 점
이 수정은 눈에 보이는 버그를 고친 게 아니라, 아직 일어나지 않은 문제를 사전에 차단한 사례였습니다. 실무에서는 이런 종류의 개선이 성과로 잘 드러나지 않지만, 개인적으로는 가장 의미 있었던 경험 중 하나였습니다. 개념을 알고 있었기 때문에 "이 코드는 지금 당장은 괜찮지만, 재사용되는 순간 위험해질 수 있다"는 판단을 미리 할 수 있었기 때문입니다.
세 사례를 관통하는 한 가지
세 사례 모두 패턴이 비슷합니다. 겉으로 드러난 증상(느린 스크롤, 과도한 요청, 잠재적 취약점)은 각각 다르지만, 원인을 추적해 들어가면 결국 "자바스크립트가 이벤트와 객체를 어떻게 다루는지"에 대한 이해로 귀결됐습니다.
- 디바운싱/쓰로틀링은 이벤트가 얼마나 자주 발생하고, 그중 어떤 시점의 실행이 진짜 필요한지를 이해해야 올바르게 선택할 수 있었습니다.
- 프로토타입 오염은 객체 하나를 조작하는 게 왜 전역에 영향을 줄 수 있는지, 프로토타입 체인 구조를 모르면 애초에 위험 자체를 인지하기 어려웠습니다.
프레임워크나 라이브러리 사용법은 문서를 찾아보면 대부분 해결됩니다. 하지만 "왜 이 코드가 느린가", "왜 이 코드가 위험한가"를 판단하는 능력은 언어 자체에 대한 이해에서 나온다는 걸, 이 세 가지 사례를 겪으며 다시 확인했습니다.
시리즈를 마무리하며
프로토타입 체인부터 시작해 프로토타입 오염, 디바운싱과 쓰로틀링, 그리고 이 개념들이 실제로 쓰인 트러블슈팅 사례까지 네 편에 걸쳐 정리해봤습니다. 앞으로도 실무에서 마주치는 문제들을 이런 방식으로 정리해나가려 합니다.
'TroubleShooting' 카테고리의 다른 글
| [TroubleShooting] 대규모 리스트 렌더링 성능 개선 (0) | 2026.07.13 |
|---|---|
| [TroubleShooting] 메모리 누수 디버깅하기 (1) | 2026.07.13 |
| [TroubleShooting] 대규모 리스트 렌더링 성능 개선 (0) | 2026.07.07 |
| [TroubleShooting] 레이스 컨디션 버그를 해결한 경험 (0) | 2026.07.07 |
| [TroubleShooting] 번들 사이즈 30% 줄인 과정 (0) | 2026.07.07 |