전체 검색

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

UI는 1px도 못 바꾸면서 구조를 갈아엎기 — SHA-256 UI 불변 게이트와 경계 래칫

64만 LOC 모노레포를 갈아엎는데 화면은 1px도 못 바꿉니다. 그걸 가능하게 한 두 안전망 — 렌더 구조를 해시로 고정하는 fingerprint와, 역행만 막는 boundary 래칫 이야기예요.

2026. 07. 14. 18:06:058분 읽기프로젝트/데이터 플랫폼
#테스트 전략#리팩터링#모노레포
조회 38
UI 불변 게이트 썸네일

미션: 64만 LOC 모노레포의 패키지 경계를 갈아엎어라. 단, 사용자 화면은 1px도 바뀌면 안 된다.

1편에서 말한 "큰 구조 변경을 두려움 없이 수행한다"의 실체가 이 글이에요. 이런 리팩터링을 하려면 두 가지 질문에 기계가 답할 수 있어야 합니다. "UI가 안 바뀌었다는 걸 어떻게 증명하지?" 그리고 "경계 정리가 진짜 진행되고 있다는 걸 어떻게 알지?"

(1px 얘기는 은유가 아니라 요구사항이었습니다.)

"화면이 안 바뀌었다"를 어떻게 증명하나

먼저 후보들을 다 검토했어요. 이 문제는 답이 여러 개처럼 보이거든요.

방법잡는 것우리 상황에서 안 된 이유
사람이 눈으로 확인큰 변화수백 화면을 매 커밋마다 볼 수 없음
픽셀 스크린샷 비교실제 렌더 결과폰트 렌더 차이·안티앨리어싱으로 오탐이 쏟아짐
DOM 스냅샷출력 문자열클래스 순서만 바뀌어도 실패
E2E 회귀 테스트주요 흐름커버리지가 화면 수를 못 따라감
소스에서 뽑은 의미 지문렌더를 결정하는 구조만들어야 함

두 번째가 제일 그럴듯해 보였는데 실제로 해 보면 못 씁니다. 같은 코드를 두 번 찍어도 이미지가 미세하게 달라지는 환경이 있거든요. 허용 오차를 넣기 시작하면 이번엔 진짜 변화를 놓치고요.

그래서 다섯 번째로 갔어요. 픽셀이 아니라 "렌더 결과를 결정하는 것"을 지문으로 만드는 겁니다.

안전망 1 — UI 불변 fingerprint

스냅샷으론 부족했어요

소스 리팩터링(패키지 이동, import 경로 변경)은 허용하면서, 렌더 결과 변경만 잡아야 했거든요. 그래서 이렇게 만들었습니다.

1. TypeScript Compiler API로 컴포넌트 소스를 transpile
2. import 경로를 '<package-boundary>' 토큰으로 치환   ← 이동은 허용
3. JSX 노드 + JSX를 포함한 제어흐름(조건·논리·switch)만 투영
4. 정규화된 결과를 SHA-256 해시
5. 매니페스트의 baseline 해시와 대조 — 다르면 실패

만드는 절차는 이래요.

1. 컴포넌트 소스를 컴파일러 API 로 파싱
2. import 경로를 정규화        ← 패키지 이동을 무시하기 위해
3. 식별자 이름을 정규화         ← 변수명 변경을 무시하기 위해
4. 렌더에 영향 없는 것을 제거    ← 주석 · 포매팅 · 타입 주석
5. 남은 구조를 정렬해 직렬화
6. SHA-256

2번과 3번이 이 지문의 존재 이유예요. 우리가 하려는 게 정확히 "파일을 옮기고 이름을 바꾸는" 작업이었거든요. 그걸 변경으로 치면 검사가 아무 소용이 없습니다.

반대로 4번에서 무엇을 지울지가 위험한 부분이었어요. 지나치게 지우면 진짜 변경까지 같이 사라지니까요.

// 해시에 포함되는 것
엘리먼트 트리 구조
의미 있는 속성 (role, aria-*, 상태 데이터 속성)
조건 분기의 형태
자식의 순서

// 해시에서 제외되는 것
import 경로
지역 식별자 이름
주석 · 포매팅 · 타입 주석
스타일 클래스 문자열의 순서

해시로 증명합니다

이 "의미 기반 스냅샷"의 효과는 명확해요. 파일을 어느 패키지로 옮기든, 변수명을 어떻게 바꾸든 해시는 유지돼요. 그런데 조건 분기 하나가 바뀌거나 JSX 구조가 달라지면 즉시 실패합니다. 테스트가 아니라 해시로 UI 불변을 증명하는 거예요. 핵심 화면들이 매니페스트로 관리되고, 아키텍처 관련 40개 + 셸 UI 80개 assertion group이 이 불변식을 지킵니다.

