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)와 기본 줄바꿈 동작을 적용했습니다.