비교 요약으로 돌아가기

Full output

Codex 원문 결과물

AI 비교 글 목록 화면 구현 / OpenAI / GPT-5.6 Terra / 실행일 2026-07-22

app/comparisons/page.tsx

import ComparisonList, {
  type Comparison,
} from "./ComparisonList";
import styles from "./comparisons.module.css";

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 default function ComparisonsPage() {
  return (
    <main className={styles.page}>
      <header className={styles.intro}>
        <p className={styles.eyebrow}>Same brief, different results</p>
        <h1>AI 비교 글</h1>
        <p>
          같은 실무 과업을 여러 AI 모델에 맡기고, 결과와 평가 기준을 함께
          기록합니다.
        </p>
      </header>

      <ComparisonList comparisons={comparisons} />
    </main>
  );
}

app/comparisons/ComparisonList.tsx

"use client";

import { useState } from "react";
import styles from "./comparisons.module.css";

export type Category = "writing" | "backend" | "frontend";
export type Filter = "all" | Category;

export type Comparison = {
  slug: string;
  category: Category;
  title: string;
  publishedAt: string;
  models: string[];
  conclusion: string;
  recommendedModel: string;
};

const filters: ReadonlyArray<{ value: Filter; label: string }> = [
  { value: "all", label: "전체" },
  { value: "writing", label: "글쓰기" },
  { value: "backend", label: "백엔드" },
  { value: "frontend", label: "프론트엔드" },
];

const categoryLabels: Record<Category, string> = {
  writing: "글쓰기",
  backend: "백엔드",
  frontend: "프론트엔드",
};

export function filterComparisons(
  comparisons: readonly Comparison[],
  filter: Filter,
) {
  return filter === "all"
    ? comparisons
    : comparisons.filter((comparison) => comparison.category === filter);
}

function formatDate(date: string) {
  return new Intl.DateTimeFormat("ko-KR", {
    year: "numeric",
    month: "long",
    day: "numeric",
  }).format(new Date(`${date}T00:00:00`));
}

export default function ComparisonList({
  comparisons,
}: {
  comparisons: readonly Comparison[];
}) {
  const [selectedFilter, setSelectedFilter] = useState<Filter>("all");
  const visibleComparisons = filterComparisons(comparisons, selectedFilter);
  const selectedLabel = filters.find(
    (filter) => filter.value === selectedFilter,
  )!.label;

  return (
    <section className={styles.archive} aria-labelledby="comparison-list-title">
      <div className={styles.archiveHeading}>
        <h2 id="comparison-list-title">비교 아카이브</h2>
        <p>과업 유형을 선택해 비교 기록을 좁혀 보세요.</p>
      </div>

      <div className={styles.filterGroup} role="group" aria-label="과업 카테고리">
        {filters.map((filter) => {
          const isSelected = selectedFilter === filter.value;

          return (
            <button
              key={filter.value}
              type="button"
              className={styles.filterButton}
              aria-pressed={isSelected}
              onClick={() => setSelectedFilter(filter.value)}
            >
              {filter.label}
            </button>
          );
        })}
      </div>

      <p className={styles.resultCount} aria-live="polite" aria-atomic="true">
        {selectedLabel} 비교 글 {visibleComparisons.length}건
      </p>

      {visibleComparisons.length > 0 ? (
        <ul className={styles.list}>
          {visibleComparisons.map((comparison) => (
            <li key={comparison.slug} className={styles.card}>
              <div className={styles.cardMeta}>
                <span className={styles.category}>
                  과업: {categoryLabels[comparison.category]}
                </span>
                <time dateTime={comparison.publishedAt}>
                  발행일: {formatDate(comparison.publishedAt)}
                </time>
              </div>
              <h3>{comparison.title}</h3>
              <dl className={styles.details}>
                <div>
                  <dt>비교 모델</dt>
                  <dd>{comparison.models.join(" · ")}</dd>
                </div>
                <div>
                  <dt>한 줄 결론</dt>
                  <dd>{comparison.conclusion}</dd>
                </div>
                <div className={styles.recommendation}>
                  <dt>추천 모델</dt>
                  <dd>{comparison.recommendedModel}</dd>
                </div>
              </dl>
            </li>
          ))}
        </ul>
      ) : (
        <div className={styles.emptyState} role="status">
          <h3>{selectedLabel} 카테고리에는 아직 비교 글이 없습니다.</h3>
          <p>
            다른 카테고리를 선택하거나 <strong>전체</strong> 버튼을 눌러 모든 비교
            글을 확인해 주세요.
          </p>
        </div>
      )}
    </section>
  );
}

app/comparisons/comparisons.module.css

.page {
  width: min(100% - 32px, 1040px);
  margin: 0 auto;
  padding: 56px 0 80px;
  color: #17221f;
}

.intro {
  max-width: 660px;
  padding-bottom: 48px;
  border-bottom: 1px solid #d5ddda;
}

