Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
bf591cc
feat: posts 페이지 성능 및 SEO 최적화
2YH02 Feb 16, 2026
ff7b6ba
docs: AGENTS 작업 가이드 추가
2YH02 Feb 16, 2026
9c8221c
refactor: 폰트 변수 연결 및 로딩 전략 최적화
2YH02 Feb 16, 2026
36a8d19
fix: 블로그 본문 렌더링 XSS 방어 로직 추가
2YH02 Feb 17, 2026
338bcd4
feat: 게시글 상세 삭제 플로우 및 액션 분기 개선
2YH02 Feb 17, 2026
ee2d4b7
refactor: 햄버거 메뉴 애니메이션 경량화
2YH02 Feb 17, 2026
e28fb13
feat: 본문 이미지 업로드 정책 및 최적화 적용
2YH02 Feb 17, 2026
639a265
refactor: posts 목록 데이터 페이로드 경량화
2YH02 Feb 17, 2026
72e5f67
feat: 인증 및 업로드 실패 UX/재시도 처리 강화
2YH02 Feb 17, 2026
c4a359d
refactor: posts 페이지 접근성 속성 보강
2YH02 Feb 17, 2026
36a8ad6
ci: posts Lighthouse 자동 측정 워크플로우 추가
2YH02 Feb 17, 2026
2d17bd1
chore: Lighthouse SEO 최소 점수 기준 상향
2YH02 Feb 17, 2026
3d65b03
docs: AGENTS 경로 안내를 상대 경로로 정리
2YH02 Feb 17, 2026
db6eee3
fix: next 보안 패치 버전 업데이트
2YH02 Feb 17, 2026
7c64014
refactor: lint 경고 정리 및 이미지 컴포넌트 적용
2YH02 Feb 17, 2026
5db3fa8
fix: metadataBase 및 posts 캐시 경고 해결
2YH02 Feb 17, 2026
14d4574
fix: posts LCP 로딩 우선순위 및 preconnect 정리
2YH02 Feb 17, 2026
bdd1807
fix: LCP 이미지 우선 로딩 및 리플로우 비용 완화
2YH02 Feb 17, 2026
4b14f4e
chore: Next 이미지 설정을 remotePatterns로 전환
2YH02 Feb 17, 2026
fdc054b
fix: legacy 애니메이션 이미지 최적화 경고 대응
2YH02 Feb 17, 2026
5c463a9
chore: Next 이미지 quality 설정 추가
2YH02 Feb 17, 2026
54649c6
ci: Lighthouse 워크플로우에 Supabase env 연결
2YH02 Feb 17, 2026
fd6efb5
ci: Lighthouse 결과 PR 코멘트 자동화
2YH02 Feb 17, 2026
2ff1b11
ci: Lighthouse 리포트 파싱 패턴 보강
2YH02 Feb 17, 2026
c9ee1c9
ci: Lighthouse 측정 경로 확장 및 PR 코멘트 개선
2YH02 Feb 17, 2026
d528ac9
ci: Lighthouse 실행 환경에 API base URL 주입
2YH02 Feb 17, 2026
ba63a5e
ci: Lighthouse URL별 분리 측정 및 코멘트 분리
2YH02 Feb 17, 2026
d362f36
ci: Lighthouse posts 상세 측정 URL 갱신
2YH02 Feb 17, 2026
42831e0
refactor: Lighthouse 기준 통일 및 코드블록 렌더링 경량화
2YH02 Feb 17, 2026
66f3f4a
ci: Lighthouse URL별 성능 기준 및 증감 코멘트 적용
2YH02 Feb 17, 2026
135e0ce
fix: posts 상세 페이지 접근성 개선
2YH02 Feb 17, 2026
130a5d5
fix: posts 상세 페이지 메타 설명 보강
2YH02 Feb 17, 2026
c8644af
fix: 메타데이터 스트리밍 비활성화 설정 추가
2YH02 Feb 17, 2026
8980d85
fix: posts 목록 페이지 seo 개선
2YH02 Feb 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
name: Lighthouse CI

on:
pull_request:
branches:
- main
workflow_dispatch:

permissions:
contents: read
pull-requests: write

