기술 블로그
AI 주도 개발에서 생긴 CSS 충돌과 로컬 감지 실패 본문
두 달 동안 멀쩡하던 어드민 화면이 에디터 모달 하나를 추가한 커밋에서 깨졌다. 로컬 dev 서버에서는 재현되지 않았고, 빌드 결과물에서야 보였다. 원인은 8월부터 코드에 있었고, 10월의 무관한 커밋이 그걸 드러냈다.
이 글은 그 버그의 원인 두 가지, 트리거가 된 커밋, 커밋 세 개를 직접 빌드해서 추적한 과정, 그리고 "AI 가 짠 코드라서 생긴 구멍" 이 정확히 어디였는지를 정리한 기록이다. 마지막에는 같은 유형을 다시 못 만나게 하려고 넣은 정적 검사와 스냅샷 테스트를 적었다.
환경: Next.js 16 (App Router, Turbopack 빌드), SCSS CSS Modules
1. 증상
빌드한 결과물을 개발 서버에 배포하고 나서 어드민을 열었더니:
- 목록 하단의 "페이지당 표시" 드롭다운이 68px 이 아니라 줄 전체로 늘어나 있다.
- 필터의 "조회구분" 드롭다운도 같은 식으로 늘어나 있다.
- 로그인 버튼과 확인 모달 버튼이 시안의 50px 이 아니라 공용 컴포넌트 기본값 48px 로 돌아가 있다.
next dev 로 띄운 로컬에서는 전부 정상이었다. 코드를 다시 읽어도 틀린 데가 없어 보였다. 실제로 틀린 데는 없었다. 순서가 달랐을 뿐이다.
2. 원인 ① — 같은 속성을 두 곳이 정하고 있었다
공용 Dropdown 컴포넌트의 루트는 자기 폭을 정한다.
// Dropdown.module.scss
.wrapper {
width: 100%;
}
그리고 루트에 className 을 받아 합친다.
// Dropdown.tsx
<div className={cx(styles.wrapper, className)}>
이 드롭다운을 쓰는 쪽은 그 className 으로 폭을 덮었다.
// PageSizeSelect.tsx
<Dropdown className={styles.dropdown} />
// PageSizeSelect.module.scss
.dropdown {
width: 68px;
}
.wrapper 와 .dropdown 은 둘 다 클래스 하나짜리 선택자라 특이도가 같다. 특이도가 같으면 CSS 는 나중에 로드된 규칙이 이긴다. 그러니까 이 드롭다운의 폭은 코드가 아니라 두 CSS 파일이 브라우저에 도착하는 순서가 정하고 있었다.
이 코드는 8월 초에 AI 세션이 작성했다. 그리고 같은 모양이 네 군데 더 있었다. 게시판·문의·결제·포트폴리오 목록의 필터가 전부 <Dropdown className={styles.searchField} /> 로 폭을 덮었고, 로그인 폼과 확인 모달은 <Button className={styles.submit} /> 로 높이를 덮었다. 세션은 매번 달랐지만 결과물은 같았다. "기존 코드와 같은 패턴을 따른다" 는 지시를 충실히 지킨 결과다.
cx(styles.root, className) 자체는 잘못이 아니다. shadcn 도 MUI 도 쓰는 가장 흔한 컴포넌트 API 다. 문제는 루트가 width 를 직접 정하면서 동시에 className 으로 덮는 길을 열어 둔 것이다. "내가 폭을 정할게, 근데 너도 덮어" 는 모순된 계약이고, 누군가 실제로 덮는 순간 승패가 순서에 걸린다. AI 는 이 모순을 만들었고, 그 다음 세션들은 모순을 네 번 더 복제했다.
3. 원인 ② — 운영 빌드만 CSS 순서를 다시 정한다
dev 서버와 빌드가 다르게 동작하는 이유는 Next 문서에 그대로 적혀 있다.
Next.js optimizes CSS during production builds by automatically chunking (merging) stylesheets. The order of your CSS depends on the order you import styles in your code.
CSS ordering can behave differently in development, always ensure to check the build (
next build) to verify the final CSS order.—
next/dist/docs/01-app/01-getting-started/11-css.md, "Ordering and Merging"
next dev: CSS 모듈을 import 되는 순서대로 그때그때<style>로 삽입한다. 필터 파일은Dropdown을 먼저 import 하고 자기.module.scss를 나중에 import 하니까 부모 CSS 가 항상 뒤에 온다. 그래서 dev 에서는 언제나 부모가 이겼다.next build: 기본 설정experimental.cssChunking: true가 CSS 모듈들을 몇 개의 파일로 합치고 재정렬한다. 문서 표현으로는 서로 다른 파일에서 import 순서가 엇갈리면 "merge the files in any order and assume there are no dependencies between them" — 순서를 임의로 정해 합치고, 파일끼리 의존이 없다고 가정한다.
즉 dev 는 import 순서를 보장하고, build 는 보장하지 않는다. 같은 코드가 두 환경에서 다른 CSS 순서를 만들 수 있고, 문서는 그걸 알고 있으니 빌드로 확인하라고 적어 둔 것이다.
4. 트리거 — 깨뜨린 커밋은 깨진 곳과 무관했다
여기가 이 글의 무게 중심이다.
8월부터 10월 13일까지 두 달 넘게 빌드는 정상이었다. 운이 좋았던 게 아니라, 그 기간 동안 빌드가 어드민 CSS 를 파일 하나에 import 순서대로 담았기 때문이다. 공용 컴포넌트가 앞, 페이지 모듈이 뒤. dev 와 같은 순서였다.
[ 파일 하나 ] Button … Dropdown … SelectTab … → … PageSizeSelect, 필터 4개 …
(공용이 앞) (부모가 뒤 → 이김 ✅)
10월 14일 커밋은 어드민 에디터에 "이미지 대체 텍스트·캡션 입력 모달" 을 추가했다. 에디터(Jodit)는 무거워서 lazy(() => import("./JoditSurface")) 로 따로 불러오고 있었고, 새 모달은 그 lazy 트리 안에 들어갔다. 모달은 당연히 공용 Button·Input·Modal 을 쓴다.
목록 페이지 ─────────────────→ Button, Dropdown, SelectTab … (원래 있던 경로)
lazy 에디터 → 이미지 모달 ────→ Button, Input, Modal (새로 생긴 경로)
이제 같은 Button CSS 를 페이지 청크와 나중에 따로 받는 lazy 청크가 둘 다 필요로 한다. 빌드는 두 쪽이 공유할 수 있게 CSS 를 다시 나눴고, 그 과정에서 공용 컴포넌트 묶음이 별도 파일로 떨어져 나갔다. 그리고 그 파일이 페이지 CSS 뒤에 로드됐다.
[ 파일 A ] Input, Modal, 페이지·필터 모듈 전부, 이미지 모달 ← 먼저 로드
[ 파일 B ] Textarea … Button, SelectTab, Dropdown … Tooltip ← 나중에 로드 → 이김 ❌
width: 100% 가 68px 를 이기고, height: 48px 가 50px 를 이겼다.
이 커밋을 만든 세션이 잘못한 건 없다. 모달을 만들었고, 공용 컴포넌트를 썼고, 에디터 화면에서 모달이 잘 뜨는 걸 확인했다. 목록 페이지의 드롭다운 폭을 볼 이유가 없었다. e2e테스트는 요소의 존재만 확인했었다. 에디터 코드를 고치면서 "이 변경이 어드민 전체의 CSS 파일 구성을 바꾸고, 그 결과 8월에 다른 세션이 다른 화면에 심어 둔 덮어쓰기가 반대로 뒤집힌다" 를 예측하는 건, 사람이든 AI 든 코드를 읽어서는 할 수 없는 일이다.
이게 이 버그의 본질이다. 원인은 8월에 심어졌고, 트리거는 10월에 무관한 곳에서 당겨졌고, 증상은 빌드에서만 나타났다. 세 지점이 시간·위치·환경에서 전부 떨어져 있다. diff 리뷰로는 어느 지점에서도 못 잡는다.
5. 추적 — 커밋 세 개를 빌드해서 CSS 오프셋 비교
가설은 "10월 14일 커밋이 CSS 청크 구성을 바꿨다" 였다. 확인은 빌드로만 된다.
메인 작업 트리를 건드리지 않으려고 scratchpad 아래에 git worktree 세 개를 만들었다. 의심 커밋 직전·의심 커밋·수정 직전. 각각 next build 를 돌리고, 어드민 목록 페이지의 prerender HTML 에서 <link rel="stylesheet"> 순서를 읽고, 각 CSS 파일 안에서 Dropdown.wrapper 와 PageSizeSelect.dropdown 의 해시 클래스 규칙이 몇 번째 바이트에 있는지 찾았다. 이 작업은 서브에이전트에게 맡겼고, "실 코드는 건드리지 말 것" 과 "가설에 맞추려 하지 말고 증거 그대로 보고할 것" 두 가지만 조건으로 걸었다.
| 커밋 | wrapper 위치 |
dropdown 위치 |
로드 순서 | 판정 |
|---|---|---|---|---|
| 의심 직전 | 05mh…css @27699 |
05mh…css @66952 |
파일 하나, dropdown 이 뒤 | 정상 |
| 의심 커밋 | 2fhy…css @24315 |
0og2…css @31831 |
0og2 → 2fhy |
버그 |
| 수정 직전 | 2fhy…css @24315 |
1zha…css @31678 |
1zha → 2fhy |
버그 |
가설대로였다. 의심 커밋에서 lazy 청크의 로더 코드가 Promise.all(["static/chunks/0og2….css", "static/chunks/2xgp….js"]) 로 처음 CSS 를 함께 요청하게 됐고, 공용 컴포넌트 파일 2fhy…css 는 그 이후 커밋에서 해시가 그대로였다 — 한 번 갈라진 구성이 쭉 이어졌다는 뜻이다.
빌드 세 번, 약 7분. 추적 자체는 짧았다.
6. 왜 못 잡았나 — AI 주도 개발의 구멍 다섯 개
버그는 Next 의 함정이지만, 두 달 동안 못 본 건 개발 방식의 문제다. 사람이 짰어도 생길 수 있는 버그를, AI 가 짜서 더 잘 숨었던 지점이 다섯 개 있다.
① 컴포넌트 계약이 없었다
디자인 시스템에서 가장 흔한 규칙 하나는 "컴포넌트는 자기 바깥 치수(width·margin)를 정하지 않는다. 부모가 정한다" 다. Radix 프리미티브는 width 가 없고, MUI 는 fullWidth 를 opt-in prop 으로 둔다. 이 규칙이 있었으면 Dropdown 루트에 width: 100% 가 들어갈 수 없었고, 그러면 덮을 것도 없었다.
규칙이 없으니 AI 는 가장 흔한 모양을 골랐다. 루트에 width: 100%, 그리고 className 개방. 사람 팀이라면 디자인 시스템 담당자가 첫 리뷰에서 걸었을 것이다. AI 세션에는 "첫 리뷰" 가 없다. 지시한 기능이 동작하면 끝이다.
② 패턴은 복사됐는데 그걸 안전하게 만드는 장치는 안 왔다
이게 AI 주도 개발 특유의 구멍이다.
cn("w-full", className) 이 shadcn 에서 안전한 이유는 cn 안의 tailwind-merge 가 클래스 문자열 단계에서 충돌을 해소하기 때문이다. w-full 과 w-[68px] 가 같이 오면 앞을 버린다. CSS 캐스케이드까지 가지 않는다. 그래서 순서가 어떻든 상관없다.
AI 는 그 모양(cx(root, className))을 SCSS Module 환경으로 가져왔다. 거기엔 tailwind-merge 도 없고 @layer 도 없다. 패턴은 옮겨졌고, 그 패턴을 안전하게 만들던 불변식은 안 옮겨졌다. AI 는 "가장 많이 본 코드" 를 쓰는데, 그 코드가 안전했던 건 주변 환경 때문이었다는 사실은 코드 자체에 안 적혀 있다.
③ 같은 코드가 다섯 번 반복됐는데 아무도 "왜 다섯 번이지" 하지 않았다
목록 4화면 필터 + 페이지 크기 선택, 다섯 군데가 똑같이 className 으로 폭을 덮었다. 사람이 손으로 짰으면 두 번째쯤에서 "이거 공용 쪽 prop 으로 올려야 하나" 가 나온다. 손이 아프니까.
AI 는 손이 안 아프다. 세션마다 독립적으로 "기존 패턴을 따라" 썼고, 리뷰는 커밋 diff 단위라 반복 자체가 시야에 안 들어왔다. 반복은 추상화 신호인데, 그 신호를 받을 주체가 없었다.
④ 검증이 로컬 서버에서 끝났다
Next 문서가 "dev 와 build 의 CSS 순서는 다를 수 있으니 build 로 확인하라" 고 명시한다. 워크플로에 빌드 산출물을 보는 단계가 없었다. 세션의 검증 규칙은 tsc·lint·curl 까지였고, 화면은 사용자가 켜 둔 dev 서버로 봤다.
AI 는 문서를 읽을 수 있고 실제로 이 문서를 알고 있다. 그런데 묻지 않으면 적용하지 않는다. "드롭다운 폭을 className 으로 덮어" 라는 지시에 "운영 빌드에서는 순서가 바뀔 수 있는데요" 라고 끼어드는 일은, 규칙으로 박아 두지 않는 한 일어나지 않는다.
⑤ 비지역적 영향
4절에서 말한 그대로다. 에디터를 고친 세션이 목록 페이지를 볼 이유가 없다. 이건 코드 리뷰가 아니라 시각 회귀 테스트의 영역이고, E2E 는 동작을 검사하지 픽셀을 검사하지 않는다. 폼을 제출하면 토스트가 뜨는지는 확인하지만, 드롭다운이 68px 인지는 확인하지 않는다.
7. 다른 팀은 안 겪나 — 사실은 겪는다
Next App Router 의 CSS Module 순서 문제는 GitHub 에 이슈가 여러 개 열려 있는 유명한 함정이다. AI 주도 개발 고유의 문제가 아니다. 다만 안 겪는 팀은 아래 중 하나 이상을 갖고 있다.
| 장치 | 원리 | 비고 |
|---|---|---|
Tailwind + tailwind-merge |
충돌을 클래스 문자열에서 해소. 캐스케이드 순서 무관 | Next 문서가 Tailwind 를 권하는 이유 중 하나. 가장 흔한 "모르고 안전한" 경우 |
Cascade layers (@layer) |
공용은 @layer components, 페이지는 층 밖 → 순서 무관 |
근본 해결. 리셋 CSS 부터 층에 넣는 이전 작업이 필요 |
| "루트는 바깥 치수 안 정함" 규칙 | 충돌 자체가 안 생김 | 디자인 시스템 팀이 코드 리뷰로 지킴 |
| zero-runtime CSS-in-JS (vanilla-extract·Panda) | 아토믹 클래스거나 단일 시트라 순서가 결정적 | 스택 교체 |
| 시각 회귀 테스트 (Chromatic·Playwright 스크린샷) | 빌드 결과물의 스크린샷 diff | ⑤를 잡는 유일한 자동화 |
| 스테이징 + QA | 빌드 결과물을 사람이 전부 눌러 봄 | 가장 원시적이고 가장 흔함 |
| 흉터 | 한 번 당한 사람이 리뷰에서 잡음 | 팀 규칙으로 코드화되면 ③ |
핵심은 그 팀들도 원리를 알아서 피한 게 아니라, 스택이나 규칙이 대신 막아 줬다는 것이다. Tailwind 를 고른 팀은 tailwind-merge 가 뭘 막고 있는지 모른 채 안전하다. SCSS Module 을 고른 순간 그 보호막이 없어졌고, AI 는 보호막 있는 환경의 패턴을 보호막 없는 환경에 옮겨 썼다.
그러니까 질문은 "AI 가 왜 틀렸나" 가 아니라 "우리 스택에서 이 패턴을 안전하게 만드는 건 뭐였나, 그게 있긴 했나" 다. 없었다.
8. 고친 것 — 충돌을 아예 만들지 않기
수정
같은 요소의 같은 속성을 두 CSS 파일이 동시에 정하지 않게 했다. 세 가지로 나눴다.
배치(폭·여백·위치)는 부모가 감싸는 요소에. 자식 루트는 width: 100% 로 채우기만 한다.
// before
<Dropdown className={styles.dropdown} />
// after
<div className={styles.dropdown}>
<Dropdown />
</div>
모양·크기 변형은 자식 prop 으로. 자식이 자기 모듈 안에서 처리한다.
// before
<Button className={styles.submit} /> // .submit { height: 50px }
// after
<Button height={50} />
// Button.tsx
<button style={height ? { height } : undefined} />
값 조절은 CSS 변수로. 자식은 var(--x, 기본값) 으로 읽기만 하고 부모가 변수를 준다.
다섯 군데 전부 이 방식으로 바꿨고, SelectTab 에 plain prop, Button 에 height prop 이 생겼다. 로드 순서와 무관하게 결과가 같아서, 번들러가 CSS 를 어떻게 쪼개든 상관없다.
정석 비교
| 방법 | 효과 | 한계 |
|---|---|---|
| 충돌 제거 (위) | 순서 무관 | 컴포넌트마다 prop 설계 필요 |
@layer |
순서 무관, 근본 해결 | 리셋부터 층에 넣어야 함. 기존 프로젝트는 이전 작업 |
:where(.wrapper) |
자식 특이도 0 → 부모가 항상 이김 | 컴포넌트마다 손이 가고 읽기 어려움 |
cssChunking: 'strict' |
import 순서대로 로드 | 청크·요청 수 증가. 모든 파일의 import 순서가 일관돼야 의미 있음 |
재발 차단 — 정적 검사
같은 모양이 다시 들어오는 걸 막으려고 검사 스크립트를 넣었다. 자식 컴포넌트에 className 을 넘기는 JSX 를 찾고, 그 클래스의 SCSS 규칙이 자식 루트 모듈과 같은 속성을 정하면 실패한다. CI 에서 돈다.
npm run check:overrides
의도적으로 괜찮은 곳은 // override-ok: 이유 로 표시한다. 그리고 프로젝트의 CLAUDE.md 에 규칙을 적었다 — 다음 AI 세션이 같은 모양을 또 쓰지 않도록. ②와 ④의 구멍은 이렇게 메운다. AI 가 묻지 않고 적용하게 하려면 규칙을 컨텍스트에 넣어야 한다.
9. 못 잡는 유형을 위해 — 빌드 결과물 스냅샷 테스트
정적 검사는 아는 유형만 잡는다. 4절 같은 비지역적 회귀는 다음에 어디서 어떤 모양으로 올지 모른다. 그건 화면을 직접 비교하는 수밖에 없다.
이미 빌드를 돌리고 있었다
프로젝트의 Playwright 설정은 처음부터 이랬다.
// playwright.config.ts
webServer: {
command: `npx next build && npx next start -p ${PORT}`,
reuseExistingServer: false,
}
E2E 가 빌드 결과물을 띄우고 있었다. 여기에 어드민 화면 스크린샷 단언 하나만 있었으면 10월 14일에 잡혔다. 도구는 있었고 쓰지 않았을 뿐이다.
추가한 것
Playwright 내장 toHaveScreenshot 으로 로그인 + 목록 4화면을 찍는다. 토큰을 localStorage 에 심어 가드를 통과하고, /me 와 목록 API 는 기존 fixture 가 가짜로 답한다.
// e2e/admin-visual.spec.ts
test.use({ viewport: { width: 1440, height: 1000 } });
const snapshot = async (page: Page, name: string) => {
await page.evaluate(() => document.fonts.ready);
await expect(page).toHaveScreenshot(`${name}.png`);
};
for (const [name, path] of Object.entries(LIST_PAGES)) {
test(`목록: ${name}`, async ({ page, api }) => {
await signIn(page, api);
api.reply("GET", path, { body: okBody(EMPTY_PAGE) });
await page.goto(`/admin/${name}`);
await expect(page.getByText("총 0건")).toBeVisible();
await snapshot(page, name);
});
}
뷰포트 1440 은 삽질 끝에 나온 숫자다. 기본 1280 으로 찍으니 어드민 셸(LNB 240px + 본문 min-width: 1200px)의 오른쪽이 잘려서, 정작 이번에 깨졌던 "페이지당 표시" 드롭다운이 프레임 밖에 있었다. 본문이 안쪽 스크롤 포트라 fullPage 도 소용없었다. 찍힌 이미지를 눈으로 확인하지 않았으면 "통과하는데 아무것도 검사 안 하는 테스트" 가 될 뻔했다.
기준 갱신 — 시각 회귀 테스트가 죽는 이유
이 테스트의 진짜 적은 flaky 가 아니라 "항상 실패하니 무시" 다. 의도한 디자인 변경 때마다 --update-snapshots 로 기준을 갱신하고 그 이미지를 같은 커밋에 넣어야 하는데, 한두 번 빼먹으면 테스트가 늘 빨간 상태가 되고, 그때부터 아무도 안 본다.
"깨진 건지 의도한 변경인지" 는 자동화가 안 된다. 픽셀 diff 는 "달라졌다" 만 안다. Chromatic·Percy 같은 서비스도 이 판단을 자동화하지 않는다 — 사람이 승인 버튼을 누르게 하고 그 클릭을 1초로 줄이는 게 그 서비스의 가치다. CI 에서 자동으로 --update-snapshots 를 돌리는 건 테스트를 없애는 것과 같다.
그래서 판단을 AI 세션에게 맡기되, 규칙 두 줄을 CLAUDE.md 에 박았다.
스냅샷이 깨지면
test-results/의expected·actual·diff이미지를 읽고 판단한다. 내가 바꾼 화면의 의도한 변화일 때만--update-snapshots로 갱신하고 그 이미지를 같은 커밋에 넣는다. 건드리지 않은 화면이 깨지면 갱신하지 말고 회귀로 보고한다 (에디터를 고쳤는데 목록 페이지가 바뀌는 식).
변경을 만든 세션은 의도를 안다. 그리고 AI 는 diff 이미지를 볼 수 있다. 남는 위험은 AI 가 도장 찍듯 "내 변경이니 갱신" 하고 넘어가는 건데, 두 번째 문장이 정확히 이번 케이스를 막는다. 에디터를 고쳤는데 목록 페이지 스냅샷이 깨지면 멈춰야 한다. 그걸 어기면 커밋에 스냅샷 이미지가 같이 들어왔는지 사람이 보면 된다. 이미지 diff 는 코드 diff 보다 훨씬 빨리 읽힌다.
10. 정리
AI 는 가장 흔한 모양을 쓴다. 그 모양이 안전했던 환경의 조건까지 같이 가져왔는지는 코드에 안 적혀 있고, AI 는 묻지 않으면 확인하지 않는다. 그 확인은 사람의 몫이고, 한 번 확인한 결과는 규칙으로 컨텍스트에 넣어야 다음 세션이 이어받는다.
이번 건에서 남긴 체크리스트:
- 계약 — 공용 컴포넌트 루트는 바깥 치수를 정하지 않는다. 배치는 부모, 변형은 prop, 값은 CSS 변수.
- 불변식 — 패턴을 가져올 때 "이게 원래 환경에서 왜 안전했나" 를 한 번 묻는다. 그 이유가 우리 스택에 없으면 패턴도 없다.
- 반복 감지 — 같은 코드가 세 번째 나오면 추상화 신호다. AI 는 손이 안 아프니 사람이 세야 한다.
- 빌드 검증 — 프레임워크가 "dev 와 다를 수 있다" 고 적어 둔 건 빌드로 본다. 매번이 아니라 공용 컴포넌트·import 그래프를 건드린 커밋에서.
- 시각 회귀 — 비지역적 회귀는 화면 비교 말고는 못 잡는다. 빌드 결과물로 찍고, 갱신 규칙을 코드화한다.
원인은 8월, 트리거는 10월, 발견은 빌드. 셋이 떨어져 있는 버그는 diff 리뷰로 안 잡힌다. 그걸 잡는 건 규칙과 테스트고, AI 주도 개발에서 그 둘을 쓰는 사람은 여전히 사람이다.
'프론트엔드' 카테고리의 다른 글
| iOS에서만 느려지는 sticky 탭바 해결하기 (0) | 2026.08.18 |
|---|---|
| React 대량 데이터 편집 모달에서 입력 지연 원인 추적기 (0) | 2026.03.20 |
| 5,000건 대량 테이블의 '전체 선택' 지연 해결하기 (Virtualization) (0) | 2026.03.19 |
| 체크박스 아이템 최적화 (0) | 2026.03.13 |
| 초성 검색을 지원하는 Local Async SelectBox 구현하기 (0) | 2026.02.09 |
