들어가며

한국어와 영어 두 언어로 블로그를 운영하다 보니 흥미로운 문제를 발견했습니다.

블로그 링크를 공유하다보면 한국어 페이지를 해외 사용자에게 공유하게 될 때도 있습니다. 한글을 못 읽는 분들은 당황하겠죠? 반대로 한국 커뮤니티에서 공유된 영문 포스트 링크를 클릭한 한국어 사용자도 마찬가지입니다.

헤더에 언어 전환 버튼이 있긴 하지만, 새로운 방문자가 그걸 찾기란 쉽지 않습니다. 이건 사용자에게 친절하지 않습니다. 넓은 의미의 접근성 관점에서도 좋은 방법이 아닙니다.

“사용자가 원하는 언어로 콘텐츠를 볼 수 있도록 친절하게 안내할 수 없을까?”

이런 고민에서 시작해서, 브라우저 언어를 감지하고 적절한 언어 버전을 제안하는 배너를 만들었습니다. 단순히 작동하는 것을 넘어, 모든 사용자가 사용할 수 있도록 접근성까지 고려해 구현했습니다.

이 글에서는 언어 감지부터 WCAG 2.2 AA 준수, 키보드 네비게이션, 스크린 리더 지원까지 전체 구현 과정을 공유합니다.

구현할 기능 설계하기

핵심 요구사항

1. 스마트한 언어 감지

  • 브라우저 언어 설정(navigator.language) 자동 감지
  • 한국어 사용자 + 영문 페이지 → 한국어 버전 제안
  • 비한국어 사용자 + 한국어 페이지 → 영어 버전 제안

2. 비침투적 UX

  • 화면을 너무 많이 가리지 않는 작은 배너
  • 하단 고정 위치 (콘텐츠 방해 최소화)
  • 1초 딜레이 후 표시 (페이지 로딩 방해 안 함)
  • 부드러운 슬라이드 업 애니메이션

3. 사용자 선택 존중

  • “계속 보기” 선택 시 다시 표시 안 함
  • localStorage에 선택 저장
  • ESC 키로 언제든 닫기 가능

4. 접근성

  • WCAG 2.2 AA 기준을 충족하도록 설계
  • 스크린 리더 지원
  • 키보드만으로 모든 기능 사용 가능
  • 터치 타겟 최소 24x24px (실제 44x44px)
codeslog의 언어 전환 배너 디자인
codeslog의 언어 전환 배너 디자인
웹페이지 하단에 언어전환 레이어를 구성

기술 스택 선택

프론트엔드:

  • Vanilla JavaScript (프레임워크 없이 가볍게)
  • CSS3 (CSS 변수로 테마 통합)
  • HTML5 (시맨틱 마크업)

Hugo 통합:

  • <link rel="alternate" hreflang> 태그 활용
  • Hugo의 translationKey 기반 언어 전환

접근성:

  • ARIA 속성 (role, aria-label, aria-live)
  • 시맨틱 HTML
  • 키보드 포커스 관리

브라우저 언어 감지하기

JavaScript의 navigator.language API로 사용자의 브라우저 언어를 감지할 수 있습니다.

javascript
function isKoreanUser() {
  const userLang = navigator.language || navigator.userLanguage;
  return userLang.startsWith('ko');
}

function isKoreanPage() {
  const htmlLang = document.documentElement.lang;
  return htmlLang === 'ko' || htmlLang.startsWith('ko-');
}

// 배너를 표시해야 하는 경우
const shouldShowBanner =
  (isKoreanUser() && !isKoreanPage()) ||  // 한국어 사용자가 영문 페이지
  (!isKoreanUser() && isKoreanPage());     // 비한국어 사용자가 한국어 페이지

주의할 점:

  • navigator.languageko-KR, en-US 같은 형식을 반환합니다
  • startsWith('ko')로 한국어 변형들(ko, ko-KR, ko-KP 등)을 모두 포착합니다
  • 서버 사이드가 아닌 클라이언트 사이드에서만 작동합니다
언어 감지 플로우차트 - 브라우저 언어 확인, 현재 페이지 언어 확인, 불일치 시 배너 표시하는 의사결정 흐름도
언어 감지 플로우차트 - 브라우저 언어 확인, 현재 페이지 언어 확인, 불일치 시 배너 표시하는 의사결정 흐름도
제작: 나노바나나

대체 언어 URL 찾기