jobs:
lighthouse:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- path: /posts
path_key: posts
perf_level: error
perf_min: "0.7"
- path: /posts/100
path_key: posts-100
perf_level: warn
perf_min: "0.5"
- path: /projects/1
path_key: projects-1
perf_level: error
perf_min: "0.8"
env:
NEXT_PUBLIC_API_BASE_URL: https://api.yonghun.me
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Build app
run: npm run build

- name: Prepare LHCI runtime config
run: |
node <<'EOF'
const fs = require("fs");
const config = JSON.parse(fs.readFileSync(".lighthouserc.json", "utf8"));
config.ci.collect.url = [`http://localhost:3000${process.env.LH_PATH}`];
config.ci.assert.assertions["categories:performance"] = [
process.env.LH_PERF_LEVEL,
{ minScore: Number(process.env.LH_PERF_MIN) },
];
fs.writeFileSync(".lighthouserc.runtime.json", JSON.stringify(config, null, 2));
EOF
env:
LH_PATH: ${{ matrix.path }}
LH_PERF_LEVEL: ${{ matrix.perf_level }}
LH_PERF_MIN: ${{ matrix.perf_min }}

- name: Run Lighthouse CI
id: lhci
continue-on-error: true
run: npx @lhci/cli@0.13.x autorun --config=.lighthouserc.runtime.json

- name: Prepare Lighthouse summary
if: github.event_name == 'pull_request'
run: |
node <<'EOF'
const fs = require("fs");
const path = require("path");

const reportsDir = path.join(process.cwd(), ".lighthouseci");
const outPath = path.join(process.cwd(), ".lighthouse-summary.md");

let lines = [];
lines.push(`<!-- lighthouse-ci-report:${process.env.LH_PATH_KEY} -->`);
lines.push("## Lighthouse CI 결과");
lines.push("");
lines.push(`- 실행 상태: **${process.env.LHCI_OUTCOME || "unknown"}**`);
lines.push(`- 대상: \`${process.env.LH_PATH}\` (3회 측정)`);
lines.push(`- 성능 기준: **${process.env.LH_PERF_LEVEL} >= ${Math.round(Number(process.env.LH_PERF_MIN) * 100)}**`);
lines.push("");

if (!fs.existsSync(reportsDir)) {
lines.push("리포트 디렉터리를 찾을 수 없습니다.");
fs.writeFileSync(outPath, lines.join("\n"));
process.exit(0);
}

const jsonFiles = fs
.readdirSync(reportsDir)
.filter((name) => name.endsWith(".json"))
.map((name) => path.join(reportsDir, name));

const rows = [];
for (const jsonFile of jsonFiles) {
try {
const report = JSON.parse(fs.readFileSync(jsonFile, "utf8"));
if (!report || typeof report !== "object" || !report.categories) {
continue;
}

const categories = report.categories || {};
const formatScore = (value) =>
typeof value === "number" ? Math.round(value * 100) : "-";

rows.push({
url: report.finalDisplayedUrl || report.finalUrl || report.requestedUrl || "-",
performance: formatScore(categories.performance?.score),
accessibility: formatScore(categories.accessibility?.score),
bestPractices: formatScore(categories["best-practices"]?.score),
seo: formatScore(categories.seo?.score),
});
} catch {
// ignore non-report JSON files
}
}

if (rows.length === 0) {
lines.push("파싱 가능한 Lighthouse report JSON이 없습니다.");
lines.push("");
lines.push(`- 감지된 JSON 파일 수: ${jsonFiles.length}`);
fs.writeFileSync(outPath, lines.join("\n"));
process.exit(0);
}

const toNumber = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
const median = (arr) => {
const clean = arr.filter((v) => v !== null).sort((a, b) => a - b);
if (clean.length === 0) return "-";
return clean[Math.floor(clean.length / 2)];
};

lines.push(`- 수집된 리포트 수: ${rows.length}`);
lines.push("");
lines.push("| URL | Perf(median) | A11y(median) | BP(median) | SEO(median) |");
lines.push("|---|---:|---:|---:|---:|");