.eyebrow {
  margin: 0 0 10px;
  color: #0c6d62;
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.intro h1,
.archiveHeading h2,
.card h3,
.emptyState h3 {
  margin: 0;
  line-height: 1.2;
}

.intro h1 {
  font-size: clamp(2.25rem, 6vw, 4rem);
  letter-spacing: -0.04em;
}

.intro > p:last-child {
  max-width: 50ch;
  margin: 18px 0 0;
  color: #52605b;
  font-size: 1.08rem;
}

.archive {
  padding-top: 42px;
}

.archiveHeading {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: 24px;
  margin-bottom: 22px;
}

.archiveHeading h2 {
  font-size: 1.5rem;
}

.archiveHeading p {
  margin: 0;
  color: #5e6b66;
  font-size: 0.94rem;
}

.filterGroup {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.filterButton {
  min-height: 42px;
  padding: 8px 14px;
  border: 1px solid #aebbb5;
  border-radius: 999px;
  background: #fff;
  color: #34413c;
  font-weight: 650;
}

.filterButton:hover {
  border-color: #0c6d62;
  background: #edf7f4;
}

.filterButton[aria-pressed="true"] {
  border-color: #07584f;
  background: #07584f;
  color: #fff;
}

.filterButton:focus-visible {
  outline: 3px solid #e28a2b;
  outline-offset: 3px;
}

.resultCount {
  margin: 18px 0 12px;
  color: #52605b;
  font-size: 0.9rem;
}

.list {
  display: grid;
  gap: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  border-top: 1px solid #d5ddda;
}

.card {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(260px, 0.9fr);
  gap: 18px 48px;
  padding: 27px 0;
  border-bottom: 1px solid #d5ddda;
}

.cardMeta {
  display: flex;
  grid-column: 1 / -1;
  flex-wrap: wrap;
  gap: 8px 16px;
  color: #5e6b66;
  font-size: 0.83rem;
}

.category {
  color: #07584f;
  font-weight: 700;
}

.card h3 {
  font-size: clamp(1.2rem, 2vw, 1.5rem);
}

.details {
  display: grid;
  gap: 12px;
  margin: 0;
}

.details div {
  display: grid;
  grid-template-columns: 88px minmax(0, 1fr);
  gap: 12px;
}

.details dt {
  color: #5e6b66;
  font-size: 0.86rem;
}

.details dd {
  margin: 0;
  color: #26332e;
  font-size: 0.92rem;
}

.recommendation dd {
  color: #07584f;
  font-weight: 750;
}

.emptyState {
  margin-top: 12px;
  padding: 28px;
  border: 1px dashed #9aa9a2;
  background: #f5f8f6;
}

.emptyState h3 {
  font-size: 1.1rem;
}

.emptyState p {
  max-width: 52ch;
  margin: 8px 0 0;
  color: #52605b;
}

@media (max-width: 767px) {
  .page {
    padding-top: 38px;
  }

  .intro {
    padding-bottom: 36px;
  }

  .archive {
    padding-top: 32px;
  }

  .archiveHeading {
    display: block;
  }

  .archiveHeading p {
    margin-top: 8px;
  }

  .filterButton {
    flex: 1 1 auto;
  }

  .card {
    grid-template-columns: 1fr;
    gap: 15px;
    padding: 22px 0;
  }

  .cardMeta {
    grid-column: auto;
  }

  .details div {
    grid-template-columns: 76px minmax(0, 1fr);
  }

  .emptyState {
    padding: 22px;
  }
}

app/comparisons/ComparisonList.test.ts

import { describe, expect, it } from "vitest";
import {
  filterComparisons,
  type Comparison,
} from "./ComparisonList";

const comparisons: Comparison[] = [
  {
    slug: "writing",
    category: "writing",
    title: "글쓰기 비교",
    publishedAt: "2026-07-15",
    models: ["Claude"],
    conclusion: "글쓰기 결과",
    recommendedModel: "Claude",
  },
  {
    slug: "backend",
    category: "backend",
    title: "백엔드 비교",
    publishedAt: "2026-07-16",
    models: ["Codex"],
    conclusion: "백엔드 결과",
    recommendedModel: "Codex",
  },
];

describe("filterComparisons", () => {
  it("전체 필터에서는 모든 항목을 반환한다", () => {
    expect(filterComparisons(comparisons, "all")).toHaveLength(2);
  });

  it("선택한 카테고리의 항목만 반환한다", () => {
    expect(filterComparisons(comparisons, "backend")).toEqual([
      comparisons[1],
    ]);
  });

  it("해당 카테고리에 항목이 없으면 빈 배열을 반환한다", () => {
    expect(filterComparisons(comparisons, "frontend")).toEqual([]);
  });
});

확인

  • 키보드로 Tab으로 각 실제 button 필터에 초점을 이동하고 Enter 또는 Space로 선택할 수 있으며, 선택 상태는 aria-pressed와 텍스트/배경 대비로 함께 전달됩니다.
  • 빈 상태는 데이터에 없는 frontend 항목만 가진 테스트 데이터에서 필터링 함수가 빈 배열을 반환하는지 검증했고, UI는 이 경우 원인과 전체 버튼으로 돌아가는 방법을 표시합니다. 실제 목업의 네 필터에는 숨은 선택지를 추가하지 않았습니다.
  • 375px 기준 미디어 쿼리에서 필터가 줄바꿈되고 카드는 한 열로 바뀌며, 긴 제목·결론은 줄바꿈되도록 minmax(0, 1fr)와 기본 줄바꿈 동작을 적용했습니다.