# 코드 블록 접근성 개선: 스크린 리더 사용자가 선택하는 라인 넘버

> Hugo 블로그의 코드 블록에 스크린 리더 사용자를 위한 접근성 개선작업을 진행합니다.

**Published:** 2026-01-09 | **Updated:** 2026-01-09

---


## 들어가며

최근 Hugo에 PaperMod 테마를 이용해서 블로그를 만들고 운영을 시작했습니다. 다른 사람이 만든 것을 그대로 사용하는건 빠르게 운영할 수 있다는 장점이 있지만, 모든 부분이 나에게 맞는 건 아니였습니다.
수많은 곳을 내 입맛과 취향에 맞춰서 수정하다가 보니 코드 블록에 문제가 좀 있더군요.

**라인 넘버가 `<table>` 태그로 구현되어 있었습니다.**

이게 접근성에 위배되는 건 아니지만, **시맨틱하지 않다**는 생각이 들었습니다. 더 나아가, 스크린 리더 사용자 입장에서는 어떨까 고민하다가 **CSS Counter**를 사용한 더 나은 방법을 찾았습니다.

이 글에서는 **왜 테이블 방식이 문제인지**, 그리고 **어떻게 접근성을 개선했는지**를 기록으로 남깁니다.

{{< img src="images/contents/code-screen-accessibility.jpg" alt="화면에 표시된 코드 - 접근성 있는 코드 블록은 모든 개발자를 위한 것입니다" caption="사진: <a href='https://unsplash.com/ko/사진/테이블-위에-앉아-있는-노트북-컴퓨터-PKqxOOQqN64' target='_blank' title='새 창에서 열림'>Unsplash</a>의<a href='https://unsplash.com/ko/@hdbernd' target='_blank' title='새 창에서 열림'>Bernd 📷 Dittrich</a>" >}}

---

## 🔍 문제 분석: 왜 테이블이 문제일까?

### 1. 시맨틱하지 않은 구조

HTML의 `<table>` 요소는 **표 형식의 데이터를 표현하기 위한 요소**입니다. 하지만 코드 블록의 라인 넘버는 데이터가 아니라 **시각적 장식**에 가깝습니다.

WCAG(Web Content Accessibility Guidelines)에서는 HTML 요소를 본래의 목적에 맞게 사용하는 **시맨틱 마크업**을 권장합니다. 테이블을 레이아웃 용도로 사용하는 것은 구시대적인 방식이며, CSS가 발전한 지금은 더 이상 필요하지 않습니다.

### 2. 스크린 리더 사용자 경험

스크린 리더는 `<table>` 태그를 만나면 **"표, 2개 열, 1개 행"**과 같은 정보를 읽어줍니다. 코드 블록을 읽을 때마다 이런 불필요한 정보가 들리는 건 좋은 경험이 아닙니다.

**실제 스크린 리더 경험:**

```
"표, 2개 열, 1개 행"
"셀 1, 1"
"1, 2, 3, 4, 5"
"셀 2, 1"
"const example = Hello World
console log example"
```

라인 넘버와 코드가 각각 별도의 셀로 인식되어 읽기 흐름이 끊깁니다.

{{< img src="images/contents/screen-reader-user.jpg" alt="노트북으로 작업하는 사람 - 스크린 리더 사용자의 경험을 고려한 설계가 중요합니다" caption="사진: <a href='https://unsplash.com/ko/사진/헤드폰을-낀-채-책상에-앉아-있는-남자-aF5_bpGIw9w' target='_blank' title='새 창에서 열림'>Unsplash</a>의<a href='https://unsplash.com/ko/@gamergeni' target='_blank' title='새 창에서 열림'>Muhammad Asim</a>" >}}

### 3. DOM 구조의 복잡도

테이블 기반 구조는 불필요하게 많은 DOM 노드를 생성합니다:

```html
<table class="lntable">
  <tbody>
    <tr>
      <td class="lntd">
        <pre><code><span>1</span><span>2</span><span>3</span></code></pre>
      </td>
      <td class="lntd">
        <pre><code>실제 코드...</code></pre>
      </td>
    </tr>
  </tbody>
</table>
```