언어를 감지했다면, 이제 사용자를 어디로 안내해야 할지 알아야 합니다. 각 페이지마다 대응하는 다른 언어 버전의 URL을 정확하게 찾는 것이 핵심입니다.

Hugo는 다국어 페이지에 <link rel="alternate" hreflang> 태그를 자동으로 생성합니다. 이 태그들을 활용하면 현재 페이지의 다른 언어 버전 URL을 쉽게 찾을 수 있습니다.

제 블로그의 경우 한국어가 기본 언어이므로:

  • 한국어: https://www.codeslog.com/posts/my-post/ (루트 경로)
  • 영어: https://www.codeslog.com/en/posts/my-post/ (언어 코드 포함)

Hugo가 생성하는 hreflang 태그:

html
<link rel="alternate" hreflang="ko" href="https://www.codeslog.com/posts/language-switcher-banner/" />
<link rel="alternate" hreflang="en" href="https://www.codeslog.com/en/posts/language-switcher-banner/" />

이 태그들을 JavaScript로 읽어서 대체 언어 URL을 찾습니다:

javascript
function getAlternateLanguageUrl() {
  const alternateLinks = document.querySelectorAll('link[rel="alternate"][hreflang]');

  for (const link of alternateLinks) {
    const hreflang = link.getAttribute('hreflang');
    const href = link.getAttribute('href');

    // 현재 한국어 페이지면 영어 링크 찾기
    if (isKoreanPage() && hreflang.startsWith('en')) {
      return href;
    }

    // 현재 영어 페이지면 한국어 링크 찾기
    if (!isKoreanPage() && hreflang.startsWith('ko')) {
      return href;
    }
  }

  // 대체 링크가 없으면 URL 패턴 변환 시도
  const currentPath = window.location.pathname;
  return isKoreanPage()
    ? `/en${currentPath}`
    : currentPath.replace(/^\/en\//, '/');
}

중요한 점:

두 언어의 포스트 파일(index.ko.md, index.en.md)에 translationKey가 반드시 동일해야 Hugo가 hreflang 링크를 생성합니다. 만약 translationKey가 다르면 Hugo가 두 페이지를 서로 번역본으로 인식하지 못해 링크가 누락됩니다.

yaml
# index.ko.md
translationKey: "my-post"

# index.en.md
translationKey: "my-post"  # 반드시 동일해야 함!

배너 UI 구현하기

HTML 구조 설계

단순히 보기 좋은 배너를 만드는 것이 아니라, 모든 사용자가 이해하고 조작할 수 있는 배너를 만들어야 합니다. 시맨틱 HTML과 적절한 ARIA 속성을 사용하면 스크린 리더 사용자도 배너의 목적과 기능을 정확히 파악할 수 있습니다.

javascript
const banner = document.createElement('div');
banner.id = 'language-switcher-banner';
banner.className = 'language-switcher-banner';
banner.setAttribute('role', 'region');
banner.setAttribute('aria-label', isKoreanPage() ? '언어 제안' : 'Language suggestion');
banner.setAttribute('aria-live', 'polite');
banner.setAttribute('tabindex', '-1');

banner.innerHTML = `
  <div class="banner-content">
    <span class="banner-icon" aria-hidden="true">🌐</span>
    <p class="banner-message">
      Would you like to read this page in English?
    </p>
    <div class="banner-actions">
      <a href="${alternateUrl}"
         class="banner-button banner-button-primary"
         lang="en">
        Switch to English
      </a>
      <button type="button"
              class="banner-button banner-button-secondary"
              data-action="dismiss"
              aria-label="Dismiss language suggestion and continue in Korean">
        Continue in Korean
      </button>
    </div>
  </div>
`;

접근성 포인트:

  • role="region": 랜드마크로 배너 영역을 정의합니다
  • aria-label: 스크린 리더에 배너 목적을 알립니다
  • aria-live="polite": 배너 표시 시 스크린 리더에 알림 (중단하지 않고)
  • lang="en": 링크 텍스트 언어를 명시합니다 (올바른 발음)
  • aria-label on button: 버튼 동작을 명확히 설명합니다
  • aria-hidden="true": 장식용 이모지를 스크린 리더에서 숨깁니다
배너 UI 컴포넌트 분해도 - 아이콘, 메시지, 주요 버튼, 보조 버튼으로 구성된 배너의 각 요소를 분해해서 보여주는 다이어그램
배너 UI 컴포넌트 분해도 - 아이콘, 메시지, 주요 버튼, 보조 버튼으로 구성된 배너의 각 요소를 분해해서 보여주는 다이어그램
배너 구성 요소를 시각적으로 분해한 다이어그램

CSS 스타일링

사용자의 테마 설정(라이트/다크 모드)에 따라 배너도 자연스럽게 어울려야 합니다. 하드코딩된 색상 대신 CSS 변수를 사용하면 테마가 전환될 때 배너도 자동으로 적절한 색상으로 변경됩니다.

css
.language-switcher-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 1000;
  background: var(--theme);
  border-top: 1px solid var(--border);
  box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
  padding: 1rem;
  transform: translateY(100%);
  transition: transform 0.3s ease-in-out;
}