const url = rows[0]?.url || `http://localhost:3000${process.env.LH_PATH}`;
const perf = median(rows.map((r) => toNumber(r.performance)));
const a11y = median(rows.map((r) => toNumber(r.accessibility)));
const bp = median(rows.map((r) => toNumber(r.bestPractices)));
const seo = median(rows.map((r) => toNumber(r.seo)));
lines.push(`| ${url} | ${perf} | ${a11y} | ${bp} | ${seo} |`);

fs.writeFileSync(outPath, lines.join("\n"));
EOF
env:
LHCI_OUTCOME: ${{ steps.lhci.outcome }}
LH_PATH: ${{ matrix.path }}
LH_PATH_KEY: ${{ matrix.path_key }}
LH_PERF_LEVEL: ${{ matrix.perf_level }}
LH_PERF_MIN: ${{ matrix.perf_min }}

- name: Comment PR with Lighthouse summary
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require("fs");
const marker = `<!-- lighthouse-ci-report:${process.env.LH_PATH_KEY} -->`;
const baseBody = fs.readFileSync(".lighthouse-summary.md", "utf8");
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const perfPattern = /\|\s*[^|]+\s*\|\s*(\d+)\s*\|/;

const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});

const existing = comments.find((c) => c.body && c.body.includes(marker));
const currentPerfMatch = baseBody.match(perfPattern);
const currentPerf = currentPerfMatch ? Number(currentPerfMatch[1]) : null;

let deltaLine = "- 이전 대비 Perf(median): 첫 측정";
if (existing) {
const prevPerfMatch = existing.body.match(perfPattern);
const prevPerf = prevPerfMatch ? Number(prevPerfMatch[1]) : null;

if (currentPerf !== null && prevPerf !== null) {
const delta = currentPerf - prevPerf;
const sign = delta > 0 ? "+" : "";
deltaLine = `- 이전 대비 Perf(median): ${sign}${delta}p (이전 ${prevPerf} -> 현재 ${currentPerf})`;
} else {
deltaLine = "- 이전 대비 Perf(median): 비교 불가";
}
}

const body = `${baseBody}\n${deltaLine}`;

if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
env:
LH_PATH_KEY: ${{ matrix.path_key }}

