참여 이벤트 | Debug Fundamentals #639
Replies: 10 comments
-
|
실시간 차트 페이지 만들다가 메모리 터져서 브라우저 죽은 이야기. 1. 진단하기주식 실시간 차트 페이지 만들었는데, QA팀에서 "30분만 켜놓으면 브라우저 죽어요"라고 제보가 들어왔어요. 직접 켜놓고 밥 먹고 왔더니 진짜 탭이 먹통이 되어 있더라구요. 2. 재현하기Memory Profiler로 스냅샷 찍어보니 5분만 지나도 detached DOM nodes랑 event listeners가 늘어나고 있었어요 useEffect(() => {
const ws = new WebSocket('wss://api.example.com/realtime');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setChartData(prev => [...prev, data]); // 데이터 계속 쌓임...
};
ws.connect();
// return 빼먹음...
}, []);
useEffect(() => {
const interval = setInterval(() => {
fetchMarketData().then(setMarketData);
}, 5000);
// clearInterval 어디갔어...
}, []);웹소켓이랑 인터벌 정리 안하고 냅뒀더니 컴포넌트 언마운트되도 계속 돌고 있었던거예요. 게다가 차트 데이터는 계속 쌓이기만 하궁 3. 수정하기cleanup 추가하고 데이터도 100개로 제한했어요. useEffect(() => {
const ws = new WebSocket('wss://api.example.com/realtime');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setChartData(prev => {
const updated = [...prev, data];
return updated.slice(-100); // 최근 100개만
});
};
ws.connect();
return () => {
ws.close(); // 이거 추가
};
}, []);
useEffect(() => {
const interval = setInterval(() => {
fetchMarketData().then(setMarketData);
}, 5000);
return () => {
clearInterval(interval); // 이것도 추가
};
}, []);AbortController로 API 요청도 취소되게 처리하고, React.memo로 쓸데없는 리렌더링도 막았어요. 4. 재발방지하기useEffect에서의 "끄기 행동"은 중요하다
ESLint에 실시간 데이터 다루는 페이지는 꼭 30분은 켜놓고 테스트해보세요. 안 그러면 또 QA팀한테 혼나요! |
Beta Was this translation helpful? Give feedback.
-
증상React Hook Form과 ZodResolver를 사용하여 작성한 상품 생성 페이지에서 “Submit” 버튼 클릭 시 아무런 반응이 없었으며, 필드 에러, 콘솔 에러도 출력되지 않아 동작이 멈춰보였습니다. 첫 시도(와 여러 시도..)
찾아낸 원인“상품 생성” 영역의 prizeConfig에서 전달된 값의 타입이 스키마에서 기대한 타입과 일치하지 않아, Zod의 transform 로직이 실행되기 이전 단계에서 검증이 실패하였습니다. 이로 인해 transform 내부에서 ctx.addIssue로 정의해 둔 커스텀 에러 로직이 수행되지 못했고, 결국 제가 의도한 필드 단위의 에러 메시지가 생성되지 않았습니다. 이 때문에 UI에서도 해당 에러 메시지가 표시되지 않는 현상으로 이어져 에러를 확인하기 어렵게 되었습니다. 해결책React Hook Form의 handleSubmit에 onError 콜백을 추가하여 유효성 검증 실패 시 에러를 핸들링 할 수 있도록 조치하였습니다. <form onSubmit={handleSubmit(onSubmit, onError)}>
<button type="submit">생성</button>
</form>문제 필드인 prizeConfig의 값 형태를 스키마와 일치하도록 조정했습니다. 재발 방지를 위한 대책유효성 검증 실패 시 무반응이 아닌, 사용자에게 명확한 에러 안내가 노출되도록 공통 처리 로직을 만들어 두기로 했습니다. Zod 스키마와 실제 form value 타입이 불일치하지 않도록 설계 단계에서 타입 정의를 명확히 하고, select 등 object 형태를 반환하는 UI 컴포넌트 사용 시 스키마와의 매핑 규칙을 사전에 정해두는 것이 재발 방지를 위해서도 좋을 것 같다고 생각했습니다. submit 로직 디버깅 시 기본적으로 onSubmit과 onError 두 경로의 콘솔 로깅을 함께 두어, 어디에서 종료되고 있는지 즉시 파악할 수 있도록 해두는것도 좋을 것 같습니다. |
Beta Was this translation helpful? Give feedback.
-
Radix UI Dialog 내 Select 컴포넌트 ESC 키 충돌 버그 사례증상
첫 시도
찾아낸 원인: 보이지 않는 두 개의 '레이어 관리자'문제의 핵심을 파고들자 GitHub 이슈에서 결정적인 단서를 찾을 수 있었어요. 원인은 Radix UI의 내부 동작 방식과 패키지 매니저의 의존성 관리 방식이 맞물려 발생하고 있었어요.
해결책: '레이어 관리자'를 하나로 통일하기원인을 알았으니 해결책은 명확해요. 우리 프로젝트에 존재하는 여러 명의 '레이어 관리자'를 해고하고, 단 한 명의 유능한 관리자만 남기는 거예요. 즉,
{
"pnpm": {
"overrides": {
"@radix-ui/react-dismissable-layer": "1.0.5"
}
}
}설정을 추가한 뒤, rm -rf node_modules pnpm-lock.yaml
pnpm install이제 모든 Radix UI 컴포넌트들이 단일 버전의 재발방지를 위한 대책
|
Beta Was this translation helpful? Give feedback.
-
Dialog가 켜지면 TV 리모콘이 작동하지 않는 문제 사례부제: 에러메세지가 없는 문제의 디버깅 사례 증상
첫 시도
찾아낸 원인발견 과정
원인 설명
해결책
|
Beta Was this translation helpful? Give feedback.
-
THREE.Cache.enabled로 인한 메모리 누수증상
첫 시도모바일에서 강제 새로고침은 보통 메모리 부족 때문인 경우가 많았어서 메모리 누수를 의심했어요.
dispose 로직은 분명 제대로 작동하고 있었는데 메모리가 안 줄어드니까, Three.js 관련 옵션들을 하나씩 살펴보기 시작했어요. GLTFLoader 설정부터 시작해서 렌더러 옵션, 그리고 프로젝트 전역에 설정된 Three.js 관련 코드들을 전부 뒤지면서 의심되는 부분들을 체크했죠. Three.js 공식 docs도 정독하고, Discourse 커뮤니티에서 "memory leak", "dispose", "cache" 같은 키워드로 비슷한 사례들을 찾아보면서 단서를 찾아갔어요. 찾아낸 원인그렇게 찾다 보니 프로젝트 초기에 성능 최적화를 위해 전역으로 설정해둔 THREE.Cache.enabled = true가 눈에 들어왔어요. 보안상 3D 모델 파일들이 pre-signed URL을 사용하고 있었는데, 한 번 다운로드되면 URL이 즉시 만료되고 새 URL로 교체되는 구조였어요. 같은 모델이라도 접근할 때마다 완전히 다른 URL을 갖게 되어 Three.js 캐시에는 '서로 다른 파일'로 인식되어 계속 쌓이기만 하고 재사용되지 않았던 거죠. 캐시 기능이 오히려 독이 되었던거였습니다. 해결책
useEffect(() => {
return () => {
// ...
THREE.Cache.remove(fbxUrl);
THREE.Cache.remove(glbUrl);
};
}, []);수정 후 메모리 프로파일링을 해보니 페이지를 이동할 때마다 JSArrayBufferData가 깔끔하게 정리되었고, 모바일에서도 10개 이상의 모델을 연속으로 로드해도 안정적으로 동작했어요. 재발방지를 위한 대책
|
Beta Was this translation helpful? Give feedback.
-
진단하기바텀시트 애니메이션에 문제가 발생했다. 사용자가 바텀시트를 위아래로 드래그하면 이를 따라 부드럽게 이동해야 하지만, 애니메이션이 느리고 버벅였다. 바텀시트가 사용자의 손가락 움직임을 따라오지 못하고 프레임이 끊기는 현상이 나타났다. 문제는 다음과 같은 상황에서만 발생했다.
재현하기첫 번째 시도: 데스크탑에서 성능 프로파일링문제가 모바일에서만 발생한다는 점에서 낮은 CPU 성능이 원인일 것이라고 가정했다. 데스크탑 DevTools의 Performance 패널로 확인해보니, 애니메이션 구간에서 Main Thread 내 리플로우가 반복적으로 발생하고 있었다. 애니메이션이 자연스럽게 보이려면 초당 60프레임(60FPS)을 안정적으로 그려야 한다. 드래그하는 동안 매 프레임마다 위치가 바뀌기 때문에, 그때마다 레이아웃을 다시 계산하면 CPU가 바빠질 수밖에 없다. 그래서
결과: 효과는 미미했다DevTools에서 CPU 부담이 다소 줄어든 것을 확인했고, 체감상으로도 조금 부드러워졌다. 그러나 모바일 기기의 프로덕션 환경에서는 여전히 느리고 둔한 움직임을 보였다. 두 번째 시도: 모바일 환경에서 직접 프로파일링데스크탑 DevTools의 CPU Throttling 기능을 이용해 4×, 6× 정도로 성능을 낮춰봤지만 문제를 재현할 수 없었다. 20× slowdown에서는 페이지 렌더링 자체가 너무 느려 정상적인 테스트가 불가능했다. 그래서 실제 모바일 기기에서 프로덕션 빌드를 실행하고, 이를 데스크탑에서 DevTools로 확인하는 방식으로 문제를 재현하기로 했다.
npm run build
npx serve -s dist모바일 환경에서 프로파일링 결과를 확인하니 문제가 명확하게 보였다.
찾아낸 원인transform으로 해결되지 않는 이유를 알 수 있었다. transform은 브라우저 렌더링 단계 중 레이아웃과 페인트를 건너뛴다. 그러나 문제는 그 이전 단계인 스타일 재계산에서 리소스를 과하게 쓰고 있었다. 뒤에서 단계를 생략해도 앞에서 병목이 생기면 큰 효용을 얻지 못하는 것이다. 로직을 확인해보니 바텀시트 위치(positionPercent)를 React State로 관리하고 있었다. const onPointerMove = (e) => {
// 매 터치 이벤트마다 호출 (초당 60~120회)
setPositionPercent(newValue); // React 상태 업데이트
};이 코드의 실행 흐름은 다음과 같다. 구현 당시에는 수정하기바텀시트 위치를 State로 상태관리했던 것을, useRef를 사용한 DOM 직접 조작으로 바꿨다. 선언적 패러다임을 일부 포기하고 성능이 중요한 구간에 명령형을 넣었다고 볼 수 있다. const onPointerMove = (e) => {
// React 상태 업데이트 없이 ref에만 저장
currentPercentRef.current = clampedPercent;
// DOM 직접 조작
containerRef.current.style.transform = `translateY(${100 - clampedPercent}%)`;
};
const onPointerUp = (e) => {
// 인라인 스타일 제거하여 React가 다시 제어
containerRef.current.style.transform = '';
// 스냅할 때만 상태 업데이트
setPositionPercent(target);
};갑자기 DOM 조작이라니, 괜찮을까? 공식문서를 찾아봤는데 괜찮다고 한다. useRef가 존재하는 이유이기도 했다.
결과수정 후 Safari 프로파일러 결과는 극적으로 개선되었다.
이제 개발/프로덕션 환경 구분 없이 바텀시트 애니메이션이 부드럽게 동작한다. 재발방지하기고빈도 업데이트에서 React 상태 사용 주의 애니메이션이나 드래그처럼 초당 60회 이상 업데이트가 필요한 인터랙션에서는 React 상태 업데이트가 병목이 될 수 있다. 특히 CSS-in-JS 라이브러리를 함께 사용할 경우 스타일 재계산 비용이 추가된다. 이런 경우 실제 환경에서의 프로파일링 중요성 개발 환경과 프로덕션 환경, 데스크톱과 모바일에서 동일한 코드가 다른 성능을 보일 수 있다. 실제 사용 환경에서 프로파일링을 수행하고, CPU와 네트워크 조건을 변경해가며 테스트하는 것이 중요하다. 앞으로 애니메이션 개발 시에는 다양한 환경에서 성능을 검증할 것이다. 여전히 궁금한 점
|
Beta Was this translation helpful? Give feedback.
-
Beta Was this translation helpful? Give feedback.
-
증상좌측 채팅 리스트 영역은 JSP로, 우측 채팅 입력 영역은 React로 구성되어 있었어요. 원래는 전체 페이지를 React로 통합하고 싶었지만 내부 사정으로 인해 우측 영역만 iframe 형태로 먼저 삽입하게 되었어요. 이후 가운데에 구분 바를 두고 width를 드래그로 조절하는 기능을 구현했는데, 드래그 도중 마우스가 iframe 위로 올라가면 더 이상 크기 조절이 되지 않는 문제가 발생했어요. 즉, mousedown → mousemove → mouseup 흐름에서 mousemove 이벤트가 iframe에 의해 끊겨버리는 상황이었어요. 첫 시도기본적인 resize 로직은 아래와 같이 작성했었어요. startResizing(event) {
this.isResizing = true;
this.initialX = event.clientX;
const listContainer = document.querySelector('.listContainer');
this.initialWidth = listContainer.offsetWidth;
document.addEventListener('mousemove', this.resize);
document.addEventListener('mouseup', this.stopResizing);
}드래그 중 커서가 우측 채팅 입력 영역(iframe) 위로 올라갈 때만 이벤트가 끊기는지 확인하기 위해, JSP 쪽에 scroll 이벤트와 간단한 console.log를 추가해 브라우저 콘솔에서 이벤트 흐름을 직접 추적해봤어요. 그 과정을 통해 커서가 iframe 영역에 들어가는 순간 부모 문서에서 mousemove 이벤트가 더 이상 실행되지 않고, 대신 mouseup 이벤트가 바로 발생한다는 사실을 처음으로 확인했어요. 찾아낸 원인문제의 원인을 찾아보니, 그래서 마우스가 iframe 내부로 들어가면 부모 문서에서 등록한 mousemove 이벤트가 더 이상 실행되지 않았어요. 해결책해결을 위해 드래그 시작 시 화면 전체를 덮는 투명 오버레이 레이어를 추가하는 방식을 적용했어요. 이 오버레이는 iframe 위에 덮이기 때문에 마우스가 어디로 이동하든 부모 문서의 이벤트가 끊기지 않게 만들 수 있었어요. startResizing(event) {
this.isResizing = true;
this.initialX = event.clientX;
const listContainer = document.querySelector('.listContainer');
this.initialWidth = listContainer.offsetWidth;
// 오버레이 추가
this._overlay = document.createElement('div');
Object.assign(this._overlay.style, {
position: 'fixed',
inset: '0',
zIndex: '999999',
cursor: 'ew-resize',
background: 'transparent',
});
document.body.appendChild(this._overlay);
document.addEventListener('mousemove', this.resize);
document.addEventListener('mouseup', this.stopResizing);
}
resize(event) {
if (this.isResizing) {
const dx = event.clientX - this.initialX;
const newWidth = this.initialWidth + dx;
if (newWidth >= 460 && newWidth <= window.innerWidth - 800) {
document.querySelector('.listContainer').style.width = `${newWidth}px`;
}
}
}
stopResizing() {
this.isResizing = false;
document.removeEventListener('mousemove', this.resize);
document.removeEventListener('mouseup', this.stopResizing);
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}이 방법으로 iframe이 있는 화면에서도 드래그 조절이 정상적으로 동작하게 되었어요. 재발 방지를 위한 대책현재는 해당 페이지 전체를 React 기반으로 완전히 분리해 iframe을 제거했어요. 그 과정에서 iframe 때문에 필요했던 event 처리나 window.parent를 통한 보완 로직들도 모두 정리했어요. 이제는 동일한 유형의 이벤트 끊김 문제가 다시 발생하지 않도록 구조적으로 해결된 상태예요. 해당 문제를 해결한 과정은 이전에 블로그에도 정리해두었어요! 이번 리뷰를 통해 한 번 더 복기할 수 있는 기회를 주신 토스팀께 감사드려요! |
Beta Was this translation helpful? Give feedback.
-
증상SNS 서비스의 피드 목록에 무한 스크롤을 구현했어요. 스크롤이 바닥에 닿으면 다음 데이터를 가져오는 기능이었는데, 스크롤을 조금만 빠르게 내리면, API 요청이 순식간에 3~4번씩 중복으로 전송되는 현상이 발생했어요. 결과적으로 받아온 데이터가 리스트에 중복으로 쌓이면서 첫 시도이벤트가 너무 자주 발동되나 싶어서 lodash의 const handleScroll = throttle(() => {
// 스크롤 감지 로직
}, 500);하지만 이건 if (inView && !isLoading) {
setIsLoading(true);
fetchNextPage();
}하지만 여전히 빠르게 스크롤 하면 요청이 두세 번씩 호출이 됐어요. 원인로그를 찍어보고 리액트 공식 문서를 다시 보며 깨달았어요. 원인은 State Update의 비동기성과 Closure(클로저) 때문이었어요.
해결책리렌더링과 상관없이 즉시 값을 변경하고 참조할 수 있는 useRef를 사용해서 해결했어요. const isFetching = useRef(false);
const loadMore = async () => {
if (isFetching.current) return;
isFetching.current = true;
try {
const data = await api.getPosts(page);
setPosts(prev => [...prev, ...data]);
} catch (e) {
console.error(e);
} finally {
isFetching.current = false;
}
};
useEffect(() => {
if (inView) loadMore();
}, [inView]);
재발 방지를 위한 대책
사실 지금 생각하면 정말 기초적인 부분인데, 당시에는 "분명 완벽한데 왜 오류가 나지?"라고 생각하며 끙끙 앓았던 기억이 있어요. 하지만 덕분에 React의 기초 동작 원리부터 다시 공부하는 시간을 가질 수 있어서 좋았어요. |
Beta Was this translation helpful? Give feedback.
-
고주사율 모니터에서만 깜빡이던 시간표 셀을 잡기까지버그에는 두 종류가 있습니다.
증상제가 참여한 프로젝트 아인슈타임에는 드래그로 시간표 셀을 선택하는 기능이 구현되어 있습니다. 드래그 구현 위에 최적화 작업에서 캐싱부터 시작해서 레이아웃 이벤트를 격리하는 온갖 best practice를 적용하고 난 뒤에, 이렇게 끝나면 좋았겠지만, QA 과정에서 고주사율 모니터(120Hz+)를 쓰는 데스크톱 Chrome에서만 선택된 셀이 간헐적으로 깜빡이는 버그를 발견했습니다. default.mp4같은 기능이 60Hz 모니터, Firefox, Safari, 심지어 120Hz 모바일 Chrome에서는 멀쩡했기 때문에, 위에서 말한 특정 조건에서만 재현되는 엣지 케이스였습니다. QA가 없었다면 몰랐겠죠. 야속하게도, 이 버그는 하필 추석 직전에 발견됐습니다. 추석 연휴가 시작됐고, 모든 게 귀찮아져서 일단 미뤄 뒀습니다. 그런데 머릿속에서는 계속 그 깜빡이는 셀이 맴돌았습니다. 첫 시도추석 3일째, 제사를 지냈습니다. 그날 남은 나물로 비빔밥을 해 먹었고, 맛있었어요. 배는 부르고 행복했는데, 마음 한구석의 찝찝함과 그 깜빡임은 여전히 남아 있었습니다. 그날 새벽 두 시쯤, 다시 문제를 떠올려 보기 시작했습니다. 가장 먼저 브라우저에게 왜 그런지 살펴봤습니다. CleanShot.2025-11-27.at.19.21.59.mp4이 친구가 문제였던 것으로 보였습니다. 이 뒤집어지는 효과를 위해서는
찾아낸 원인추석 6일째, 더 이상 미룰 수가 없어서 테크니컬 라이팅을 하면서 드래그 로직을 처음부터 다시 복기했습니다. 글을 다 쓰고 나니 새벽 2시쯤이었는데, 심심한 김에 여기저기 코드를 뒤져보다가 결국 새벽 5시까지 이 버그를 붙잡고 있었습니다. 한 손에는 송편을 쥐고, 다른 한 손은 키보드에 올린 채로요. 첫 시도가 소용없다는 걸 확인하고 나서, 아예 가설을 새로 세웠습니다. "이 페이지가 다른 페이지와 정확히 뭐가 다른가?" 여기에 집중해서 코드를 정리해 보니, 세 가지 특징이 보였습니다.
120Hz 기준으로 약 8.3ms마다 rAF 콜백이 실행되는데요, 이 콜백 안에서는 336개 셀에 대해 히트박스 충돌 검사와 선택 상태 토글이 일어납니다. 그리고 상태가 바뀔 때마다 CSS-in-JS가 스타일을 런타임에 주입하고 있었습니다. 이 모든 일이 3D 컨텍스트 안에서 반복되니, paint와 composite 사이에서 충돌이 나면서 깜빡임이 발생한 것으로 보였습니다. 조건을 하나씩 껐다 켰다 하면서 테스트해 본 결과, 정리하면, 이 버그는:
이 네 가지가 동시에 겹칠 때만 나타나는 엣지 케이스였습니다. 다시 생각해 보면 왜 버그가 안 터지는지 알 수 없을 정도로 demanding한 연산을 굉장히 잦게 시키고 있었네요. 해결책추석 7일째, 일어나자마자 바로 책상에 앉았습니다. 전날 새벽에 잡았던 실마리를 코드에 적용해 볼 차례였습니다. 1.
|
Beta Was this translation helpful? Give feedback.


















Uh oh!
There was an error while loading. Please reload this page.
-
참여 이벤트 | Debug Fundamentals
프론트엔드 접근성의 모든 것
https://frontend-fundamentals.com/debug/pages/event.html
Beta Was this translation helpful? Give feedback.
All reactions