Electron i18n을 유지보수 가능하게 만든 여섯 가지 결정
locale 설정, 타입이 지정된 카탈로그, 폴백 동작, Intl 포맷, renderer에서 main 프로세스로의 동기화를 다루는 소규모 Electron i18n 설계를 설명합니다.
11분 분량가이드작성자: Temposhot Team
언어를 5개에서 10개 지원하는 데스크톱 앱에는 큰 i18n 프레임워크가 거의 필요하지 않습니다. 다만 명확한 규칙 몇 가지는 필요합니다. Electron 앱에는 텍스트를 표시하는 프로세스가 둘 있기 때문입니다. renderer는 UI를 그리고, main 프로세스는 메뉴, 대화상자, 알림을 담당합니다. 아래 여섯 가지 결정은 앱과 카탈로그가 커져도 직접 작성한 소규모 i18n 레이어가 올바르게 동작하도록 유지합니다.
1. locale 대신 사용자 설정을 저장합니다
사용자가 독일어를 고를 때 "de"를 저장하는 방식은 한동안 충분해 보입니다. 하지만 앱이 운영체제 언어를 따르기를 원하는 사용자가 생기면 문제가 됩니다. 사용자의 선택 의도를 저장하고, 실제 locale은 실행 시점에 결정합니다:
export const SUPPORTED = ["en", "de", "it", "ja", "ko", "zh-Hans"] as const;
export type Locale = (typeof SUPPORTED)[number];
export type LanguagePreference = "system" | Locale;
const ALIASES: Record<string, Locale> = { "zh-cn": "zh-Hans", "zh-sg": "zh-Hans" };
export function resolveLocale(pref: LanguagePreference, systemLanguages: string[]): Locale {
if (pref !== "system") return pref;
for (const tag of systemLanguages) {
const lower = tag.toLowerCase();
const exact = SUPPORTED.find((l) => lower === l.toLowerCase() || lower.startsWith(l.toLowerCase() + "-"));
if (exact) return exact;
if (ALIASES[lower]) return ALIASES[lower];
}
return "en";
}
main 프로세스에서 app.getPreferredSystemLanguages()는 운영체제가 정한 순서의 언어 목록을 반환합니다. "system"이 저장되어 있으면 사용자가 OS 언어를 바꾼 뒤 다음 실행부터 새 언어가 적용되며, 앱 설정은 바꿀 필요가 없습니다.
resolver는 목록의 첫 항목만 확인하지 않고 전체 설정 목록을 순회합니다. 포르투갈어, 영어 순으로 설정한 사용자는 영어를 받습니다. 영어가 기본값이어서가 아니라 두 번째 선택이기 때문입니다. 접두사 매칭으로 de-CH는 독일어에, zh-Hans-CN은 간체 중국어에 대응합니다. zh-TW는 어떤 항목과도 맞지 않으므로, 간체 중국어로 바뀌지 않고 다음 설정으로 넘어갑니다.
결정 순서
함수는 처음 일치하는 값에서 반환합니다. 검사 순서가 중요합니다. 저장된 locale이 시스템 목록에 의해 덮어써지면 안 되기 때문입니다.
시스템 목록에 지원 언어가 둘 이상 있으면 앞쪽 항목이 선택됩니다. macOS는 Language & Region에서 사용자가 설정한 순서대로 목록을 유지하므로, 첫 번째 지원 항목이 사용자의 첫 번째 지원 선택입니다.
실제 저장값 세 가지
아래 표는 같은 함수가 세 사용자를 어떻게 처리하는지 보여줍니다. 각 행은 저장된 값과 운영체제 목록을 넣은 resolveLocale 호출 한 번입니다.
사용자 1은 프랑스어를 먼저 선택했지만 앱은 프랑스어를 제공하지 않습니다. 반복문은 fr-FR을 건너뛰고, 두 번째 항목인 de-DE와 맞춰 독일어를 반환합니다. 사용자 3은 다른 이유로 영어를 받습니다. zh-TW는 별칭 표에 없고 간체 중국어에도 접두사로 맞지 않으므로, 반복문은 en-GB까지 진행해 영어를 반환합니다. 사용자 2는 시스템 목록을 전혀 읽지 않습니다. 저장된 값이 "system"이 아니라 locale이기 때문입니다.
제거된 locale의 저장값
다음 릴리스에서 언어가 제거될 수 있습니다. 그러면 저장된 값이 SUPPORTED에 없어지고 카탈로그 파일도 찾을 수 없습니다. 설정을 불러올 때 알 수 없는 저장값을 "system"으로 바꾸고, 수정된 값을 한 번 다시 저장하여 저장 파일이 코드와 일치하도록 합니다. 사용자는 업데이트 후 첫 실행에서도 사용할 수 있는 언어를 유지합니다.
2. 잘못 고른 뒤에도 언어 목록을 읽을 수 있게 유지합니다
사용자가 실수로 일본어를 고르면 모든 언어 이름이 일본어로 표시되어 "English"를 다시 찾기 어려워집니다. 각 언어는 그 언어 자체로 표시합니다(endonym). 그러면 현재 UI 언어와 관계없이 목록이 같게 보입니다:
export const LANGUAGE_NAMES: Record<Locale, string> = {
en: "English",
de: "Deutsch",
it: "Italiano",
ja: "日本語",
ko: "한국어",
"zh-Hans": "简体中文",
};
Intl.DisplayNames로도 이 이름을 만들 수 있지만, 스크립트 변형의 출력이 엔진과 버전마다 다릅니다. 고정된 여섯 개 문자열 표가 리뷰하기 쉽습니다. 언어 목록에서 번역되는 항목은 "System default"에 해당하는 문구 하나뿐입니다. 언어 이름이 아니라 문구이기 때문입니다.
Temposhot의 적용 사례
Temposhot Mac 앱의 Language 메뉴도 같은 endonym 방식을 사용합니다. 메뉴는 Settings의 General 탭에 있으며 항목은 7개입니다. 각 언어 이름은 해당 언어로 표기됩니다.
표의 마지막 행이 이 글의 독자에게 중요합니다. 앱은 선택값을 저장하고 즉시 locale을 전환합니다. 다만 나머지 여섯 개 언어의 번역이 배포되기 전까지는 전환 후에도 일부 인터페이스 텍스트가 영어로 표시됩니다.
메뉴 순서
메뉴는 SUPPORTED의 순서를 따르며 English가 첫 항목입니다. 순서가 고정되어 있으면 테스트하기 쉽고, 번역자가 이름을 수정해도 순서가 바뀌지 않습니다. endonym으로 정렬하면 문자 체계마다 다른 정렬 규칙에 따라 순서가 달라지므로, pull request에서 검토하기 어려워집니다.
locale을 추가하려면 SUPPORTED에 항목을 덧붙입니다. 그러면 LANGUAGE_NAMES의 Record<Locale, string> 타입이 새 항목의 이름이 생길 때까지 빌드를 실패시킵니다. 따라서 메뉴에 표시되는 이름 없이는 locale이 resolver에 들어갈 수 없습니다.
릴리스 전 메뉴 확인
언어 메뉴는 단위 테스트가 잡지 못하는 방식으로 깨지는 경우가 있습니다. 잘못된 문자 체계로 표시된 이름, 누락된 항목, 저장되지 않는 항목이 그 예입니다. 릴리스 전마다 짧은 수동 점검을 하면 이런 문제를 잡을 수 있습니다.
시스템 언어가 영어가 아닌 Mac에서 점검합니다. 영어 시스템에서의 점검은 영어 경로만 검증합니다.
3. 영어 카탈로그를 TypeScript 계약으로 사용합니다
영어를 원본으로 두고 타입을 여기서 파생합니다:
export const en = {
"settings.language": "Language",
"settings.language.system": "System default",
"capture.count": "{count} screenshots",
} as const;
export type MessageKey = keyof typeof en;
export type Catalog = Record<MessageKey, string>;
이제 t("settings.language")의 모든 호출이 컴파일러 검사를 받습니다. 오타나 영어 파일에서 제거된 키는 빌드를 실패시킵니다. 번역자는 다른 카탈로그에서 작업하지만, 영어 파일에 없는 키는 추가할 수 없습니다. 따라서 영어 파일에는 UI가 사용하는 모든 키가 항상 들어 있습니다.
파일 편집 담당
엔지니어는 en.ts를 편집합니다. 번역자는 locale 파일을 하나씩 편집하며 en.ts는 변경하지 않습니다. en.ts에만 키를 추가하는 pull request도 컴파일러는 통과합니다. 다른 카탈로그가 partial 타입으로 지정되어 있기 때문입니다. 대신 완성도 테스트가 locale별 누락 키를 나열하므로, 팀은 릴리스에 필요한 미완성 번역을 한곳에서 확인합니다.
키 추가
새 키는 네 곳에 영향을 줍니다. 아래 순서를 따르면 첫 단계부터 컴파일러가 도움을 줍니다.
마지막 단계는 팀이 자주 건너뛰는 단계입니다. 독일어와 핀란드어 문자열은 영어보다 긴 경우가 많아서, 영어에서는 맞던 버튼 라벨이 줄바꿈되거나 잘릴 수 있습니다. 컴파일러와 테스트는 이를 확인할 수 없으므로, 가장 긴 문자열에 대한 시각 점검이 여전히 필요합니다.
카탈로그 폴더 구조
폴더에는 locale 파일이 하나씩 있고, 이를 읽는 보조 파일이 함께 있습니다. 평평한 구조는 import 경로를 짧게 유지하고, 번역자가 편집할 파일을 분명하게 보여줍니다.
en.ts만 as const 객체를 export합니다. 다른 카탈로그 파일은 index.ts에서 Partial<Catalog>를 import하므로, 번역 파일이 영어에 없는 키를 정의할 수 없습니다.
키 이름 짓기
각 키 앞에 사용하는 화면이나 영역 이름을 붙입니다. settings.language와 capture.count는 코드를 열지 않아도 문자열이 어디에 있는지 보여주며, settings.로 검색하면 해당 화면의 모든 문자열이 나옵니다. 영어 문장을 키로 사용하지 않습니다. "Language"처럼 문장을 키로 쓰면, 나중에 문구를 고칠 때 텍스트와 함께 키도 바꿔야 합니다.
의미당 키 하나
키는 문장의 의미를 따르며, 그 문장이 표시되는 곳의 수를 따르지 않습니다. 예시 앱은 결과 헤더, 상태 표시줄, 내보내기 대화상자에 캡처 개수를 표시합니다. 세 곳 모두 capture.count를 사용하고, 호출하는 쪽이 {count}를 채웁니다. 번역자는 문구를 한 번만 고치면 세 곳에 모두 반영됩니다. 같은 영어 문구를 가진 두 번째 키가 있었다면 시간이 지나면서 번역이 서로 어긋났을 것입니다.
4. 런타임 폴백과 완성도 검사를 함께 유지합니다
다른 카탈로그는 번역 도구에서 JSON으로 들어오는 경우가 많아, 컴파일러가 그 내용을 보장할 수 없습니다. 이들을 Partial<Catalog>로 지정하고, 런타임에 키 단위로 영어 값으로 대체합니다:
let current: Partial<Catalog> = {};
export function setCatalog(next: Partial<Catalog>) {
current = next;
}
export function t(key: MessageKey, vars: Record<string, string | number> = {}): string {
const template = current[key] ?? en[key];
return template.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? `{${name}}`));
}
폴백은 한 문자열의 번역이 늦어도 릴리스를 계속 사용할 수 있게 합니다. 하지만 이 격차를 가리기도 하므로, 배포되는 locale이 불완전하면 실패하는 테스트를 추가합니다. 폴백은 사용자를 보호하고, 테스트는 팀이 폴백을 실수로 배포하지 않도록 막습니다. 둘 다 필요합니다. 아래 테스트 절에서 이 검사를 설명합니다.
누락된 문자열의 모습
아래 예시는 설정 화면 번역이 끝나기 전에 잘린 독일어 카탈로그를 사용합니다. 수치는 예시의 값이며 배포된 빌드의 값이 아닙니다.
사용자는 세 개 라벨은 영어로, 나머지는 독일어로 보게 됩니다. 화면에 settings.language 같은 원시 키가 표시되면 더 나쁜 결과입니다. 영어에는 모든 키가 항상 정의되어 있으므로 폴백이 이를 막습니다.
누락된 변수는 화면에 남김
vars에 값이 없으면 t 함수는 출력에 {name}을 그대로 남깁니다. 앱은 계속 실행되고, 리뷰어는 스크린샷이나 테스트 실패에서 채워지지 않은 placeholder를 볼 수 있습니다. 조용히 빈 문자열이 되는 것보다 placeholder가 찾기 쉽고, 화면의 나머지 부분은 정상적으로 렌더링됩니다.
폴백 경로 테스트
테스트에서 실행되지 않는 폴백은 아무도 모르게 깨질 수 있습니다. 빈 카탈로그를 사용하는 짧은 테스트로 이 경로를 검증합니다:
it("falls back to English for a missing key", () => {
setCatalog({});
expect(t("settings.language")).toBe("Language");
});
다른 테스트에서는 실제 카탈로그로 setCatalog를 호출하여 각 테스트가 알려진 상태에서 시작하게 합니다.
완성도 테스트는 디스크의 파일이 아니라 SUPPORTED에서 locale 목록을 읽어야 합니다. 그래야 목록에 없는 파일이 실수로 배포되지 않고, 목록에 있는데 파일이 없는 locale은 즉시 테스트에서 실패합니다.
폴백은 키 단위로 동작함
폴백은 화면 전체가 아니라 키마다 적용됩니다. 독일어 카탈로그에서 설정 키 42개 중 40개만 번역되었다면, 누락된 두 라벨만 영어로 표시되고 나머지 40개는 독일어로 표시됩니다. 화면 단위 폴백은 누락된 두 개 때문에 완성된 40개 문자열까지 영어로 표시하므로, 사용자에게 더 나쁜 결과입니다.
5. 메시지 카탈로그 밖에서 날짜와 숫자를 포맷합니다
카탈로그에는 문장을 넣고, 포맷 규칙은 넣지 않습니다. 날짜, 숫자, 파일 크기, 복수형은 확인된 locale을 사용하는 Intl에서 가져옵니다:
const date = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
const number = new Intl.NumberFormat(locale);
const plural = new Intl.PluralRules(locale);
카탈로그 문자열에는 {date}나 {count} 같은 placeholder가 들어가고, 호출하는 쪽이 값을 포맷한 뒤 전달합니다. 번역자가 날짜 패턴을 다룰 필요가 없고, 누구도 패턴을 직접 작성하지 않아도 미국 영어 사용자는 "Mar 2, 2026"을, 독일어 사용자는 "02.03.2026"을 보게 됩니다. 복수형 범주는 언어마다 다르므로, count === 1이 아니라 Intl.PluralRules로 메시지 변형을 고릅니다.
복수형 범주는 언어마다 다름
Intl.PluralRules는 zero, one, two, few, many, other 중 최대 여섯 개 범주 중 하나를 반환합니다. 영어는 그중 두 개를 사용하고, 러시아어는 네 개를 사용합니다. count === 1 검사는 영어에서는 맞지만 러시아어에서는 틀립니다.
카탈로그는 locale이 사용하는 범주마다 변형 하나를 둡니다. 헬퍼는 plural.select(count)를 읽고, capture.count.one이나 capture.count.few처럼 일치하는 키를 찾습니다. 누락된 범주는 other로 대체되므로, 일부만 번역된 복수형도 계속 사용할 수 있습니다.
단위는 값에 속함
Intl.NumberFormat은 자릿수 구분과 소수점 기호를 처리하므로, 영어의 1.2 MB와 독일어의 1,2 MB는 같은 코드 경로에서 나옵니다. 단위는 카탈로그가 아니라 포맷된 값의 일부입니다. "{size} saved" 같은 항목은 포맷된 크기를 호출하는 쪽에서 받고, 번역자는 값 주변의 문장만 바꿉니다.
정확한 날짜 출력은 런타임에 따라 다름
Intl.DateTimeFormat의 출력은 런타임에 포함된 ICU 데이터를 따르므로, Electron 버전이 바뀌면 정확한 문자열이 달라질 수 있습니다. "Mar 2, 2026, 9:05 AM"을 고정값으로 비교하는 테스트는 앱과 관계없는 이유로 업그레이드 후 실패합니다. 테스트에서는 같은 Intl 호출의 결과와 비교하고, 시간대를 고정하여 모든 머신에서 테스트가 통과하게 합니다.
6. renderer의 선택을 main 프로세스와 동기화합니다
설정 화면은 renderer에 있지만, 네이티브 메뉴, 대화상자, 트레이, 알림은 main 프로세스에서 만들어집니다. main 프로세스가 설정값의 단일 소유자가 되도록 합니다:
// preload.ts
contextBridge.exposeInMainWorld("i18n", {
setPreference: (pref: LanguagePreference) => ipcRenderer.invoke("i18n:set", pref),
onChange: (cb: (locale: Locale) => void) =>
ipcRenderer.on("i18n:changed", (_e, locale: Locale) => cb(locale)),
});
// main.ts
ipcMain.handle("i18n:set", (_e, pref: unknown) => {
if (!isPreference(pref)) throw new Error("invalid language preference");
store.set("language", pref);
const locale = resolveLocale(pref, app.getPreferredSystemLanguages());
rebuildMenu(locale);
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("i18n:changed", locale);
return locale;
});
renderer는 변경을 요청하고, main 프로세스가 값을 결정하고 저장한 뒤 모든 창에 전파합니다. 이후에 열리는 창을 포함한 모든 창은 같은 locale을 읽습니다. 핸들러는 들어온 값을 검사합니다. IPC 입력은 신뢰할 수 없기 때문입니다.
설정 화면이 renderer에서 동작하므로 renderer의 localStorage에 저장하는 방식이 흔한 지름길입니다. 하지만 그 값은 한 창의 저장소에만 있고, main 프로세스는 창이 열리기 전에는 값을 읽을 수 없습니다. 메뉴와 트레이는 첫 창보다 먼저 만들어지므로 main 프로세스의 저장소 값이 필요합니다.
언어 변경 한 번의 경로
언어 변경 한 번은 두 프로세스를 거칩니다. 아래 순서는 각 단계를 어느 쪽이 담당하는지 보여줍니다.
renderer는 "system"을 직접 결정하지 않습니다. 각 프로세스가 설정을 따로 결정하면, OS 언어가 바뀐 뒤 창과 메뉴가 서로 다른 언어를 보일 수 있습니다. main 프로세스의 resolver 하나가 둘을 항상 같게 유지합니다.
핸들러에서 값 검증
preload 스크립트가 인자의 타입을 지정하지만, renderer는 ipcRenderer.invoke로 어떤 값이든 보낼 수 있습니다. main 프로세스는 타입의 출처가 되는 것과 같은 목록으로 값을 대조합니다:
const isPreference = (value: unknown): value is LanguagePreference =>
value === "system" || (typeof value === "string" && (SUPPORTED as readonly string[]).includes(value));
거부된 값은 예외를 던지고 저장소에 쓰지 않습니다. 두 목록에 있는 값만 store.set에 도달합니다.
변경 후에 열린 창
변경 이후에 만들어진 창은 브로드캐스트를 기다리지 않아야 합니다. 로드 시점에 main 프로세스에 직접 요청합니다:
ipcMain.handle("i18n:get", () =>
resolveLocale(store.get("language", "system") as LanguagePreference, app.getPreferredSystemLanguages()),
);
renderer는 시작할 때 i18n:get을 한 번 호출하고, 이후에는 i18n:changed를 사용합니다. 두 경로 모두 같은 resolver를 호출하므로, 새 창과 기존 창의 값이 서로 어긋나지 않습니다.
변경 중에 닫힌 창
브로드캐스트 전에 닫힌 창은 BrowserWindow.getAllWindows()에 더 이상 없으므로 반복문이 건너뜁니다. 다시 로드되는 창은 i18n:get을 다시 호출하고 현재 값을 받습니다. 브로드캐스트는 최선 노력(best effort) 방식이며, 올바른 값은 로드 경로가 전달합니다.
경계를 지키는 테스트
- Resolver table.
["de-CH", "en-US"](독일어),["zh-TW", "en"](영어),[](영어) 입력이 기대한 locale로 매핑되는지 확인함. - Completeness. 배포되는 locale마다
en의 모든 키가 존재하고 비어 있지 않은지 확인함. - Placeholder parity. 각 번역이 영어와 같은 placeholder를 사용하는지 확인하여
{count}가 누락되거나 이름이 바뀌지 않게 함. - Formatting. 고정된 시간대로 날짜 출력을 확인하여 테스트가 모든 머신에서 통과하게 함.
- IPC handler. 잘못된 설정값은 거부하고, 올바른 값은 메뉴 재생성과 브로드캐스트를 일으키는지 확인함.
for (const [locale, catalog] of Object.entries(catalogs)) {
for (const key of Object.keys(en) as MessageKey[]) {
expect(catalog[key], `${locale}: ${key}`).toBeTruthy();
}
}
각 테스트는 경계 하나를 검증합니다. 아래 표는 테스트가 실패했을 때 가리키는 내용을 보여줍니다.
| 테스트 | 실패가 의미하는 것 |
|---|---|
| Resolver table | 검사 순서 또는 별칭 표가 변경됨 |
| Completeness | 배포되는 카탈로그에 영어에 있는 키가 없음 |
| Placeholder parity | 번역이 변수 이름을 바꾸거나 제거함 |
| Formatting | 날짜 또는 숫자 출력이 locale이 아니라 머신에 따라 달라짐 |
| IPC handler | 잘못된 입력이 저장소에 도달했거나 창에 알림이 가지 않음 |
카탈로그가 바뀔 때마다 테스트 스위트를 실행합니다. 릴리스 직전에만 실행하지 않습니다. placeholder 이름을 바꾸는 번역은 그 번역을 넣은 pull request에서 고치는 비용이 가장 낮고, 릴리스 컷에서 발견하면 비용이 가장 큽니다.
소규모 i18n 레이어에도 명확한 경계가 필요합니다
각 결정은 선을 하나씩 긋습니다. 의도와 결정된 locale, 이름과 번역된 라벨, 계약으로서의 영어, 폴백과 완성도, 문장과 포맷, renderer와 main입니다. 이 선을 지키면 수백 줄의 직접 작성한 레이어가, 같은 일을 하도록 설정한 프레임워크보다 이해하기 쉽게 유지됩니다.
프레임워크는 카탈로그가 이 레이어에 없는 기능을 요구할 때 비용을 감수할 가치가 생깁니다. 예를 들어 소스 코드에서 메시지를 추출하는 기능, 검토 상태를 추적하는 번역 도구, 앱과 별도로 배포되는 카탈로그의 런타임 로딩이 있습니다. 그런 요구가 생기기 전까지는 위의 여섯 가지 결정으로 흔한 실패를 막을 수 있습니다.
같은 경계는 웹 뷰나 설정 시트처럼 텍스트를 표시하는 다른 창에도 적용됩니다. 각 창은 main 프로세스에서 결정된 locale을 읽고, 같은 카탈로그 타입을 사용하며, 같은 완성도 테스트를 통과합니다. 새 화면에는 새 규칙이 필요 없고 t 호출만 추가하면 됩니다.
이 사이트가 다루는 Mac 앱 Temposhot은 Xcode로 만든 네이티브 macOS 앱이며 Electron 앱이 아닙니다. Language 설정에는 7개 옵션이 있습니다. System default와 각 언어를 자체 이름으로 표시한 여섯 개 언어입니다.
클릭 기록을 단계별 가이드로 만들기
위와 같은 설정 변경을 문서화하려면 Action Capture로 기록합니다. Temposhot은 macOS 14 이상을 실행하는 Apple Silicon Mac용으로 무료 다운로드할 수 있습니다.
electroni18ntypescriptengineering
반복되는 스크린샷을 완성된 결과물로 만듭니다
Temposhot은 타이머 또는 클릭할 때마다 캡처하고, 결과를 PDF, 이미지, ZIP 또는 MP4로 내보냅니다.
관련 글
- macOS에서 명령줄로 스크린샷 자동화하기macOS screencapture 반복문으로 전체 화면, 고정 영역, 페이지 넘김 스크린샷을 찍습니다. 안전한 파일 이름 짓기와 자주 발생하는 오류의 해결 방법도 다룹니다.
- Playwright로 웹사이트 스크린샷을 자동화하는 방법JavaScript와 Playwright로 웹사이트 스크린샷을 자동화합니다. 한 페이지, 전체 페이지, 모바일 화면, URL 일괄 캡처를 재시도와 함께 다룹니다.
- Puppeteer로 웹사이트 스크린샷을 자동화하는 방법JavaScript로 Puppeteer를 사용해 웹사이트 스크린샷을 자동화합니다. 한 페이지, 전체 페이지, URL 일괄 캡처, 모바일 뷰포트를 재사용 가능한 스크립트로 다룹니다.
- 2026년 Mac용 최고의 Xnapper 대안Mac용 Xnapper 대안을 비교합니다. 반복 캡처, 스크롤 캡처와 녹화, 무료 배경 도구, 크로스 플랫폼 가이드, 기본 제공 앱을 다룹니다.