---
title: "260522_E6-AI브리프Markdown렌더링"
notion_id: "3682296208688143b2e3f37cf8a5d727"
notion_url: "https://app.notion.com/p/3682296208688143b2e3f37cf8a5d727"
category: "workreport"
parent: "Claude Code 작업보고"
updated: "2026-05-22"
priority: "Medium"
purpose: "/coach AI 브리프 카드의 raw Markdown을 react-markdown+DOMPurify로 실제 렌더링"
---

작성일: 2026-05-22  
브랜치: main  
빌드 결과: npm run build 성공 (vite 5.4.21, 360 modules transformed)
---
## 작업 목표
`/coach` AI 브리프 카드에서 raw Markdown으로 노출되던 `##` 헤더, 목록, 강조, `json` 코드블록을 실제 렌더링되도록 수정.
- react-markdown + remark-gfm + DOMPurify 기반
- h2/h3와 코드블록 지원, h1은 차단
- HTML/script 무력화 (dangerouslySetInnerHTML 금지)
---
## 작업자 대화 요약
### 사전 확인
- `git status` 결과: `.playwright-mcp/page-...yml`, `docs/260520_playwright_browsing_report.md` 이전 세션에서 이미 스테이지된 상태. E-6 작업과 무관하므로 건드리지 않음.
- `src` 디렉토리 구조 스캔 → `src/pages/CoachNotes.tsx`가 AI 브리프 카드의 본문 렌더링 담당이라는 점 확인.
- `SummaryBrief.tsx`는 Summary 페이지의 키워드/차트 브리프(Claude 직접 호출)로, /coach AI 브리프와는 별개.
### 패키지 설치
- 기존 package.json에 react-markdown / remark-gfm / dompurify 미존재 확인.
- `npm install react-markdown remark-gfm dompurify` → 99개 패키지 추가
- `npm install -D @types/dompurify` → @types/dompurify 추가
- 최종 버전: react-markdown \^10.1.0, remark-gfm \^4.0.1, dompurify \^3.4.5, @types/dompurify \^3.0.5
### 구현 내용
1. 신규 컴포넌트 [src/components/MarkdownBriefRenderer.tsx](src/components/MarkdownBriefRenderer.tsx) 작성
	- react-markdown + remarkGfm 사용
	- DOMPurify로 입력 단계에서 HTML 태그 전부 제거 (ALLOWED_TAGS: \[\], ALLOWED_ATTR: \[\]) — defense-in-depth
	- h1은 차단 (h2 스타일로 다운그레이드), h2/h3/p/ul/ol/li/strong/em/code/pre/a/hr/blockquote 컴포넌트 정의
	- inline code와 fenced code block 모두 지원, 토큰 스타일은 v3fix5 톤(`var(--bg3)`, `var(--accent)`, monospace 폰트)에 맞춰 정리
	- 링크는 새 탭 + rel="noopener noreferrer"
	- dangerouslySetInnerHTML 미사용
2. [src/pages/CoachNotes.tsx](src/pages/CoachNotes.tsx) 수정
	- `MarkdownBriefRenderer` import 추가
	- 기존 line 936-938의 `<div style={{...whiteSpace: 'pre-wrap'}}>` raw 렌더링 부분을 brief 카테고리에만 `<MarkdownBriefRenderer content={renderNoteContent(n.content)} />` 적용
	- 그 외 카테고리(review/milestone)는 기존 pre-wrap 텍스트 렌더 유지
	- 접기/수정/삭제 등 기존 기능과 시각화 카드 렌더링은 일체 변경하지 않음
