비교 요약으로 돌아가기

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개)

  1. app/comparisons/types.ts - 타입 정의 및 고정 목업 데이터
  2. app/comparisons/comparisons.module.css - CSS Modules 스타일시트
  3. app/comparisons/CategoryFilter.tsx - 카테고리 필터 및 live region 컴포넌트
  4. app/comparisons/ComparisonCard.tsx - 비교 글 단일 항목 카드 컴포넌트
  5. app/comparisons/page.tsx - 목록 화면 메인 진입점 (Client Component)
  6. 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))로 자동 전환되어 세로로 과도하게 늘어나는 현상을 방지합니다.