전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

브라우저가 뭘 받아가는지 CI가 지켜봅니다

로그인 화면인데 대시보드 코드가 딸려 오고 있었어요. 눈으로는 절대 못 잡는 문제라, 진짜 브라우저를 띄워 네트워크 탭을 검사하는 테스트를 만들었습니다.

2026. 07. 29. 10:07:508분 읽기프로젝트/데이터 플랫폼
조회 32
관제실 화면의 네트워크 요청 목록에서 한 줄을 짚으며 의아해하는 검사관 일러스트

로그인 화면을 열었을 뿐인데, 브라우저는 대시보드 코드를 받아가고 있었어요.

화면은 멀쩡합니다. 로그인 폼이 잘 뜨고, 버튼도 잘 눌려요. 아무도 불평하지 않았고요.

그런데 네트워크 탭을 열면 아직 보지도 않은 화면의 코드가 줄줄이 내려오고 있었습니다.

이건 왜 눈으로 못 잡느냐면

import 한 줄 때문에 생기는 일이거든요.

어떤 화면이 공용 유틸을 하나 가져다 씁니다. 그 유틸이 사는 파일이 옆에 있는 무거운 화면도 같이 내보내고 있어요. 그러면 번들러 입장에서는 둘이 한 덩어리예요.

코드 리뷰에서 이걸 잡으려면 리뷰어가 머릿속에서 의존성 그래프를 끝까지 따라가야 합니다. 사람이 할 일이 아니죠.

(그리고 이런 건 리뷰에서 지적당해도 억울해요. "제 코드는 한 줄인데요?" 맞는 말이거든요.)

말로만 하면 감이 안 오니까 제일 흔한 모양을 적어 볼게요.

// utils/index.ts — 어디에나 있는 배럴 파일
export * from "./format-date";
export * from "./report-heavy-table";   // 무거운 화면이 같이 산다

// 로그인 화면
import { formatDate } from "@app/utils";
// 한 줄 썼을 뿐인데 report-heavy-table 도 같은 덩어리에 들어옵니다

작성자는 날짜 포맷 함수 하나를 가져다 썼을 뿐이에요. 그런데 번들러 입장에서는 그 파일이 내보내는 것들이 서로 붙어 있는 한 덩어리입니다.

이런 게 리뷰에서 안 잡히는 이유가 여기 있어요. 잘못한 사람이 없거든요. 배럴을 만든 사람도, 유틸을 쓴 사람도 각자 합리적이었습니다.

그래서 진짜 브라우저를 띄웠습니다

정적 분석으로 잡을까 고민했는데, 그러면 번들러가 실제로 어떻게 쪼갤지를 우리가 다시 예측해야 해요. 예측이 틀리면 검사도 틀리고요.

그래서 반대로 갔습니다. CI에서 실제 개발 서버를 띄우고, 실제 브라우저로 화면을 열고, 실제로 오간 네트워크 요청을 봅니다. 예측이 아니라 관측이에요.

규칙은 한 문장입니다. 브라우저는 지금 열린 주소와 지금 연 것에 필요한 코드만 요청한다.

예측 대신 관측

정적 분석으로 갈지 실제 브라우저로 갈지 꽤 오래 고민했어요. 정리하면 이런 차이였습니다.

방식보는 것한계
정적 의존성 분석import 그래프번들러의 청크 분할 규칙을 우리가 다시 예측해야 함
번들 결과 분석청크 구성어떤 청크가 "언제" 요청되는지는 모름
실제 브라우저 관측실제 네트워크 요청서버를 띄워야 해서 느림

세 번째를 골랐어요. 느린 건 참을 수 있는데, 틀린 건 못 참거든요. 예측이 틀리면 검사가 통과했는데 사용자는 여전히 무거운 앱을 받게 됩니다.

// CI에서 도는 브라우저 검사 (요지)
const page = await browser.newPage();   // 캐시·서비스워커 없는 새 세션
const seen = [];
page.on("request", r => seen.push({ url: r.url(), initiator: r.initiator() }));

// 1. 로그인 화면에 직접 진입
await page.goto("/login");
assertNone(seen, /\/product-[a-z]+\/page-/);   // 다른 제품 페이지 모듈이 오면 실패

// 2. 팝업은 "열 때" 와야 한다
seen.length = 0;
await page.click("[data-test=open-export]");
assertSome(seen, /export-dialog/);

두 번째 블록이 중요해요. 팝업은 "안 오는 것"만 검사하면 부족합니다. 열었는데도 안 오면 그건 그것대로 깨진 거니까요. 오면 안 되는 시점과 와야 하는 시점을 둘 다 못 박았어요.

구체적으로 이런 것들을 봅니다