.language-switcher-banner.is-visible {
  transform: translateY(0);
}

.banner-content {
  max-width: 900px;
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
}

/* WCAG 2.5.8: 터치 타겟 최소 크기 기준 상회 */
.banner-button {
  min-height: 44px;
  min-width: 44px;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  font-size: 0.875rem;
}

/* 키보드 포커스 표시 */
.banner-button:focus-visible {
  outline: 3px solid var(--primary);
  outline-offset: 2px;
}

반응형 디자인:

css
@media (max-width: 768px) {
  .banner-content {
    flex-direction: column;
    align-items: flex-start;
  }

  .banner-icon {
    display: none; /* 모바일에서는 아이콘 숨김 */
  }

  .banner-actions {
    width: 100%;
  }

  .banner-button {
    flex: 1;
    text-align: center;
  }
}

접근성 체계적으로 구현하기

웹 접근성은 단순히 법적 요구사항을 충족하기 위한 것이 아닙니다. 키보드만 사용하는 사람, 스크린 리더를 사용하는 시각장애인, 운동 장애가 있는 사람 등 다양한 사용자가 동등하게 웹을 이용할 수 있도록 보장하는 것입니다. 접근성은 나중에 추가하는 게 아니라, 처음부터 설계에 포함되어야 합니다.

접근성 체크리스트 - 키보드, 스크린 리더, 시각 표시, 터치 타겟 크기 등 접근성 요소들
접근성 체크리스트 - 키보드, 스크린 리더, 시각 표시, 터치 타겟 크기 등 접근성 요소들
배너 접근성 점검 항목 요약

1. 스크린 리더 지원

ARIA Live Regions:

javascript
banner.setAttribute('aria-live', 'polite');

배너가 표시될 때 스크린 리더가 자동으로 알림을 읽습니다. polite는 현재 읽고 있는 내용을 중단하지 않고 끝난 후 알립니다.

명확한 레이블:

javascript
banner.setAttribute('aria-label', isKoreanPage() ? '언어 제안' : 'Language suggestion');

스크린 리더 사용자가 배너의 목적을 바로 이해할 수 있습니다.

버튼 설명:

html
<button aria-label="언어 제안 닫기 및 영어로 계속하기">
  Continue in English
</button>

버튼 텍스트만으로는 부족할 수 있는 정보를 aria-label로 보충합니다.

2. 키보드 네비게이션

초기 포커스 이동:

배너가 표시되면 주요 액션 버튼으로 포커스를 이동해 사용자가 바로 조작할 수 있게 합니다. 포커스를 트랩하지 않고, 자연스러운 탭 순서를 유지합니다.

javascript
const primaryAction = banner.querySelector('.banner-button-primary');
primaryAction?.focus();

ESC 키로 닫기:

javascript
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    dismissBanner();
  }
});

키보드만 사용하는 사용자도 언제든 배너를 닫을 수 있습니다.

3. 시각적 포커스 표시

명확한 outline:

css
.banner-button:focus-visible {
  outline: 3px solid var(--primary);
  outline-offset: 2px;
}

.language-switcher-banner:focus-within {
  box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.15);
}

키보드 사용자가 현재 어디에 포커스가 있는지 명확하게 볼 수 있습니다.

키보드 네비게이션 예시
키보드 네비게이션 예시
Tab 이동과 포커스 표시 예시

4. 터치 타겟 크기

WCAG 2.2의 **2.5.8 Target Size (Minimum)**는 최소 24x24px를 요구합니다. 저는 실사용성을 위해 44x44px로 더 크게 잡았습니다.

css
.banner-button {
  min-height: 44px;
  min-width: 44px;
}

운동 장애가 있는 사용자나 모바일 사용자가 쉽게 탭할 수 있습니다.

