클라이언트 번들에 서버 모듈이 딸려 나갈 때 원인 찾는 법

라우트별 클라이언트 청크 크기를 나란히 늘어놓고 보다가 한 줄에서 눈이 멈췄다. 다른 라우트는 다 고만고만한데 딱 하나만 몇 배는 무거웠다. 처음에는 그림이나 아이콘 같은 정적 자산이 잘못 끼어 들어간 줄 알았다. 열어 보니 전혀 다른 물건이었다. 서버에서만 돌아야 할 코드가 브라우저로 그대로 실려 나가고 있었다.
이런 종류의 문제는 겉으로 잘 안 드러난다. 화면은 멀쩡히 뜨고 기능도 잘 작동한다. 사용자가 체감할 만큼 느려지기 전까지는 아무도 눈치채지 못한다. 그래서 "왜 이 라우트만 무거운가"라는 질문에 답하려면 그럴듯한 설명이 아니라 확정할 수 있는 근거가 있어야 했다.
결론부터 말하면 원인은 값 import(타입이 아니라 실제로 실행되는 코드를 그대로 가져오는 import) 한 줄이었다. 그런데 "한 줄이 원인이다"라고 말하려면 그 한 줄을 없애면 좋아진다는 사실만으로는 부족했다. 다른 후보도 원인이 아니라는 걸 같이 보여야 진짜 확정이다.
라우트 하나만 유독 무거운 이유부터 확인한다
성능 감사를 시작할 때 가장 먼저 한 일은 라우트별 클라이언트 청크 크기를 표로 뽑아 늘어놓는 것이었다. 청크(chunk)는 빌드 도구가 라우트나 기능 단위로 쪼개 놓은 번들 조각을 말한다. 사용자가 특정 페이지에 들어갈 때 그 페이지에 필요한 청크만 내려받게 하려고 이렇게 나눈다. 라우트마다 담긴 기능이 다르니 청크 크기도 어느 정도는 차이가 나게 마련이다. 그 차이가 라우트의 기능으로 설명되는 수준이면 정상이다.
이번 감사에서는 아니었다. 한 라우트의 청크가 다음으로 무거운 라우트보다 몇 배는 컸다. 우연이라 보기엔 차이가 너무 컸다. 무거운 청크 안에 뭐가 들었는지 열어 보니 그 라우트 화면에는 전혀 안 쓰이는 코드가 잔뜩 들어 있었다. 값 검증 라이브러리의 런타임 전체와, 데이터베이스 스키마를 정의하는 서버 전용 모듈이 그 안에 통째로 실려 있었다.
이 조합이 이상했다. 값 검증 라이브러리는 사용자가 입력한 값이 맞는지 확인할 때 쓰는데, 그 확인은 대개 서버에서 한다. 데이터베이스 스키마 정의는 두말할 것도 없이 서버 전용이다. 이 둘이 브라우저로 가는 코드 조각 안에 들어 있다는 사실 자체가 경계 어딘가가 뚫렸다는 신호였다. 크기 차이는 증상이고, 그 안에 뭐가 들었는지가 원인 조사의 출발점이었다.
크기 차이가 우연이 아니라고 확신할 수 있었던 이유는 측정 방식에 있다. 빌드 결과물의 청크 크기는 같은 커밋, 같은 빌드 설정이면 네트워크 상태나 캐시 여부와 무관하게 같은 값이 나온다. 라우트를 몇 번 다시 열어 보고 체감으로 판단하는 것과 달리, 이 조건에서는 측정할 때마다 같은 숫자가 나온다는 점이 신뢰할 수 있는 근거였다. 다만 같은 소스로 다시 빌드했는데 해시나 크기가 흔들린다면 그 비결정성부터 먼저 잡아야 한다. 그렇지 않으면 앞뒤 비교 자체가 읽을 수 없는 숫자가 된다. 그래서 청크 크기 비교는 이 감사에서 가장 먼저, 그리고 가장 믿을 수 있게 손댈 수 있는 신호였다.
이 시점에서 할 수 있는 판단은 하나였다. 서버 코드가 어떻게 클라이언트 쪽으로 넘어왔는지 경로를 거슬러 올라가야 한다는 것. 청크가 무겁다는 사실만으로는 아무것도 고칠 수 없다. 무엇이 그 코드를 끌고 왔는지 알아야 고칠 지점이 나온다.
값 import 한 줄이 체인 전체를 끌고 온다
서버 전용 코드가 클라이언트 번들에 섞이는 경로는 대개 한 방향이다. 클라이언트에서 렌더링되는 컴포넌트가 어딘가에서 값을 가져오는데, 그 값을 가져오는 함수가 서버 쪽 모듈을 거쳐서 정의돼 있는 경우다. 이번 사례도 그랬다. 라우트 컴포넌트 함수 본문 안에서 검증 헬퍼 함수를 호출하고 있었다. 그 헬퍼는 서버 액션 모듈을 값으로 import하고 있었고, 서버 액션 모듈은 다시 서버 데이터베이스 모듈을, 데이터베이스 모듈은 스키마 정의를, 스키마 정의는 값 검증 라이브러리를 끌고 왔다.
빌드 도구 입장에서 보면 이건 자연스러운 동작이다. 어떤 파일이 다른 파일을 값으로 import하면, 빌드 도구는 그 값이 실제로 쓰이는지와 무관하게 일단 그 파일 전체를 번들에 포함할 후보로 본다. 트리 셰이킹(tree shaking, 실제로 쓰지 않는 코드를 빌드 시점에 자동으로 걸러내는 최적화)이 상당 부분을 잘라내지만, 값이 실제로 호출되는 코드 경로 안에 있으면 트리 셰이킹은 그걸 지우지 못한다. 지우면 프로그램이 깨지기 때문이다. 체인을 의사코드로 줄이면 이렇다.
// 실제 코드가 아니라 구조를 보여주는 예시일 뿐, 그대로 동작하는 코드는 아니다
// 클라이언트에서 렌더되는 라우트 컴포넌트 (의사코드)
function RouteComponent(input) {
const result = validateHelper(input) // 값으로 import
return render(result)
}
// validateHelper.ts
import { runServerAction } from "./server-action" // 값으로 import
export function validateHelper(input) {
return runServerAction.schema.parse(input)
}
// server-action.ts -> db.ts -> schema.ts -> 검증 라이브러리
// 체인 끝까지 값으로 이어지면 전부 번들에 포함된다
타입스크립트 같은 언어에서는 타입만 가져오는 import(예: interface나 type 선언)는 컴파일 시점에 지워진다. 실행 코드가 아니라 타입 검사에만 쓰이므로 빌드 산출물에는 흔적조차 남지 않는다. 반대로 값 import는 실제로 실행되는 함수나 객체를 가져오므로, 그 값이 정의된 파일 전체가 번들에 딸려 올 후보가 된다. 검증 헬퍼가 서버 액션 모듈을 "타입으로만" 참조했다면 체인은 애초에 성립하지 않았을 것이다. 값으로 참조했기 때문에 그 뒤로 이어지는 모든 파일이 함께 끌려왔다.
이 체인이 특히 뼈아팠던 이유는 따로 있다. "클라이언트 라우트 모듈은 검증 라이브러리·데이터베이스·서버 액션 모듈을 값으로 import하지 않는다"는 규칙이 이미 문서로 정해져 있었다. 문서에 적혀 있었는데도 새는 걸 막지 못했다. 계약이 문서로만 있고 빌드 시점에 기계적으로 강제되지 않으면, 코드를 짜는 순간에는 아무도 그 줄이 규칙을 어기는지 알아채지 못한다. 사람의 부주의라기보다 검증 방식의 구멍이었다.
체인을 눈으로 다 따라갔다고 바로 "이게 원인이다"라고 말할 수는 없었다. 코드를 읽고 그럴듯해 보이는 설명을 만드는 것과, 그 설명이 실제로 맞는지 확인하는 것은 다른 작업이다. 코드에 들어맞는다고 느껴지는 설명은 아직 원인이 아니다. 이 줄을 지웠을 때 청크가 실제로 줄어드는지와, 남은 후보를 건드려도 결과가 그대로인지 둘 다 확인돼야 비로소 원인이라고 부를 수 있었다.
되돌림 실험으로 상한 효과부터 잰다
가장 먼저 한 실험은 단순했다. 의심되는 호출 한 줄, 즉 라우트 컴포넌트 안에서 검증 헬퍼를 부르는 그 줄만 지우고 다시 빌드했다. 나머지 코드는 하나도 안 건드렸다. 변수는 딱 하나만 남겨야 결과를 그 변수 탓으로 돌릴 수 있어서다.
결과는 확실했다. 그 청크는 눈에 띄게, 한 자릿수 배수만큼 줄었다. 검증 라이브러리 런타임도, 데이터베이스 스키마 모듈도 더 이상 그 청크 안에 없었다. 한 줄을 지웠을 뿐인데 체인 전체가 같이 빠졌다. 이건 그 한 줄이 체인의 시작점이라는 가설과 정확히 들어맞는 결과였다.
다만 여기서 멈추면 안 됐다. 이 실험이 보여주는 건 "이 줄을 고치면 이만큼 좋아진다"는 상한선이지, "다른 원인은 없다"는 증명이 아니다. 되돌림 실험 하나만으로 원인을 확정했다고 말하는 건 성급하다. 의심 가는 후보가 하나 더 있었다. 액션 전용 경로로만 쓰이는 다른 함수도 값으로 import되고 있었는데, 이게 진짜 새는 지점인지 아니면 이미 걸러지고 있는 무해한 참조인지 이 실험만으로는 구분이 안 됐다.
두 후보가 남아 있는 상태에서 한쪽만 확인하고 끝내면, 나중에 다른 후보가 진짜 원인이었다는 사실이 드러날 위험이 남는다. 그래서 나머지 후보도 같은 방식으로, 다만 반대 방향으로 검증했다.
반증 실험으로 다른 후보를 지운다
이번에는 방향을 바꿨다. 남은 후보였던 액션 전용 경로를, 값 import가 아니라 동적 import(코드가 실제로 필요한 시점에만 불러오도록 지연시키는 방식)로 바꾸고 다시 빌드했다. 이 경로가 정말로 그 무거운 청크에 섞여 들어가고 있었다면, 동적 import로 바꾼 순간 청크 크기가 줄어야 한다.
바뀐 게 없었다. 청크 크기도, 빌드 결과물의 해시값도 완전히 동일했다. 해시가 같다는 건 실용적으로는 산출물이 바이트 단위로 같다는 뜻이다. 그 경로는 이미 트리 셰이킹으로 걸러지고 있었고, 애초에 무거운 청크와는 관계가 없었다. 이 실험이 반증 실험이다. "이것도 원인일 수 있다"는 후보를 세우고, 바꿔도 결과가 안 변한다는 걸 보여서 후보에서 지우는 방식이다.
되돌림 실험과 반증 실험은 서로 다른 질문에 답한다. 되돌림 실험은 "이걸 고치면 좋아지는가"를 확인하고, 반증 실험은 "다른 것도 원인일 수 있는가"를 가른다. 되돌림만 하고 멈추면 첫 질문에만 답한 셈이라 확정이 아니라 추정에 머문다. 둘 다 해야 "이 한 줄이 원인이고 다른 후보는 아니다"라고 말할 근거가 갖춰진다. 이 두 실험을 나란히 돌린 다음에야 검증 헬퍼 호출 한 줄을 원인으로 확정했다.
baseline 없이는 회귀라 부를 수 없다
원인은 확정했지만 확정하지 못한 것도 그만큼 많았다. 가장 큰 구멍은 저장소에 체크인된 성능 baseline(비교 기준이 되는 과거 측정치 기록)이 없었다는 점이다. baseline을 캡처하는 스크립트는 있었는데, 그 스크립트가 결과를 저장하도록 가리키는 출력 디렉터리가 실제로는 존재하지 않았다. 스크립트는 있고 그 스크립트가 쓰는 산출물은 없는 상태였다.
이게 왜 문제가 되냐면, baseline이 없으면 이 문제를 "회귀(regression, 예전에는 괜찮았는데 나빠진 상태)"라고 부를 근거가 없어서다. 이 라우트가 원래부터 무거웠는지, 어느 시점에 값 import 한 줄이 추가되면서 갑자기 무거워졌는지 구분할 방법이 없었다. 그래서 이 감사에서는 "회귀"라는 말을 쓰지 않았다. 청크가 필요 이상으로 무겁다는 사실과 그 원인은 확정했지만, 그게 언제부터 그랬는지는 확정하지 못했다.
측정하지 않은 것도 그대로 남겨 뒀다. Lighthouse 점수, 사용자가 체감하는 로딩 속도 지표, 프로덕션 데이터베이스 응답 지연, 서버가 처음 요청을 받을 때의 콜드 스타트 CPU 사용량은 이번 감사에서 재지 않았다. 그러니 "사용자가 실제로 얼마나 빨라지는가"라는 질문에는 이 감사가 답을 못 준다. 청크가 가벼워졌다는 사실과 사용자 체감 속도가 빨라졌다는 사실은 서로 다른 주장이고, 후자는 별도로 측정해야 한다.
Lighthouse는 페이지 로딩 전반을 자동으로 채점하는 도구고, LCP(Largest Contentful Paint, 화면에서 가장 큰 요소가 실제로 그려지는 시점)와 INP(Interaction to Next Paint, 사용자 입력에 화면이 반응하는 속도)는 사용자가 실제로 느끼는 체감 속도에 더 가깝다. 청크 파일 크기가 줄었다고 이 지표들이 반드시 같이 좋아진다는 보장은 없다. 네트워크 환경, 캐시 정책, 다른 병목이 더 크게 작용할 수도 있어서다. 그래서 이번 결과는 "번들에서 서버 코드를 걷어냈다"까지만 말할 수 있고, "사용자가 체감할 만큼 빨라졌다"는 이 감사가 아니라 다음 측정이 답해야 할 질문으로 남겨 뒀다.
이번 감사에서 확정한 것과 확정하지 못한 것을 나눠 보면 이렇다.
| 항목 | 상태 |
|---|---|
| 어느 라우트 청크가 무거운가 | 확정 — 크기 비교로 특정 |
| 무거운 이유(원인 체인) | 확정 — 값 import 한 줄까지 추적 |
| 그 한 줄이 진짜 원인인가 | 확정 — 되돌림 + 반증 두 실험으로 교차 확인 |
| 언제부터 이랬는가(회귀 여부) | 미확정 — baseline 산출물 없음 |
| 사용자 체감 속도 개선폭 | 미확정 — Lighthouse·LCP·INP 미측정 |
| 서버 쪽 영향(DB 지연, 콜드 스타트) | 미확정 — 별도 측정 필요 |
재발을 막으려면 네 가지 방향이 남는다. 첫째, 서버 전용 모듈에 파일명만 보고도 알 수 있는 표시를 남긴다. 둘째, 클라이언트 진입점에서 서버 전용 모듈을 값으로 참조하지 않는다. 타입만 필요하면 타입 import로 바꾸고, 값이 정말 필요하면 그 호출 자체를 서버 쪽으로 옮긴다. 셋째, 이 계약을 빌드 시점에 기계적으로 강제한다. 구체적으로는 세 가지 장치를 후보로 본다. 하나는 클라이언트 진입점에서 서버 전용 모듈을 import하면 걸리는 린트 규칙이다. 또 하나는 서버 전용 파일 자체에 마커를 심어, 클라이언트 번들에 실수로 딸려 들어가면 그 즉시 예외를 던지게 만든다. 나머지 하나는 클라이언트 청크 안에 금지된 모듈 이름이 나타나면 빌드가 실패하도록 검사를 붙인다. 이 셋은 이번 감사에서는 아직 실제로 붙이지 않았다. 다음 작업으로 남겨 둔 항목이다. 넷째, 무엇보다 다음 감사를 위해 baseline 산출물부터 실제로 확보해 둔다. 되돌림·반증 실험을 매번 새로 돌리지 않아도, 숫자로 남겨 둔 baseline이 있으면 회귀인지 아닌지는 그 자리에서 바로 판단할 수 있다.
클라이언트와 서버 코드를 아예 같은 빌드에 두지 않는 선택지도 있다. 번들러 없이 정적 사이트로 앱을 만든 경험처럼 서버 로직 자체를 빼면 이런 경계 문제가 구조적으로 생기지 않지만, 서버와 클라이언트가 한 빌드를 공유하는 이번 구조에서는 그 선택지가 늘 가능하지는 않다. 비슷한 원인 추적 기록은 프로그래밍 카테고리에 모아 둔다. 그래서 결국 남는 숙제는 baseline이다. 이 라우트가 정확히 언제부터 무거워졌는지는 이번 감사로 답하지 못했고, baseline이 저장소에 남기 전까지는 계속 답할 수 없는 질문으로 남는다.
자주 묻는 질문
청크가 무겁다는 것만으로 원인을 확정할 수 있나?
아니다. 청크 크기 차이는 증상일 뿐이고, 그 안에 무엇이 들었는지 확인해야 원인 조사가 시작된다. 이번 사례에서도 크기 비교는 출발점이었고, 실제 확정은 되돌림 실험과 반증 실험을 함께 돌린 뒤에야 이뤄졌다.
되돌림 실험만으로 충분하지 않은 이유는 무엇인가?
되돌림 실험은 의심되는 코드를 지웠을 때 얼마나 좋아지는지, 즉 상한 효과만 보여준다. 다른 후보가 원인이 아니라는 것까지는 증명하지 못한다. 남아 있는 다른 후보를 반증 실험으로 따로 확인해야 이 한 줄이 원인이고 다른 건 아니라고 말할 수 있다.
문서로 계약을 적어 두면 값 import 문제는 막히지 않나?
문서만으로는 막히지 않는다. 이번 사례도 클라이언트 라우트 모듈이 검증 라이브러리·DB·서버 액션 모듈을 값으로 import하지 않는다는 계약이 이미 문서에 있었는데도 위반이 들어갔다. 문서는 코드를 짜는 순간에 아무것도 강제하지 못하므로, 될 수 있으면 빌드 시점에 기계적으로 걸리는 장치가 있어야 한다.
baseline이 없으면 이 문제를 회귀라고 부를 수 있나?
부를 수 없다. 회귀는 예전에는 괜찮았는데 나빠졌다는 뜻인데, 비교할 이전 수치가 없으면 원래부터 무거웠는지 어느 시점에 나빠졌는지 구분할 방법이 없다. 이번 감사에서도 baseline 산출물이 없어 회귀라는 표현 대신 원인 확정까지만 다뤘다.
아직 댓글이 없습니다.