홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

삭제한 기능이 남긴 참조가 새 빌드를 죽였습니다

정기창·2026년 7월 10일

앞 글에서 서버를 옮기며, 최신 코드로 이미지를 새로 빌드했습니다. 신 서버에 배포하고 화면을 열었더니, 잘 돌던 사이트가 첫 화면부터 하얗게 죽어 있었습니다. "문제가 발생했습니다. 페이지를 새로고침해주세요." 정작 구 서버에서는 멀쩡히 돌고 있는 화면이었습니다.

증상: 빌드는 성공했는데 첫 렌더에서 크래시

콘솔을 열어 보니 에러 바운더리가 붙잡은 런타임 예외가 찍혀 있었습니다.

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 캐싱과 인증서와 잠든 버그를 차례로 만났습니다. 계획서에는 없던 함정들이었지만, 하나씩 지나고 나니 다음 번엔 덜 헤맬 지도가 한 장 남았습니다.

TypeScript리팩터링빌드프론트엔드기술부채

관련 글

무중단 서버 이전: DNS 컷오버와 Let's Encrypt 타이밍 함정

이미지를 옮기고 DNS만 바꾸면 끝일 줄 알았는데, Let's Encrypt 인증서가 발급되지 않았습니다. 무중단 이전의 설계와, TTL을 미리 낮추지 않아 인증서 발급이 지연된 함정을 로그와 함께 정리했습니다.

관련도 90%

Deep Health Check로 Next.js 배포 안정성 높이기: 롤링 업데이트의 함정을 피하는 방법

Coolify 롤링 업데이트에서 Health Check가 성공해도 실제 페이지 렌더링이 실패하는 문제를 경험했습니다. ESM 모듈 호환성 에러와 정적 렌더링 실패를 해결하고, Deep Health Check를 구현하여 배포 안정성을 개선한 과정을 공유합니다.

관련도 90%

SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들

글력(SaaS 블로그 플랫폼)을 Coolify에 처음 배포하면서 겪은 메모리 최적화, OG 이미지 문제, 봇 스캔 방어, Redis 연결, CORS까지. 실전에서 하나씩 해결한 과정을 정리했습니다.

관련도 90%