5. 사용자 환경 설정 존중

모션 감소 설정:

css
@media (prefers-reduced-motion: reduce) {
  .language-switcher-banner {
    transition: none;
  }
}

전정 장애가 있는 사용자가 모션 감소 설정을 켜면 애니메이션이 비활성화됩니다.

고대비 모드:

css
@media (prefers-contrast: high) {
  .banner-button-primary {
    border-width: 2px;
  }
}

저시력 사용자를 위한 고대비 지원입니다.

localStorage로 선택 기억하기

사용자가 이미 “현재 언어로 계속 보겠다"고 선택했는데 다음 페이지에서 또 같은 질문을 하면 어떨까요? 사용성이 좋지 않습니다. 사용자의 선택을 존중하고 기억해서, 한 번 결정한 내용은 다시 묻지 않도록 해야 합니다. 이를 위해 localStorage를 활용합니다.

javascript
const STORAGE_KEY = 'language-preference-asked';

function dismissBanner() {
  const banner = document.getElementById('language-switcher-banner');
  if (!banner) return;

  banner.classList.remove('is-visible');

  setTimeout(() => {
    banner.remove();
  }, 300);

  // localStorage에 저장
  try {
    localStorage.setItem(STORAGE_KEY, 'true');
  } catch (e) {
    console.warn('Failed to save language preference:', e);
  }
}

function init() {
  // 이미 물어봤는지 확인
  try {
    if (localStorage.getItem(STORAGE_KEY) === 'true') {
      return; // 배너 표시 안 함
    }
  } catch (e) {
    console.warn('Failed to read language preference:', e);
  }

  // ... 배너 표시 로직
}

에러 처리:

프라이빗 브라우징 모드나 일부 보안 설정에서는 localStorage 접근이 차단될 수 있습니다. 이런 환경에서도 배너가 정상적으로 작동하도록 try-catch로 에러를 처리합니다. 저장이 실패하더라도 배너 자체는 문제없이 표시되고 닫힙니다.

localStorage 흐름도 - 사용자 선택 저장, 다음 방문 시 확인, 배너 표시/숨김 결정 과정을 보여주는 플로우차트
localStorage 흐름도 - 사용자 선택 저장, 다음 방문 시 확인, 배너 표시/숨김 결정 과정을 보여주는 플로우차트
선택 저장 및 재방문 처리 흐름

성능 최적화

배너 기능이 아무리 좋아도 페이지 로딩을 느리게 만들거나 사용자 경험을 해치면 안 됩니다. 가볍고 빠르게 작동하면서도 페이지의 핵심 콘텐츠 렌더링을 방해하지 않도록 몇 가지 최적화를 적용했습니다.

1. defer 로딩

html
<script src="{{ "js/language-switcher-banner.js" | relURL }}" defer></script>

스크립트를 defer로 로딩해서 페이지 렌더링을 방해하지 않습니다.

2. 1초 딜레이 표시

javascript
setTimeout(() => {
  showBanner();
}, 1000);

페이지가 로드되자마자 배너가 뜨면 사용자가 콘텐츠를 읽기 시작하려는 순간 방해받게 됩니다. 1초의 여유를 주면 사용자가 페이지에 안착하고 콘텐츠를 인식한 후에 배너가 표시되어 훨씬 자연스럽습니다.

3. 작은 번들 크기

  • JavaScript: 약 5KB (minified)
  • CSS: 약 3KB (minified)
  • 총 8KB로 매우 가볍습니다

4. 중복 실행 방지

javascript
if (localStorage.getItem(STORAGE_KEY) === 'true') {
  return; // 배너 표시 안 함
}

이미 선택한 사용자에게는 불필요한 JavaScript 실행을 건너뜁니다.

Hugo에 통합하기

1. CSS 추가

layouts/partials/extend_head.html:

html
<!-- Language Switcher Banner Styles -->
<link rel="stylesheet" href="{{ "css/language-switcher-banner.css" | relURL }}" />

2. JavaScript 추가

layouts/partials/extend_footer.html:

html
<!-- 언어 전환 제안 배너 -->
<script src="{{ "js/language-switcher-banner.js" | relURL }}" defer></script>

3. translationKey 확인

배너가 제대로 작동하려면 Hugo가 각 언어 페이지를 서로 연결할 수 있어야 합니다. 다국어 페이지의 translationKey가 동일한지 반드시 확인하세요:

yaml
# index.ko.md
translationKey: "my-post"

