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개)
src/App.tsx- 메인 애플리케이션 진입점 및 컴포넌트 로직src/index.css- 전역 반응형 및 모던 다크 모드 테마 스타일시트src/setupTests.ts- 테스트용 jest-dom 매처 설정 파일src/App.test.tsx- 요구사항 검증을 위한 Vitest + React Testing Library 단위 테스트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를 활용해 코드를 정상적으로 클립보드에 기록한 다음,copyStatus를success로 상태 전환합니다. - 복사 완료 직후 사용자 피드백을 위해 복사 버튼의 라벨이
복사됨으로 실시간 갱신되며, 스크린 리더 사용자를 위해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)하고 우측에 넓은 코드 뷰어 영역을 매칭해 편안한 독서 흐름을 구축했습니다.