이는 성능에도 영향을 줄 수 있으며, CSS 스타일링도 복잡해집니다.

{{< img src="images/contents/before-accessibility-improvement.png" alt="개선 전 코드 블록 화면 - 테이블 태그로 구현된 라인 넘버가 보입니다" >}}

---

## ✅ 해결 방법: 접근성을 고려한 라인 넘버 구현

최종적으로 구현한 방식은 다음과 같은 특징이 있습니다:

1. **시맨틱한 HTML**: `<span class="code-line">` 구조로 각 줄을 명확히 구분
2. **CSS Counter로 시각적 라인 넘버**: 항상 표시되는 시각적 라인 넘버
3. **HTML 라인 넘버**: 스크린 리더 사용자가 선택적으로 들을 수 있는 라인 넘버
4. **aria-hidden 토글**: 사용자가 필요에 따라 라인 넘버 읽기를 켜고 끌 수 있음
5. **Syntax Highlighting 유지**: Chroma의 신택스 하이라이팅 완벽 보존
6. **복사 시 라인 넘버 제외**: 드래그 복사, Copy 버튼 모두 라인 넘버 제외

### 1단계: Hugo 설정 변경

먼저 Hugo의 기본 라인 넘버 기능을 비활성화합니다.

**파일: `config.yaml`**

```yaml
markup:
  highlight:
    noClasses: false
    lineNos: false  # 테이블 기반 라인 넘버 비활성화
    codeFences: true
    guessSyntax: true
```

### 2단계: 커스텀 코드 블록 렌더러 작성

Hugo의 render hook을 사용하여 코드 블록을 커스텀 렌더링합니다.

**파일: `layouts/_default/_markup/render-codeblock.html`**

```html
{{- $lang := .Type -}}
{{- $code := .Inner -}}

{{- /* Highlight code with Chroma to get syntax highlighting */ -}}
{{- $highlighted := transform.Highlight $code $lang "lineNos=false,noClasses=false" -}}

<div class="highlight code-block-wrapper">
  <div class="code-header">
    {{- if $lang }}
    <span class="code-lang">{{ $lang }}</span>
    {{- end }}
    <div class="code-header-controls">
      <button class="line-numbers-toggle" aria-pressed="false" aria-label="라인 넘버 읽기 토글">
        <svg class="icon-line-numbers" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
          <path d="M2.5 3.5a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1h-11zm0 3a.5.5 0 0 1 0-1h6a.5.5 0 0 1 0 1h-6zm0 3a.5.5 0 0 1 0-1h6a.5.5 0 0 1 0 1h-6zm0 3a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1h-11z"/>
        </svg>
        <span class="toggle-text">라인 넘버 읽기: OFF</span>
      </button>
      <button class="line-numbers-help" aria-label="라인 넘버 읽기 기능 도움말" aria-expanded="false">
        <svg class="icon-help" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
          <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
          <path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/>
        </svg>
      </button>
      <div class="line-numbers-help-tooltip" role="tooltip" aria-hidden="true">
        <h4>라인 넘버 읽기 기능</h4>
        <p>이 버튼은 스크린 리더 사용자를 위한 기능입니다.</p>
        <ul>
          <li><strong>OFF (기본값):</strong> 스크린 리더가 라인 넘버를 읽지 않습니다. 코드 내용만 읽습니다.</li>
          <li><strong>ON:</strong> 스크린 리더가 각 줄의 라인 넘버를 함께 읽습니다. (예: "Line 1: console.log...")</li>
        </ul>
        <p class="help-note">시각적으로는 항상 라인 넘버가 표시됩니다. 이 설정은 스크린 리더의 읽기 방식만 변경합니다.</p>
        <button class="help-close" aria-label="도움말 닫기">
          <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
            <path d="M11.25 1.81L10.19.75 6 4.94 1.81.75.75 1.81 4.94 6 .75 10.19l1.06 1.06L6 7.06l4.19 4.19 1.06-1.06L7.06 6z"/>
          </svg>
        </button>
      </div>
    </div>
  </div>
  {{ $highlighted | safeHTML }}
</div>
```

