Figma에서는 완벽했는데요: CDN 압축 설정 하나가 90초 로딩을, Bun 런타임이 새벽 2시 장애를 만든 이야기

Figma에서는 완벽했는데요: CDN 압축 설정 하나가 90초 로딩을, Bun 런타임이 새벽 2시 장애를 만든 이야기

프로덕션에서 터지는 1초를 잡기 위해 compress: false 한 줄, 터치 이벤트 Ref 하나, useMemo 삭제 한 번이 필요했던 실전 디버깅 기록

Next.js Azure Front Door CDN 압축 Bun 런타임 CrashLoopBackOff Ghost Click 모바일 터치 이벤트 Core Web Vitals
광고

로컬에서는 0.3초, 프로덕션에서는 90초

사용자 입장에서 90초는 "앱이 죽었다"와 같은 말입니다. Lighthouse 점수고 뭐고, 페이지가 안 뜨면 끝이에요. dev.to에 올라온 Felix Schober의 트러블슈팅 포스트를 읽다가 등골이 서늘해졌습니다. Next.js 앱을 Azure Container Apps에 올리고, 앞단에 Azure Front Door Premium을 붙인 — 솔직히 교과서적인 구성이거든요. 그런데 모든 JS 청크가 정확히 90초간 멈춘 뒤 ERR_HTTP2_PROTOCOL_ERROR를 뱉었다고 합니다.

핵심은 놀라울 정도로 단순했습니다. curlAccept-Encoding: gzip 헤더 하나만 붙이면 303ms짜리 응답이 90초로 뻥튀기되는 거예요. 같은 파일, 같은 라우트, 같은 오리진인데 말이죠. 오리진(Next.js)이 compress: true 기본값으로 gzip 응답을 보내는데, Front Door의 HTTP/2 릴레이 경로가 이미 압축된 응답을 제대로 중계하지 못하고 커넥션을 90초(비설정 가능한 keep-alive idle timeout) 뒤에 끊어버린 겁니다. Microsoft Q&A 스레드에서도 동일 증상과 동일 해결책이 반복적으로 보고되고 있었고요.

수정은 next.config.jscompress: false 한 줄. CDN이 엣지에서 압축하도록 위임하는 것. 이게 전부였습니다. "오리진과 CDN에서 동시에 압축하지 마라" — 원칙 자체는 누구나 아는 건데, 실제로는 Next.js 기본값이 compress: true라는 사실을 간과하기가 너무 쉽습니다. Figma에서 디자인 넘기고, Terraform으로 인프라 찍고, CI 파이프라인 초록불 확인하고… 그런데 프로덕션에서 사용자가 보는 건 90초짜리 하얀 화면이었던 거죠. Core Web Vitals의 LCP가 90초라니, Lighthouse 리포트를 열어볼 용기조차 안 났을 겁니다.

Bun 런타임의 달콤한 함정: 새벽 2시 PagerDuty가 울린 이유

속도에 대한 유혹은 런타임 레벨에서도 반복됩니다. dev.to의 TechResolve 포스트가 전하는 이야기가 정확히 그거예요. 주니어 엔지니어가 node:20-alpine 베이스 이미지를 oven/bun:latest로 교체했고, CI는 통과했고, 빌드 시간은 단축됐고, 그리고 프로덕션 Kubernetes에서 CrashLoopBackOff가 터졌습니다.

Bun은 정말 대단한 엔지니어링이지만, Node.js의 C++ 애드온 인터페이스(N-API)를 100% 재현하지는 못합니다. sharp, bcrypt, node-postgres 같은 네이티브 바인딩 라이브러리가 symbol not found로 죽는 거죠. 문제는 직접 의존하는 패키지가 아니라 의존의 의존, 그 깊은 곳에 숨어 있는 네이티브 모듈일 때가 많다는 점입니다. 번들 사이즈 줄이겠다고 트리 쉐이킹 열심히 했는데, 런타임 호환성 때문에 컨테이너가 죽으면 의미가 없잖아요.

해당 포스트에서 제안하는 실전 해법이 꽤 현실적이었습니다. 멀티 스테이지 Dockerfile로 빌드 단계에서는 oven/bun:1.0의 속도를 취하되, 프로덕션 러너는 node:20-alpine으로 돌리는 하이브리드 전략. "200ms 빠른 빌드 타임을 쫓다가 4시간짜리 프로덕션 장애를 만들지 마라"는 경고가 뼈를 때립니다. 사실 이건 프론트엔드 엔지니어라면 늘 마주하는 트레이드오프의 축소판이에요 — 새 도구의 벤치마크 숫자와 프로덕션 안정성 사이의 간극.

모바일에서 터치하면 왜 두 번 눌리죠? Ghost Click과의 전쟁

인프라 레벨의 폭탄을 해제했다고 끝이 아닙니다. 사용자의 손끝에서도 전쟁은 계속됩니다. Velog에 올라온 '짜조(JJAJO)' 개발일지가 바로 이 이야기를 하고 있어요. 캘린더에서 날짜를 길게 눌러 일정을 추가하는 Long Press를 구현했는데, 모바일 브라우저의 고질적인 "유령 클릭(Ghost Clicks)" 문제에 직면한 겁니다.

