웹 접근성 학습 플랫폼 A11yGym을 개발하면서 겪었던 흥미로운 기술적 문제들과 해결 과정을 공유합니다. 특히 iframe 내에서 사용자 코드를 실행하고 접근성을 검증하는 과정에서 예상치 못한 문제들을 만났는데, 이를 해결하는 과정이 다른 개발자분들께도 도움이 될 것 같아 정리해봤습니다.
프로젝트 소개
A11yGym은 KWCAG 2.2 가이드라인을 실습으로 학습할 수 있는 플랫폼입니다. 사용자가 접근성 오류가 있는 코드를 직접 수정하면서 웹 접근성을 체득할 수 있도록 설계했습니다.
주요 기술 스택: Next.js 14, TypeScript, Monaco Editor, axe-core, Supabase
도전 1: React JSX를 iframe에서 렌더링하기
문제 상황
사용자가 Monaco Editor에서 작성한 React JSX 코드를 실시간 미리보기로 보여줘야 했습니다. 하지만 iframe은 순수 HTML만 지원하죠.
// 사용자가 작성하는 코드
<div className="button" onClick={() => alert('clicked')} tabIndex={0}>
구독하기
</div>
이 코드를 그대로 iframe에 넣으면 className, tabIndex 같은 React 전용 속성이 작동하지 않습니다.
해결 방법
정규식을 활용한 JSX → HTML 변환기를 구현했습니다:
function convertReactJSXToHTML(code: string): string {
let html = code;
// className → class
html = html.replace(/\bclassName\s*=\s*["']([^"']+)["']/g, 'class="$1"');
// tabIndex={0} → tabindex="0"
html = html.replace(/\btabIndex\s*=\s*\{0\}/g, 'tabindex="0"');
// htmlFor → for
html = html.replace(/\bhtmlFor\s*=\s*["']([^"']+)["']/g, 'for="$1"');
// 간단한 onClick만 변환
html = html.replace(
/\bonClick\s*=\s*\{\(\)\s*=>\s*alert\(["']([^"']+)["']\)\}/g,
'onclick="alert(\'$1\')"'
);
return html;
}
학습용 플랫폼이라는 점을 고려해 복잡한 AST 파서 대신 정규식을 선택했습니다. 빠르고 예측 가능하며, 대부분의 학습 시나리오를 충분히 커버할 수 있었습니다.
한계점: 복잡한 이벤트 핸들러나 컴포넌트는 변환할 수 없습니다. 향후 Babel/SWC 파서 도입이나 iframe 내 React 런타임 실행을 고려 중입니다.
도전 2: iframe 내부에서 axe-core 실행하기
가장 까다로웠던 부분입니다. 사용자 코드의 접근성을 자동으로 분석하기 위해 axe-core를 iframe 내부에서 실행해야 했는데, 세 가지 큰 문제를 만났습니다.
문제 1: "exports is not defined" 에러
// 처음 시도한 코드
(iframeWindow as any).eval(axe.source);
// ❌ ReferenceError: exports is not defined
원인: npm에서 가져온 axe.source는 Node.js(CommonJS) 환경을 전제로 exports 키워드를 사용합니다. 하지만 브라우저 환경에는 이런 전역 변수가 없죠.
해결: CommonJS 호환 Shim을 먼저 주입했습니다.
const compatibilityScript = `
var module = { exports: {} };
var exports = module.exports;
var process = { env: { NODE_ENV: 'production' } };
`;
// Shim과 axe 소스를 함께 실행
(iframeWindow as any).eval(compatibilityScript + axe.source);
// 전역 window.axe가 없으면 module.exports에서 가져오기
let axeApi = (iframeWindow as any).axe;
if (!axeApi) {
const iframeModule = (iframeWindow as any).module;
if (iframeModule?.exports?.run) {
axeApi = iframeModule.exports;
(iframeWindow as any).axe = axeApi;
}
}
문제 2: "Axe is already running" 에러
사용자가 "코드 실행" 버튼을 빠르게 연타하거나, 여러 이벤트가 동시에 발생하면 이런 에러가 터졌습니다.
원인 분석:
runAnalysesWhenReady함수가 여러 경로에서 호출됨 (iframe.onload, readyState 체크, DOMContentLoaded)- 이전
axe.run()실행이 끝나기 전에 새 실행이 시작됨 - axe-core는 동시 실행을 허용하지 않음
해결: 3단계 방어 메커니즘을 구축했습니다.
// 1단계: 분석 프로세스 레벨 보호
const analysisInProgressRef = useRef<Promise<void> | null>(null);
const runAnalysesWhenReady = async () => {
// 이미 진행 중이면 대기
if (analysisInProgressRef.current) {
try {
await analysisInProgressRef.current;
} catch {}
}
const analysisPromise = (async () => {
// ... 분석 로직
})();
analysisInProgressRef.current = analysisPromise;
try {
await analysisPromise;
} finally {
if (analysisInProgressRef.current === analysisPromise) {
analysisInProgressRef.current = null;
}
}
};
// 2단계: 스케줄링 중복 방지
let hasScheduledAnalysis = false;
const scheduleAnalysis = () => {
if (hasScheduledAnalysis) return;
hasScheduledAnalysis = true;
void runAnalysesWhenReady();
};
// 3단계: axe.run() 직접 호출 보호
const axeRunInProgressRef = useRef<Promise<unknown> | null>(null);
if (axeRunInProgressRef.current) {
try {
await axeRunInProgressRef.current;
} catch {}
}
const axePromise = axeApi.run(iframeDoc, {...});
axeRunInProgressRef.current = axePromise;
각 레벨에서 Promise를 추적하고 이전 실행이 완료될 때까지 대기하도록 구현했습니다. 이렇게 계층적으로 보호 장치를 만들자 안정성이 크게 향상됐습니다.
문제 3: 탭 전환 시 iframe 초기화
shadcn/ui의 Tabs는 기본적으로 비활성 탭의 내용을 언마운트합니다. "A11y 트리" 탭을 봤다가 다시 "화면" 탭으로 돌아오면 iframe이 사라지는 문제가 있었습니다.
해결: forceMount 속성으로 모든 탭의 콘텐츠를 DOM에 유지했습니다.
<TabsContent value="render" forceMount>
{/* iframe은 항상 DOM에 존재 */}
</TabsContent>
<TabsContent value="a11y" forceMount>
{/* A11y 트리도 항상 존재 */}
</TabsContent>
도전 3: 색 대비 자동 검사
WCAG 2.1 AA 기준(4.5:1)을 만족하는지 자동으로 검사해야 했습니다. 다행히 axe-core의 color-contrast 규칙이 이를 완벽하게 지원했습니다.
const results = await axeApi.run(iframeDoc, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'],
},
});
const colorContrastViolations = results.violations.filter(
v => v.id === 'color-contrast'
);
axe-core는 실제 렌더링된 색상(computed styles)을 읽어서 정확한 대비 비율을 계산합니다. 인라인 스타일, CSS 클래스, 상속된 스타일을 모두 고려하기 때문에 매우 정확합니다.
<!-- 대비 부족 (2.84:1) ❌ -->
<p style="color:#9ca3af; background:#ffffff;">
오늘 20:00~21:00 동안 점검이 진행됩니다.
</p>
<!-- 대비 충족 (21:1) ✅ -->
<p style="color:#111111; background:#ffffff;">
오늘 20:00~21:00 동안 점검이 진행됩니다.
</p>
사용자 피드백으로 발견한 문제들
초기 베타 테스트에서 사용자 피드백이 정말 중요했습니다.
"각 챌린지에서 메인화면으로 돌아갈 방법이 없어" → 상단 액션 바에 홈 아이콘 버튼 추가
"preview는 어떤 용도야? 마크업된 UI를 보여줘야 하는 거 아니야?" → JSX→HTML 변환 기능 구현 (위에서 설명한 도전 1)
챌린지 변경 시 이전 결과가 그대로 표시됨
→ 챌린지 ID 변경 시 testResult 초기화 로직 추가
useEffect(() => {
if (!challenge) return;
setTestResult({ status: 'idle' }); // 상태 초기화
}, [challenge?.id]);
배운 교훈
1. 환경 불일치는 반드시 온다
Node.js용 라이브러리를 브라우저에서 쓸 때는 항상 호환성 레이어를 고려해야 합니다. CommonJS Shim은 이제 제 도구 상자에 기본으로 들어갔습니다.
2. 비동기 작업은 추적하라
Promise 기반 비동기 작업은 ref로 추적하고, 이전 실행이 끝날 때까지 대기하는 로직이 필수입니다. 특히 "이미 실행 중" 에러를 던지는 라이브러리를 다룰 때는 더욱 그렇습니다.
3. 계층적 방어가 답이다
하나의 보호 장치만으로는 부족합니다. 외부(분석 프로세스), 중간(스케줄링), 내부(axe.run 호출) 각 레벨에서 중복 실행을 방지하니 안정성이 극적으로 향상됐습니다.
4. 사용자 피드백 > 추측
아무리 잘 설계해도 실제 사용자가 어떻게 쓰는지 보기 전까진 모릅니다. 베타 테스터의 한 마디가 며칠간 고민한 설계보다 더 명확한 방향을 제시해줬습니다.
향후 개선 방향
현재 정규식 기반 JSX 변환은 한계가 명확합니다. Babel 파서를 도입하거나, 아예 iframe 내에서 React를 실행하는 방향을 검토 중입니다. 또한 AST 기반 접근성 검증으로 전환하면 더 정확하고 복잡한 케이스도 처리할 수 있을 것 같습니다.
33개 KWCAG 지침 중 현재는 일부만 챌린지로 구현되어 있는데, 모든 지침을 커버하는 것이 다음 목표입니다.
웹 접근성은 단순히 규정 준수가 아니라, 모든 사람이 웹을 사용할 수 있게 만드는 일입니다. A11yGym을 통해 더 많은 개발자들이 접근성을 자연스럽게 체득하길 바랍니다.