이 템플릿은:
- 언어 태그 표시
- 라인 넘버 읽기 토글 버튼
- 도움말 버튼과 툴팁
- Chroma의 신택스 하이라이팅 적용

### 3단계: JavaScript로 HTML 구조 변환

Chroma가 생성한 HTML을 접근성 친화적인 구조로 변환합니다.

**파일: `assets/js/code-block-accessibility.js`**

```javascript
/**
 * Code Block Accessibility Enhancement
 * Converts Chroma-generated code blocks to accessible structure
 * Implements line number toggle for screen readers
 */

document.addEventListener('DOMContentLoaded', function() {
  // Find all code block wrappers
  const codeBlocks = document.querySelectorAll('.code-block-wrapper');

  codeBlocks.forEach(wrapper => {
    const pre = wrapper.querySelector('pre');
    const code = pre?.querySelector('code');

    if (!code) return;

    // Get code content - preserve Chroma's syntax highlighting structure
    const lines = [];

    // Chroma generates <span class="line"><span class="cl">content</span></span> structure
    if (code.children.length > 0) {
      Array.from(code.children).forEach((child) => {
        if (child.tagName === 'SPAN' && child.classList.contains('line')) {
          // Get the inner <span class="cl"> which contains the highlighted code
          const clSpan = child.querySelector('.cl');
          if (clSpan) {
            // Preserve the innerHTML of .cl which has all the syntax highlighting
            lines.push(clSpan.innerHTML);
          } else {
            // Fallback: use the whole line's innerHTML
            lines.push(child.innerHTML);
          }
        }
      });
    } else {
      // Fallback: split by newlines
      const textContent = code.textContent || code.innerText;
      textContent.split('\n').forEach(line => {
        lines.push(line);
      });
    }

    // Remove last empty line if exists
    if (lines.length > 0 && !lines[lines.length - 1].trim()) {
      lines.pop();
    }

    // Create new structure with line numbers
    const newContent = lines.map((lineContent, index) => {
      const lineNumber = index + 1;
      return `<span class="code-line">` +
        `<span class="line-no" aria-hidden="true" data-line="${lineNumber}">Line ${lineNumber}: </span>` +
        `<span class="line-content">${lineContent}</span>` +
        `</span>`;
    }).join('');

    // Replace code content
    code.innerHTML = newContent;

    // Prevent line numbers from being copied when dragging/selecting
    code.addEventListener('copy', (e) => {
      const selection = window.getSelection();
      if (!selection.rangeCount) return;

      const container = document.createElement('div');
      for (let i = 0; i < selection.rangeCount; i++) {
        container.appendChild(selection.getRangeAt(i).cloneContents());
      }

      container.querySelectorAll('.line-no').forEach(el => el.remove());
      e.clipboardData.setData('text/plain', container.textContent);
      e.preventDefault();
    });

    // Setup toggle button functionality
    const toggleButton = wrapper.querySelector('.line-numbers-toggle');
    if (toggleButton) {
      toggleButton.addEventListener('click', function() {
        const isPressed = this.getAttribute('aria-pressed') === 'true';
        const newState = !isPressed;

        // Update button state
        this.setAttribute('aria-pressed', newState);

        // Update wrapper class
        if (newState) {
          wrapper.classList.add('line-numbers-visible');
        } else {
          wrapper.classList.remove('line-numbers-visible');
        }

        // Update button text
        const toggleText = this.querySelector('.toggle-text');
        if (toggleText) {
          toggleText.textContent = newState ? '라인 넘버 읽기: ON' : '라인 넘버 읽기: OFF';
        }

        // Toggle aria-hidden on line numbers
        const lineNumbers = wrapper.querySelectorAll('.line-no');
        lineNumbers.forEach(lineNo => {
          if (newState) {
            lineNo.removeAttribute('aria-hidden');
          } else {
            lineNo.setAttribute('aria-hidden', 'true');
          }
        });

        // Announce state change
        const announcement = document.createElement('div');
        announcement.setAttribute('role', 'status');
        announcement.setAttribute('aria-live', 'polite');
        announcement.className = 'sr-only';
        announcement.textContent = newState
          ? '라인 넘버가 스크린 리더에서 읽힙니다'
          : '라인 넘버가 스크린 리더에서 숨겨졌습니다';
        document.body.appendChild(announcement);
        setTimeout(() => announcement.remove(), 1000);
      });
    }
  });

  // Setup help button functionality
  setupHelpButtons();
});

/**
 * Setup help button functionality with accessibility support
 */
function setupHelpButtons() {
  document.querySelectorAll('.code-block-wrapper').forEach(wrapper => {
    const helpButton = wrapper.querySelector('.line-numbers-help');
    const tooltip = wrapper.querySelector('.line-numbers-help-tooltip');
    const closeButton = tooltip?.querySelector('.help-close');

    if (!helpButton || !tooltip) return;

    // Toggle tooltip on help button click
    helpButton.addEventListener('click', (e) => {
      e.stopPropagation();
      const isExpanded = helpButton.getAttribute('aria-expanded') === 'true';

      if (isExpanded) {
        closeTooltip();
      } else {
        openTooltip();
      }
    });

    // Close tooltip on close button click
    if (closeButton) {
      closeButton.addEventListener('click', (e) => {
        e.stopPropagation();
        closeTooltip();
      });
    }

    // Close tooltip when clicking outside
    document.addEventListener('click', (e) => {
      if (!wrapper.contains(e.target)) {
        closeTooltip();
      }
    });

    // Close tooltip on Escape key
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape' && helpButton.getAttribute('aria-expanded') === 'true') {
        closeTooltip();
        helpButton.focus();
      }
    });

    function openTooltip() {
      helpButton.setAttribute('aria-expanded', 'true');
      tooltip.setAttribute('aria-hidden', 'false');
      if (closeButton) {
        setTimeout(() => closeButton.focus(), 100);
      }
    }

    function closeTooltip() {
      helpButton.setAttribute('aria-expanded', 'false');
      tooltip.setAttribute('aria-hidden', 'true');
    }
  });
}
```

