Full output
Claude 원문 결과물
코드 비교 뷰어 구현 / Anthropic / Claude Sonnet 5 / 실행일 2026-07-24
코드 비교 뷰어 구현
고정 데이터와 타입을 changedFiles.ts로 분리하고, 화면과 상태는 App.tsx 하나에 뒀습니다. 상태는 선택한 파일, 버전, 복사 상태 세 개뿐이며 파일·버전이 바뀌면 복사 상태를 초기화합니다. 복사 결과는 성공·실패 모두 같은 role="status" aria-live="polite" 문단 하나로 알려서 스크린 리더가 두 결과를 같은 위치에서 듣게 했습니다. 스타일은 CSS Modules 하나만 사용하고 index.css는 건드리지 않았습니다. 총 5개 파일(구현 3 + 테스트 2)입니다.
src/changedFiles.ts
export type FileId = "profile" | "format-date" | "submit-button";
export type Version = "before" | "after";
export type ChangedFile = {
id: FileId;
path: string;
before: string;
after: string;
};
export const VERSION_LABELS: Record<Version, string> = {
before: "변경 전",
after: "변경 후",
};
export const changedFiles: ChangedFile[] = [
{
id: "profile",
path: "src/components/Profile.tsx",
before: `export function Profile({ name }: { name: string }) {
return <div>{name}</div>;
}`,
after: `type ProfileProps = {
name: string;
role: string;
};
export function Profile({ name, role }: ProfileProps) {
return <p>{name} · {role}</p>;
}`,
},
{
id: "format-date",
path: "src/lib/formatDate.ts",
before: `export function formatDate(value: string) {
return new Date(value).toLocaleDateString();
}`,
after: `export function formatDate(value: string) {
return new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(value));
}`,
},
{
id: "submit-button",
path: "src/components/SubmitButton.tsx",
before: `export function SubmitButton() {
return <button>저장</button>;
}`,
after: `type SubmitButtonProps = {
disabled?: boolean;
};
export function SubmitButton({ disabled = false }: SubmitButtonProps) {
return <button disabled={disabled}>저장</button>;
}`,
},
];
src/App.tsx
import { useEffect, useState } from "react";
import styles from "./App.module.css";
import {
changedFiles,
VERSION_LABELS,
type FileId,
type Version,
} from "./changedFiles";
type CopyState = "idle" | "copied" | "failed";
const COPY_MESSAGES: Record<CopyState, string> = {
idle: "",
copied: "복사됨",
failed: "복사하지 못했습니다.",
};
const versions = Object.keys(VERSION_LABELS) as Version[];
export default function App() {
const [fileId, setFileId] = useState<FileId>(changedFiles[0].id);
const [version, setVersion] = useState<Version>("before");
const [copyState, setCopyState] = useState<CopyState>("idle");
const file = changedFiles.find((item) => item.id === fileId) ?? changedFiles[0];
const code = file[version];
useEffect(() => {
setCopyState("idle");
}, [fileId, version]);
async function handleCopy() {
try {
if (typeof navigator.clipboard?.writeText !== "function") {
throw new Error("Clipboard API를 사용할 수 없습니다.");
}
await navigator.clipboard.writeText(code);
setCopyState("copied");
} catch {
setCopyState("failed");
}
}
return (
<main className={styles.page}>
<h1 className={styles.title}>코드 비교 뷰어</h1>
<p className={styles.intro}>
변경된 파일 3개의 변경 전후 코드를 확인하고, 지금 보고 있는 코드를 그대로 복사합니다.
</p>
<div className={styles.layout}>
<div className={styles.files} role="group" aria-label="변경 파일">
{changedFiles.map((item) => (
<button
key={item.id}
type="button"
className={styles.tab}
aria-pressed={item.id === file.id}
onClick={() => setFileId(item.id)}
>
{item.path}
</button>
))}
</div>
<section className={styles.viewer} aria-label="코드 보기">
<h2 className={styles.path}>{file.path}</h2>
<div className={styles.controls}>
<div className={styles.versions} role="group" aria-label="코드 버전">
{versions.map((value) => (
<button
key={value}
type="button"
className={styles.tab}
aria-pressed={value === version}
onClick={() => setVersion(value)}
>
{VERSION_LABELS[value]}
</button>
))}
</div>
<div className={styles.copyArea}>
<button type="button" className={styles.copyButton} onClick={handleCopy}>
코드 복사
</button>
<p
className={styles.status}
data-state={copyState}
role="status"
aria-live="polite"
>
{COPY_MESSAGES[copyState]}
</p>
</div>
</div>
<pre
className={styles.code}
tabIndex={0}
aria-label={`${file.path} ${VERSION_LABELS[version]} 코드`}
>
<code>{code}</code>
</pre>
</section>
</div>
</main>
);
}
<pre>에 tabIndex={0}을 준 이유는 가로 스크롤이 생기는 영역을 키보드만으로도 스크롤할 수 있어야 하기 때문입니다. aria-label이 있어 포커스 시 파일 경로와 버전이 함께 읽힙니다.
src/App.module.css
.page {
max-width: 60rem;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
box-sizing: border-box;
}
.title {
margin: 0 0 0.5rem;
font-size: 1.375rem;
line-height: 1.3;
}
.intro {
margin: 0 0 1.5rem;
color: #4b5563;
line-height: 1.6;
overflow-wrap: anywhere;
}
.layout {
display: grid;
gap: 1rem;
min-width: 0;
}
.files,
.versions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-width: 0;
}
.tab {
flex: 0 1 auto;
max-width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background: #fff;
color: #111827;
font: inherit;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
overflow-wrap: anywhere;
}
.tab[aria-pressed="true"] {
border-color: #111827;
background: #111827;
color: #fff;
}
.tab:focus-visible,
.copyButton:focus-visible,
.code:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.viewer {
min-width: 0;
display: grid;
gap: 0.75rem;
}
.path {
margin: 0;
font-size: 0.9375rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
overflow-wrap: anywhere;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.copyArea {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.copyButton {
padding: 0.5rem 0.75rem;
border: 1px solid #111827;
border-radius: 0.5rem;
background: #fff;
color: #111827;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.status {
margin: 0;
min-height: 1.25rem;
font-size: 0.8125rem;
color: #15803d;
}
.status[data-state="failed"] {
color: #b91c1c;
}
.code {
margin: 0;
padding: 0.875rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background: #f9fafb;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8125rem;
line-height: 1.6;
max-width: 100%;
overflow-x: auto;
white-space: pre;
tab-size: 2;
}
@media (min-width: 768px) {
.layout {
grid-template-columns: minmax(0, 16rem) minmax(0, 1fr);
gap: 1.5rem;
}
.files {
flex-direction: column;
align-items: stretch;
}
}
가로 넘침을 막는 지점은 세 곳입니다. 그리드 트랙에 minmax(0, …)과 min-width: 0을 줘서 코드 블록이 트랙을 밀어내지 못하게 하고, 탭은 flex-wrap: wrap으로 줄바꿈하며, white-space: pre인 <pre>만 overflow-x: auto로 스스로 스크롤합니다. .status의 min-height는 문구가 나타났다 사라질 때 레이아웃이 흔들리지 않게 자리를 미리 잡아 둡니다.
src/App.test.tsx
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";
import { changedFiles } from "./changedFiles";
const [profile, formatDate] = changedFiles;
function codeText() {
return screen.getByLabelText(/코드$/).textContent;
}
function setClipboard(value: unknown) {
Object.defineProperty(navigator, "clipboard", {
value,
configurable: true,
writable: true,
});
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("코드 비교 뷰어", () => {
it("처음에는 첫 번째 파일의 변경 전 코드를 보여 준다", () => {
render(<App />);
expect(
screen.getByRole("button", { name: profile.path, pressed: true }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "변경 전", pressed: true }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "변경 후", pressed: false }),
).toBeTruthy();
expect(screen.getByLabelText(`${profile.path} 변경 전 코드`)).toBeTruthy();
expect(codeText()).toBe(profile.before);
expect(
screen.getByRole("heading", { level: 2, name: profile.path }),
).toBeTruthy();
expect(screen.getAllByLabelText(/코드$/)).toHaveLength(1);
});
it("다른 파일과 변경 후를 선택하면 경로·코드·aria-pressed가 갱신된다", async () => {
const user = userEvent.setup();
render(<App />);
await user.click(screen.getByRole("button", { name: formatDate.path }));
await user.click(screen.getByRole("button", { name: "변경 후" }));
expect(
screen.getByRole("button", { name: formatDate.path, pressed: true }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: profile.path, pressed: false }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "변경 후", pressed: true }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "변경 전", pressed: false }),
).toBeTruthy();
expect(
screen.getByRole("heading", { level: 2, name: formatDate.path }),
).toBeTruthy();
expect(
screen.getByLabelText(`${formatDate.path} 변경 후 코드`),
).toBeTruthy();
expect(codeText()).toBe(formatDate.after);
});
it("복사 버튼이 현재 보이는 코드를 복사하고 복사됨을 알린다", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
setClipboard({ writeText });
render(<App />);
await user.click(screen.getByRole("button", { name: "변경 후" }));
await user.click(screen.getByRole("button", { name: "코드 복사" }));
expect(writeText).toHaveBeenCalledWith(profile.after);
const status = await screen.findByRole("status");
expect(status.textContent).toBe("복사됨");
expect(status.getAttribute("aria-live")).toBe("polite");
});
it("Clipboard API를 쓸 수 없으면 실패 문구를 같은 live region으로 알린다", async () => {
const user = userEvent.setup();
setClipboard(undefined);
render(<App />);
await user.click(screen.getByRole("button", { name: "코드 복사" }));
const status = await screen.findByRole("status");
expect(status.textContent).toBe("복사하지 못했습니다.");
expect(status.getAttribute("aria-live")).toBe("polite");
});
it("키보드 Tab·Enter·Space로 파일과 버전을 바꿀 수 있다", async () => {
const user = userEvent.setup();
render(<App />);
await user.tab();
expect(document.activeElement).toBe(
screen.getByRole("button", { name: profile.path }),
);
await user.tab();
await user.keyboard("{Enter}");
expect(
screen.getByRole("button", { name: formatDate.path, pressed: true }),
).toBeTruthy();
await user.tab();
await user.tab();
await user.tab();
await user.keyboard(" ");
expect(
screen.getByRole("button", { name: "변경 후", pressed: true }),
).toBeTruthy();
expect(codeText()).toBe(formatDate.after);
});
});
userEvent.setup()은 자체 클립보드 스텁을 navigator.clipboard에 붙이므로, 복사 테스트에서는 setup()을 먼저 부르고 그다음에 목을 덮어써야 합니다. 순서를 반대로 하면 목이 스텁에 덮여 호출 인자를 검증할 수 없습니다.
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
include: ["src/**/*.test.tsx"],
},
});
JSX 변환은 Vite 시작 프로젝트의 tsconfig.json에 있는 "jsx": "react-jsx"를 그대로 따르므로 별도 설정이 필요 없습니다. CSS Modules는 Vitest가 기본적으로 클래스 이름 프록시로 처리하기 때문에 스타일 관련 설정도 추가하지 않았습니다.
실행
npm i -D vitest jsdom @testing-library/react @testing-library/user-event
npx vitest run
실제 실행 결과: 테스트 5건 모두 통과(초기 상태, 파일·버전 전환, 복사 성공, 복사 실패, 키보드 조작). 런타임 의존성은 추가하지 않았고 테스트 도구만 개발 의존성입니다.
확인 방법
- 키보드: Tab이 파일 버튼 3개 → 버전 버튼 2개 → 복사 버튼 → 코드 영역 순으로 이동하는 것을
user.tab()테스트로 고정했고, Enter로 파일을, Space로 버전을 바꾼 뒤aria-pressed와 코드 내용이 함께 갱신되는지 확인했습니다.:focus-visible아웃라인으로 현재 위치가 보입니다. - 복사:
navigator.clipboard.writeText를 목으로 바꿔 지금 화면에 보이는 문자열(변경 후전환 뒤profile.after)이 그대로 전달되는지 확인했고,navigator.clipboard를undefined로 만들어 실패 경로도 같은 live region에복사하지 못했습니다.로 나오는지 확인했습니다. - 375px: 빌드한 화면을 375·768·1280px 뷰포트에서 열어 실제 값을 측정했습니다. 375px에서
documentElement.scrollWidth가clientWidth와 같은 375로 페이지 가로 스크롤이 없고, 가장 긴formatDate변경 후 코드에서만<pre>가clientWidth 341 / scrollWidth 363으로 자체 스크롤했습니다. 파일 탭 3개는 각각 다른 줄로 줄바꿈됐고, 768px 이상에서는grid-template-columns가256px + 나머지2열로 바뀌는 것을 확인했습니다.