비교 요약으로 돌아가기

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-wrapoverflow-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열 그리드로 재배치됩니다.