### 4단계: CSS 스타일링

**파일: `assets/css/extended/chroma.css`** (주요 부분만 발췌)

```css
/* Code block wrapper */
.code-block-wrapper {
  position: relative;
  margin: 1.5rem 0;
}

/* Code header with controls */
.code-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.5rem 1rem;
  background: var(--code-bg);
  border: 1px solid var(--border);
  border-bottom: none;
  border-radius: 8px 8px 0 0;
}

.code-header-controls {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  position: relative;
}

/* Line numbers toggle button */
.line-numbers-toggle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.25rem 0.75rem;
  background: transparent;
  border: 1px solid var(--border);
  border-radius: 4px;
  color: var(--content);
  font-size: 0.8125rem;
  cursor: pointer;
  transition: all 0.2s ease;
}

.line-numbers-toggle:hover {
  background: var(--theme);
  border-color: var(--primary);
}

.line-numbers-toggle[aria-pressed="true"] {
  background: var(--primary);
  color: var(--theme);
  border-color: var(--primary);
}

/* Code line structure */
.code-block-wrapper pre code {
  counter-reset: line;
  display: block;
}

.code-line {
  counter-increment: line;
  position: relative;
  display: block;
  padding-left: 4.5em;
  min-height: 1.5em;
}

/* Visual line numbers (CSS counter - always visible) */
.code-line::before {
  content: counter(line);
  position: absolute;
  left: 0;
  width: 3.5em;
  padding-right: 1rem;
  text-align: right;
  color: #939ab7;
  opacity: 0.6;
  user-select: none;
  border-right: 1px solid var(--border);
}

/* Screen reader line numbers (HTML, controlled by aria-hidden) */
.line-no {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

/* Help tooltip styles */
.line-numbers-help-tooltip {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  width: 320px;
  max-width: calc(100vw - 2rem);
  padding: 1rem;
  background: var(--entry);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  z-index: 1000;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-8px);
  transition: all 0.2s ease;
}

.line-numbers-help-tooltip[aria-hidden="false"] {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}
```

