Files
landing_page/refer_landing_page/REVIEW.md
T

365 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 코드 리뷰 — refer_landing_page ("Issue 01" 디자인 시스템 적용)
> 리뷰 대상: `main` 브랜치, `893dd91` (Initial commit) 이후의 working-tree 변경분
> (`git status` 기준 12 modified + 9 untracked files, +1,092 / -262 lines, 5개 라우트 + 5개 신규 컴포넌트 + 디자인 문서 1건)
> 검증 방법: `git diff` 정독, `npx tsc --noEmit` (clean), `npm run build` (clean, 8/8 정적 페이지), 디자인 정합성/HTML 유효성/접근성 정적 분석
> 작성일: 2026-06-16 (1차) · **재검증: 2026-06-17 (2차)**
> ### ⚠️ 2차 재검증 노트 (2026-06-17)
> 1차 리뷰(2026-06-16) **이후 코드가 일부 수정**되었습니다. 1차의 P0/P1 중 여러 건이 이미 working tree에 반영되어, 아래 본문의 일부 항목은 **stale(이미 해결)** 상태입니다. 2차 재검증 결과 상태를 갱신하고, 1차가 놓친 **신규 버그 1건(`display-sm` 클래스 미정의 — §3 신규 A)** 을 추가했습니다.
>
> | 항목 | 1차 상태 | 2차 현재 상태 |
> |---|---|---|
> | §3 #1 publications `<ol>`>`<div>`>`<li>` | 🟠 미해결 | ✅ **해결됨** (`as="li"` 적용, `publications/page.tsx:133`) |
> | §4 P1 #4 Marquee `w-full max-w-full` | 미반영 | ✅ **해결됨** (`Marquee.tsx:22`) |
> | §3 medium·P1 #3 HeroComposition 텍스트 겹침 | 🟡 미해결 | ✅ **해소됨** (주석을 상단 모서리 y=24로 이동) |
> | §4 P0 #2 Next.js 14.2.5 보안 | 🟠 미해결 | 🟠 **여전히 미해결** — 잔존 P0 |
> | **신규 A** `display-sm` 클래스 미정의 | — | 🔴 **신규 High (1차 누락)** |
> | **신규 B** `Counter` rAF unmount 미취소 | — | 🟡 **신규 Medium (1차 누락)** |
---
## 1. 개요 (Overview)
이번 변경은 "한 권의 인쇄 매거지(Issue 01)" 컨셉의 **에디토리얼 디자인 시스템**을 처음부터 적용한 대규모 시각·구조 리뉴얼입니다. 단순한 클래스 리네임이 아니라 **레이아웃 언어·타이포그래피·모션·테마 토큰 전체**를 재설계했습니다.
핵심 변화:
- **테마 토큰 재설계**: `brand`(KNU 블루) / `accent` 단일 톤 → `ink` / `ivory` / `paper` / `line` + 두 개의 에디토리얼 강조색 `vermillion`(MCM) · `cobalt`(QUIC) 시스템으로 교체. `tailwind.config.ts``app/globals.css` `:root` 양쪽에 미러링되어 있어 토큰 일관성 보장.
- **타이포그래피 교체**: 시스템 sans → Fraunces(영문 디스플레이 세리프) + Noto Serif KR(한글 세리프 헤드라인) + Inter(본문) 3-페어. `next/font`로 CSS 변수 주입.
- **신규 모션·UI 프리미티브 4종**: `Reveal`(IntersectionObserver 기반 스크롤 등장), `Counter`(rAF 카운트업, `prefers-reduced-motion` 존중), `Marquee`(CSS 키프레임 러닝 티커), `SectionLabel`("01 / 05" 스프레드 넘버링).
- **페이지 단위 전면 재구성**: 5개 라우트 모두 cover spread → figures → 메인 콘텐츠 → 보조 섹션의 잡지식 레이아웃으로 재작성. 카운터, 마키, 섹션 라벨이 페이지마다 다른 의미로 재사용됨.
- **디자인 문서 정비**: `docs/DESIGN.md` 정본 보강, `docs/LAYOUT_AUDIT.md` 17개 이슈 카탈로그(이번 변경으로 일부 해결), `docs/TYPOGRAPHY_FIXES.md` 작업 노트 추가.
- **문서**: `README.md`에 디자인 시스템 섹션·커스터마이징 훅 5종(색·폰트·모션·콘텐츠·공유 컴포넌트) 추가.
기술적으로 **빌드/lint/타입 체크 모두 통과**합니다 (`npm run build` → 8/8 정적 페이지 생성, 87.1 KB shared first-load JS, no warnings).
### 이전에 카탈로그화된 이슈 해결 현황
`docs/LAYOUT_AUDIT.md` 17개 이슈 중 이번 diff에서 **다수가 해결**되었습니다 (해결 항목 8, 잔존 5, 미해결 4, 검증 후 OK 3):
| # | 이슈 | 상태 |
|---|------|------|
| 1 | `border-current/*` opacity modifier → fixed gray | ✅ 해결 (`border-ink/20` 또는 `border-vermillion/30`로 교체) |
| 2 | HeroComposition `transformOrigin` 박스 미스매치 | ✅ 해결 (인라인 `transformBox: "fill-box"` 추가) |
| 3 | HeroComposition 우하단 텍스트와 4번 스트림 충돌 | ✅ 해결 (text `y` 356 → 334로 이동) |
| 5 | Footer `mt-24` × `<main flex-1>` 이중 여백 | ✅ 해결 (`mt-0 lg:mt-8`) |
| 6 | PageHeader `<h1>` 한국어 단어 분리 | ✅ 해결 (`break-keep` + `text-balance` 추가) |
| 7 | "Standards" 9rem 캡 오버플로 | ✅ 해결 (`display` 캡 9rem → 7rem) |
| 8 | Members 3그룹 2명 → 3-col ghost cell | ✅ 해결 (`lg:grid-cols-2`로 통일) |
| 9 | `.reveal` no-JS 폴백 부재 | ✅ 해결 (`<noscript><style>` 추가) |
| 10 | PageHeader `display` 미지정 시 `en` 풀사이즈 | ⚠️ **부분 무효**`display ?? en` 분기는 맞으나 폴백이 의존하는 `display-sm` 클래스가 **미정의**라 의도된 3.75rem이 아닌 ~16px로 렌더됨. **신규 A 참조** |
| 11 | Counter "2026" 0→2026 카운트업 | ⚠️ **부분 무효**`static` prop은 정상이나 `display-sm` 스케일이 **미적용**(~16px). **신규 A 참조** |
| 17 | `corner-label` 14px → 16px | ✅ 해결 (`text-sm``text-base`) |
---
## 2. 변경된 파일 (Files changed)
| 파일 | 변경 종류 | 설명 |
|---|---|---|
| `tailwind.config.ts` | modified | 컬러 토큰 전체 교체(ink/ivory/paper/line/vermillion/cobalt, brand는 back-compat 별칭), 폰트 패밀리 3종, `display`/`display-sm` 매거진 스케일, marquee/stream-dash/node-pulse 5종 keyframes·animation 추가. |
| `app/globals.css` | modified | `:root` CSS 변수 미러, 종이 그레인 배경, `::selection` 강조, `.display`/`.headline-ko`/`.kicker`/`.corner-label`/`.pull-quote`/`.rule`/`.spread-card`/`.draw-underline` 에디토리얼 프리미티브, `.reveal` 4종 variant, `.marquee-track`, `prefers-reduced-motion` 폴백 추가. 기존 `.card`/`.section-title`/`.section-subtitle` 제거(어디서도 미사용). |
| `app/layout.tsx` | modified | `next/font`로 3개 폰트 로드 + CSS 변수 주입, `<html className>`에 변수 부착, `<noscript>` 폴백. |
| `components/Header.tsx` | modified | 데스크탑 nav에 0105 섹션 번호 + 영문 라벨 + 활성 상태 애니언더라인, 모바일 햄버거 아이콘 6×6→5×5 + 테두리 토글, 이슈 스트립 ("Issue 01 — 2026"), sticky ivory 배경. |
| `components/Footer.tsx` | modified | 3-col 단순 링크 → 5-col 잡지 콜로폰(colophon/contact/index), `<Marquee>` 콜로폰 티커, 라벨별 인덱스 01–05, `draw-underline` 호버 효과, 세리프 산세리프if credit. |
| `components/PageHeader.tsx` | modified | 그라데이션 히어로 → ivory 잡지 마스트헤드, `<SectionLabel>`+`<Reveal>` 조합, 한국어 serif `headline-ko` + 영어 `display` 워드 + 우측 보더라인 데스크립션, `display` 폴백 안전장치. |
| `app/intro/page.tsx` | modified | Cover spread(헤드라인 + HeroComposition SVG) + 카운터 행(연구 축/표준화 기구/Issue) + 미션 풀쿼트 + 두 추력 비교 카드(vermillion × cobalt 연결 라인) + Focus Areas 4-col 그리드. `thrusts` 데이터에 `no`/`tag`/`accent` 필드 추가, 키워드/figures 데이터 추가. |
| `app/members/page.tsx` | modified | 카운터 3종(지도교수/대학원/학부) + advisor 7+5 콜룸 + research groups 3그룹(vermillion/cobalt/ink 액센트). 3번째 그룹(학부) 2명 → `sm:grid-cols-2`로 통일. |
| `app/publications/page.tsx` | modified | Venue `<Marquee>` + 카운터 3종(전체/저널/학회) + 잡지식 출판물 리스트(번호·연도·타입 좌측 rail + 제목·저자·venue 우측 본문), `Journal` → vermillion, `Conference` → cobalt. |
| `app/standardization/page.tsx` | modified | Org `<Marquee>` + 카운터 3종(표준화 기구/기여 항목/기술 영역) + 4개 표준화 기구 카드(oneM2M/W3C WoT/IETF QUIC/OMA LwM2M), 기구별 accent(vermillion/cobalt 교차) + 적응형 보더. |
| `app/lectures/page.tsx` | modified | 강의 3종을 3-col 카운터-스타일 카드로 재구성, 강의별 accent(vermillion/cobalt/ink), 우상단 0103 spread 번호, 호버 시 accent 배경 + ivory 텍스트. |
| `README.md` | modified | 디자인 시스템 섹션, 컴포넌트 트리 보강(SectionLabel/Reveal/Counter/Marquee 추가), 커스터마이징 훅 5종(색·폰트·모션·콘텐츠·공유 컴포넌트) 상세화. |
| **신규** `components/Reveal.tsx` | added (60 lines) | `IntersectionObserver` 기반 스크롤 등장, `variant: up|left|right|scale`, `delay` ms, `as` prop으로 태그 오버라이드 가능. cleanup에서 `io.disconnect()`. |
| **신규** `components/Counter.tsx` | added (78 lines) | rAF 카운트업 (`easeOutExpo`), `prefers-reduced-motion` 존중 시 즉시 렌더, `static` prop으로 years/고정값 처리. |
| **신규** `components/Marquee.tsx` | added (41 lines) | 콘텐츠를 2번 복제해 `-50%` 트랜슬레이트 루프, `reverse` prop으로 방향 반전, ✦ 세퍼레이터. |
| **신규** `components/SectionLabel.tsx` | added (28 lines) | "01 / 05" 스프레드 넘버 + hairline + kicker. |
| **신규** `app/intro/_components/HeroComposition.tsx` | added (116 lines) | 인라인 SVG 에디토리얼 일러스트, MCM 노드 메시(vermillion) + QUIC 멀티스트림(cobalt), `node-pulse`·`stream-dash` CSS 애니메이션. |
| **신규** `docs/DESIGN.md` (17,872 B) | added | 디자인 시스템 정본(토큰·타이포·모션·컴포넌트 4종). |
| **신규** `docs/LAYOUT_AUDIT.md` (22,936 B) | added | 17개 이슈 카탈로그(이번 리뷰 시점 기준 11개 해결). |
| **신규** `docs/TYPOGRAPHY_FIXES.md` (4,322 B) | added | 타이포그래피 작업 노트. |
| **신규** `.antigravity-session.md`, `.kanban-*.md` (4 files) | added | Antigravity CLI 세션 / 카반 작업 산출물 (리뷰 대상 아님, 무시). |
| **신규** `package-lock.json` | added | 의존성 잠금 파일 (기존 `package.json`은 변경 없음 — dev install로 추정, 커밋 대상 검토 필요, §3 참조). |
---
## 3. 발견된 이슈 (Issues)
### 🔴 신규 A (High) — `display-sm` 클래스가 정의되어 있지 않아 매거진 디스플레이 텍스트가 ~16px로 축소 렌더 *(1차 누락)*
**위치:** `components/PageHeader.tsx:43`, `app/intro/page.tsx:53` (+ `Counter` 경유)
`display-sm``tailwind.config.ts:77`에서 **`fontSize` 키**로 정의되어 있습니다. Tailwind의 `fontSize` 키는 `text-<key>` 유틸리티만 생성하므로 실제로 만들어지는 클래스는 `text-display-sm`이고, **맨(bare) `display-sm` 클래스는 생성되지 않습니다.** 한편 `app/globals.css`에는 `.display` 컴포넌트 클래스만 있고 `.display-sm` 컴포넌트 클래스는 없습니다.
검증 (컴파일된 CSS 기준):
```bash
$ grep -o "display-sm" .next/static/css/app/layout.css | wc -l
0 # ← display-sm 규칙이 전혀 없음
$ grep -o "\.display" .next/static/css/app/layout.css | wc -l
1 # ← .display(컴포넌트 클래스)는 존재
```
따라서 `className="display-sm ..."`은 **아무 폰트 크기도 적용하지 않는 no-op 클래스**이고, 해당 텍스트는 브라우저 기본 크기(~16px)로 렌더됩니다.
**영향:**
- `PageHeader``display` prop을 넘기지 않으면 fallback `en` 단어가 `display-sm`으로 렌더됩니다. **현재 `PageHeader`를 사용하는 4개 페이지(members/publications/lectures/standardization) 모두 `display`를 넘기지 않으므로**, "Members" · "Publications" · "Lectures" · "Standardization" 영문 디스플레이 단어가 의도된 `clamp(2rem, 5.5vw, 3.75rem)`이 아니라 **~16px 작은 vermillion 세리프**로 표시됩니다 — 마스트헤드의 큰 영문 단어가 사실상 사라집니다.
- `app/intro/page.tsx:53`의 연도 "2026" 피겨는 `size: "display-sm"`이라 `Counter``display-sm`으로 렌더 → **~16px**. 같은 행의 "2" · "4"는 `display`(최대 7rem)라 **극단적인 크기 불일치**로 피겨 행이 깨져 보입니다.
- 이 버그는 본 문서 §1 LAYOUT_AUDIT 표의 #10/#11(및 #6/#7 일부) "해결" 주장을 **무효화**합니다.
**왜 1차에서 못 잡았나 / build가 clean한 이유:** Tailwind/PostCSS는 **미정의 유틸리티 클래스를 에러 없이 조용히 무시**합니다. 따라서 `npx tsc --noEmit`도 `npm run build`도 통과하며, "build clean"이 **시각적 정합성을 보장하지 않습니다.**
**권장 수정 (택1):**
```css
/* (A안, 권장) app/globals.css 컴포넌트 레이어에 .display-sm 추가 */
.display-sm { @apply font-display text-display-sm font-light; }
/* → 추가 시 PageHeader의 중복 `font-display font-light`는 제거 가능 */
```
또는 사용처를 `display-sm``text-display-sm`으로 바꾸되, 폰트 패밀리/굵기 클래스(`font-display font-light`)를 함께 명시 (Counter 사용처는 현재 폰트 클래스가 없으므로 누락 주의).
### 🟡 신규 B (Medium) — `Counter`의 `requestAnimationFrame` 루프가 unmount 시 취소되지 않음 *(1차 누락)*
**위치:** `components/Counter.tsx:52-69`
```tsx
const io = new IntersectionObserver(([entry]) => {
...
let raf = 0;
const tick = (now) => { ...; if (t < 1) raf = requestAnimationFrame(tick); };
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf); // ← IO 콜백의 반환값 → 아무 데도 안 쓰임
}, { threshold: 0.4 });
io.observe(el);
return () => io.disconnect(); // ← useEffect cleanup: 옵저버만 끊고 rAF는 안 멈춤
```
`return () => cancelAnimationFrame(raf)`**IntersectionObserver 콜백의 반환값**이라 호출되지 않습니다. `useEffect`의 cleanup은 `io.disconnect()`만 수행하므로, **진행 중인 rAF 카운트업 루프는 중단되지 않습니다.** 카운트업 도중 컴포넌트가 언마운트되면(예: 빠른 라우트 전환) rAF가 언마운트된 컴포넌트에 `setDisplay`를 계속 호출합니다. React 18은 이 setState를 경고 없이 no-op 처리하므로 **실 피해는 경미(낭비 프레임 수 개)**하지만, 명백한 누수입니다.
**권장:** `raf` 변수를 `useEffect` 스코프로 끌어올려 cleanup에서 `cancelAnimationFrame(raf)`을 호출. 또는 `isStatic`/완료 플래그로 보호.
---
### ✅ ~~🟠 High — 잘못된 HTML 구조: `<div>`가 `<ol>`/`<ul>` 안에서 `<li>`를 감쌈~~ — **[2026-06-17 해결됨]**
> **2차 재검증:** 현재 코드는 `app/publications/page.tsx:133`에서 `<Reveal as="li" ...>`로 렌더되며 주석에 본 항목(`REVIEW.md §3 #1`)을 인용해 둠. `<ol>`의 직접 자식 계약(`<li>`만 허용)을 준수하므로 **해결**. 아래 설명은 이력 보존용.
**위치:** `app/publications/page.tsx:131-160`
```tsx
<ol className="mt-12 border-t border-ink">
{publications.map((p, i) => (
<Reveal key={p.title} delay={(i % 3) * 80}> {/* default as="div" */}
<li className="group grid gap-6 border-b border-ink py-8 ...">
...
</li>
</Reveal>
))}
</ol>
```
`Reveal`은 기본 `as: "div"`로 렌더링됩니다. 결과 DOM은 `<ol><div class="reveal"><li>...</li></div></ol>`이 되어 HTML 명세 위반입니다([spec](https://html.spec.whatwg.org/multipage/grouping-content.html#the-ol-element): `<ol>`의 자식은 0개 이상의 `<li>`/`<script>`/`<template>`만 허용). React는 콘솔 경고를 띄우지 않지만, 자동복구로 DOM이 재정렬될 수 있고(`<li>``<ol>` 밖으로 이동), `<ol>``start`/`type` 카운터 의미가 깨질 수 있습니다.
나머지 페이지(lectures/standardization/members/intro)는 모두 `<Reveal>` 안에서 `<article>`을 감싸므로 안전합니다. 오직 publications만 `<li>`를 직접 감쌉니다.
**권장 수정:**
```tsx
<Reveal as="li" key={p.title} delay={(i % 3) * 80}
className="group grid gap-6 border-b border-ink py-8 ...">
...
</Reveal>
```
### 🟠 High — `npm audit` 1 critical + 6 high 보안 권고 미반영
**위치:** `package.json` / `package-lock.json` (Next.js `14.2.5`)
`.kanban-final-report.md` §2에 따르면 의존성에 8개 권고(1 critical, 6 high, 1 moderate)가 있습니다. 핵심은 `next@14.2.5``14.2.35` 패치 한 줄로 21개가 해결됩니다(서버 컴포넌트 DoS, 캐시 포이즈닝, 미들웨어 SSRF, PostCSS XSS 등). 호환성: 같은 14.x 라인, React 18 락도 그대로.
**권장 수정:**
```bash
npm install next@14.2.35
npm audit
```
### 🟡 Medium — `package-lock.json`이 untracked 상태로 추가됨
**위치:** working tree 루트
`package.json`은 diff에 없는데 `package-lock.json`이 untracked로 들어왔습니다. 이전 커밋(`893dd91`)에도 lockfile이 없었다면 이번에 처음 도입된 것인데, 그 경우:
- **의도된 커밋**이면 그대로 두고, 향후 `package.json` 변경 시 함께 업데이트되는지 CI 또는 가이드로 명시.
- **무심코 추가된 것**이면(예: 로컬 `npm install` 부산물) `.gitignore`로 옮기는 것을 권장. 단, 215 KB인 lockfile을 추적하면 재현 가능한 빌드가 보장되므로 보통은 추적하는 편이 낫습니다.
**권장 결정:** lockfile 추적 유지(재현 가능한 빌드의 정석). 단, 의도가 불분명하면 `git log --diff-filter=A -- package-lock.json`으로 첫 등장 시점 확인.
### 🟡 Medium — `Reveal`의 IntersectionObserver cleanup 시점: `disconnect`이 unmount 시점에만 실행
**위치:** `components/Reveal.tsx:47`
```tsx
useEffect(() => {
...
io.observe(el);
return () => io.disconnect();
}, []);
```
`unobserve`가 첫 intersection 시점에 호출되어(entry isIntersecting → unobserve, line 39) 이후 콜백이 다시 안 불리지만, dependency array가 `[]`라서 `variant`/`delay` prop이 바뀌어도 옵저버가 재생성되지 않습니다. 현재 `variant`는 CSS attribute로 적용되므로 prop 변경이 옵저버 동작에는 영향이 없어 실 문제는 없지만, **마운트 이후 prop만 바뀌는 경우 stale observer**가 됩니다(미세).
**권장:** `useEffect` deps에 `[variant, delay]`를 추가하거나, `entry.target``data-variant` 속성을 read해서 비교. 다만 현재 사용 패턴(컴포넌트가 한 번 마운트되면 prop이 안 바뀌는 정적 리스트)에서는 영향 없음 — **minor**.
### 🟡 Medium — `Reveal as` prop 시그니처가 런타임 안전성을 보장하지 않음
**위치:** `components/Reveal.tsx:19`, `26`
`as?: ElementType`이라 `<Reveal as="ol">`처럼 잘못된 부모(예: `ol` 안의 `ol` 금지)나, `<Reveal as="li">`를 잊고 wrapper를 list 안에 넣는 사용을 막을 수 없습니다. #1의 문제가 발생한 이유도 이 가드 부재입니다. 호출 컨벤션으로 잡혀 있지만(모든 페이지가 `<Reveal>` 안에 article/div를 둠), 실수 방지 차원에서 **사용 시 `li`/`ol`/`ul` 컨텍스트일 땐 `as`를 강제하는 lint 룰(예: eslint-plugin-jsx-a11y custom rule) 또는 README에 "list 내부에서 쓸 땐 `as=\"li\"`" 규칙**을 명시하면 좋겠습니다.
### 🟡 Medium — `Marquee`의 트랙이 `flex` + 콘텐츠가 `inline-flex`인데, width가 콘텐츠에 맞춰져 viewBox/parent overflow 계산이 viewport에 의존
**위치:** `components/Marquee.tsx:22-38`
`<div className="marquee-track">` 안의 시퀀스가 `[0, 1].map(dup)`로 2번 복제됩니다. CSS는 `translateX(0 → -50%)`로 50% 이동 후 같은 위치로 와서 seamless loop. **하지만 `marquee-track`이 `inline-flex`이고 콘텐츠가 `flex`인 점이 데스크탑에서만 동작**합니다 — 부모에 `overflow-hidden`은 있으니 화면이 줄어들면 콘텐츠가 부모 너비에 의해 클립되지만, **부모 width가 명시되지 않은 경우(예: `<Reveal>`의 wrapper)** `inline-flex`는 콘텐츠 폭만큼 자라서 페이지를 가로로 스크롤 가능하게 만들 수 있습니다. 현재는 모두 `container-content` 안에 있어 안전하지만, 다른 곳에서 단독 사용 시 가로 스크롤이 생길 수 있습니다.
**권장:** `Marquee` 최상위에 `max-w-full` 또는 `w-full`을 추가하고, 부모 wrapper에서도 가로 클립이 보장되는지 명시.
### ✅ ~~🟡 Medium — HeroComposition 4번 스트림이 우측 모서리 가까이 통과, 우하단 텍스트와의 간격이 610px~~ — **[2026-06-17 해소됨]**
> **2차 재검증:** 현재 코드는 주석(annotation) 텍스트를 **상단 두 모서리(y=24)** 로 이동했습니다(`HeroComposition.tsx:104,107`). 스트림(y≥250)·노드 메시(y=60~240)와 수직으로 분리되어 겹침이 구조적으로 제거되었습니다. 1차의 `y=334` 우하단 배치 가정은 더 이상 유효하지 않습니다. 아래 설명은 이력 보존용.
**위치:** `app/intro/_components/HeroComposition.tsx:99-113`
LAYOUT_AUDIT #3은 `y=356 → 334`로 이동하여 해결되었지만, 스트림 곡선의 마지막 control point는 `360, 320` (i=3) 입니다. 텍스트 baseline은 `y=334`이고 폰트 size 11 + letterSpacing 2이므로 텍스트 상단 ~y=323, 하단 ~y=336. 텍스트의 우측 끝(`x=360, textAnchor="end"`)은 x=360에 anchor되므로 가로 폭이 좁지는 않지만, **스트림 4번은 x=240~360 / y=300~340 대역**을 지나가며 텍스트의 시작점(좌측)에 근접합니다. 텍스트 내용("QUIC · STREAMS")이 ~100px 폭이라면 우측 x≈260~360에 그려지는데, 4번 스트림의 끝점 (360, 320)이 텍스트의 (260, 334) 근처를 지나갑니다.
해결된 것으로 보이지만 실제로는 텍스트가 스트림 곡선과 **여전히 시각적으로 겹칠 수 있는** 좁은 마진입니다. `y=320` 정도 또는 `y=348` 정도로 더 멀리 두는 편이 안전합니다.
### 🟡 Medium — `text-balance` + `break-keep`이 한국어에 대해 100% 안정적이지 않음
**위치:** `components/PageHeader.tsx:32`
```tsx
<h1 className="headline-ko break-keep text-balance text-3xl leading-[1.05] text-ink sm:text-4xl lg:text-5xl">
```
- `text-balance` (CSS `text-wrap: balance`)는 2024년 기준 Chrome 114+ / Safari 17.5+ / Firefox 121+에서 지원. **지원하지 않는 브라우저에서는 무시되며 fallback 없음** — 큰 문제 아님.
- `break-keep` (`word-break: keep-all`)은 한국어/일본어/중국어 텍스트에서 단어 경계로 줄바꿈을 강제. 한국어는 공백이 거의 없어서 효과가 제한적입니다. `"멀티에이전트 오케스트레이션"` 같이 공백이 있는 제목은 효과가 있고, `"사물인터넷표준연구실"` 같이 공백이 없는 경우 여전히 글자 단위로 깨질 수 있습니다.
**권장:** 한 줄이 너무 길어질 가능성이 있는 한국어 헤드라인은 강제로 `<br />`을 박거나 `whitespace-pre-line` + 줄바꿈 문자(`\n`)를 사용.
### 🟡 Medium — `Reveal`로 감싼 `<div>`/`<article>` 안에 있는 `<a>` 링크가 reveal 애니메이션과 충돌할 가능성
`Reveal``opacity: 0` → 1로 페이드 인하는데, `prefers-reduced-motion`이 설정되지 않은 환경에서 사용자가 링크를 빠르게 따라가려 할 때(약 0.8s transition + delay) **클릭 타깃이 시각적으로 보이지만 실제 클릭 가능 시점이 늦어지는** 사각지대가 생깁니다. IntersectionObserver의 `threshold: 0.12`는 12%가 뷰포트에 들어와야 트리거되므로 화면 상단에서 스크롤 시 약 100ms 정도만 보입니다.
**권장:** `prefers-reduced-motion` 사용자에게만 즉시 표시 + 일반 사용자에게는 0.4s로 duration 단축 검토(현재 820ms는 약간 길음). 또는 threshold를 더 낮춰 0.05 정도로.
### 🟢 Low — `key={p.title}`가 title 충돌 시 깨짐
**위치:** `app/publications/page.tsx:131`, `app/standardization/page.tsx`
publication title이 unique하지 않은 경우(같은 제목이 여러 venue에 출판될 수 있음) React key 충돌이 일어납니다. 현재 데이터는 unique이지만, 향후 같은 title이 들어오면 오류. `key={\`${p.title}-${p.year}-${p.venue}\`}` 등 composite key가 더 안전.
### 🟢 Low — `accent` 식별자가 색상 이름과 충돌
**위치:** `app/lectures/page.tsx:18,26,34`, `app/members/page.tsx:25,34,44`, `app/standardization/page.tsx:20,30,40,50`
각 페이지가 data field 이름으로 `accent`를 쓰고(`accent: "vermillion" as const`), `accentText`/`accentDot` 맵의 key로도 씁니다. 가독성은 양호하지만 `accent`는 Tailwind 유틸리티 클래스 이름과 충돌할 수 있고, 추후 CSS `accent-color` 속성과 헷갈릴 여지가 있습니다. 의미상 `theme` 또는 `tint`가 더 명확합니다.
### 🟢 Low — `<Reveal>`의 SSR/CSR hydration mismatch 가능성 (이론적)
`Reveal`은 `"use client"`이고 첫 렌더에서 `is-visible` 클래스를 아직 안 가진 상태로 마운트됩니다. HTML은 SSR 시에도 `.reveal` + `data-variant` + `opacity: 0`이 적용되어 전송되고, JS hydrate 후 IntersectionObserver가 `is-visible`을 토글합니다. **서버에서는 항상 `opacity: 0` → 클라이언트에서는 항상 `opacity: 0`이라 hydration mismatch는 없음**. 다만 **no-JS 환경**에서는 noscript 폴백(`<noscript><style>.reveal { opacity: 1 !important; ... }</style></noscript>`)이 의도대로 동작하므로 해결됨. ✅
### 🟢 Low — `Header` 모바일 메뉴의 z-index와 `<Marquee>` band의 sticky 충돌 가능성
`Header`는 `sticky top-0 z-50`. `<Marquee>`는 absolute/sticky 없이 일반 flow. 모바일에서 햄버거 메뉴가 열렸을 때(`<nav id="mobile-menu">` 펼쳐진 상태) `<Marquee>`가 위에 있으면 클릭이 가려질 수 있으나, `<Marquee>`는 `aria-hidden`이라 인터랙션 요소는 아닙니다. **실 문제 아님**.
### 🟢 Low — `app/intro/page.tsx:90-105`에 중첩된 `<Reveal>` 4연타
```tsx
<Reveal delay={60}>...</Reveal>
<Reveal delay={120}>...</Reveal>
<Reveal delay={200}>...</Reveal>
<Reveal delay={260}>...</Reveal>
```
같은 부모 영역에 4개 sibling Reveal이 60ms 간격으로 등장. `Reveal` 각각이 별도 IntersectionObserver 인스턴스를 생성하므로 **브라우저 메모리에 observer 4개가 동시** 만들어집니다(가벼우나 관용적이지 않음). 같은 영역이면 하나의 Reveal로 묶고 내부 stagger를 CSS `transition-delay`로 처리하는 편이 깔끔합니다.
### 🟢 Low — 접근성: `Marquee`에 `prefers-reduced-motion` 폴백은 있으나 키보드/SR 사용자용 일시정지 토글 없음
`prefers-reduced-motion` 환경에서는 `.marquee-track { animation: none }`이 적용되어 멈춥니다. 그러나 키보드 사용자나 스크린리더 사용자에게 명시적인 정지 토글은 없습니다. `aria-hidden`이라 SR은 무시하므로 큰 문제는 아니나, 인지적으로 천천히 움직이는 콘텐츠가 본문 읽기를 방해할 수 있습니다(학습장애 사용자 등). WCAG 2.2.2 (Pause, Stop, Hide) 권고. 현재 컨텐츠는 "decorative ticker"로 분류 가능하므로 **선택적 개선사항**.
### 🟢 Low — `HeroComposition` SVG의 `aria-label`이 한글/영문 혼합
`aria-label="MCM 상호운용 노드와 QUIC 멀티스트림을 형상화한 추상 일러스트레이션"` — 영문+한글 혼합. SR 호환성을 고려하면 영문만 또는 한/영 둘 다 `<title>` + `<desc>`로 분리하는 게 표준. 현재 SR 환경에서 한글이 잘 읽히므로 **minor**.
---
## 4. 개선 제안 (Concrete Suggestions)
> **2차 갱신:** 1차 P0 #1과 P1 #3/#4는 이미 해결되어 목록에서 제거(취소선)했고, **신규 A(`display-sm` 미정의)** 를 P0로 승격했습니다.
### P0 (반드시 머지 전 처리)
1. **신규 A — `display-sm` 클래스 정의** — `app/globals.css`에 `.display-sm { @apply font-display text-display-sm font-light; }` 추가. 미적용 시 4개 PageHeader 페이지의 영문 디스플레이 단어와 intro "2026" 피겨가 ~16px로 깨짐. **§3 신규 A 참조.** 1분 작업.
2. **`#2` npm audit 해결** — `npm install next@14.2.35` 후 `npm audit` clean 확인. `package.json`은 여전히 `14.2.5` (미반영). 1분 작업.
3. ~~**`#1` HTML invalidity 수정**~~ — ✅ **이미 해결됨** (`publications/page.tsx:133` `<Reveal as="li">`).
### P1 (다음 PR에서)
4. ~~**HeroComposition 우하단 텍스트 마진**~~ — ✅ **해소됨** (주석을 상단 y=24로 이동).
5. ~~**Marquee `w-full max-w-full` 명시**~~ — ✅ **이미 적용됨** (`Marquee.tsx:22`).
6. **신규 B — `Counter` rAF cleanup** — `raf`를 useEffect 스코프로 올려 cleanup에서 `cancelAnimationFrame`. **§3 신규 B 참조.** 5분 작업.
7. **`Reveal` 내부 stagger를 `transition-delay`로 통합** — intro 페이지 4연타는 단일 wrapper로 묶고 자식은 `style={{ transitionDelay: ... }}`로 처리. (미해결, minor) 10분 작업.
8. **`Reveal`의 `transition-duration`을 820ms → 600ms로 단축** — 모션 선호 off 환경에서 페이지 응답성 개선. (`globals.css:20` 아직 820ms) 1분 작업.
### P2 (정리)
7. **README에 "List 안에서 `<Reveal>` 사용 시 `as=\"li\"` 필수" 규칙 추가** — `docs/DESIGN.md` 4절 보강.
8. **데이터 필드명 `accent` → `tint` 리네임** — lecture/member/standardization 페이지 3곳 + intro. 15분 작업.
9. **`publications` 키를 composite으로** — `key={\`${p.title}-${p.year}\`}`. 1분 작업.
10. **package.json에 `"engines": { "node": ">=20" }` 명시** — Node 22 환경 명시. 1분.
11. **마키에 명시적 정지 토글** (선택) — `prefers-reduced-motion` 외에 키보드/터치 사용자가 토글 가능하도록 `<button aria-label="티커 일시정지">` 추가. 30분 작업.
12. **`Counter`에 `aria-live="polite"` 추가** — 카운트업이 끝났을 때 SR이 "전체 논문 4건" 식으로 알려줄 수 있음. 5분 작업.
---
## 5. 결론 (Verdict) — **2026-06-17 재검증 기준**
### **Needs changes (잔존 P0 2건 머지 전 처리 필요)**
매우 잘 정리된 변경입니다. 1차 리뷰 이후 코드가 갱신되어 **1차 P0 #1(publications HTML)·P1 #3(HeroComposition)·P1 #4(Marquee) 3건이 이미 해결**되었습니다. 다만 2차 재검증에서 **1차가 놓친 시각 버그 1건**을 발견했고, 1차의 보안 권고는 아직 미반영입니다.
**머지 전 반드시 처리할 2건 (P0):**
1. **`display-sm` 클래스 미정의** (`globals.css`에 `.display-sm` 추가) — 4개 PageHeader 페이지의 영문 디스플레이 단어와 intro "2026" 피겨가 의도된 ~3.75rem이 아니라 ~16px로 렌더되어 마스트헤드/피겨 행이 깨짐. **build/tsc가 잡지 못하는 조용한 버그.** §3 신규 A.
2. **Next.js 14.2.5 보안 권고 1 critical + 6 high** — 동일 라인 14.2.35로 한 줄 업그레이드로 해결. (아직 `package.json` 미반영)
이 두 건을 처리하면 **LGTM**입니다. 그 후 P1(신규 B Counter cleanup, Reveal stagger/duration)은 다음 PR에서 정리하면 됩니다.
### 핵심 강점 (정리)
- **토큰 일관성**: `tailwind.config.ts` ↔ `app/globals.css` `:root` 양쪽 미러, `tailwind.config.ts` 한 줄 변경이 사이트 전체에 전파.
- **모션 일관성**: 4개 페이지에서 같은 카운터/마키/리빌이 다른 의미로 재사용되어 통일된 리듬.
- **접근성 기본기**: `aria-hidden` (Marquee), `aria-expanded` (Header), `aria-label` (HeroComposition SVG), `noscript` 폴백, `prefers-reduced-motion` 존중.
- **타입 안전성**: 모든 prop이 optional/required 명시, `as const` 리터럴 타입으로 액센트 매핑, TypeScript 5.9 / strict 모드 clean.
- **문서화**: `docs/DESIGN.md`(정본), `docs/LAYOUT_AUDIT.md`(자체 점검), `docs/TYPOGRAPHY_FIXES.md`(작업 노트) 3종이 변경을 추적 가능하게 만듦.
### 결론 요약
| 항목 | 평가 |
|---|---|
| 빌드/타입/lint | ✅ Clean (단, 미정의 클래스 `display-sm`는 빌드가 못 잡음 — §3 A) |
| 디자인 일관성 | ⚠️ 5개 라우트 잡지 미학은 일관되나, `display-sm` 미정의로 영문 마스트헤드/연도 피겨가 실제로는 깨짐 |
| 모션/타이포/테마 | ✅ 토큰 시스템 작동 (타이포 한 군데 `display-sm` 누락 제외) |
| 1차 이슈 해결 (2차 확인) | ✅ P0 #1·P1 #3·P1 #4 추가 해결됨 |
| 신규 발견 이슈 (2차) | 🔴 1건(`display-sm` 미정의, High), 🟡 1건(Counter rAF cleanup) |
| 잔존 P0 | 🟠 2건 — (a) `display-sm` 정의, (b) Next.js 14.2.35 업그레이드 |
| 머지 가능 여부 | **잔존 P0 2건 처리 후 가능** |
---
*리뷰어 메모: 변경량 대비 코드 품질이 매우 높습니다. 특히 LAYOUT_AUDIT에 카탈로그된 이슈 중 핵심 8건을 한 번에 해결한 점, 그리고 `<noscript>` 폴백을 layout 단계에 추가한 점은 마이너 변경에서 놓치기 쉬운 디테일을 잘 챙긴 흔적입니다. 위 2건(P0)만 정리되면 본 변경은 그대로 main에 머지해도 좋을 수준입니다.*