삭제한 기능이 남긴 참조가 새 빌드를 죽였습니다
앞 글에서 서버를 옮기며, 최신 코드로 이미지를 새로 빌드했습니다. 신 서버에 배포하고 화면을 열었더니, 잘 돌던 사이트가 첫 화면부터 하얗게 죽어 있었습니다. "문제가 발생했습니다. 페이지를 새로고침해주세요." 정작 구 서버에서는 멀쩡히 돌고 있는 화면이었습니다.
증상: 빌드는 성공했는데 첫 렌더에서 크래시
콘솔을 열어 보니 에러 바운더리가 붙잡은 런타임 예외가 찍혀 있었습니다.
TypeError: Cannot read properties of undefined (reading 'borderDefault')
Error caught by boundary
무언가가 undefined인데 그 위에서 속성을 읽으려다 죽은 것이었습니다. 흥미로운 건, 빌드는 아무 에러 없이 성공했다는 점입니다. 타입 검사도, 번들링도 통과했고, 이미지도 정상적으로 만들어졌습니다. 문제는 오직 런타임에만, 그것도 첫 렌더에 드러났습니다.
추적: 구 서버와 신 서버의 번들이 다르다
가장 먼저 든 의문은 "왜 구 서버는 멀쩡한가"였습니다. 두 서버가 내려주는 자바스크립트 번들 파일명을 비교해 봤습니다.
# 각 서버가 내려주는 메인 번들 파일명 추출
curl -s https://old-server/ | grep -oE 'index-[A-Za-z0-9]+\.js'
curl -s https://new-server/ | grep -oE 'index-[A-Za-z0-9]+\.js'
해시가 서로 달랐습니다. 즉 구 서버는 몇 달 전에 빌드한 예전 번들을 그대로 돌리고 있었고, 이번에 새로 만든 번들은 그것과 다른 코드였습니다. 컨테이너의 생성 시각을 확인해 보니, 구 서버의 프론트엔드는 다섯 달 전에 배포된 것이었습니다.
다시 말해, 지난 다섯 달 동안 main에는 여러 커밋이 쌓였지만 한 번도 배포되지 않았고, 그 사이 어딘가에서 이 버그가 심어진 뒤 조용히 잠들어 있었던 것입니다. 이번에 서버를 옮기며 처음으로 최신 코드를 빌드하는 순간, 그게 깨어났습니다.
원인: 삭제한 기능이 남긴 dangling 참조
깨진 컴포넌트는 폼 화면 하나였습니다. 이 컴포넌트는 디자인 토큰에서 특정 기능 전용 색상을 가져다 쓰고 있었습니다.
// 살아남은 폼 컴포넌트
border: `1px solid ${tokens.colors.legacyFeature.borderDefault}`
// ^^^^^^^^^^^^^ 이제 undefined
그런데 커밋 이력을 보니, 얼마 전 더 이상 쓰지 않는 기능을 정리하면서 그 기능 전용 색상 토큰(legacyFeature)을 디자인 토큰 파일에서 통째로 삭제했습니다. 문제는, 그 기능은 사라졌지만 이 폼 컴포넌트는 살아남아 여전히 삭제된 토큰을 참조하고 있었다는 점입니다.
결국 tokens.colors.legacyFeature는 undefined가 됐고, 그 위에서 .borderDefault를 읽으려는 순간 첫 렌더에서 앱 전체가 죽었습니다. "불필요한 코드를 다 정리했다"는 커밋 뒤에 남은, 아주 흔한 종류의 정리 부작용이었습니다.
더 곱씹게 된 건, 이 어긋남을 타입 시스템도, 빌드도 잡아 주지 않았다는 사실이었습니다. 토큰 객체의 실제 모양과 그것을 참조하는 코드 사이의 간극이, 컴파일 시점에는 드러나지 않고 런타임까지 살아남았습니다. 빌드가 성공했다는 것이 앱이 동작한다는 뜻은 아니라는 걸, 새삼 확인했습니다.
해결: 값은 그대로, 이름만 바로잡기
고치는 방향은 두 가지였습니다. 삭제한 토큰을 되살리거나, 컴포넌트가 살아있는 다른 토큰을 참조하도록 바꾸는 것입니다. 기능은 의도적으로 없앤 상태를 유지하고 싶었으므로, 그 폼이 실제로 쓰던 색상 값만 용도에 맞는 이름의 새 토큰으로 재도입하고 참조를 옮겼습니다.
이때 옛 값을 그대로 보존하는 것이 중요했습니다. 폼 테두리 색은 다른 입력 필드의 회색과 미묘하게 다른 값을 일부러 쓰고 있었기 때문입니다. 값을 정확히 옮기니 시각적으로는 아무 변화 없이 크래시만 사라졌습니다. 고친 뒤 다시 빌드하자 타입 검사도 통과하고, 화면도 정상으로 돌아왔습니다.
정리하며
이 버그가 하필 서버 이전 중에 튀어나온 건, 오히려 다행이었다는 생각이 들었습니다. 신 서버에 먼저 배포해 검증하는 스테이징 단계가 있었기에, 실제 사용자가 아니라 저 혼자 이 하얀 화면을 마주할 수 있었습니다.
남는 교훈은 이렇습니다.
- 배포하지 않은
main은 검증되지 않은 코드다: 오래 배포하지 않을수록, 쌓인 커밋 어딘가에 잠든 버그가 있을 확률이 커집니다. 언젠가 배포하는 순간 한꺼번에 터집니다. - 기능을 지울 때는 참조까지 따라간다: 무언가를 삭제했다면, 그것을 참조하던 곳이 전부 함께 정리됐는지 전체 검색으로 확인하는 편이 좋겠습니다. 정리의 부작용은 대개 이 dangling 참조에서 나옵니다.
- 빌드 성공이 동작을 보장하지 않는다: 특히 객체의 모양이 코드와 어긋날 때, 크래시는 런타임까지 미뤄질 수 있습니다.
- fresh 빌드는 좋은 그물이다: 이전이나 큰 변경 전에 최신 코드를 한 번 완전히 새로 빌드해 스테이징에 띄워 보면, 이런 잠복 버그를 사용자보다 먼저 만날 수 있습니다.
여러 편에 걸쳐, 무료 ARM 서버 두 대를 한 대로 합치는 여정을 정리했습니다. 빌드를 서버 밖으로 옮기고, 그 과정에서 QEMU와 :latest 캐싱과 인증서와 잠든 버그를 차례로 만났습니다. 계획서에는 없던 함정들이었지만, 하나씩 지나고 나니 다음 번엔 덜 헤맬 지도가 한 장 남았습니다.
관련 글
코드는 한 줄도 안 건드렸는데 모든 커밋이 막혔습니다 — Node 버전업과 require(ESM) 인터롭 함정
코드는 한 줄도 안 건드렸는데 pre-commit이 모든 커밋을 막았습니다. 'Definition for rule not found'의 범인은 Node 버전업이었습니다. require(ESM) 인터롭이 ESLint 플러그인의 규칙을 .default에 가둔 과정을 추적했습니다.
급락 경보가 정작 급락에서 침묵했습니다 — GSC API가 0인 날짜를 생략하는 함정
검색 성과가 급락하면 Slack으로 알려주는 경보를 만들었는데, 정작 트래픽이 완전히 소멸하는 최악의 순간에 조용히 침묵했습니다. GSC Search Analytics API가 0인 날짜의 행을 아예 생략하는 성질과 위치 기반 슬라이스가 맞물린 역설을 한 줄씩 추적하고, 날짜 경계로 나눠 고친 버그 회고입니다.
혼자 개발하는데 CI가 왜 필요했나 — 편의 기능인 줄 알았는데 임계 경로였습니다
1인 개발자에게 CI는 사치일까요. 어느 날 CI가 통째로 멈추고 나서야, 그것이 테스트를 대신 돌려주는 편의 기능이 아니라 머지 자체를 막는 임계 경로였다는 것을 알게 됐습니다.