무엇을 의미로 볼 것인가

정규화 규칙을 정하는 게 이 작업의 핵심이었어요. 너무 느슨하면 진짜 변경을 놓치고, 너무 빡빡하면 일반 스냅샷과 똑같아집니다.

일반 스냅샷이 왜 안 됐는지는 이 표로 정리돼요.

변경 종류통과해야 하나일반 스냅샷은
파일을 다른 패키지로 이동통과경로에 따라 실패
변수·함수 이름 변경통과통과
클래스명 순서만 바뀜통과실패 ← 문제
조건 분기 추가실패실패
JSX 구조 변경실패실패

세 번째 줄이 골칫거리였어요. 의미가 같은데 문자열이 달라지면 스냅샷은 실패합니다. 그런 실패가 쌓이면 사람들이 스냅샷을 습관적으로 갱신하기 시작해요. 그러면 진짜 변경도 같이 통과하고요.

그리고 이 정규화 규칙 자체를 문서로 못 박았습니다. 안 그러면 실패할 때마다 규칙이 조금씩 느슨해지거든요.

그리고 이 지문을 만드는 코드에도 테스트를 붙였어요.

// 무시해야 하는 변경 — 해시가 같아야 합니다
test("파일을 다른 패키지로 옮겨도 지문은 같다", () => {
  expect(fingerprint(before)).toBe(fingerprint(movedToOtherPackage));
});

// 잡아야 하는 변경 — 해시가 달라야 합니다
test("조건 분기가 바뀌면 지문이 달라진다", () => {
  expect(fingerprint(before)).not.toBe(fingerprint(conditionChanged));
});

두 번째 테스트가 더 중요해요. 아무것도 안 잡는 검사가 제일 위험하거든요. 항상 통과하는 검사는 통과했다는 사실 자체로 안심을 주는데, 실제로는 아무 보증도 안 합니다.

그래서 "이건 반드시 실패해야 한다"는 케이스를 먼저 썼어요. 검사기를 만들 때 순서를 이렇게 잡으면 스스로를 속이기가 어려워집니다.

안전망 2 — boundary 메트릭 래칫

경계 정리는 한 번에 안 끝나요. 진행 중 상태를 수치로 만들지 않으면 "거의 다 됐다"는 말만 남죠. 그래서 boundary 메트릭 스크립트가 barrel(index.ts) 수, export * 개수, 레거시 alias import 잔여량, 4개 이상 레이어를 섞은 mixed barrel을 JSON으로 리포트합니다.

// 어느 시점의 실제 리포트 (방향이 중요하다)export * 배럴        : 0건   ← 완료
barrel index         : 95개
레거시 alias import  : @module 607건, @shared 405건  ← 감소 추적 중
mixed barrel         : 1

이 수치는 래칫(ratchet)으로 써요. 줄어드는 건 좋고, 늘어나면 리뷰에서 걸립니다. "전부 0이 될 때까지 머지 금지" 같은 비현실적 규칙 대신, 역행만 막으면 대규모 정리도 일상 개발과 공존할 수 있어요.

("거의 다 됐다"는 수치가 아니니까요.)

래칫이 세 번째 길인 이유

경계 정리 같은 작업은 한 번에 안 끝나요. 몇 달이 걸리고, 그동안 다른 개발도 계속됩니다. 여기서 흔한 실패가 둘이에요.

접근왜 실패하나
전부 정리될 때까지 머지 금지개발이 멈춤 — 현실적으로 불가능
정리는 나중에 몰아서영영 안 옴 — 그 사이 더 쌓임
수치 없이 "진행 중""거의 다 됐다"만 반복됨

래칫은 현재 수치를 기록해두고 늘어나는 것만 막습니다. 줄이는 건 자유고요. 그래서 대규모 정리와 일상 개발이 공존할 수 있어요.

(래칫은 한 방향으로만 도는 톱니바퀴예요. 풀리지 않는 게 전부인 장치죠.)

래칫에 무엇을 걸었나

세는 항목을 고르는 게 절반이었어요. 각각이 왜 경계를 흐리는지도 같이 적어 둘게요.

세는 것왜 경계를 흐리나이상적인 값
통짜 진입점(배럴)안쪽 무엇이든 꺼내 쓸 수 있게 됨최소
재수출(export *)무엇이 나가는지 파일만 봐서는 모름0
레거시 별칭옛 경로가 살아 있어 정리가 안 끝남0
레이어를 넘는 import의존 방향이 뒤집힘0
순환 의존시작점을 정할 수 없음0

두 번째가 특히 고약해요. `export *` 한 줄은 그 파일이 무엇을 공개하는지 읽는 사람에게 안 알려주고, 나중에 안쪽에 파일이 추가되면 공개 범위가 저절로 늘어납니다. 아무도 결정하지 않았는데 API가 커지는 거죠.

