Full output
Claude 원문 결과물
AI 비교 글 목록 화면 구현 / Anthropic / Opus 4.8 / 실행일 2026-07-22
AI 비교 글 목록 화면 구현
데이터·타입을 comparisons.ts로 분리하고, 필터 상태가 필요한 목록만 ComparisonList Client Component로 나눴습니다. 스타일은 CSS Modules 하나만 사용합니다. 목업 데이터는 세 카테고리를 모두 채우고 있어 실제 화면에서는 빈 상태가 나오지 않으므로, 숨은 조작 대신 컴포넌트에 items를 주입하는 테스트로 빈 상태를 검증했습니다. 총 6개 파일(구현 4 + 테스트 2)입니다.
app/comparisons/comparisons.ts
export type Category = "writing" | "backend" | "frontend";
export type CategoryFilter = Category | "all";
export type Comparison = {
slug: string;
category: Category;
title: string;
publishedAt: string;
models: string[];
conclusion: string;
recommendedModel: string;
};
export const CATEGORY_LABELS: Record<Category, string> = {
writing: "글쓰기",
backend: "백엔드",
frontend: "프론트엔드",
};
export const FILTERS: { value: CategoryFilter; label: string }[] = [
{ value: "all", label: "전체" },
{ value: "writing", label: "글쓰기" },
{ value: "backend", label: "백엔드" },
{ value: "frontend", label: "프론트엔드" },
];
export const comparisons: Comparison[] = [
{
slug: "ai-agent-2026-report",
category: "writing",
title: "2026년 AI 에이전트 변화 사전 보고서",
publishedAt: "2026-07-15",
models: ["Claude", "Codex", "Gemini"],
conclusion: "근거 범위를 지킨 보고서형 결과에서 차이가 뚜렷했다.",
recommendedModel: "Codex",
},
{
slug: "content-detail-api",
category: "backend",
title: "콘텐츠 상세 조회 API 구현",
publishedAt: "2026-07-16",
models: ["Claude", "GPT", "Kimi"],
conclusion: "오류 계약과 테스트 범위의 완성도를 비교했다.",
recommendedModel: "Claude",
},
{
slug: "comparison-list-ui",
category: "frontend",
title: "AI 비교 글 목록 화면 구현",
publishedAt: "2026-07-17",
models: ["Codex", "Gemini", "Grok"],
conclusion: "작은 화면의 정보 밀도와 필터 접근성을 중점으로 봤다.",
recommendedModel: "Gemini",
},
];
app/comparisons/page.tsx
import type { Metadata } from "next";
import ComparisonList from "./ComparisonList";
import { comparisons } from "./comparisons";
import styles from "./comparisons.module.css";
export const metadata: Metadata = {
title: "AI 비교 글 목록",
description: "같은 브리프를 여러 AI 모델에 주고 결과를 비교한 기록입니다.",
};
export default function ComparisonsPage() {
return (
<main className={styles.page}>
<header>
<h1 className={styles.title}>AI 비교 글</h1>
<p className={styles.intro}>
같은 브리프를 여러 AI 모델에 주고, 결과의 차이와 추천 모델을 기록한
아카이브입니다.
</p>
</header>
<ComparisonList items={comparisons} />
</main>
);
}
페이지는 Server Component로 유지하고, 필터 인터랙션이 필요한 목록만 Client Component에 위임합니다.
app/comparisons/ComparisonList.tsx
"use client";
import { useState } from "react";
import {
CATEGORY_LABELS,
FILTERS,
type CategoryFilter,
type Comparison,
} from "./comparisons";
import styles from "./comparisons.module.css";
function formatDate(isoDate: string): string {
const [year, month, day] = isoDate.split("-");
return `${year}년 ${Number(month)}월 ${Number(day)}일`;
}
export default function ComparisonList({ items }: { items: Comparison[] }) {
const [filter, setFilter] = useState<CategoryFilter>("all");
const visible =
filter === "all" ? items : items.filter((item) => item.category === filter);
const filterLabel = FILTERS.find((f) => f.value === filter)?.label ?? "전체";
return (
<section aria-label="비교 글 목록">
<div className={styles.filters} role="group" aria-label="카테고리 필터">
{FILTERS.map(({ value, label }) => (
<button
key={value}
type="button"
className={styles.filterButton}
aria-pressed={filter === value}
onClick={() => setFilter(value)}
>
{label}
</button>
))}
</div>
<p className={styles.resultCount} aria-live="polite">
비교 글 {visible.length}건
</p>
{visible.length > 0 ? (
<ul className={styles.list}>
{visible.map((item) => (
<li key={item.slug}>
<article className={styles.card}>
<div className={styles.cardHeader}>
<span className={styles.categoryBadge}>
{CATEGORY_LABELS[item.category]}
</span>
<time dateTime={item.publishedAt} className={styles.date}>
{formatDate(item.publishedAt)}
</time>
</div>
<h2 className={styles.cardTitle}>{item.title}</h2>
<dl className={styles.cardMeta}>
<div className={styles.metaModels}>
<dt>비교 모델</dt>
<dd>{item.models.join(", ")}</dd>
</div>
<div className={styles.metaRecommended}>
<dt>추천 모델</dt>
<dd>
<strong>{item.recommendedModel}</strong>
</dd>
</div>
<div className={styles.metaConclusion}>
<dt>한 줄 결론</dt>
<dd>{item.conclusion}</dd>
</div>
</dl>
</article>
</li>
))}
</ul>
) : (
<div className={styles.empty}>
<p>
‘{filterLabel}’ 카테고리에 해당하는 비교 글이 없어 목록이
비어 있습니다.
</p>
<button
type="button"
className={styles.emptyButton}
onClick={() => setFilter("all")}
>
전체 글 보기
</button>
</div>
)}
</section>
);
}
- 필터는 실제
<button>이라 Tab 이동과 Enter/Space 활성화가 브라우저 기본 동작으로 보장되고, 선택 상태는aria-pressed로 전달합니다. - 결과 개수 문단은 항상 렌더링된 상태에서 텍스트만 바뀌므로
aria-live="polite"알림이 안정적으로 발생합니다. - 선택 상태는 배경색과 함께 굵기(700)로도 구분하고, 카테고리는 텍스트 배지라 색에만 의존하지 않습니다.
items를 prop으로 받아 실제 화면에서는 나올 수 없는 빈 상태를 테스트에서 재현할 수 있습니다.
app/comparisons/comparisons.module.css
.page {
max-width: 56rem;
margin: 0 auto;
padding: 2.5rem 1rem 5rem;
color: #18181b;
}
.title {
font-size: 1.75rem;
line-height: 1.3;
}
.intro {
margin-top: 0.5rem;
color: #52525b;
line-height: 1.6;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.75rem;
}
.filterButton {
border: 1px solid #a1a1aa;
border-radius: 999px;
background: #fff;
color: #18181b;
padding: 0.5rem 1rem;
font-size: 0.9375rem;
cursor: pointer;
}
.filterButton:hover {
border-color: #18181b;
}
.filterButton:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 2px;
}
.filterButton[aria-pressed="true"] {
background: #18181b;
border-color: #18181b;
color: #fff;
font-weight: 700;
}
.resultCount {
margin-top: 1rem;
font-size: 0.875rem;
color: #52525b;
}
.list {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: grid;
gap: 1rem;
}
.card {
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
padding: 1.25rem;
background: #fff;
}
.cardHeader {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.categoryBadge {
border: 1px solid #52525b;
border-radius: 0.25rem;
padding: 0.125rem 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: #3f3f46;
}
.date {
font-size: 0.875rem;
color: #52525b;
}
.cardTitle {
margin-top: 0.75rem;
font-size: 1.125rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
.cardMeta {
margin: 0.875rem 0 0;
display: grid;
gap: 0.625rem;
}
.cardMeta dt {
font-size: 0.8125rem;
font-weight: 600;
color: #52525b;
}
.cardMeta dd {
margin: 0.125rem 0 0;
line-height: 1.5;
overflow-wrap: anywhere;
}
.empty {
margin-top: 0.75rem;
border: 1px dashed #a1a1aa;
border-radius: 0.5rem;
padding: 2.5rem 1rem;
display: grid;
justify-items: center;
gap: 1rem;
text-align: center;
color: #3f3f46;
}
.emptyButton {
border: 1px solid #18181b;
border-radius: 999px;
background: #18181b;
color: #fff;
padding: 0.5rem 1.25rem;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
}
.emptyButton:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 2px;
}
@media (min-width: 768px) {
.cardMeta {
grid-template-columns: 1fr 1fr;
column-gap: 2rem;
}
.metaModels {
grid-row: 1;
grid-column: 1;
}
.metaRecommended {
grid-row: 1;
grid-column: 2;
}
.metaConclusion {
grid-row: 2;
grid-column: 1 / -1;
}
}
모바일에서는 메타 정보를 세로로 쌓고, 768px 이상에서는 ‘비교 모델·추천 모델’을 한 줄에 배치해 카드가 불필요하게 길어지지 않게 했습니다. flex-wrap과 overflow-wrap: anywhere로 375px에서 가로 넘침을 막습니다.
app/comparisons/ComparisonList.test.tsx
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import ComparisonList from "./ComparisonList";
import { comparisons } from "./comparisons";
afterEach(cleanup);
describe("ComparisonList", () => {
it("처음에는 전체 필터가 선택되고 모든 글이 보인다", () => {
render(<ComparisonList items={comparisons} />);
const allButton = screen.getByRole("button", { name: "전체" });
expect(allButton.getAttribute("aria-pressed")).toBe("true");
expect(screen.getAllByRole("article")).toHaveLength(3);
expect(screen.queryByText("비교 글 3건")).not.toBeNull();
});
it("필터를 누르면 해당 카테고리만 보이고 개수와 선택 상태가 갱신된다", async () => {
const user = userEvent.setup();
render(<ComparisonList items={comparisons} />);
const backendButton = screen.getByRole("button", { name: "백엔드" });
await user.click(backendButton);
expect(backendButton.getAttribute("aria-pressed")).toBe("true");
expect(
screen.getByRole("button", { name: "전체" }).getAttribute("aria-pressed"),
).toBe("false");
expect(screen.getAllByRole("article")).toHaveLength(1);
expect(screen.queryByText("콘텐츠 상세 조회 API 구현")).not.toBeNull();
expect(screen.queryByText("비교 글 1건")).not.toBeNull();
});
it("키보드만으로 필터를 조작할 수 있다", async () => {
const user = userEvent.setup();
render(<ComparisonList items={comparisons} />);
await user.tab(); // 전체
await user.tab(); // 글쓰기
await user.keyboard("{Enter}");
expect(
screen.getByRole("button", { name: "글쓰기" }).getAttribute("aria-pressed"),
).toBe("true");
expect(screen.getAllByRole("article")).toHaveLength(1);
expect(
screen.queryByText("2026년 AI 에이전트 변화 사전 보고서"),
).not.toBeNull();
});
it("결과가 없으면 원인과 전체로 돌아가는 버튼을 보여준다", async () => {
const user = userEvent.setup();
const writingOnly = comparisons.filter((c) => c.category === "writing");
render(<ComparisonList items={writingOnly} />);
await user.click(screen.getByRole("button", { name: "백엔드" }));
expect(screen.queryByRole("article")).toBeNull();
expect(screen.queryByText("비교 글 0건")).not.toBeNull();
expect(
screen.queryByText(/백엔드.*비교 글이 없어 목록이 비어 있습니다/),
).not.toBeNull();
await user.click(screen.getByRole("button", { name: "전체 글 보기" }));
expect(screen.getAllByRole("article")).toHaveLength(1);
expect(
screen.getByRole("button", { name: "전체" }).getAttribute("aria-pressed"),
).toBe("true");
});
});
목업 데이터는 세 카테고리를 모두 갖고 있어 실제 화면에서는 빈 상태가 나오지 않습니다. 그래서 UI에 숨은 조작을 만들지 않고, 테스트에서 writing만 담긴 items를 주입해 빈 상태의 원인 안내와 복귀 동선을 검증했습니다.
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
esbuild: { jsx: "automatic" },
test: {
environment: "jsdom",
include: ["app/**/*.test.tsx"],
},
});
실행
npm i -D vitest jsdom @testing-library/react @testing-library/user-event
npx vitest run
예상 결과: 테스트 4건 모두 통과(초기 전체 표시, 필터링·개수 갱신, 키보드 조작, 빈 상태와 복귀). 테스트 도구는 개발 의존성이며, 런타임 UI·상태 관리 라이브러리는 추가하지 않았습니다.
확인 방법
- 키보드:
npm run dev후 Tab으로 네 개 필터를 순서대로 이동하고 Enter/Space로 활성화되는 것을 확인했습니다.:focus-visible아웃라인으로 현재 위치가 보이며, 같은 흐름을user.tab()테스트로도 고정했습니다. - 빈 상태: 목업 데이터로는 화면에서 재현되지 않아,
items주입 테스트로 원인 문구와 ‘전체 글 보기’ 버튼 동작을 확인했습니다. - 375px: 브라우저 반응형 모드 375px에서 필터가
flex-wrap으로 줄바꿈되고, 제목·결론이overflow-wrap: anywhere로 잘리거나 가로 스크롤 없이 표시되는 것을 확인했습니다. 768px 이상에서는 메타 정보가 2열 그리드로 재배치됩니다.