- name: Fail when Lighthouse assertions fail
if: steps.lhci.outcome == 'failure'
run: |
echo "Lighthouse CI assertions failed."
exit 1
37 changes: 37 additions & 0 deletions .lighthouserc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"ci": {
"collect": {
"startServerCommand": "npm run start -- --port=3000",
"startServerReadyPattern": "Ready",
"settings": {
"preset": "desktop",
"formFactor": "desktop",
"screenEmulation": {
"mobile": false,
"width": 1350,
"height": 940,
"deviceScaleFactor": 1,
"disabled": false
},
"throttlingMethod": "devtools"
},
"url": [
"http://localhost:3000/posts",
"http://localhost:3000/posts/100",
"http://localhost:3000/projects/1"
],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["error", {"minScore": 0.8}],
"categories:accessibility": ["warn", {"minScore": 0.9}],
"categories:best-practices": ["warn", {"minScore": 0.9}],
"categories:seo": ["warn", {"minScore": 1}]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
113 changes: 113 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# AGENTS.md

이 문서는 이 저장소 루트(`./`)에서 작업하는 사람/에이전트를 위한 실행 가이드입니다.

## 1) 프로젝트 요약

- 스택: Next.js App Router + TypeScript + Tailwind CSS + Zustand
- 런타임: React 19, Next 15
- 데이터: 외부 백엔드 API(`https://api.yonghun.me`) + Supabase Storage
- 주요 도메인:
- 포트폴리오/소개 페이지
- 블로그 목록/상세/작성 페이지
- SEO(메타데이터, sitemap, robots)

## 2) 핵심 디렉터리

- `src/app`: 라우트 및 페이지(App Router)
- `src/app/posts`: 블로그 목록/상세/작성 UI
- `src/components`: 공통 UI, provider, 레이아웃 컴포넌트
- `src/lib/api`: API 호출 유틸 및 도메인 API 함수
- `src/lib/supabase`: Supabase 클라이언트
- `src/data`: 정적 데이터
- `src/store`: Zustand 스토어

## 3) 환경변수

현재 코드 기준으로 아래 키를 사용합니다.

- `NEXT_PUBLIC_API_BASE_URL`
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `NEXT_PUBLIC_GOOGLE_ANALYTICS`

주의:
- `NEXT_PUBLIC_*` 값은 클라이언트에 노출됩니다. 비밀값(secret)은 절대 넣지 않습니다.
- API 기본값 fallback은 `http://localhost:8080` 입니다(`src/lib/api/apiClient.tsx`).

## 4) 실행/검증 명령어

- 개발 서버: `npm run dev`
- 빌드: `npm run build`
- 프로덕션 실행: `npm run start`
- 린트: `npm run lint`

작업 완료 전 최소 검증:
1. `npm run lint`
2. 변경한 라우트 수동 확인(특히 `posts`, `posts/[id]`, `posts/add`)
3. SEO 변경 시 메타데이터/OG/canonical 값 확인

## 5) 커밋 규칙 (이 저장소 표준)

최근 커밋 로그를 보면 `feat/fix/docs/refactor/style` 타입은 잘 사용되지만, 대소문자(`Feat`, `Fix`, `Docs`)가 섞여 있습니다.
앞으로는 아래 규칙으로 통일합니다.

형식:
- `<type>: <subject>`

규칙:
- `type`은 반드시 **소문자**
- `subject`는 한국어/영어 모두 가능, 짧고 구체적으로 작성
- 마침표(`.`) 생략
- 한 커밋에는 하나의 목적만 담기

허용 타입:
- `feat`: 기능 추가/확장
- `fix`: 버그 수정
- `refactor`: 동작 변화 없는 구조 개선
- `style`: UI 스타일 변경(로직 영향 없음)
- `docs`: 문서 수정
- `chore`: 빌드/설정/의존성/기타 유지보수

좋은 예시:
- `feat: posts 페이지 성능 및 SEO 최적화`
- `fix: posts 목록 빈 상태 처리 오류 수정`
- `refactor: blog API 응답 타입 분리`
- `docs: README 배포 섹션 업데이트`

피해야 할 예시:
- `Feat: 수정` (타입 대문자 + 의미 불명확)
- `fix: 이것저것 수정` (범위 과다)

## 6) 브랜치/PR 권장 규칙

- 브랜치명: `feature/*`, `fix/*`, `refactor/*`, `docs/*`
- PR 제목도 커밋 규칙과 동일한 톤 권장
- PR 본문에 반드시 포함:
- 변경 목적
- 주요 변경 파일
- 사용자 영향(화면/API/SEO)
- 테스트/검증 결과

## 7) 코드 작업 원칙

- TypeScript `strict`를 깨는 `any` 남발 금지
- 기존 import alias(`@/*`) 우선 사용
- API 레이어(`src/lib/api/*`)에서 타입 우선 정의 후 페이지에 전달
- 네트워크 요청 실패 경로를 고려하고, 사용자 노출 메시지/상태를 처리
- 불필요한 클라이언트 컴포넌트(`"use client"`) 증가 지양
- 이미지/콘텐츠 변경 시 성능(LCP), 접근성(alt), SEO 메타데이터를 함께 확인

## 8) 변경 시 주의 포인트 (이 프로젝트 특화)

- `next.config.ts`의 `/api/:path*` rewrites는 백엔드 연동 핵심 경로이므로 함부로 변경하지 않습니다.
- `src/lib/api/blog.tsx`의 `revalidate`(ISR) 정책 변경 시 캐시 전략을 PR에 명시합니다.
- `posts/add` 인증 흐름(`AuthForm`, `Auth`) 변경 시 Guest/Admin 분기 동작을 반드시 수동 검증합니다.
- Supabase 업로드 로직 변경 시 public URL, placeholder, blur 데이터 동작까지 함께 확인합니다.

## 9) 작업 완료 체크리스트

1. 코드가 린트 오류 없이 통과한다.
2. 변경 범위의 핵심 페이지가 의도대로 동작한다.
3. 커밋 메시지가 소문자 타입 규칙을 지킨다.
4. 문서/타입/코드가 서로 모순되지 않는다.
Loading