### 5단계: Copy 버튼에서 라인 넘버 제외

기존 PaperMod 테마의 Copy 버튼을 수정하여 라인 넘버를 제외합니다.

**파일: `layouts/partials/footer.html`** (Copy 버튼 부분만 수정)

```javascript
copybutton.addEventListener('click', (cb) => {
    // Extract only code content, not line numbers
    const lineContents = codeblock.querySelectorAll('.line-content');
    let textToCopy = '';

    if (lineContents.length > 0) {
        const lines = Array.from(lineContents)
            .map(el => {
                const text = el.textContent || '';
                return text.replace(/\n$/, '');
            });

        if (lines.length > 0 && lines[lines.length - 1].trim() === '') {
            lines.pop();
        }

        textToCopy = lines.join('\n');
    } else {
        const container = codeblock.cloneNode(true);
        container.querySelectorAll('.line-no').forEach(el => el.remove());
        textToCopy = container.textContent;
    }

    navigator.clipboard.writeText(textToCopy);
    copyingDone();
});
```

---

## ✅ AI를 사용해 따라하기

"이 기능 좋은데, 우리 블로그에도 적용하고 싶어요!" 하시는 분들을 위해 준비했습니다.

**쉬운 꿀팁**: 복잡한 설명 대신, AI에게 바로 물어보세요! 우리 블로그에서는 **복사-붙여넣기만 하면 되는 프롬프트**를 제공합니다. Claude Code, Cursor, ChatGPT, 혹은 어떤 AI 도구를 쓰시든 상관없어요. 아래 프롬프트를 그대로 복사해서 AI에게 전달하면 끝입니다.

```
Hugo 블로그의 코드 블록 접근성을 개선하고 싶어요. 다음 기능을 구현해주세요:

1. Hugo config.yaml에서 lineNos를 false로 설정
2. layouts/_default/_markup/render-codeblock.html 생성
   - 언어 태그 표시
   - 라인 넘버 읽기 토글 버튼 (aria-pressed)
   - 도움말 버튼 (aria-expanded)
   - 도움말 툴팁 (role="tooltip")
3. assets/js/code-block-accessibility.js 생성
   - Chroma의 <span class="line"><span class="cl">...</span></span> 구조를
     <span class="code-line"><span class="line-no" aria-hidden="true">...</ span><span class="line-content">...</span></span>로 변환
   - 라인 넘버 토글 기능 (aria-hidden 제어)
   - 도움말 버튼 토글 (Escape 키, 외부 클릭 처리, 포커스 관리)
   - 드래그 복사 시 라인 넘버 제외 (copy 이벤트)
4. assets/css/extended/chroma.css 스타일링
   - CSS Counter로 시각적 라인 넘버 (::before)
   - .line-no는 sr-only 스타일
   - 도움말 툴팁 스타일 (슬라이드 애니메이션)
5. layouts/partials/footer.html의 Copy 버튼 수정
   - .line-content만 추출하여 복사
   - trailing newline 제거

참고: https://www.codeslog.com/ko/posts/code-block-accessibility-improvement/
```

이게 전부입니다. AI가 알아서 필요한 파일들을 생성하고 수정해줄 거예요. 작업이 끝나면 로컬에서 테스트해보고, 문제없으면 배포하시면 됩니다!

**수동 적용을 원하신다면**: 위 프롬프트를 AI에게 주면 자동으로 생성되지만, 직접 손으로 적용하고 싶다면 이 포스트의 구현 단계별 코드를 참고하세요.

**중요**:

- 생성된 코드는 반드시 **스크린 리더로 테스트**하세요 (NVDA, VoiceOver 등)
- Copy 기능이 정상 작동하는지 확인하세요 (드래그 복사 + Copy 버튼)

---

## 📊 개선 효과

### 1. 시맨틱한 HTML 구조

**변경 전 (테이블 기반):**

```html
<table class="lntable">
  <tbody>
    <tr>
      <td class="lntd">
        <pre><code><span class="lnt">1</span>
<span class="lnt">2</span></code></pre>
      </td>
      <td class="lntd">
        <pre><code class="language-javascript">const example = "Hello, World!";
console.log(example);</code></pre>
      </td>
    </tr>
  </tbody>
</table>
```