### 보안 처리
- react-markdown 기본 동작이 raw HTML을 무력화함 + DOMPurify의 ALLOWED_TAGS:\[\] 옵션으로 모든 HTML 태그를 입력 단계에서 strip
- 결과적으로 `<script>`, `<iframe>` 등이 응답에 섞여 들어와도 마크다운 텍스트로만 처리되며 절대 실행되지 않음
### 검증
- `npm run build` 성공: tsc 통과, vite production 빌드 통과 (1,013.36 kB / gzip 302.79 kB)
- 빌드 산출물 사이즈 경고는 기존 차트/Supabase 의존성 영향으로 E-6 추가분(99 packages)이 주 원인이 아님
---
## 수정 파일 목록 (git diff --name-only)
```javascript
package.json
package-lock.json
src/pages/CoachNotes.tsx
src/components/MarkdownBriefRenderer.tsx  (신규)
```
스테이징 방식: `git add package.json package-lock.json src/pages/CoachNotes.tsx src/components/MarkdownBriefRenderer.tsx` — 개별 지정, `git add .` / `-A` 미사용
---
## 완료 기준 체크리스트
- [x] npm run build 성공
- [x] /coach AI 브리프에서 `##` 헤더가 실제 제목으로 렌더링됨 (h2 컴포넌트로 매핑, var(--font-display) 14px + border-bottom)
- [x] json 코드블록이 raw 문자열이 아니라 코드블록 형태로 표시됨 (pre + code, var(--bg3) 배경 + monospace 폰트)
- [x] 목록/굵게/문단 줄바꿈 정상 표시
- [x] 기존 접기/수정/삭제 기능 유지 (CoachNotes.tsx의 비-렌더 부분 일체 변경 없음)
- [x] git diff --name-only 출력으로 수정 파일 목록 보고
---
## 주의 사항 준수 확인
- /coach AI 브리프 렌더링 문제만 수정. F-6d / C-6 / F-6e / C-7 관련 코드는 미변경.
- 기존 브리프 데이터 구조와 Supabase 저장 구조는 변경하지 않음 (renderNoteContent 함수 호출 결과를 그대로 마크다운 렌더러에 전달).
- AI 프롬프트 문구 자체는 변경하지 않음 (buildSystemPrompt / buildBriefContext 미수정).
- 데스크탑 우선 구조 유지. 모바일 퍼스트 설계 미적용.
- dangerouslySetInnerHTML 미사용.
- 데스크탑 3-column 레이아웃 / 사이드바 / 모바일 탭바 / 대시보드 / 설정 탭 일체 미수정.
---
## 핵심 코드 발췌
### MarkdownBriefRenderer.tsx (핵심부)
```typescript
const components: Components = {
  h1: ({ children }) => <div style={h2Style}>{children}</div>, // h1 차단
  h2: ({ children }) => <h2 style={h2Style}>{children}</h2>,
  h3: ({ children }) => <h3 style={h3Style}>{children}</h3>,
  p: ({ children }) => <p style={pStyle}>{children}</p>,
  ul: ({ children }) => <ul style={ulStyle}>{children}</ul>,
  ol: ({ children }) => <ol style={olStyle}>{children}</ol>,
  li: ({ children }) => <li style={liStyle}>{children}</li>,
  strong: ({ children }) => <strong style={strongStyle}>{children}</strong>,
  code: ({ className, children, ...rest }) => {
    const isBlock = !!className && /language-/.test(className)
    if (isBlock) return <code className={className} {...rest}>{children}</code>
    return <code style={inlineCodeStyle}>{children}</code>
  },
  pre: ({ children }) => <pre style={preStyle}>{children}</pre>,
}

export default function MarkdownBriefRenderer({ content }: Props) {
  const sanitized = DOMPurify.sanitize(content || '', { ALLOWED_TAGS: [], ALLOWED_ATTR: [] })
  return (
    <div style={{ fontSize: '13px', color: 'var(--text2)', lineHeight: 1.7 }}>
      <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
        {sanitized}
      </ReactMarkdown>
    </div>
  )
}
```
### CoachNotes.tsx 적용부 (diff)
before:
```typescript
) : (
  <div style={{ fontSize: '13px', color: 'var(--text2)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
    {n.category === 'brief' ? renderNoteContent(n.content) : n.content}
  </div>
)
```
after:
```typescript
) : (
  n.category === 'brief' ? (
    <MarkdownBriefRenderer content={renderNoteContent(n.content)} />
  ) : (
    <div style={{ fontSize: '13px', color: 'var(--text2)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
      {n.content}
    </div>
  )
)
```
---
## 후속 권장사항 (선택)
- 실제 /coach 페이지에서 시각적 확인 필요 (npm run dev → AI 브리프 새로 생성 후 ##/json 블록이 정상 렌더되는지 육안 검증)
- 빌드 산출물 1MB 초과 경고는 동적 import / manualChunks 로 차후 개선 가능 (E-6 범위 외)
- review/milestone 카테고리 노트도 마크다운 렌더링이 필요해지면 동일 컴포넌트 재사용 가능
