전체 검색

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

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

화면에 필요한 코드가 제때 내려오는지, 사이드바와 홈이 같은 API를 중복 요청하지 않는지 브라우저에서 확인해요. 코드 격리와 초기 로딩 API 검사의 판정 기준·관측 범위를 나눠 기록했습니다.

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

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

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

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

이건 왜 눈으로 못 잡느냐면

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 — 쓰는 쪽이 주는 쪽을 아는 wrapperexport 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로 감싸면 번들은 갈라져요. 그래서 네트워크 검사는 통과할 수도 있습니다. 그런데 구조는 하나도 안 고쳐졌어요. 검사를 통과하는 것과 문제를 푸는 것은 다른 일이죠.

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

같은 API를 두 번 요청하는지도 봅니다

코드 모듈이 제때 내려오는지와 API 캐시가 재사용되는지는 서로 다른 문제예요. 사이드바와 홈처럼 같은 데이터를 사용하는 곳에서 중복 호출을 확인하려고 별도의 초기 로딩 검사를 연결했습니다.

Playwright로 접근 가능한 페이지를 열고 초기 로딩 응답을 관측해요. HTTP method·정규화된 URL·요청 본문을 묶어 동일 요청을 구분하고, 수집 구간에서 같은 요청이 두 번 이상 나타나면 중복으로 집계해 테스트를 실패시킵니다.

페이지별 테스트는 first-load-duplicate-api.mock.spec.ts, 수집과 집계는 first-load-api-request.util.ts에서 확인할 수 있어요. dev·preview 검사는 전체 브라우저 CI 검증 흐름에 연결돼 있습니다. 이 설명은 2026년 9월 8일의 코드·설정 확인이며, 이번 글 수정 중 테스트를 새로 실행한 결과는 아니에요.

여기서 쓰는 요청 지문은 UI 렌더 결과의 해시가 아닙니다. 같은 endpoint라도 파라미터나 본문이 다르면 다른 요청일 수 있고, 정상적인 갱신까지 모두 낭비라고 볼 수는 없어요. 대상 요청과 관측 구간을 먼저 정해야 합니다.

초기 로딩 검사가 통과해도 모든 사용자 인터랙션에서 중복 요청이 없다는 뜻은 아니에요. 캐시 정책 전체의 정확성이나 화면 완료 시간의 개선율도 별도 검증 대상입니다.

CI에 어떻게 얹었냐면

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

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

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

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

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

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

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

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

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

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

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

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

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

치른 값

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

그래서 뭐가 달라졌냐면

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

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


규칙을 기계에 맡기는 이야기는 이걸로 세 편이 됐어요. 반복되는 컨벤션을 검사로 옮기고, 구조와 동작 검증의 범위를 구분한 뒤, 브라우저의 코드·API 요청까지 확인합니다.

이렇게 반복 검사의 일부를 자동화하니, 다른 직군이 프론트엔드 작업에 참여할 때도 정해진 절차로 시작할 수 있었어요. 최종 결과는 프론트엔드팀이 검토합니다. 다음 편은 백엔드 개발자가 프론트 화면을 고치기 시작한 이야기예요.

시리즈 이어읽기

규칙은 기계가 지킨다

3 / 4회

이야기 나누기

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

댓글0
서로를 존중하는 대화를 남겨주세요.
아직 남겨진 대화가 없어요
첫 댓글을 남겨 대화를 시작해보세요.
새 글 구독
새 글이 공개되면 이메일로 알려드려요.