모바일 브라우저는 touchend 이후 약 300ms 뒤에 mousedownclick 이벤트를 자동 발생시킵니다. 롱프레스로 모달을 열었는데, 그 뒤에 따라오는 유령 click이 모달 뒤의 요소를 또 누르는 거죠. Figma 프로토타입에서는 절대 재현되지 않는 버그입니다. 실제 디바이스에서 손가락으로 눌러봐야만 잡히는, 디자인-개발 갭의 전형적인 사례예요.

해결은 touchActiveRef라는 Ref 하나로 터치 상태를 추적하고, 터치 중일 때 마우스 이벤트를 무시하는 방식. 여기에 onTouchMoveonTouchCancel로 스크롤 시 롱프레스를 즉시 취소하는 로직까지. 라이브러리 의존성을 늘리지 않고 Ref 기반으로 직접 해결한 건 번들 사이즈 관점에서도 좋은 판단입니다. 다만 이런 터치 이벤트 교통정리는 접근성(a11y) 측면에서도 놓치면 안 되는 부분이에요 — 스크린리더 사용자에게 Long Press는 인지조차 되지 않을 수 있거든요.

같은 개발일지에서 건진 두 가지 작은 교훈

짜조 개발일지에서 인상적이었던 건 두 가지 더 있습니다. 하나는 디자인 토큰 정비. 컴포넌트마다 rounded-lg(8px), rounded-xl(12px), rounded-md(6px)가 뒤섞여 있던 걸 tailwind.config.js와 CSS 변수로 중앙 집중화한 부분. 사소해 보이지만 이런 불일치가 쌓이면 앱이 "덜 만들어진 느낌"을 줍니다. 사용자는 1px의 어긋남을 본능적으로 느끼거든요. 나중에 다크 모드 도입할 때도 CSS 변수만 바꾸면 되니, 디자인 시스템의 절반은 먹고 들어가는 셈이죠.

다른 하나는 useMemo 제거. 10~20개짜리 todo 배열 정렬에 관성적으로 useMemo를 걸어놨던 걸 과감하게 벗겨낸 건데, React 공식 문서에서도 "최적화는 공짜가 아니다"라고 명시하고 있죠. 메모이제이션의 오버헤드가 정렬 비용보다 큰 상황에서는 오히려 메모리만 잡아먹는 겁니다. Flutter 달력 앱에서 setState 대신 ValueNotifier로 드래그 오프셋과 그리드 빌드를 분리한 Velog 포스트도 같은 원칙을 말하고 있어요 — 빠르게 바뀌는 것과 느리게 바뀌는 것을 분리하라. 결국 그 Flutter 개발자도 직접 구현 대신 PageView라는 기본 위젯이 이미 해결하고 있었다는 걸 깨닫고 코드를 절반으로 줄였고요.

프로덕션에서 터지는 1초를 잡는 체크리스트

네 개의 소스가 공통으로 가리키는 건 결국 하나입니다. "로컬/스테이징에서 괜찮았다"는 문장은 프로덕션에서 아무 의미가 없다는 것. CDN 압축 설정, 런타임 호환성, 모바일 터치 이벤트, 관성적 메모이제이션 — 전부 CI 초록불 뒤에 숨어 있다가 실사용자의 디바이스에서 터지는 것들이에요.

프론트엔드 엔지니어로서 가져갈 실전 원칙을 정리하면 이렇습니다:

  1. CDN 뒤에서는 오리진 압축을 끄세요. Next.js의 compress: false든, Express의 compression 미들웨어 제거든, Nginx의 gzip off든. CDN이 엣지에서 할 일을 오리진이 선점하면 HTTP/2 스트림이 죽습니다.
  2. 새 런타임은 빌드에서만 쓰고, 프로덕션 러너는 검증된 것으로. Bun의 속도는 bun installbun run build에서 취하되, CMDnode로.
  3. 모바일 인터랙션은 실기기에서 검증하세요. Ghost Click, 300ms 딜레이, 스크롤 충돌 — Figma 프로토타입과 Chrome DevTools의 모바일 에뮬레이터로는 잡히지 않는 버그가 있습니다.
  4. 최적화는 측정 후에. useMemo, React.memo, ValueNotifier — 전부 좋은 도구지만, 프로파일러 없이 관성적으로 적용하면 오히려 복잡성만 늘어납니다.

결국 프로덕션의 1초는 코드 한 줄이 아니라, 인프라 설정 × 런타임 호환성 × 디바이스 이벤트 모델 × 렌더링 전략이 교차하는 지점에서 만들어집니다. 그리고 그 교차점은 항상, 예외 없이, 사용자의 손끝에서 드러나죠. "Figma에서는 괜찮았는데" — 이 문장을 올해 안에 한 번도 안 쓰는 게 목표입니다. 어렵겠지만요.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요