상황받아와야 하는 것
로그인 화면로그인 UI와 그 고객사의 로그인 방식뿐
제품 진입그 제품의 껍데기와 경로뿐
페이지 진입그 페이지에 연결된 조각뿐
팝업 열기 전아무것도 (열 때 받는다)

마지막 줄이 특히 자주 깨졌어요. 팝업은 열기 전엔 화면에 없는데, 코드는 이미 와 있는 경우가 많거든요.

요청 수로 판정하면 안 되는 이유

처음엔 "로그인 화면의 요청이 N개 이하" 같은 기준을 세웠다가 금방 버렸어요. 개발 서버는 소스 모듈 단위로 내려주기 때문에 요청이 원래 많거든요.

관측된 것그냥 실패로 볼까실제 판정
요청이 200개많아 보임개발 서버 특성 — 무엇이 왔는지를 봐야 함
다른 제품의 페이지 모듈실패실패 — 지금 화면과 무관함
공용 UI 라이브러리 청크실패?통과 — 지금 화면이 실제로 씀
안 연 팝업의 구현 청크실패실패 — 열기 전엔 오면 안 됨
아이콘 세트 전체실패?조건부 — 배럴을 타고 왔다면 실패

그래서 판정 기준을 "몇 개가 왔나"가 아니라 "이게 왜 왔나"로 바꿨습니다. 요청마다 누가 불러왔는지를 같이 기록해서, 의존 경로를 근거로 판정해요.

마지막 줄이 실제로 제일 자주 걸린 항목이에요. 아이콘 하나 쓰려고 배럴에서 가져오면 세트 전체가 따라옵니다.

여기서 함정이 하나 있었어요

요청 수를 줄이는 게 목표가 되면 이상한 일이 벌어집니다. 공용 UI를 떼거나 기능을 빼서 숫자를 맞추게 되거든요.

그래서 규칙에 문장을 하나 더 붙였습니다. 요청 수를 줄이려고 기능이나 공용 UI를 제거하지 않는다. 고칠 것은 잘못 연결된 의존성이지 기능이 아니에요.

숫자가 목표가 되는 순간 숫자는 지표를 그만두고 목적이 됩니다. 그건 다른 종류의 망가짐이죠.

그리고 방향을 정했습니다

검사만으로는 안 되고, 애초에 그렇게 안 짜지도록 방향을 못 박아야 했어요.

그래서 이렇게 갈랐습니다. 확장 지점을 쓰는 쪽이 계약을 소유하고, 기능을 주는 쪽은 그 계약에 맞춘 입구 파일과 구현만 가집니다.

쓰는 쪽은 주는 쪽을 가져다 쓰지 않아요. 주는 쪽도 쓰는 쪽의 내부 구현을 가져다 쓰지 않고요. 화살표가 한 방향으로만 흐릅니다.

그러면 입구 파일은 가벼워요. 무거운 구현은 실제로 필요할 때 따로 불러옵니다.

방향을 코드로 못 박기

"화살표가 한 방향으로만 흐른다"를 말로 하면 안 지켜져요. 그래서 소유권을 표로 확정했습니다.

역할소유하는 것소유하지 않는 것
쓰는 쪽확장 계약 · 레지스트리 키와 범위 · 레지스트리 구현 · 활성화와 합성주는 쪽의 어떤 파일도
주는 쪽계약을 쓰는 입구 파일 · 자기 패키지 내부 구현쓰는 쪽의 레지스트리 · 라우트 설정 · 서비스

그리고 입구 파일이 어떻게 생겨야 하는지를 고정했어요. 여기가 이 구조의 핵심입니다.

// 기능을 주는 쪽이 만드는 입구 파일 — 구현이 여기 없습니다
const entry = createRegistryEntry({
  activation: "page:reports:detail",   // 어느 화면에서 필요한가
  fragment:   "page:reports:detail",   // 어떤 단위로 묶어 내릴 것인가
  registry:   REPORT_WIDGET_REGISTRY.key,
  scope:      SCOPE.PAGE,
  source:     { layer: "product", id: "product-a" },
  requiredSources: [{ layer: "product", id: "product-b" }],

  // 무거운 건 전부 이 안쪽에
  load: () => import("./report-widget.impl").then(m => m.provider),
});

규칙은 셋이에요. load 바깥은 항상 가벼울 것, 입구에 사용자 상태나 런타임 분기를 넣지 말 것, 그리고 requiredSources는 "이 패키지가 설치돼 있어야 한다"는 조건이지 import가 아닐 것.

세 번째가 은근히 자주 오해받았어요. 필요하다고 적었으니 가져다 쓰면 되지 않냐고요. 그 순간 화살표가 양방향이 됩니다.

bridge는 이름을 바꿔도 bridge예요

가장 많이 시도된 우회가 이거였어요. 직접 import를 동적 import로 감싸는 겁니다.

