Full output
Antigravity 원문 결과물
AI 비교 글 목록 화면 구현 / Google / Gemini 3.6 Flash / 실행일 2026-07-22
03. AI 비교 글 목록 화면 구현 (Antigravity)
Next.js App Router와 TypeScript 기반으로 AI 비교 글 목록 화면(/comparisons)을 구현했습니다. 외부 UI 라이브러리나 상태 관리 라이브러리 없이 React standard hook(useState), 접근성을 보장하는 native HTML 요소, CSS Modules만을 사용했습니다.
구현 및 테스트 코드는 요청 조건(4~8개 파일)을 만족하는 총 6개 파일로 작성되었습니다.
변경·추가된 파일 목록 (총 6개)
app/comparisons/types.ts- 타입 정의 및 고정 목업 데이터app/comparisons/comparisons.module.css- CSS Modules 스타일시트app/comparisons/CategoryFilter.tsx- 카테고리 필터 및 live region 컴포넌트app/comparisons/ComparisonCard.tsx- 비교 글 단일 항목 카드 컴포넌트app/comparisons/page.tsx- 목록 화면 메인 진입점 (Client Component)tests/comparisons-list.test.ts- 필터링 및 데이터 렌더링 검증 단위 테스트
app/comparisons/types.ts
export type Category = "writing" | "backend" | "frontend";
export type FilterCategory = "all" | Category;
export type Comparison = {
slug: string;
category: Category;
title: string;
publishedAt: string;
models: string[];
conclusion: string;
recommendedModel: string;
};
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"
}
];
export const CATEGORY_LABELS: Record<Category, string> = {
writing: "글쓰기",
backend: "백엔드",
frontend: "프론트엔드"
};
app/comparisons/comparisons.module.css
.page {
max-width: 960px;
margin: 0 auto;
padding: 40px 20px;
}
.pageHeader {
margin-bottom: 32px;
border-bottom: 1px solid var(--line, #d7ddda);
padding-bottom: 24px;
}
.title {
font-size: 2rem;
font-weight: 700;
color: var(--ink, #18201e);
margin-bottom: 12px;
line-height: 1.25;
}
.description {
font-size: 1rem;
color: var(--muted, #5d6663);
line-height: 1.6;
margin: 0;
max-width: 65ch;
overflow-wrap: break-word;
}
.container {
display: flex;
flex-direction: column;
gap: 24px;
}
.filterNav {
display: flex;
flex-direction: column;
gap: 12px;
}
.filterBar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filterButton {
min-height: 38px;
padding: 8px 16px;
border-radius: var(--radius, 6px);
border: 1px solid var(--line, #d7ddda);
background-color: var(--surface, #ffffff);
color: var(--muted, #5d6663);
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.filterButton:hover {
background-color: var(--surface-muted, #eef1ee);
color: var(--ink, #18201e);
border-color: var(--accent, #087b70);
}
.filterButton.active {
background-color: var(--accent, #087b70);
color: #ffffff;
border-color: var(--accent-strong, #05655c);
font-weight: 600;
}
.filterButton:focus-visible {
outline: 3px solid var(--accent, #087b70);
outline-offset: 2px;
}
.liveRegion {
font-size: 0.875rem;
color: var(--faint, #7c8581);
min-height: 1.25rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
.listItem {
display: flex;
}
.card {
width: 100%;
border: 1px solid var(--line, #d7ddda);
border-radius: var(--radius, 6px);
background-color: var(--surface, #ffffff);
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
border-color: var(--accent, #087b70);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.categoryBadge {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
background-color: var(--accent-soft, #d7ebe6);
color: var(--accent-strong, #05655c);
font-size: 0.8125rem;
font-weight: 600;
}
.publishedAt {
font-size: 0.875rem;
color: var(--faint, #7c8581);
font-family: var(--font-mono, monospace);
}
.cardTitle {
font-size: 1.25rem;
font-weight: 650;
color: var(--ink, #18201e);
margin: 0;
line-height: 1.35;
overflow-wrap: break-word;
}
.conclusion {
font-size: 0.9375rem;
color: var(--muted, #5d6663);
margin: 0;
line-height: 1.55;
overflow-wrap: break-word;
}
.cardFooter {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding-top: 16px;
border-top: 1px solid var(--line, #d7ddda);
flex-wrap: wrap;
}
.metaGroup {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
}
.metaLabel {
color: var(--faint, #7c8581);
}
.metaValue {
color: var(--ink, #18201e);
font-weight: 500;
}
.recommendedModel {
color: var(--accent-strong, #05655c);
font-weight: 700;
}
.emptyState {
padding: 48px 24px;
border: 1px dashed var(--line, #d7ddda);
border-radius: var(--radius, 6px);
text-align: center;
background-color: var(--surface-muted, #eef1ee);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.emptyText {
margin: 0;
font-size: 1rem;
color: var(--muted, #5d6663);
overflow-wrap: break-word;
}
.resetButton {
padding: 8px 20px;
border-radius: var(--radius, 6px);
border: 1px solid var(--accent, #087b70);
background-color: var(--accent, #087b70);
color: #ffffff;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease;
}
.resetButton:hover {
background-color: var(--accent-strong, #05655c);
}
.resetButton:focus-visible {
outline: 3px solid var(--accent, #087b70);
outline-offset: 2px;
}
@media (min-width: 768px) {
.list {
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
}
}
app/comparisons/CategoryFilter.tsx
"use client";
import { FilterCategory } from "./types";
import styles from "./comparisons.module.css";
interface CategoryFilterProps {
selectedCategory: FilterCategory;
onSelectCategory: (category: FilterCategory) => void;
resultCount: number;
}
const FILTER_OPTIONS: { id: FilterCategory; label: string }[] = [
{ id: "all", label: "전체" },
{ id: "writing", label: "글쓰기" },
{ id: "backend", label: "백엔드" },
{ id: "frontend", label: "프론트엔드" }
];
export function CategoryFilter({
selectedCategory,
onSelectCategory,
resultCount
}: CategoryFilterProps) {
return (
<nav className={styles.filterNav} aria-label="카테고리 필터">
<div className={styles.filterBar}>
{FILTER_OPTIONS.map((option) => {
const isSelected = selectedCategory === option.id;
return (
<button
key={option.id}
type="button"
className={`${styles.filterButton} ${isSelected ? styles.active : ""}`}
aria-pressed={isSelected}
onClick={() => onSelectCategory(option.id)}
>
{option.label}
</button>
);
})}
</div>
<div className={styles.liveRegion} aria-live="polite" role="status">
{selectedCategory === "all"
? `전체 ${resultCount}개의 비교 글을 표시하고 있습니다.`
: `'${FILTER_OPTIONS.find((opt) => opt.id === selectedCategory)?.label}' 카테고리 ${resultCount}개의 비교 글을 표시하고 있습니다.`}
</div>
</nav>
);
}
app/comparisons/ComparisonCard.tsx
import { Comparison } from "./types";
import { CATEGORY_LABELS } from "./types";
import styles from "./comparisons.module.css";
interface ComparisonCardProps {
comparison: Comparison;
}
export function ComparisonCard({ comparison }: ComparisonCardProps) {
const categoryLabel = CATEGORY_LABELS[comparison.category];
return (
<article className={styles.card}>
<header className={styles.cardHeader}>
<span className={styles.categoryBadge}>{categoryLabel}</span>
<time className={styles.publishedAt} dateTime={comparison.publishedAt}>
{comparison.publishedAt}
</time>
</header>
<h2 className={styles.cardTitle}>{comparison.title}</h2>
<p className={styles.conclusion}>{comparison.conclusion}</p>
<footer className={styles.cardFooter}>
<div className={styles.metaGroup}>
<span className={styles.metaLabel}>비교 모델</span>
<span className={styles.metaValue}>{comparison.models.join(", ")}</span>
</div>
<div className={styles.metaGroup}>
<span className={styles.metaLabel}>추천 모델</span>
<strong className={styles.recommendedModel}>
{comparison.recommendedModel}
</strong>
</div>
</footer>
</article>
);
}
app/comparisons/page.tsx
"use client";
import { useState } from "react";
import { comparisons, FilterCategory } from "./types";
import { CategoryFilter } from "./CategoryFilter";
import { ComparisonCard } from "./ComparisonCard";
import styles from "./comparisons.module.css";
export default function ComparisonsPage() {
const [selectedCategory, setSelectedCategory] = useState<FilterCategory>("all");
const filteredComparisons = comparisons.filter((item) => {
if (selectedCategory === "all") return true;
return item.category === selectedCategory;
});
return (
<main className={styles.page}>
<header className={styles.pageHeader}>
<h1 className={styles.title}>AI 모델 비교 아카이브</h1>
<p className={styles.description}>
다양한 실무 과업에서 주요 AI 모델(Claude, Codex, Gemini, Grok 등)의 정확성, 완성도, 실무성 평가 결과를 비교합니다.
</p>
</header>
<div className={styles.container}>
<CategoryFilter
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
resultCount={filteredComparisons.length}
/>
{filteredComparisons.length > 0 ? (
<ul className={styles.list} aria-label="비교 글 목록">
{filteredComparisons.map((comparison) => (
<li key={comparison.slug} className={styles.listItem}>
<ComparisonCard comparison={comparison} />
</li>
))}
</ul>
) : (
<section className={styles.emptyState} aria-label="결과 없음">
<p className={styles.emptyText}>
선택한 카테고리에 해당하는 비교 글이 존재하지 않습니다.
</p>
<button
type="button"
className={styles.resetButton}
onClick={() => setSelectedCategory("all")}
>
전체 목록 보기
</button>
</section>
)}
</div>
</main>
);
}
tests/comparisons-list.test.ts
import { describe, expect, it } from "vitest";
import { comparisons, CATEGORY_LABELS } from "../app/comparisons/types";
describe("AI Comparisons List logic & data", () => {
it("contains exactly 3 mock comparison items", () => {
expect(comparisons).toHaveLength(3);
});
it("has valid category labels mapping", () => {
expect(CATEGORY_LABELS.writing).toBe("글쓰기");
expect(CATEGORY_LABELS.backend).toBe("백엔드");
expect(CATEGORY_LABELS.frontend).toBe("프론트엔드");
});
it("correctly filters items by category", () => {
const writingItems = comparisons.filter((item) => item.category === "writing");
const backendItems = comparisons.filter((item) => item.category === "backend");
const frontendItems = comparisons.filter((item) => item.category === "frontend");
expect(writingItems).toHaveLength(1);
expect(writingItems[0].slug).toBe("ai-agent-2026-report");
expect(writingItems[0].recommendedModel).toBe("Codex");
expect(backendItems).toHaveLength(1);
expect(backendItems[0].slug).toBe("content-detail-api");
expect(backendItems[0].recommendedModel).toBe("Claude");
expect(frontendItems).toHaveLength(1);
expect(frontendItems[0].slug).toBe("comparison-list-ui");
expect(frontendItems[0].recommendedModel).toBe("Gemini");
});
it("verifies all required fields exist for each comparison item", () => {
comparisons.forEach((item) => {
expect(item.slug).toBeTruthy();
expect(item.category).toBeTruthy();
expect(item.title).toBeTruthy();
expect(item.publishedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(item.models.length).toBeGreaterThan(0);
expect(item.conclusion).toBeTruthy();
expect(item.recommendedModel).toBeTruthy();
});
});
});
검증 방법
1. 키보드 조작 및 접근성 검증
- 버튼 요소 사용 및 포커스 관리: 모든 필터 항목은 표준
<button>요소로 구현되어Tab키로 순차 탐색이 가능하며, Focus가 닿을 때:focus-visible스타일(3px 두께의 accent outline)이 명확히 노출됩니다. - 상태 전달 (
aria-pressed): 선택된 필터 버튼은aria-pressed="true", 비선택 버튼은aria-pressed="false"로 설정되어 시각적 색상 변화(배경 및 테두리)와 함께 스크린 리더에도 선택 상태가 정확히 전달됩니다. - 동적 알림 (
aria-live="polite"): 필터를 변경할 때마다 라이브 영역(role="status",aria-live="polite")에서 변경된 필터명과 결과 개수 메시지를 업데이트하여 스크린 리더 사용자에게 갱신 결과를 즉시 안내합니다.
2. 빈 상태(Empty State) 검증
- 결과 없음 처리: 필터링된 결과 배열 길이(
filteredComparisons.length)가 0일 경우, 목록 대신emptyState섹션이 노출됩니다. - 원인 안내 및 복구 액션: 원인 메시지("선택한 카테고리에 해당하는 비교 글이 존재하지 않습니다.")와 함께
전체 목록 보기버튼을 제공하여 클릭 시selectedCategory상태를"all"로 리셋시킵니다.
3. 375px 모바일 레이아웃 검증
- 유연한 래핑 (
flex-wrap: wrap): 필터 바, 카드 헤더, 카드 푸터 등 주요 요소에flex-wrap: wrap을 적용하여 375px의 좁은 화면에서도 텍스트나 버튼이 가로 영역을 벗어나 잘리지 않습니다. - 줄바꿈 처리 (
overflow-wrap: break-word): 카드 제목 및 긴 텍스트 영역에overflow-wrap: break-word를 부여하여 단어가 임의로 잘리거나 가로 스크롤을 발생시키지 않습니다. - 반응형 정보 밀도: 768px 미만에서는 single-column 카드 뷰로 구성되고, 768px 이상에서는
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr))로 자동 전환되어 세로로 과도하게 늘어나는 현상을 방지합니다.