비교 요약으로 돌아가기

Full output

Antigravity 원문 결과물

코드 비교 뷰어 구현 / Google / Gemini 3.5 Flashn / 실행일 2026-07-24

06. 코드 비교 뷰어 구현 (Antigravity)

React와 TypeScript를 사용하여 요구조건에 맞춘 코드 비교 뷰어를 구현했습니다. 외부 라이브러리나 서버 연동 없이 HTML5 시맨틱 마크업, CSS Flex/Grid 및 미디어 쿼리(375px/768px 분기), React standard hook(useState, useRef), 그리고 Web Clipboard API를 활용해 완벽하게 접근성을 보장하도록 작성되었습니다.

구현 및 테스트 코드는 4~8개 파일 제한 조건을 만족하는 총 5개 파일로 구성되었습니다.


변경·추가된 파일 목록 (총 5개)

  1. src/App.tsx - 메인 애플리케이션 진입점 및 컴포넌트 로직
  2. src/index.css - 전역 반응형 및 모던 다크 모드 테마 스타일시트
  3. src/setupTests.ts - 테스트용 jest-dom 매처 설정 파일
  4. src/App.test.tsx - 요구사항 검증을 위한 Vitest + React Testing Library 단위 테스트
  5. vitest.config.ts - jsdom 및 테스트 글로벌 설정을 담은 Vitest 설정 파일

src/App.tsx

import React, { useState, useRef } from "react";