// 금지 1 — 쓰는 쪽이 주는 쪽을 아는 wrapper
export const loadProductA = () =>
  import("@app/product-a/internal/provider");

// 금지 2 — 주는 쪽이 쓰는 쪽의 레지스트리를 직접 실행
const register = async () => {
  const registry = await import("@app/product-b/internal/registry");
  registry.register(provider);
};

// 파일 이름이 loader든 adapter든 connector든 register든
// 의존 방향이 그대로면 그건 bridge입니다.

동적 import로 감싸면 번들은 갈라져요. 그래서 네트워크 검사는 통과할 수도 있습니다. 그런데 구조는 하나도 안 고쳐졌어요. 검사를 통과하는 것과 문제를 푸는 것은 다른 일이죠.

그래서 이건 네트워크 검사가 아니라 아키텍처 테스트가 잡습니다. 두 검사가 보는 게 다르거든요.

CI에 어떻게 얹었냐면

검사 단계를 순서대로 세웠습니다. 의존성 설치 → 정적 검사와 테스트 → 브라우저 네트워크 검사 → 결과물 빌드 → 결과물 계약 검사.

여기에 규칙을 하나 더 뒀어요. 같은 검사를 여러 단계에서 반복하지 않는다. 개발자가 빠르게 돌려보는 명령이 따로 있는데, 그걸 전체 검사 뒤에 또 돌리지 않습니다.

(중복 검사는 안전해 보이지만 실제로는 CI 시간만 먹고 아무도 안 읽는 로그를 만들어요.)

없으면 사라지되, 망가지진 않게

이 구조의 부수 효과가 하나 있어요. 어떤 패키지가 아예 없을 수도 있게 됩니다. 고객사마다 설치된 제품이 다르니까요.

없는 것사라지는 것유지되는 것
제품 하나그 제품의 라우트 · 헤더 · 사이드바 기여나머지 전부
플러그인 하나그 플러그인이 더한 기능기본 기능
고객사 전용 기여고객사 맞춤 로그인·레이아웃기본 로그인·레이아웃
다른 제품이 주던 옵션그 옵션 항목옵션을 쓰던 화면

규칙은 한 줄이에요. 없는 패키지를 import 오류나 합성 오류로 처리하지 않는다. 없으면 그냥 목록에서 빠집니다.

반대로 이런 것도 금지했어요. 필수 기능을 선택적 패키지에 두는 것. 그러면 "그 패키지 없으면 앱이 안 돌아요"가 되고, 선택적이라는 말이 거짓말이 되니까요.

그리고 훑기를 금지했습니다

기여를 모으는 제일 쉬운 방법은 런타임에 파일을 훑는 거예요. 그리고 그게 이 구조를 가장 빠르게 무너뜨립니다.

// 금지 — 런타임에 파일을 훑어서 기여를 찾기
const modules = import.meta.glob("/src/**/*.register.ts", { eager: true });

// 무엇이 언제 로드되는지 빌드 시점에 알 수 없게 됩니다.
// 훑기는 빌드 단계에서만 허용하고,
// 결과는 생성된 목록으로 고정합니다.

그래서 새 기여를 추가하는 방법은 하나예요. 정해진 위치에 입구 파일을 만들면 빌드가 목록에 넣습니다. 사람이 손으로 관리하는 중앙 목록은 없어요.

치른 값

치른 것왜 받아들였나
CI 시간 증가서버를 띄우고 브라우저를 여니까 느림 — 대신 예측이 아니라 사실을 봄
검사 자체의 취약함화면이 바뀌면 셀렉터가 깨짐 — 테스트용 속성으로 고정해서 완화
입구 파일이라는 추가 개념기여할 때마다 파일이 하나 더 늘어남 — 대신 방향이 강제됨
초기 전환 비용배럴을 걷어내는 작업이 지루하고 김한 번 걷으면 다시 자라지 않음

그래서 뭐가 달라졌냐면

이제 잘못된 의존성은 리뷰가 아니라 빨간불로 돌아옵니다. "이 화면에서 이 코드가 왜 내려오죠?"를 사람이 묻지 않아요.

사람이 못 보는 것은 사람에게 맡기면 안 됩니다. 이 시리즈가 계속 같은 말을 하고 있네요.


규칙을 기계에 맡기는 이야기는 이걸로 세 편이 됐어요. 문서 9,900줄로 시작해서, 화면을 해시로 고정하고, 이번엔 브라우저가 뭘 받아가는지까지 지켜봅니다.

그리고 규칙이 전부 기계가 되고 나니, 예상 못 한 배당금이 하나 더 왔어요. 기여자가 프론트엔드 개발자일 필요가 없어진 겁니다. 다음 편은 백엔드 개발자가 프론트 화면을 고치기 시작한 이야기예요.

시리즈 이어읽기

규칙은 기계가 지킨다

3 / 4

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.