# index.en.md
translationKey: "my-post"

이 값이 다르면 Hugo가 두 페이지를 별개의 글로 인식해서 hreflang 링크가 생성되지 않고, 결과적으로 배너가 대체 URL을 찾을 수 없게 됩니다.

WCAG 2.2 준수 현황

구현한 배너는 WCAG 2.2 AA 기준 충족을 목표로 설계했습니다:

기준레벨상태구현 내용
1.3.1 Info and RelationshipsA시맨틱 HTML, ARIA 속성
1.4.1 Use of ColorA밑줄, 아이콘, 명확한 텍스트
1.4.3 ContrastAA4.5:1 이상 색상 대비
2.1.1 KeyboardATab, Shift+Tab, ESC 지원
2.1.2 No Keyboard TrapA포커스 트랩 없음, ESC로 배너 닫기 가능
2.4.3 Focus OrderA논리적 포커스 순서
2.4.7 Focus VisibleAA3px outline, focus-within
2.5.8 Target Size (Minimum)AA최소 24x24px (실제는 44x44px)
3.2.4 Consistent IdentificationAA일관된 버튼 레이블
4.1.2 Name, Role, ValueAARIA, 의미있는 레이블
4.1.3 Status MessagesAAaria-live=“polite”
WCAG 2.2 AA 준수 일러스트
WCAG 2.2 AA 준수 일러스트
WCAG 2.2 AA 기준 충족을 상징하는 배지

향후 개선할 점

현재 구현은 잘 작동하지만, 앞으로 개선할 수 있는 부분들이 있습니다:

1. 지리적 위치 감지

현재는 브라우저 언어만 감지하지만, Cloudflare Workers의 cf-ipcountry 헤더를 활용하면 더 정확할 수 있습니다.

javascript
// 미래 개선안
const userCountry = request.headers.get('cf-ipcountry');
if (userCountry === 'KR') {
  // 한국 IP → 한국어 제안
}

2. 더 많은 언어 지원

현재(2026년 1월 기준) 제 블로그(https://www.codeslog.com)는 한국어/영어 2개 언어만 지원하지만, 다국어를 지원중이라면 3개 이상 언어로 확장 가능합니다:

javascript
const supportedLanguages = ['ko', 'en', 'ja', 'zh'];
const userLang = navigator.language.split('-')[0];
const suggestedLang = supportedLanguages.includes(userLang)
  ? userLang
  : 'en'; // 기본값

3. A/B 테스트

저는 현재 Google Analytics를 설정해두고 몇가지 테스트를 하고 있는데요. 향후 사용성을 개선하기 위해 몇가지 시도를 할 생각입니다. 배너의 효과를 측정하기 위해 Google Analytics 이벤트를 추가할 수 있습니다:

javascript
// 배너 표시
gtag('event', 'language_banner_shown', {
  from_lang: currentLang,
  to_lang: suggestedLang
});

// 언어 전환 클릭
gtag('event', 'language_switched', {
  from_lang: currentLang,
  to_lang: suggestedLang
});

4. 쿠키 동의와 통합

GDPR을 준수해야 한다면, 쿠키 동의 배너와 통합할 수 있습니다:

javascript
if (!hasConsentForStorage()) {
  // localStorage 대신 세션스토리지 사용
  sessionStorage.setItem(STORAGE_KEY, 'true');
}

마무리

다국어 블로그를 운영한다면 언어 전환 UX는 정말 중요합니다. 사용자가 원하는 언어로 콘텐츠를 볼 수 있도록 친절하게 안내하는 것, 그것도 모든 사용자가 사용할 수 있는 방식으로 구현하는 것이 핵심입니다.

이번 구현에서 중요하게 생각한 원칙들:

  1. 비침투적 설계 - 콘텐츠를 방해하지 않으면서 필요할 때만 표시
  2. 사용자 선택 존중 - 강제하지 않고 선택권 제공
  3. 접근성 우선 - 처음부터 모든 사용자를 고려한 설계
  4. 성능 고려 - 가볍고 빠르게 작동

특히 접근성은 “있으면 좋은 것"이 아니라 필수입니다. 키보드만 사용하는 사용자, 스크린 리더를 사용하는 사용자, 운동 장애가 있는 사용자 모두가 불편 없이 사용할 수 있어야 합니다.

여러분의 다국어 블로그나 웹사이트에도 이런 기능이 필요하다면, 이 글이 도움이 되었으면 좋겠어요!

참고 자료