Frontend Engineer · Platform & Build Architecture

기능을 만드는 것과
동작함을 판정 가능하게 만드는 것은
같은 작업이다.

오태훈. 여러 팀의 프론트엔드 앱을 하나의 제품으로 묶는 플랫폼 계층을 3년간 담당했습니다. 도구의 오탐과 제 자신의 진단 오류까지 실측으로 검증하고 정정하는 것을 습관으로 삼습니다.

github.com/oth54477
oth5447@naver.com
온프레미스 클라우드 관리 플랫폼 · B2B 어드민
636158
CI 파이프라인 총시간 (4.0배)
1693
순환 의존 (−98%)
10<1
개발 서버 HMR
v1.20.3
@module-federation/vite upstream PR 머지·릴리스
01Open Source · Module Federation

빌드는 성공하는데 배포하면 죽는 결함, upstream에서 고쳤다

CI가 원리적으로 못 잡는 조용한 실패 — 이슈 등록부터 릴리스 반영까지 하루.

문제
eager: true shared 사용 시 프로덕션 빌드 산출물이 부팅 시점에 크래시. 빌드는 EXIT 0·경고 0으로 정상 종료 — 배포 후 런타임에만 드러난다.
접근
5개 버전 영향 매트릭스로 결함 도입 버전을 실측 정정(1.16.1 → 1.17.0). 재현 필요조건 3가지를 분리해 파일 6개 최소 재현으로 축소. 원인이 번들러가 아니라 플러그인의 정규식 후처리임을 산출물 수준에서 특정 — 네임스페이스 import가 "함수 호출이 없다"는 이유로 삭제되고 있었다.
결과
첫 수정안이 기존 테스트를 깨뜨리자 판정 기준 자체를 "호출되는가"에서 "참조되는가"로 교체. PR 머지 → v1.20.3 릴리스, upstream 테스트 1,017개 통과. "빌드 성공"과 "산출물이 동작함"은 다른 명제다.
자세히 — 결함의 구조와 규명 과정

결함의 구조 (단순화한 요지)

// 플러그인 후처리: Vite preload 헬퍼를 지우려는 정규식이 모듈 경로를 보지 않고
// `{ _ as X }` 형태의 import를 전부 매칭한 뒤, X의 "함수 호출"이 없으면 문장째 삭제한다.
// 네임스페이스 import는 값으로만 참조되므로 여기 걸려 지워졌다.
if (!hasCall(local, chunk)) dropImportStatement()          // 판정: 호출되는가

// 수정(v1.20.3): 판정 기준을 "참조되는가"로 교체 — 기존 동작 보존, 결함만 제거
if (!new RegExp(`\\b${local}\\b`).test(chunk)) dropImportStatement()

과정

  • 사내 빌드에서 증상 발견 — 기존 보고 이슈 0건인 미보고 결함. 사내 코드 주석의 "1.16.1부터"가 실측으로는 1.17.0부터임도 함께 정정
  • 초기 합성 재현이 실패한 원인을 청크 토폴로지 차이로 좁혀, 재현 필요조건 3가지(eager shared · 다중 entry · 앱 소스의 런타임 직접 import)를 분리
  • 빌드 중 정규식 콜백에 로그를 심어 삭제되는 문장을 직접 포착 — 어느 후처리 단계인지까지 특정
  • 메인테이너가 1.5시간 만에 머지한 별도 수정이 호출부만 제거한 것임을 지적하고 근본 수정 PR로 연결
  • 회귀 테스트가 fix 없이는 실패함을 확인하고, 배포 tarball에 수정 판정식이 실제로 들어갔는지까지 검증
02Architecture · Vite ↔ Webpack

이종 번들러 Module Federation — 도입했고, 데이터로 뒤집었다

"전체를 마이그레이션해야 한다"는 전제를 POC 하나로 반박하고 1년 6개월 프로덕션 운영.