**변경 후 (접근성 친화적 구조):**

```html
<div class="highlight code-block-wrapper">
  <div class="code-header">
    <span class="code-lang">JAVASCRIPT</span>
    <div class="code-header-controls">
      <button class="line-numbers-toggle" aria-pressed="false">
        <!-- 라인 넘버 읽기 토글 -->
      </button>
      <button class="line-numbers-help" aria-expanded="false">
        <!-- 도움말 버튼 -->
      </button>
    </div>
  </div>
  <pre><code>
    <span class="code-line">
      <span class="line-no" aria-hidden="true">Line 1: </span>
      <span class="line-content">const example = "Hello, World!";</span>
    </span>
    <span class="code-line">
      <span class="line-no" aria-hidden="true">Line 2: </span>
      <span class="line-content">console.log(example);</span>
    </span>
  </code></pre>
</div>
```

훨씬 의미있는 구조입니다! 라인 넘버는 `aria-hidden`으로 제어되며, 사용자가 선택할 수 있습니다.

### 2. 스크린 리더 경험 획기적 개선

**변경 전 (테이블 기반):**
```
"표, 2개 열, 1개 행" → "셀 1, 1" → "1, 2, 3" → "셀 2, 1" → "const example..."
```

**변경 후 (라인 넘버 OFF - 기본값):**
```
"const example = Hello World" → "console log example"
```

**변경 후 (라인 넘버 ON - 사용자 선택 시):**
```
"Line 1: const example = Hello World" → "Line 2: console log example"
```

이제 스크린 리더 사용자가 **직접 선택**할 수 있습니다!
- **기본값 OFF**: 코드만 읽음 (대부분의 경우 적합)
- **ON**: 라인 넘버와 함께 읽음 (디버깅이나 협업 시 유용)

### 3. 복사 기능 완벽 지원

**드래그 복사:**
- `copy` 이벤트를 인터셉트하여 `.line-no` 요소 제거
- 라인 넘버 없이 코드만 복사됨

**Copy 버튼:**
- `.line-content`의 `textContent`만 추출
- 각 줄의 trailing newline 제거하여 깔끔한 복사

### 4. Syntax Highlighting 유지

Chroma가 생성한 `<span class="line"><span class="cl">...</span></span>` 구조를 그대로 보존:
- `.cl` span의 `innerHTML`을 추출하여 모든 syntax highlighting 클래스 유지
- 색상, 굵기, 스타일이 모두 그대로 표시됨

{{< img src="images/contents/code-syntax-highlighting.jpg" alt="다채로운 색상의 신택스 하이라이팅이 적용된 코드 화면" caption="사진: <a href='https://unsplash.com/ko/사진/많은-텍스트가-있는-컴퓨터-화면-TkZYCXmrKK4' target='_blank' title='새 창에서 열림'>Unsplash</a>의<a href='https://unsplash.com/ko/@nickkarvounis' target='_blank' title='새 창에서 열림'>Nick Karvounis</a>" >}}

### 5. 사용자 경험 (UX) 개선

**도움말 기능:**
- ? 버튼 클릭 시 기능 설명 툴팁 표시
- 키보드 접근성: Escape로 닫기, 포커스 관리
- 외부 클릭 시 자동 닫힘

**접근성 피드백:**
- 토글 버튼 상태 변경 시 `aria-live` announcement
- `aria-pressed`, `aria-expanded` 속성으로 상태 전달
- 버튼 텍스트도 상태에 따라 변경 (ON/OFF)

{{< img src="images/contents/after-accessibility-improvement.png" alt="개선 후 코드 블록 화면 - 라인 넘버 토글 버튼과 도움말 버튼이 추가되었습니다" >}}

---

## 💡 이 작업의 의미

### "접근성은 선택의 자유를 주는 것"

이번 작업을 통해 배운 가장 큰 교훈은 **접근성은 한 가지 정답이 없다**는 것입니다.

