무한로딩의 진짜 범인은 오타 하나였다
목차
아침에 슬랙 켜자마자 무한로딩 제보가 들어왔다. 대량환불 미리보기 화면인데 스피너만 돌고 아무것도 안 그려진다는 것. 일단 네트워크 탭 열었더니 AJAX 응답은 정상이었다. 200 오고, 페이로드도 제대로 내려오고. 그래서 처음엔 서버 렌더링 쪽을 의심했다. PHP 템플릿 문제인가, 캐시가 껴 있나, 하면서 30분 가까이 서버 사이드만 팠다.
진짜 범인은 클라이언트 콘솔에 있었다. escapeHtml is not defined. ReferenceError 하나가 콜백 전체를 날려버리고 있었다. 응답 파싱 도중 예외가 터지니까 DOM이 그냥 아무것도 안 그려진 채로 멈춘 것. 스피너를 숨기는 코드도 그 콜백 안에 있었으니 로딩이 영원히 도는 것처럼 보인 거다.
찾고 나서 허무했다. 함수 선언 하나가 누락된 게 전부였다. 리팩토링 과정에서 공통 유틸로 빼면서 import를 빠뜨린 것으로 추정되는데, 로컬 테스트에선 다른 파일에서 글로벌로 올라와 있던 함수가 해당 페이지에선 없었던 거다. 이런 류의 버그가 골치 아픈 이유는 정확히 이것 때문이다. 서버가 멀쩡하면 클라이언트를, 클라이언트가 멀쩡해 보이면 서버를 의심하게 된다. 에러 경계가 없으면 진짜 원인이 어디에 있는지 한참 돌아가게 된다.
AJAX 에러 처리를 얼마나 촘촘하게 짜야 하는가
이 버그를 고치면서 같이 잡은 게 세션 만료 무한로딩이다. 구조 자체가 비슷한 문제였다. AJAX 응답이 와도 세션이 끊긴 상태면 서버가 로그인 페이지 HTML을 돌려보내는데, 클라이언트는 그걸 그냥 파싱 실패로 퉁치고 있었다. 실패 처리가 없으니 스피너만 남는다.
수정 방향은 단순했다.
$.ajax({
url: '/admin/refund/preview',
success: function(res) {
if (res.redirect || res.session_expired) {
window.location.href = '/login';
return;
}
renderPreview(res.data);
},
error: function(xhr) {
if (xhr.status === 401 || xhr.status === 403) {
window.location.href = '/login';
return;
}
showErrorMessage('일시적인 오류가 발생했습니다.');
},
complete: function() {
hideSpinner(); // 성공/실패 관계없이 반드시 실행
}
});
핵심은 complete 콜백에 스피너 숨김을 두는 것이다. success 안에 두면 예외 상황에서 스피너가 살아남는다. 그리고 세션 관련 응답을 별도로 감지해서 redirect 처리해줘야 한다. 당연한 얘기 같은데, 이게 빠진 채로 운영되는 코드를 꽤 자주 본다.
같은 날 클라이프스 쪽에서도 버그 두 건이 있었다. MAX CONN ERROR는 동시 처리 수를 줄이고 재시도 로직을 붙여서 막았다. 외부 API 연동에서 동시성을 제어 안 하면 언젠간 이렇게 터진다. 식별자 20자 초과 발급 실패는 실패 코드를 역으로 타고 올라가다 겨우 찾았다. 생성 로직 어딘가에서 길이 체크 없이 그냥 쓰고 있었던 것. 이런 건 정상 케이스에선 절대 안 보인다.
recharts 버전 고정의 교훈
어드민 대시보드 차트가 배포 후 깨진 것도 오늘 처리했다. recharts가 메이저 버전 경계에서 API가 조용히 바뀌어 있었다. package.json에 ^ 붙여놨으면 자동으로 당겨 받는다. CI는 통과했는데 프로덕션에서 깨지는 전형적인 패턴이다. 락파일 커밋 안 하거나, 락파일 있어도 CI에서 무시하면 이 함정에 빠진다.
버전 고정으로 우선 진화시켰지만 이건 임시방편이다. recharts 최신 API로 마이그레이션 하거나, 아니면 의존을 끊는 방향을 정해야 한다. 차트 라이브러리가 메이저 버전마다 이렇게 거칠게 움직이면 유지보수 부담이 생각보다 크다.
| 문제 | 원인 | 조치 |
|---|---|---|
| 대량환불 무한로딩 | escapeHtml 미정의 |
함수 import 복구 |
| 세션 만료 무한로딩 | AJAX complete 누락, 세션 감지 없음 | complete 콜백 분리, 세션 redirect 처리 |
| MAX CONN ERROR | 동시처리 수 무제한 | concurrency 제한 + 재시도 |
| 식별자 20자 초과 | 길이 검증 누락 | 생성 로직에 체크 추가 |
| 차트 깨짐 | recharts 버전 부동 | 버전 핀 고정 |
| WCAG 명암비 | 색 수정 연쇄 충돌 | 사이트별 색 교정 |
| 조회수 0 | 도메인 변경 미반영 | 로그 파싱 패턴 갱신 |
| PV 부풀림 | 봇 필터 미갱신 | 새 봇 패턴 추가 |
WCAG 명암비는 오늘 블로그, 어드민, 포트폴리오 세 군데서 동시에 터졌다. 색 하나 고치면 다른 데가 깨지는 식의 연쇄라 완전히 잡는 데 시간을 많이 썼다. 접근성 색 작업은 디자인 토큰을 중앙화해서 관리하지 않으면 이렇게 터지는데, 각 사이트가 제각각 색을 들고 있으니 어쩔 수 없는 부분도 있다.
kpopdex 카드 회사명 잘림은 알고 보면 간단했다. CSS Grid에서 1fr은 내용이 넘치면 셀을 밀어낸다. minmax(0, 1fr)로 바꾸면 셀이 늘어나지 않고 내용을 자른다. 이거 알면서도 매번 한 번씩 걸린다. 1fr과 minmax(0,1fr)의 차이를 헷갈리는 건 Grid 초기에 워낙 반직관적으로 동작해서 그렇다고 생각한다.
통계 버그 두 건이 오늘 중에 가장 찜찜하게 남는다. 도메인 변경을 로그 파싱에 반영 안 해서 특정 경로 조회수가 0으로 찍히던 것, 봇 필터가 새로운 봇들을 못 잡아서 PV가 부풀려지던 것. 둘 다 숫자가 이상해야 파고드는 버그다. 조용히 틀린 수치를 쌓고 있었을 텐데, 얼마나 오래 그랬는지 모른다는 게 불편하다. 로그 파싱처럼 환경 변화에 영향받는 코드는 변경 때마다 같이 점검하는 습관이 필요하다. 그게 안 되면 적어도 이상치 경보라도 있어야 한다.
대량환불 연동 규격서는 오늘 커밋해뒀다. 다음 단계로 넘어갈 준비는 됐다. 오늘은 너무 넓게 건드려서 검증이 덜 된 것들이 남아 있다. 내일은 조금 좁게 잡고 가야겠다.
댓글 0
첫 댓글 달아줘.