문제
기존 솔루션은 Webpack + Vue 3, 신규 Host는 React + Vite. 전체 마이그레이션 비용 때문에 기술 전환 자체가 막혀 있었다.
접근
Vite(Host) ↔ Webpack(Remote) 런타임 통신을 POC로 먼저 입증하고 프로덕션 규모로 확장. React Query와 Vue Query가 단일 Query Client를 공유하도록 통합해 두 프레임워크가 같은 API 캐시를 참조.
결과
HMR 10초 → 1초 미만, 서비스 중단 없이 점진적 React 전환 경로 확보. 6개월 운영 데이터로 런타임 유연성의 비용(파드 4개 상주·월 2~3건 통합 장애)을 정량화해 빌드 시점 통합(모노레포) 전환을 설계자신이 도입한 아키텍처를 데이터로 부정하는 것도 같은 사이클의 일부다.
자세히 — 구조도와 운영 중 잡은 결함
Host React 18 · Vite · ESM 번들러 경계 (ESM ↔ webpack) Remote A — Vue 3 · Webpack Remote B — Vue 3 · Webpack Remote C — Vue 3 · Webpack remoteEntry 런타임 로드 shared scope — 단일 TanStack Query Client (React ↔ Vue가 같은 API 캐시 참조)
이종 번들러 구조 — Host(Vite·ESM)가 경계 너머의 Webpack Remote 3개를 런타임에 로드하고, 상태는 하나의 shared scope로 공유한다.

운영 중 잡은 결함

  • shared 버전 오보 — Host가 실제 번들한 것과 다른 버전을 공유 스코프에 광고해, singleton 승자 선정이 왜곡될 수 있던 상태를 빌드 산출물 manifest와 설치 트리 실측 대조로 규명·수정. 재발 방지 가드를 빌드에 내장하고 일부러 깨뜨려 실제로 차단됨을 역방향 검증
  • 로컬은 통과·CI만 실패하는 빌드 — 플러그인의 의존 버전 조회가 패키지 매니저 hoist 승자(설치 형태에 따라 달라지는 비결정 값)로 풀린다는 점을 규명. 컨테이너 없이 hoist 디렉터리만 조작해 CI 실패를 로컬에서 결정적으로 재현하고, 실제 번들되는 사본에서 읽은 버전을 주입해 비결정성 의존 자체를 제거
  • 메모리 누수 — React와 Vue의 생명주기 차이로 발생한 구독 누수를 cleanup 로직 분리(unmount 시 리스너 정리·구독 해제)로 해결
03Performance · CI Pipeline

CI 636초 → 158초 — 채택한 조치만큼 철회한 조치가 산출물

잡을 빠르게 만드는 것과 파이프라인을 빠르게 만드는 것은 다른 문제였다.

규명
정적분석 350초 중 269초가 분석이 아니라 파일 트리 순회 — pnpm 심볼릭 링크가 실파일 7.5만 개를 33만 경로로 부풀리고 있었다. CPU를 두 배로 늘려도 총시간이 그대로인 원인을 스케줄링(큐잉 76초 + 스테이지 직렬화 82초)으로 특정해 사장시간 82초 → 3초.
규율
직접 넣은 조치 2건을 실측으로 철회. 절감폭 −68%짜리 안은 "이후 모든 테스트가 실행 순서를 의식해야 하는 영구 부채"라는 이유로 의도적 보류. 대조군 표준편차가 목표 효과를 압도하자 "측정 불가"를 계산으로 판정하고 중단. 좋은 수치가 항상 채택 근거는 아니다.
결과
파이프라인 636초 → 158초(4.0배), 정적분석 잡 350초 → 80초(4.4배).
자세히 — 병목별 측정 분해와 철회 기록

병목별 분해

병목원인 (실측)조치효과
정적분석 스캔순회 86만 경로 — pnpm 심볼릭 링크가 실파일 7.5만 개를 33만 경로로 증폭, 제외 설정은 순회를 막지 않음스캔 루트를 실제 대상만 열거 (순회 → 약 2.8만 경로)350 → 80초
캐시 아카이빙4개 잡이 동일한 2.5GB 캐시를 각각 push쓰기를 임계 경로 밖 잡으로 한정잡당 −35~39초
테스트 환경전역 jsdom 설정이 파일 단위 환경 선언과 충돌 — DOM 불필요한 167개 파일이 비용 지불기본값 반전 + 누락 독블록 6개 보강 (전수 실행으로 확인)91 → 59초
러너 자원호스트 14코어 중 4개만 VM에 할당VM 8코어로 확장 (공용 머신이라 메모리는 불변)vitest 124 → 58초
스케줄링슬롯 부족 76초 큐잉(queued_duration으로 확인) + 의존 없는 잡의 스테이지 직렬화 82초concurrent 확장 + needs: []사장 82 → 3초