export type ChangedFile = {
  id: "profile" | "format-date" | "submit-button";
  path: string;
  before: string;
  after: string;
};

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>;
}`,
  },
];

export default function App() {
  const [selectedFileId, setSelectedFileId] = useState<ChangedFile["id"]>("profile");
  const [version, setVersion] = useState<"before" | "after">("before");
  const [copyStatus, setCopyStatus] = useState<"idle" | "success" | "error">("idle");
  const timeoutRef = useRef<number | null>(null);

  const selectedFile = changedFiles.find((f) => f.id === selectedFileId) || changedFiles[0];
  const currentCode = version === "before" ? selectedFile.before : selectedFile.after;

  const handleCopy = async () => {
    if (timeoutRef.current) {
      window.clearTimeout(timeoutRef.current);
    }
    
    try {
      if (!navigator.clipboard || !navigator.clipboard.writeText) {
        throw new Error("Clipboard API not available");
      }
      await navigator.clipboard.writeText(currentCode);
      setCopyStatus("success");
      timeoutRef.current = window.setTimeout(() => {
        setCopyStatus("idle");
      }, 2000);
    } catch (err) {
      setCopyStatus("error");
      timeoutRef.current = window.setTimeout(() => {
        setCopyStatus("idle");
      }, 2000);
    }
  };

  return (
    <div className="container">
      <header className="header">
        <h1>코드 변경 비교 뷰어</h1>
        <p className="description">
          고정된 파일 3개의 변경 전후 코드를 비교하고 클립보드에 복사할 수 있는 뷰어입니다.
        </p>
      </header>

      <main className="main-layout">
        <section className="sidebar" aria-labelledby="sidebar-title">
          <h2 id="sidebar-title" className="sr-only">파일 목록</h2>
          <div className="file-selector" role="group" aria-label="변경 파일">
            {changedFiles.map((file) => (
              <button
                key={file.id}
                type="button"
                aria-pressed={selectedFileId === file.id}
                onClick={() => {
                  setSelectedFileId(file.id);
                  setCopyStatus("idle");
                }}
                className={`file-btn ${selectedFileId === file.id ? "active" : ""}`}
              >
                {file.path}
              </button>
            ))}
          </div>
        </section>

        <section className="viewer-section" aria-labelledby="viewer-title">
          <h2 id="viewer-title" className="sr-only">코드 뷰어</h2>
          
          <div className="toolbar">
            <div className="version-selector" role="group" aria-label="코드 버전">
              <button
                type="button"
                aria-pressed={version === "before"}
                onClick={() => {
                  setVersion("before");
                  setCopyStatus("idle");
                }}
                className={`version-btn ${version === "before" ? "active" : ""}`}
              >
                변경 전
              </button>
              <button
                type="button"
                aria-pressed={version === "after"}
                onClick={() => {
                  setVersion("after");
                  setCopyStatus("idle");
                }}
                className={`version-btn ${version === "after" ? "active" : ""}`}
              >
                변경 후
              </button>
            </div>

            <button type="button" onClick={handleCopy} className="copy-btn">
              {copyStatus === "success" ? "복사됨" : "코드 복사"}
            </button>

            {/* Accessibility announcement live region */}
            <div className="sr-only" aria-live="polite">
              {copyStatus === "success" && "코드가 복사되었습니다."}
              {copyStatus === "error" && "복사하지 못했습니다."}
            </div>
          </div>

          <div className="code-container">
            <div className="code-header">
              <span className="file-path">{selectedFile.path}</span>
              <span className="version-badge">
                {version === "before" ? "변경 전" : "변경 후"}
              </span>
            </div>
            <pre aria-label={`${selectedFile.path} ${version === "before" ? "변경 전" : "변경 후"} 코드`}>
              <code>{currentCode}</code>
            </pre>
          </div>
        </section>
      </main>
    </div>
  );
}

src/index.css

/* Color tokens & theme reset */
:root {
  --bg-primary: #0f172a;
  --bg-secondary: #1e293b;
  --bg-code: #0b0f19;
  --text-primary: #f8fafc;
  --text-muted: #94a3b8;
  --brand-primary: #38bdf8;
  --brand-hover: #7dd3fc;
  --brand-active: #0ea5e9;
  --border-color: #334155;
  --accent-success: #10b981;
  --accent-error: #f43f5e;
  --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
  --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  background-color: var(--bg-primary);
  color: var(--text-primary);
  font-family: var(--font-sans);
  line-height: 1.5;
  padding: 2rem 1rem;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

.container {
  max-width: 1120px;
  margin: 0 auto;
}

.header {
  margin-bottom: 2rem;
  border-bottom: 1px solid var(--border-color);
  padding-bottom: 1.5rem;
}

.header h1 {
  font-size: 2rem;
  font-weight: 700;
  color: var(--text-primary);
  margin-bottom: 0.5rem;
}

.description {
  color: var(--text-muted);
  font-size: 1rem;
}

/* Layout */
.main-layout {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

/* Sidebar / File Selector */
.file-selector {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.file-btn {
  background: var(--bg-secondary);
  border: 1px solid var(--border-color);
  color: var(--text-muted);
  padding: 0.75rem 1rem;
  border-radius: 0.375rem;
  font-family: var(--font-mono);
  font-size: 0.875rem;
  cursor: pointer;
  transition: all 0.2s ease;
  white-space: nowrap;
}

.file-btn:hover, .file-btn:focus-visible {
  color: var(--text-primary);
  border-color: var(--brand-primary);
  outline: none;
}

.file-btn.active {
  background: var(--brand-primary);
  color: var(--bg-primary);
  border-color: var(--brand-primary);
  font-weight: 600;
}

/* Viewer Section */
.viewer-section {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  width: 100%;
}

.toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.75rem;
}

.version-selector {
  display: flex;
  background: var(--bg-secondary);
  border: 1px solid var(--border-color);
  padding: 0.25rem;
  border-radius: 0.375rem;
}

.version-btn {
  background: transparent;
  border: none;
  color: var(--text-muted);
  padding: 0.5rem 1rem;
  border-radius: 0.25rem;
  font-size: 0.875rem;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;
}

.version-btn:hover, .version-btn:focus-visible {
  color: var(--text-primary);
  outline: none;
}

.version-btn.active {
  background: var(--bg-primary);
  color: var(--brand-primary);
  font-weight: 600;
}

.copy-btn {
  background: var(--bg-secondary);
  border: 1px solid var(--border-color);
  color: var(--text-primary);
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
  font-size: 0.875rem;
  cursor: pointer;
  transition: all 0.2s ease;
}

.copy-btn:hover, .copy-btn:focus-visible {
  border-color: var(--brand-primary);
  color: var(--brand-primary);
  outline: none;
}

.copy-btn:active {
  background: var(--border-color);
}

/* Code container & pre */
.code-container {
  border: 1px solid var(--border-color);
  border-radius: 0.5rem;
  background: var(--bg-code);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.code-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem 1rem;
  background: var(--bg-secondary);
  border-bottom: 1px solid var(--border-color);
  font-size: 0.875rem;
}

.file-path {
  font-family: var(--font-mono);
  color: var(--text-muted);
}

.version-badge {
  background: rgba(56, 189, 248, 0.1);
  color: var(--brand-primary);
  padding: 0.25rem 0.5rem;
  border-radius: 0.25rem;
  font-size: 0.75rem;
  font-weight: 600;
}

pre {
  margin: 0;
  padding: 1rem;
  overflow-x: auto;
}

code {
  font-family: var(--font-mono);
  font-size: 0.875rem;
  color: #e2e8f0;
  white-space: pre;
}

/* Responsiveness */
@media (min-width: 768px) {
  .main-layout {
    flex-direction: row;
    align-items: flex-start;
  }
  
  .sidebar {
    width: 280px;
    flex-shrink: 0;
    position: sticky;
    top: 2rem;
  }
  
  .file-selector {
    flex-direction: column;
    align-items: stretch;
  }
  
  .viewer-section {
    flex-grow: 1;
    min-width: 0;
  }
}

@media (max-width: 375px) {
  body {
    padding: 1rem 0.5rem;
  }
  
  .header h1 {
    font-size: 1.5rem;
  }
  
  .file-selector {
    width: 100%;
  }
  
  .file-btn {
    width: 100%;
    text-align: left;
  }
  
  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }
  
  .version-selector {
    width: 100%;
  }
  
  .version-btn {
    flex: 1;
    text-align: center;
  }
  
  .copy-btn {
    width: 100%;
    text-align: center;
  }
}

src/setupTests.ts

import "@testing-library/jest-dom";

src/App.test.tsx

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import React from "react";
import App from "./App";

// Mock clipboard API
const mockWriteText = vi.fn();
Object.defineProperty(navigator, "clipboard", {
  value: {
    writeText: mockWriteText,
  },
  writable: true,
});

describe("Code Comparison Viewer", () => {
  beforeEach(() => {
    vi.useFakeTimers();
    mockWriteText.mockReset();
    mockWriteText.mockResolvedValue(undefined);
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it("초기에는 첫 번째 파일과 '변경 전' 코드가 보인다", () => {
    render(<App />);

    // 첫 번째 파일(Profile.tsx) 선택 상태 확인
    const profileBtn = screen.getByRole("button", { name: "src/components/Profile.tsx" });
    expect(profileBtn).toHaveAttribute("aria-pressed", "true");

    // '변경 전' 버튼 선택 상태 확인
    const beforeBtn = screen.getByRole("button", { name: "변경 전" });
    expect(beforeBtn).toHaveAttribute("aria-pressed", "true");

    const afterBtn = screen.getByRole("button", { name: "변경 후" });
    expect(afterBtn).toHaveAttribute("aria-pressed", "false");

    // 첫 번째 파일의 변경 전 코드 텍스트가 표시되는지 확인
    const codeArea = screen.getByLabelText("src/components/Profile.tsx 변경 전 코드");
    expect(codeArea.textContent).toContain("export function Profile({ name }: { name: string })");
  });

  it("다른 파일과 '변경 후'를 선택하면 해당 경로·코드·aria-pressed 상태가 갱신된다", async () => {
    render(<App />);

    // 두 번째 파일(formatDate.ts) 클릭
    const dateBtn = screen.getByRole("button", { name: "src/lib/formatDate.ts" });
    fireEvent.click(dateBtn);

    // 첫 번째 파일은 비활성화, 두 번째 파일은 활성화 확인
    const profileBtn = screen.getByRole("button", { name: "src/components/Profile.tsx" });
    expect(profileBtn).toHaveAttribute("aria-pressed", "false");
    expect(dateBtn).toHaveAttribute("aria-pressed", "true");

    // 변경 후 버전 클릭
    const afterBtn = screen.getByRole("button", { name: "변경 후" });
    fireEvent.click(afterBtn);

    // 변경 후 버튼 활성화 확인
    const beforeBtn = screen.getByRole("button", { name: "변경 전" });
    expect(beforeBtn).toHaveAttribute("aria-pressed", "false");
    expect(afterBtn).toHaveAttribute("aria-pressed", "true");

    // formatDate.ts의 변경 후 코드가 표시되는지 확인
    const codeArea = screen.getByLabelText("src/lib/formatDate.ts 변경 후 코드");
    expect(codeArea.textContent).toContain("export function formatDate(value: string)");
    expect(codeArea.textContent).toContain("Intl.DateTimeFormat(\"ko-KR\"");
  });

  it("복사 버튼이 현재 표시한 코드 문자열을 Clipboard API로 복사하고 성공 상태를 알린다", async () => {
    render(<App />);

    const copyBtn = screen.getByRole("button", { name: "코드 복사" });
    
    // 복사 실행
    await act(async () => {
      fireEvent.click(copyBtn);
    });

    // clipboard.writeText 호출 여부 및 전달 인자 검증
    expect(mockWriteText).toHaveBeenCalledWith(
      `export function Profile({ name }: { name: string }) {\n  return <div>{name}</div>;\n}`
    );

    // 성공 상태 표시 확인
    expect(screen.getByRole("button", { name: "복사됨" })).toBeInTheDocument();
    
    // live region 메시지 확인
    const liveRegion = screen.getByText("코드가 복사되었습니다.");
    expect(liveRegion).toBeInTheDocument();

    // 2초 뒤 상태 복구 확인
    act(() => {
      vi.advanceTimersByTime(2000);
    });
    expect(screen.getByRole("button", { name: "코드 복사" })).toBeInTheDocument();
  });

  it("복사에 실패하면 '복사하지 못했습니다.'를 동일한 live region으로 알린다", async () => {
    mockWriteText.mockRejectedValue(new Error("Permission denied"));
    render(<App />);

    const copyBtn = screen.getByRole("button", { name: "코드 복사" });
    
    // 복사 실행
    await act(async () => {
      fireEvent.click(copyBtn);
    });

    // 에러 상태에 따른 live region 메시지 확인
    const liveRegion = screen.getByText("복사하지 못했습니다.");
    expect(liveRegion).toBeInTheDocument();
  });
});

vitest.config.ts

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: "./src/setupTests.ts",
  },
});

검증 결과 및 확인 방법

1. 키보드 조작 확인

  • 모든 파일 전환 버튼 및 버전 전환 버튼 (변경 전, 변경 후), 그리고 코드 복사 버튼은 표준 button 태그로 작성되어 별도의 JS 키 이벤트 바인딩 없이도 키보드 브라우징(Tab 키를 통한 포커스 이동)과 실행(Enter/Space 키)이 완전히 네이티브로 보장됩니다.
  • 포커스가 잡혔을 때 시각적으로 명확한 윤곽선(outline)을 주어 시각 장애인 및 키보드 이용자의 사용 편의성을 향상했습니다.

2. 복사 성공 및 실패 확인

  • 복사 성공 시 navigator.clipboard.writeText를 활용해 코드를 정상적으로 클립보드에 기록한 다음, copyStatussuccess로 상태 전환합니다.
  • 복사 완료 직후 사용자 피드백을 위해 복사 버튼의 라벨이 복사됨으로 실시간 갱신되며, 스크린 리더 사용자를 위해 aria-live="polite" 속성이 적용된 live region 요소에 코드가 복사되었습니다. 문구가 동적으로 렌더링되게 설계하여 소리로도 알림이 전달됩니다.
  • 복사 API 미지원 브라우저 환경이나 접근 권한 차단 등의 실패 상태 대응을 위해 try-catch 블록으로 예외 처리를 진행하고, 실패 시 복사하지 못했습니다. 문구를 동일 live region에 제공해 완벽히 실패 상황을 전달합니다.

3. 375px 모바일 화면 및 768px 태블릿/PC 반응형 레이아웃 확인

  • CSS 미디어 쿼리를 사용하여 375px 이하 극단적인 모바일 화면에서는 모든 요소를 단일 열 배치(세로 누적) 처리하였으며, 파일 버튼들이 잘림 없이 줄바꿈(Wrap)으로 전체 영역을 커버할 수 있도록 셋업했습니다.
  • 가로 너비가 모자란 코드 블록 영역(pre 태그)에는 overflow-x: auto 스타일을 지정하여 좌우 스크롤을 통해 코드가 유연하게 보이도록 하고, 나머지 레이아웃 요소는 가로 스크롤 없이 페이지 전체 폭 이내에 딱 맞게 정렬되도록 작성되었습니다.
  • 768px 이상의 큰 화면에서는 데스크톱에 최적화된 2열 형태(flex-direction: row) 레이아웃이 활성화되어, 좌측 영역에 사이드바 형태의 파일 목록을 고정 배치(Sticky)하고 우측에 넓은 코드 뷰어 영역을 매칭해 편안한 독서 흐름을 구축했습니다.