# 어느 시점의 실제 리포트 — 절대값보다 방향이 중요합니다
export * 배럴        : 0건    ← 완료
배럴 진입점          : 감소 중
레거시 별칭          : 감소 중
레이어 위반          : 0건    ← 자동 차단
순환 의존            : 0건    ← 자동 차단

마지막 두 줄만 자동 차단이에요. 나머지는 리뷰 신호고요. 이 구분이 이 체계에서 제일 중요한 부분입니다.

전부 자동 차단으로 만들면 정당한 증가가 막히고, 막힌 사람은 우회로를 찾습니다. 우회로가 생기는 순간 수치는 거짓말이 되고요. 어떤 건 막고 어떤 건 보이게만 하는 구분이 필요해요.

무엇을 셀 것인가

수치로 만들 항목을 고르는 것도 결정이었어요. 세기 쉬운 걸 세면 의미가 없고, 의미 있는 건 세기 어렵거든요.

결국 "경계를 흐리는 구조"를 셌습니다. 이것들의 공통점은 의존 방향을 숨긴다는 거예요. 아무 데서나 가져다 쓸 수 있게 만드는 통짜 진입점, 무엇이 나가는지 안 보이는 재수출, 옛 경로로 남은 별칭, 여러 레이어가 섞인 진입점.

숨겨진 의존은 당장은 편한데 나중에 반드시 문제가 됩니다. 그래서 "지금 몇 개인가"를 셀 수 있게 만든 거예요.

두 안전망의 역할이 다릅니다

UI 해시경계 래칫
막는 것결과가 바뀌는 것구조가 나빠지는 것
판정통과 / 실패증가 / 감소
목표 상태영원히 동일서서히 0으로
어길 때머지 불가리뷰에서 논의

마지막 줄이 중요해요. 래칫은 자동 차단이 아니라 리뷰 신호입니다. 정당한 증가도 있을 수 있거든요 — 다만 말없이 늘어나지는 않게 하는 거죠.

모든 검사를 자동 차단으로 만들면 우회가 생깁니다. 어떤 건 막고 어떤 건 보이게만 하는 구분이 필요해요.

구조 — 19개 패키지, 방향은 한쪽

워크스페이스는 계약(contract) / 런타임 / 코어 / 공용 UI / 애플리케이션 셸 / 호스트 / 제품 6종 / 플러그인 3종 / 고객사 8종으로 나뉘어요. 의존은 계약 쪽으로만 흐르고, 역방향과 패키지 내부 직접 import는 2편에서 다룬 아키텍처 게이트가 차단합니다. 여기에 turbo 태스크 그래프가 패키지 단위 빌드·테스트를 병렬화하고요.

안전망을 만드는 데 든 값

치른 것왜 받아들였나
지문 생성기 자체를 만들고 유지정규화 규칙이 바뀌면 전체 해시가 갱신됨 — 대신 화면 보증이 자동
CI 시간 증가컴포넌트 전체를 파싱해야 함 — 큰 변경을 겁 없이 하는 값
정규화 규칙 논쟁"이건 의미 있는 변경인가"를 매번 판정 — 문서로 못 박아 반복을 줄임
수치 항목 선정세기 쉬운 게 아니라 의미 있는 걸 골라야 함

첫 줄이 실제로 한 번 문제가 됐어요. 정규화 규칙을 하나 고쳤더니 모든 지문이 한꺼번에 바뀌어서, 그 변경이 진짜 UI 변경을 감추고 있는지 확인할 수가 없었거든요.

그래서 규칙 변경은 따로 다룹니다. 규칙만 바꾸는 변경에서는 다른 코드를 건드리지 않아요. 그러면 지문이 전부 바뀌어도 "규칙 때문"이라는 게 자명하니까요.

검사기도 코드고, 코드는 바뀝니다. 검사기가 바뀔 때의 절차까지 정해 두는 게 검사기를 만드는 일의 일부였어요.

안전망이 실제로 산 순간

중간 아키텍처(entry-first Registry)를 폐기하고 최종 구조로 갈아탈 때(1편), 수백 개 파일이 패키지를 옮겼어요. UI fingerprint는 그대로였고, boundary 수치는 단조 감소했고, 게이트는 레거시 패턴 부활을 잡아냈습니다.

안전망의 가치는 평시가 아니라 자기부정의 순간에 증명돼요. 만들 때는 과해 보이던 장치들이, 갈아엎을 때는 유일한 근거가 됩니다.

기계에 맡기는 이야기가 한 편 더 있어요. 이번엔 화면이 아니라 브라우저가 뭘 받아가는지를 지켜봅니다.

시리즈 이어읽기

규칙은 기계가 지킨다

2 / 4

이야기 나누기

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

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

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

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