- 어떤 스크린 리더 사용자는 라인 넘버를 듣고 싶어 할 수 있습니다 (디버깅, 협업)
- 어떤 사용자는 라인 넘버 없이 코드만 듣고 싶어 할 수 있습니다 (일반적인 학습)

**정답을 강요하지 말고, 선택권을 주는 것** - 이것이 진정한 접근성입니다.

{{< img src="images/contents/user-choice-illustration.png" alt="사용자가 라인 넘버 읽기를 선택할 수 있는 토글 인터페이스 일러스트" caption="접근성은 한 가지 정답이 아닌, 사용자가 선택할 수 있는 자유를 제공하는 것입니다. (제작: 나노바나나)" >}}

### 구현의 진화 과정

이 기능은 여러 시행착오를 거쳐 완성되었습니다:

1. **1차 시도**: CSS Counter만 사용 → 스크린 리더가 전혀 읽지 못함
2. **2차 시도**: CSS `::after`로 스크린 리더용 텍스트 → `::after`는 스크린 리더가 읽지 않음
3. **최종**: HTML에 라인 넘버 + `aria-hidden` 토글 → 사용자가 선택!

실패를 통해 배운 것들:
- CSS 가상 요소는 접근성 트리에 없음
- `aria-hidden`은 강력하지만 조심히 사용해야 함
- 사용자에게 선택권을 주는 것이 가장 좋은 해결책

### Chroma와의 통합

Chroma의 신택스 하이라이팅을 유지하면서 구조를 변경하는 것이 가장 어려웠습니다:

- Chroma는 `<span class="line"><span class="cl">...</span></span>` 구조 생성
- `.cl` span 안에 모든 highlighting 클래스가 있음
- JavaScript로 DOM을 재구성하면서도 `.cl`의 innerHTML을 보존해야 함

결과적으로 **Syntax Highlighting이 전혀 손실되지 않았습니다**!

### 개발자로서의 책임

웹접근성은 단순히 **"체크리스트를 통과하는 것"**이 아닙니다. **"모든 사용자에게 좋은 경험을 제공하는 것"**입니다.

개발 블로그를 운영하는 개발자로서, 제 블로그가 **웹접근성의 모범 사례**가 되었으면 합니다. 이런 작은 개선 하나하나가 누군가에게는 영감이 될 수 있다고 믿습니다.

---

## 마무리

코드 블록 하나를 개선하는 작업이 이렇게 깊은 고민과 시행착오를 거쳤습니다. **테이블 구조 제거**, **CSS Counter**, **aria-hidden 토글**, **도움말 기능**까지 - 그 이면에는 **시맨틱 HTML**, **웹접근성**, **사용자 경험**에 대한 깊은 고민이 담겨 있습니다.

그리고 이제는 **AI를 활용**하면 이런 복잡한 작업도 쉽게 적용할 수 있습니다. 위의 프롬프트를 Claude Code나 Cursor에 붙여넣기만 하면, AI가 알아서 구현해줍니다.

작은 개선이지만, 이런 작은 개선들이 모여서 **더 나은 웹**을 만든다고 믿습니다.

여러분의 블로그나 시스템에도 이런 작은 개선 포인트가 숨어 있을지도 모릅니다. 작은 움직임이 모두를 위한 웹을 만들 수 있습니다.

{{< img src="images/contents/accessibility-web-development.png" alt="다양한 사람들이 함께 기술을 사용하는 모습 - 접근성 있는 웹은 모두를 위한 것입니다" caption="제작: 나노바나나" >}}

---

**참고 자료:**
- [Hugo Documentation - Syntax Highlighting](https://gohugo.io/content-management/syntax-highlighting/)
- [Hugo Render Hooks](https://gohugo.io/templates/render-hooks/)
- [MDN - CSS Counters](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Counter_Styles/Using_CSS_counters)
- [WCAG 2.2 - ARIA](https://www.w3.org/WAI/WCAG22/Understanding/)
- [MDN - aria-hidden](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden)
- [Claude Code](https://claude.ai/claude-code) / [Cursor](https://cursor.sh/)

