본문으로 건너뛰기

ADR-0034: 반응형 레이아웃 — 컨테이너 쿼리 + 한국어 keep-all + AI 패널 오버레이

  • 상태: 승인
  • 날짜: 2026-07-31
  • 관련: #2759 (본 결정), #2762 (잔여 압착 후보 후속)

배경

"창 크기를 줄이거나 AI 에이전트 패널을 열면 문구가 세로 한 줄로 깨진다"는 신고에서 출발해, 전 라우트를 실측했다.

계측 방법 — Playwright 로 63 라우트 × 창 폭 7종(1536/1366/1280/1152/1024/900/800) × AI 패널 개폐 2종 = 882 샘플. 각 샘플에서 텍스트 노드가 만든 줄 박스Range.getClientRects() 로 세어 줄수 >= 3 && 글자/줄 <= 4.5 && 줄폭 <= 160px 을 붕괴로 판정했다. (요소 높이 / line-height 로 줄수를 추정하면 형제 아이콘·버튼의 높이가 섞여 오탐이 난다 — 실제로 초기 계측의 26/68 건이 이 오탐이었다.)

결과: 42건 붕괴. 원인은 세 층으로 갈렸다.

  1. 한국어 개행 규칙 — 기본값(word-break: normal)에서 CJK 는 글자 단위로 끊긴다. 따라서 축소 가능한 텍스트의 min-content 폭이 1글자로 수렴한다. /get-data 카드 설명은 폭 15px · 25줄 · 1.4자/줄까지 붕괴했다.
  2. 열 수를 창 폭으로 정하는 그리드sm:/md:/lg:뷰포트 미디어쿼리다. 사이드바(180px) + AI 패널(420px) 이 먹은 폭은 여기 보이지 않아서 "창은 1152px 인데 카드는 130px" 상태가 만들어졌다. 접두사 없는 grid-cols-3 은 더 심하다.
  3. AI 패널이 본문을 밀어냄 — 항상 나란히 배치하면 컨텐츠 폭이 1024px 창에서 424px, 800px 창에서 200px 이 된다. 42건 중 35건이 이 구간에서만 발생했다.

결정

D-1. 한국어 개행은 word-break: keep-all 을 전역 기본값으로 한다

ui/src/index.cssbodyword-break: keep-all 을 둔다.

  • 어절(공백) 단위로만 끊기므로 min-content 가 "가장 긴 어절"이 되고, 1글자/줄 붕괴가 구조적으로 불가능해진다.
  • 라틴 문자에서 keep-allnormal 과 동일하게 동작하므로 영문 UI 렌더는 바뀌지 않는다.
  • 긴 토큰(URL·FQN)을 쪼개야 하는 자리는 이미 break-words(overflow-wrap: break-word) 를 쓰고 있고, overflow-wrapkeep-all 보다 우선하므로 그 동작도 유지된다.

컴포넌트별 break-keep 을 흩뿌리는 대안은 기각했다 — 원인이 전역 개행 규칙이므로 차단점도 한 곳이어야 하고, 신규 화면이 자동으로 보호된다.

D-2. 열 수는 뷰포트가 아니라 컨테이너 폭으로 정한다

카드/KPI 그리드는 sm:/md:/lg: 대신 @container + @3xl: 계열 컨테이너 쿼리를 쓴다.

// ❌ 창 폭 기준 — 사이드바·AI 패널이 먹은 폭이 안 보인다
<div className="grid gap-4 sm:grid-cols-2">

// ✅ 컨테이너 폭 기준
<div className="@container">
<div className="grid gap-4 @3xl:grid-cols-2">

주의: 컨테이너 쿼리는 자기 자신을 조회할 수 없다. @container 는 래퍼에 두고 변형(@3xl:)은 자손에 둔다.

D-3. AI 에이전트 패널은 좁은 창(xl 미만)에서 본문 위에 겹친다

xl(1280px) 이상에서는 기존처럼 본문 옆에 나란히(xl:static), 그 미만에서는 absolute inset-y-0 right-0 오버레이 + 스크림(바깥 클릭 시 닫힘)으로 전환한다. 컨텐츠 폭이 200~400px 로 짜부라지는 구간 자체를 없애는 것이 개별 화면을 패치하는 것보다 근본적이다.

D-4. 회귀 가드는 클래스가 아니라 렌더 결과를 단언한다

ui/tests/responsive-text-squeeze-e2e.spec.ts — 위 계측기를 그대로 스펙으로 만들었다. className 문자열을 단언하면 구현을 바꿀 때마다 깨지고 결함은 놓친다. 줄 박스 계측은 구현과 무관하게 결함만 잡는다.

결과

동일 하네스 882 샘플 재측정: 붕괴 42건 → 0건, 신규 회귀 0건, 페이지 수평 오버플로 0건, 라우트 로드 실패 0건. UI 단위 테스트 1,589건 전건 통과.

넓은 화면(1536px, 패널 닫힘) 스크린샷은 수정 전과 사실상 동일하다 — keep-all · flex-wrap · 컨테이너 쿼리는 모두 "자리가 부족할 때만" 동작하기 때문이다.

후속

정적 감사(전 컴포넌트 스캔 6그룹 + 그룹별 적대 검증)에서 적대 검증을 통과한 59건 중 데이터가 채워진 상태에서만 재현되는 후보(다열 테이블의 min-w-* 부재, Pipeline Studio 툴바 9버튼, 온톨로지 인스턴스 탭 등)는 #2762 로 분리해 같은 계측기로 검증하며 처리한다.

주의: min-w-0 은 이 결함의 해법이 아니다. flex item 의 min-width: auto(= min-content) 를 0 으로 풀면 박스가 어절보다 좁아져 글자가 박스를 넘쳐 형제 위에 겹친다. truncate 와 짝지을 때만 쓴다.