프론트엔드
AI 비교 글 목록 화면 구현
Next.js App Router와 TypeScript로 고정 목업 데이터 3건을 보여 주는 AI 비교 글 목록 화면을 구현한다. 카테고리 필터, 결과 수 알림, 빈 상태, 키보드 접근성과 모바일 반응형을 모두 갖춰야 한다.
Task
성공 조건
- 전체·글쓰기·백엔드·프론트엔드 실제 button 필터가 선택 항목만 표시하고 aria-pressed로 상태를 전달한다.
- 각 항목에 카테고리, 제목, 발행일, 비교 모델, 한 줄 결론, 추천 모델을 모두 표시한다.
- 필터 변경 후 결과 수를 aria-live=polite 영역에서 알리고, 빈 상태에는 원인과 전체로 돌아가는 방법을 제시한다.
- 375px에서 가로 넘침 없이 읽히고, 768px 이상에서는 정보가 과도하게 세로로 쌓이지 않는다.
Common conditions
핵심 조건
- Next.js App Router 목록 화면을 고정 데이터 3건으로 구현한다.
- 실제 버튼 필터와 live region, 빈 상태를 포함한다.
- 키보드 접근성과 375px 모바일 레이아웃을 우선한다.
Model results
모델별 결과
서버 페이지와 클라이언트 목록을 분리하고, 상호작용·키보드·빈 상태까지 UI 테스트로 검증한 접근성 중심 구현.
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열 그리드로 재배치됩니다.
Recommendation
이 과업의 추천 모델: Claude
명세의 접근성·빈 상태·반응형 조건을 구현에만 그치지 않고 실제 UI 테스트로 고정했으며, 서버/클라이언트 경계도 불필요하게 넓히지 않았다.
잘 맞는 경우
- 접근성과 상태별 테스트가 함께 필요한 Next.js 목록 화면
- 고정 데이터 화면에서 빈 상태를 테스트 가능한 구조로 설계할 때
주의할 점
- 프로젝트의 기존 테스트 도구와 설정을 먼저 확인해 제시한 Vitest 의존성·설정을 중복 추가하지 않아야 한다.
Prompt and environment
공통 프롬프트와 실행 환경
프롬프트 전문 보기
Next.js App Router와 TypeScript로 /comparisons AI 비교 글 목록 화면을 구현해 주세요. writing, backend, frontend 카테고리를 가진 고정 목업 3건만 사용하고, 전체·글쓰기·백엔드·프론트엔드 필터를 제공합니다.
필터는 실제 button과 aria-pressed를 사용하고, 선택 뒤 결과 개수를 aria-live="polite"로 알리세요. 각 항목에는 카테고리, 제목, 발행일, 비교 모델, 한 줄 결론, 추천 모델을 표시합니다. 결과가 없으면 원인과 전체로 돌아가는 방법을 보여 주세요.
CSS Modules 또는 기존 전역 CSS 하나만 사용하고 외부 UI·상태 관리·서버 데이터 패칭은 사용하지 마세요. 375px과 768px 이상 레이아웃, 키보드 조작, 빈 상태를 검증하며 구현과 테스트는 4-8개 파일로 제한합니다.
- Next.js App Router
- React 19
- TypeScript
- CSS Modules
- 고정 목업 데이터 3건