비교 요약으로 돌아가기

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로 스스로 스크롤합니다. .statusmin-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.clipboardundefined로 만들어 실패 경로도 같은 live region에 복사하지 못했습니다.로 나오는지 확인했습니다.
  • 375px: 빌드한 화면을 375·768·1280px 뷰포트에서 열어 실제 값을 측정했습니다. 375px에서 documentElement.scrollWidthclientWidth와 같은 375로 페이지 가로 스크롤이 없고, 가장 긴 formatDate 변경 후 코드에서만 <pre>clientWidth 341 / scrollWidth 363으로 자체 스크롤했습니다. 파일 탭 3개는 각각 다른 줄로 줄바꿈됐고, 768px 이상에서는 grid-template-columns256px + 나머지 2열로 바뀌는 것을 확인했습니다.