철회·보류 기록

  • pnpm store 이전 — 폐기. 정적분석은 목표대로 빨라졌지만 테스트 잡이 165 → 241초로 악화 — 매 잡 1,256개 패키지 재다운로드가 디스크를 처닝해 테스트 전 구간을 1.7배 늦춤
  • node_modules 캐시 제거 — 착수 전 보류. restore −6초 대비 install +10~20초로 산술이 반대
  • 테스트 격리 해제(−68%) — 의도적 보류. 깨지는 18개 파일의 원인을 모듈 레지스트리 재사용 하나로 수렴시킨 뒤, 비용이 코드가 아니라 이후 모든 테스트가 실행 순서를 의식해야 하는 영구 부채임을 밝힘
  • 후속 안 CI 검증 — 측정 불가 판정. 대조군 표준편차 35초가 목표 효과 15초를 압도, 검정력 80%에 한 쪽당 80회 이상 필요함을 계산하고 중단. 대신 분산 자체(같은 잡이 2.3배 흔들림)가 러너 경합이라는 별개 문제임을 분리 기록
04Test Infrastructure · Compiler Plugin

E2E 식별자 자동 부여 — 가장 자신 있던 결론을 스스로 반증했다

이미 발행한 문서를 정정하고 주입 축을 컴파일 타임으로 재설계.

반증
"호스트 1회 배선으로 타 팀 공수 70~85 인일 소거"라는 자기 결론을 적대적 검증으로 기각 — 런타임 정보로는 데이터 건수·권한·정렬에 흔들리지 않는 식별자를 만들 수 없다 (실측: 라우트당 동종 컴포넌트 26개 이상).
설계
Vue 컴파일러 nodeTransform으로 빌드 타임 주입. 템플릿 AST에서 업무 의미를 추출하는 9단계 폴백 + 위치 기반 스냅샷 병합으로 문구 수정이 테스트를 깨뜨리지 않게. 해결 안 되는 구간은 "해결했다" 대신 미보증 등급으로 정직하게 표기.
결과
보증 등급 요소 자동 라벨 도달률 83.2%, 식별자 충돌 0건. 덤: 플러그인 테스트가 프로덕션과 다른 옵션으로 돌던 사각지대를 발견, 빌드 산출물 스냅샷 대조 검증을 추가해 숨은 결함 수정.
자세히 — 반증의 근거와 설계 장치

반증의 근거

  • 런타임 자동 ID의 소스 후보를 실측으로 차례로 기각 — 라벨 조합은 다국어·시트 동기화로 불안정, 대안으로 직접 제안한 i18n 키 파생도 키의 97.7%가 한국어 원문 문장이라 문구 수정 시 키가 바뀌어 안정성이 동일함을 확인하고 자기 추천을 정정
  • 중복 수가 데이터 건수·권한·정렬에 따라 실행 중 변동 — 원격 솔루션 라우트당 동종 컴포넌트 26개 이상 실측
  • 이미 발행한 문서를 즉시 정정 발행 — 결론이 틀렸으면 정정 기록을 남기는 것까지가 검증

설계 장치

  • 9단계 라벨 폴백 — 템플릿 AST에서 i18n 라벨 → aria-label → v-model 경로 → 이벤트 핸들러명 → 아이콘명 순으로 업무 의미를 추출, i18n 사전을 한→영 슬러그 변환에 재활용해 사전 구축 비용 0
  • 위치 기반 스냅샷 병합 — 슬롯(위치)을 키로 잡고 기존 ID를 유지해, 문구 수정이 E2E 로케이터를 조용히 깨뜨리는 경로를 구조적으로 차단
  • 1 노드 → N 인스턴스 대응 — v-for는 :key 접미로 런타임 고유화, 다중 인스턴스 컴포넌트는 호출처가 접두를 내려주는 testid prop 계약 신설(자식 30종·호출처 101곳 자동 배선)
  • 미보증 2등급 체계 — 해결 안 되는 반복 렌더 구간은 "해결했다" 대신 미보증 등급으로 표기해 수기 보완 큐로 이관

검증

  • 플러그인 단위 테스트가 프로덕션과 다른 컴파일 옵션(prefixIdentifiers off)으로 돌고 있음을 발견 — 전체 3,500여 건 그린 상태에서 실제 빌드에는 주입되지 않던 케이스를 빌드 산출물 덤프·스냅샷 대조로 적발·수정
  • 보증 등급 요소 자동 라벨 도달률 83.2% (완료 기준 80%) · ID 211개 충돌 0 · 수기 100곳 예상 작업을 자동화로 대체
05Selected Work

그 밖의 작업