기능이 동작하는 것과 사용할 수 있는 것은 다르다
이전 장까지 만든 앱은 창을 열고, 파일을 저장하고, 메뉴와 단축키를 제공하며, 보안과 성능도 고려한다. 하지만 배포 가능한 앱이 되려면 한 단계가 더 필요하다. 다양한 사용자가 각자의 장치, 언어, 운영체제 설정 안에서 앱을 자연스럽게 사용할 수 있어야 한다.
접근성은 일부 사용자를 위한 부가 기능이 아니다. 키보드만으로 앱을 조작할 수 있는지, 포커스 위치가 분명한지, 스크린 리더가 버튼과 입력창의 의미를 읽을 수 있는지는 앱의 기본 품질이다. 국제화도 마찬가지다. 화면 문구를 영어로 바꾸는 수준을 넘어 날짜, 숫자, 단수·복수, 문장 순서가 언어에 따라 달라진다는 점을 구조에 반영해야 한다.
Electron 앱은 Chromium 기반 UI와 운영체제 네이티브 기능 사이에 걸쳐 있다. 그래서 웹 접근성 원칙과 데스크톱 앱 품질 기준을 함께 확인해야 한다.
AI 생성 이미지
키보드로 끝까지 사용할 수 있는 화면 만들기
접근 가능한 화면의 첫 기준은 키보드 탐색이다. 마우스 없이 Tab, Shift+Tab, Enter, Space, 방향키, Esc만으로 주요 기능을 사용할 수 있어야 한다.
가장 먼저 지킬 원칙은 네이티브 HTML 요소를 우선 사용하는 것이다.
<button type="button" id="saveButton">저장</button>
<label for="noteTitle">노트 제목</label>
<input id="noteTitle" type="text" />
<nav aria-label="노트 목록">
<ul>
<li><button type="button">회의 메모</button></li>
<li><button type="button">작업 계획</button></li>
</ul>
</nav>div에 클릭 이벤트를 붙여 버튼처럼 만드는 방식은 피한다. 버튼, 입력창, 체크박스, 선택 목록은 이미 키보드 동작과 접근성 정보가 포함되어 있다. 입력창에는 보이는 레이블을 연결하고, 목록이나 탐색 영역에는 사용자가 어떤 영역에 있는지 알 수 있는 이름을 제공한다. 부득이하게 커스텀 컴포넌트를 만들어야 할 때만 role, aria-*, 키보드 이벤트를 직접 설계한다.
포커스 표시도 제거하면 안 된다. CSS에서 outline: none을 사용했다면 반드시 대체 표시를 제공해야 한다.
:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
button:focus-visible,
.list-item:focus-visible {
box-shadow: 0 0 0 3px var(--focus-ring);
}포커스는 사용자가 지금 어디에 있는지 알려 주는 지도다. 디자인상 어색하다는 이유로 숨기면 키보드 사용자와 저시력 사용자는 앱의 상태를 잃어버린다.
대화상자처럼 사용자의 주의를 한 영역에 묶어야 하는 UI에서는 포커스 이동도 관리해야 한다. 설정 창이나 확인 모달이 열리면 첫 번째 조작 가능한 요소로 포커스를 보내고, 닫을 때는 원래 버튼으로 포커스를 돌려준다.
let lastFocusedElement = null;
function openConfirmDialog() {
lastFocusedElement = document.activeElement;
const dialog = document.querySelector('#confirmDialog');
dialog.hidden = false;
const firstFocusable = dialog.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
}
function closeConfirmDialog() {
const dialog = document.querySelector('#confirmDialog');
dialog.hidden = true;
lastFocusedElement?.focus();
}모달 내부에서는 Esc로 닫기, Enter로 기본 동작 실행, Tab 순환을 고려한다. HTML의 <dialog> 요소를 사용할 수도 있지만, 어떤 방식을 택하든 포커스가 배경 화면으로 새어 나가지 않는지 직접 확인해야 한다. Electron의 BrowserWindow로 별도 설정 창을 띄우는 경우에도 같은 원칙을 적용한다. 새 창이 열렸다면 어떤 창이 활성화되었는지, 닫힌 뒤 어느 창으로 돌아가는지 사용자가 예측할 수 있어야 한다.
ARIA는 의미를 보완할 때만 사용한다
ARIA는 화면 요소의 의미와 상태를 보조 기술에 알려 주는 속성이다. 하지만 잘못 쓰면 없는 것보다 나빠진다. 기본 원칙은 "가능하면 HTML 의미 요소를 쓰고, 부족할 때 ARIA로 보완한다"이다.
예를 들어 저장 상태를 화면에 표시한다면 시각적으로만 색을 바꾸지 말고, 스크린 리더가 읽을 수 있는 영역을 둔다.
<p id="saveStatus" aria-live="polite">모든 변경 사항이 저장되었습니다.</p>aria-live="polite"는 내용이 바뀌었을 때 보조 기술이 적절한 시점에 읽도록 요청한다. 저장 성공, 동기화 완료, 네트워크 재시도처럼 사용자가 알아야 하지만 즉시 작업을 멈출 필요는 없는 상태에 어울린다. 삭제 실패, 권한 거부처럼 바로 알려야 하는 오류는 더 눈에 띄는 시각 표현과 함께 별도의 알림 영역을 두고, 상황에 따라 role="alert"처럼 즉시 전달되는 방식을 사용할 수 있다.
아이콘 버튼에는 이름이 필요하다.
<button type="button" aria-label="노트 삭제">
<svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
</button>텍스트가 없는 버튼은 화면을 보는 사용자에게는 아이콘으로 이해될 수 있지만, 스크린 리더에는 "버튼"으로만 들릴 수 있다. aria-label로 동작을 명확히 적는다.
반대로 장식용 아이콘은 aria-hidden="true"로 숨긴다. 읽을 필요가 없는 요소가 많아지면 보조 기술 사용자에게 잡음이 된다.
Electron에서 접근성 동작 확인하기
Electron의 화면은 Chromium 렌더러에서 동작하므로 웹 접근성 원칙을 따른다. 하지만 최종 사용자는 브라우저가 아니라 데스크톱 앱으로 실행한다. 따라서 운영체제의 보조 기술에서 실제로 확인해야 한다.
권장 확인 절차는 다음과 같다.
모든 주요 기능을 마우스 없이 실행한다.
Tab순서가 화면의 시각적 흐름과 맞는지 확인한다.포커스 표시가 밝은 테마와 어두운 테마 모두에서 보이는지 확인한다.
스크린 리더로 버튼 이름, 입력창 레이블, 오류 메시지, 저장 상태를 읽어 본다.
메뉴, 단축키, 트레이, 알림처럼 운영체제와 연결된 기능도 보조 기술에서 어색하지 않은지 확인한다.
운영체제별로 대표적인 스크린 리더가 다르다. Windows에서는 Narrator나 NVDA, macOS에서는 VoiceOver, Linux에서는 Orca를 기준으로 확인할 수 있다. 모든 조합을 완벽하게 테스트하기 어렵다면, 최소한 주 배포 대상 운영체제에서 핵심 흐름을 직접 들어 봐야 한다.
개발 중에는 Electron의 접근성 지원 상태를 확인할 수 있다.
const { app } = require('electron');
app.whenReady().then(() => {
console.log('accessibility:', app.isAccessibilitySupportEnabled());
});필요한 경우 app.setAccessibilitySupportEnabled(true)로 접근성 지원을 강제로 켤 수 있다. 다만 이것은 테스트와 진단을 돕는 도구이지, 접근성 구현을 대신하지 않는다. 화면 구조가 잘못되어 있거나 버튼 이름이 없다면 접근성 지원을 켜도 사용자 경험은 나아지지 않는다.
다국어 리소스는 코드에서 분리한다
국제화를 나중에 붙이려고 하면 문구가 코드 곳곳에 흩어진다. 초기에는 한국어와 영어만 지원하더라도 리소스 구조를 먼저 나누는 편이 낫다.
예를 들어 다음처럼 언어별 JSON 파일을 둘 수 있다.
src/
locales/
ko.json
en.json{
"app.title": "작업 노트",
"note.save": "저장",
"note.saved": "모든 변경 사항이 저장되었습니다.",
"note.deleteConfirm": "이 노트를 삭제할까요?"
}{
"app.title": "Work Notes",
"note.save": "Save",
"note.saved": "All changes have been saved.",
"note.deleteConfirm": "Delete this note?"
}렌더러에서는 직접 문자열을 쓰지 않고 번역 함수를 거친다.
const messages = await window.appSettings.getMessages();
function t(key, params = {}) {
const template = messages[key] ?? key;
return template.replace(/{(w+)}/g, (_, name) => {
return params[name] ?? '';
});
}
saveButton.textContent = t('note.save');
saveStatus.textContent = t('note.saved');규모가 커지면 i18next, FormatJS 같은 라이브러리를 사용할 수 있다. 중요한 것은 라이브러리 선택보다 문구를 코드와 분리하고, 화면 단위가 아니라 의미 단위의 키로 관리하는 것이다.
문장을 조각내서 조합하는 방식은 피한다.
// 피해야 할 방식
const message = count + ' ' + t('note.unit') + ' ' + t('note.savedSuffix');언어마다 숫자의 위치, 단수·복수 규칙, 어순이 다르다. 문장 전체를 번역 리소스로 두고 숫자만 변수로 넣는 편이 안전하다.
{
"note.count": "{count}개의 노트가 있습니다."
}영어처럼 단수와 복수 표현이 달라지는 언어는 복수형 규칙도 함께 고려해야 한다.
{
"note.count.one": "You have {count} note.",
"note.count.other": "You have {count} notes."
}function noteCountMessage(count, locale) {
const rule = new Intl.PluralRules(locale).select(count);
return t(`note.count.${rule}`, { count });
}실제 프로젝트에서는 직접 복수형 함수를 계속 늘리기보다 국제화 라이브러리의 메시지 포맷 기능을 사용하는 편이 안전하다. 핵심은 번역자가 문장 전체를 보고 자연스럽게 바꿀 수 있는 구조를 만드는 것이다.
날짜, 숫자, 정렬은 Intl을 사용한다
날짜와 숫자는 문자열 치환으로 처리하지 않는다. 사용자의 언어와 지역 설정에 따라 2026. 5. 28., May 28, 2026, 28/05/2026처럼 표기가 달라진다. JavaScript의 Intl API를 사용하면 운영체제와 브라우저 환경의 지역화 데이터를 활용할 수 있다.
function formatSavedAt(date, locale) {
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date);
}
function formatFileSize(bytes, locale) {
return new Intl.NumberFormat(locale, {
style: 'unit',
unit: 'byte',
unitDisplay: 'short'
}).format(bytes);
}위 예시는 바이트 값을 지역화된 단위 표기로 보여 주는 정도에 적합하다. 사용자가 기대하는 KB, MB, GB 단위로 자동 변환하려면 별도의 변환 로직을 거친 뒤 Intl.NumberFormat으로 숫자와 단위를 표시하는 식으로 나누는 편이 좋다.
정렬도 주의해야 한다. 한국어, 영어, 숫자가 섞인 노트 제목을 단순히 sort()로 정렬하면 사용자 기대와 다를 수 있다.
const collator = new Intl.Collator(locale, {
numeric: true,
sensitivity: 'base'
});
notes.sort((a, b) => collator.compare(a.title, b.title));언어 설정은 운영체제 언어를 기본값으로 삼되, 앱 내부에서 바꿀 수 있게 제공하는 것이 좋다. Electron의 main 프로세스에서 app.getLocale()을 읽고, 사용자 설정이 있으면 그 값을 우선 적용한다.
const { app, ipcMain } = require('electron');
ipcMain.handle('locale:get', async () => {
const savedLocale = await loadUserLocale();
return savedLocale ?? app.getLocale();
});언어 선택은 UI 문구만 바꾸는 기능이 아니다. 날짜, 숫자, 메뉴 레이블, 오류 메시지, 접근성 레이블, 알림 문구까지 같은 기준으로 바뀌어야 한다.
운영체제별 차이를 숨기지 말고 다룬다
Electron은 하나의 코드베이스로 여러 운영체제에 배포할 수 있게 해 주지만, 모든 차이를 없애 주지는 않는다. 완성도 있는 앱은 차이를 무시하지 않고 각 플랫폼의 관례를 따른다.
폰트와 텍스트 렌더링
운영체제마다 기본 폰트와 글자 높이가 다르다. 특정 폰트에 맞춰 버튼 높이와 줄 간격을 고정하면 다른 환경에서 텍스트가 잘릴 수 있다.
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
}
button,
input,
textarea {
font: inherit;
}한글과 영문이 섞이는 앱에서는 줄 높이와 입력창 높이를 여유 있게 잡는다. 텍스트 확대, 운영체제 배율, 고해상도 화면에서도 레이아웃이 깨지지 않는지 확인한다.
창 장식과 메뉴 위치
macOS는 앱 메뉴가 화면 상단 메뉴 바에 있고, 창 제어 버튼이 왼쪽에 있다. Windows와 Linux는 대체로 창 안쪽 상단에 메뉴와 제어 버튼이 있다. 프레임 없는 창을 만들 때는 특히 주의해야 한다.
const mainWindow = new BrowserWindow({
width: 1100,
height: 760,
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
});커스텀 타이틀 바를 만들면 드래그 영역, 더블 클릭 최대화, 창 제어 버튼, 접근성 이름, 고대비 표시까지 직접 책임져야 한다. 특별한 브랜드 경험이 필요한 경우가 아니라면 운영체제 기본 창 장식을 사용하는 편이 유지보수에 유리하다.
파일 경로와 권한
파일 경로는 문자열 결합으로 만들지 않는다. Windows는 드라이브 문자와 역슬래시를 사용하고, Linux와 macOS는 슬래시 기반 경로를 사용한다. 대소문자 구분도 파일 시스템에 따라 다르다.
const path = require('node:path');
const { app } = require('electron');
const dataDir = path.join(app.getPath('userData'), 'notes');사용자 데이터는 app.getPath('userData') 아래에 저장한다. 사용자가 직접 선택한 문서와 앱 내부 설정 파일은 위치와 백업 정책이 다르다. macOS의 권한 요청, Windows의 보안 경고, Linux 배포 방식별 샌드박스 정책도 파일 접근에 영향을 줄 수 있다. 권한 오류는 예외로만 처리하지 말고 사용자에게 복구 방법을 안내해야 한다.
다크 모드와 고대비 모드 반영하기
테마는 색상 취향만의 문제가 아니다. 다크 모드, 고대비 모드, 시스템 대비 설정은 눈부심을 줄이고 정보를 구분하기 위한 사용자의 환경 설정이다.
렌더러에서는 CSS 미디어 쿼리로 시스템 테마를 반영할 수 있다.
:root {
--bg: #ffffff;
--fg: #1f2328;
--border: #d0d7de;
--focus-ring: #0969da;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--fg: #e6edf3;
--border: #30363d;
--focus-ring: #58a6ff;
}
}
@media (forced-colors: active) {
:focus-visible {
outline: 2px solid Highlight;
}
button {
border: 1px solid ButtonText;
}
}
body {
background: var(--bg);
color: var(--fg);
}Electron main 프로세스에서는 nativeTheme으로 현재 테마를 확인하고 변경 이벤트를 받을 수 있다.
const { BrowserWindow, ipcMain, nativeTheme } = require('electron');
ipcMain.handle('theme:get', () => ({
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors
}));
nativeTheme.on('updated', () => {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('theme:updated', {
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors
});
}
});앱에 자체 테마 설정을 제공한다면 선택지는 보통 세 가지다.
시스템 설정 따르기
밝은 테마 고정
어두운 테마 고정
기본값은 시스템 설정을 따르는 것이 무난하다. 사용자가 운영체제에서 정한 접근성 설정을 앱이 무시하지 않기 때문이다. 색상을 설계할 때는 배경색과 글자색의 대비를 확인하고, 정보 전달을 색에만 의존하지 않는다. 예를 들어 오류는 빨간색만 쓰지 말고 아이콘, 텍스트, aria-live 메시지를 함께 제공한다.
배포 전 품질 체크리스트
접근성, 국제화, 플랫폼 품질은 마지막 하루에 몰아서 확인하기 어렵다. 기능을 만들 때마다 같은 기준으로 점검해야 한다.
배포 전에는 다음 항목을 확인한다.
키보드만으로 노트 생성, 편집, 저장, 삭제, 검색을 수행할 수 있는가?
현재 포커스 위치가 모든 테마에서 명확히 보이는가?
버튼, 입력창, 메뉴, 상태 메시지가 스크린 리더에서 의미 있게 읽히는가?
한국어와 영어에서 문장이 잘리지 않고 레이아웃이 유지되는가?
날짜, 숫자, 파일 크기, 정렬이 지역 설정에 맞게 표시되는가?
Windows, macOS, Linux에서 메뉴 위치와 창 동작이 플랫폼 관례에 맞는가?
파일 경로 처리와 권한 오류 안내가 운영체제별로 동작하는가?
다크 모드, 고대비 모드, 텍스트 확대에서 주요 화면을 사용할 수 있는가?
이 항목들은 새로운 기능을 추가할 때마다 반복해서 확인해야 한다. Electron 앱은 웹 기술로 만들지만 사용자는 데스크톱 앱으로 평가한다. 접근성, 언어, 플랫폼 차이를 반영하면 앱은 단순히 실행되는 프로그램을 넘어 실제 환경에서 오래 사용할 수 있는 도구가 된다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.