BLOG
디자이너가 직접 만든 Figma 플러그인 — 언어팩 자동화 개발기
Google Sheets 언어팩을 Figma Variables로 자동 동기화하는 플러그인을 디자이너가 직접 개발한 과정

들어가며
이 글은 코딩을 전공하지 않은 프로덕트 디자이너가 팀의 반복 작업을 줄이기 위해 Figma 플러그인을 직접 만든 경험을 담고 있다. 완벽한 코드를 작성한 이야기가 아니라, 문제를 정의하고 시도하고 실패하고 다시 고치는 과정의 기록이다.
기술적으로 완벽하지 않더라도 팀의 실제 문제를 해결할 수 있다면 그것으로 충분하다고 생각했다. 비슷한 고민을 하는 디자이너에게 조금이라도 도움이 됐으면 한다.
문제 인식 — 왜 만들게 됐나
반복되는 수작업
B2B SaaS 서비스의 디자인 시스템을 운영하면서 언어팩 관리는 늘 번거로운 작업이었다. 서비스에서 사용하는 모든 텍스트는 Google Sheets에서 key-value 형태로 관리되고 있었는데, 구조는 이렇다.

개발 팀은 이 Sheets를 소스 오브 트루스로 사용해 코드에 언어팩을 반영한다. 문제는 디자인이었다. 언어팩이 변경될 때마다 디자이너가 Figma 파일을 열어 하나씩 텍스트를 수동으로 업데이트해야 했다.
기존 방식
Google Sheets 언어팩 변경 (개발팀 작업)
↓
디자이너가 변경 사항 파악
↓
Figma 파일에서 해당 텍스트 레이어 찾기
↓
하나씩 수동으로 텍스트 수정
↓
누락 발생, 개발-디자인 불일치
실제로 발생했던 문제들이다.
- 언어팩 변경 후 디자이너가 모르고 지나치는 경우
- 같은 텍스트가 여러 컴포넌트에 쓰이는 경우 일부만 업데이트되는 경우
- 개발 코드에는 반영됐지만 디자인 목업에는 여전히 이전 텍스트가 남아있는 경우
왜 기존 솔루션으로 해결이 안 됐나
Lokalise 같은 기존 플러그인도 있었다. 하지만 우리 팀의 언어팩 구조는 Lokalise가 아닌 자체 Google Sheets로 관리되고 있었고, API 토큰 방식의 서드파티 연동은 보안 정책상 적합하지 않았다.
결국 자체 플러그인을 만드는 방향으로 결론을 냈다.
목표 정의
플러그인이 해결해야 할 문제를 명확히 정의했다. 범위를 너무 넓게 잡으면 완성하지 못할 것 같아서 핵심 기능에만 집중했다.
Must Have
- Google Sheets 언어팩 데이터를 Figma Variables로 자동 등록
- 등록된 key/value 검색 기능
- 팀 내부 플러그인으로 배포 (팀원 누구나 사용 가능)
Should Have
- 텍스트 레이어에 Variable을 일괄 바인딩
- 언어팩 변경 시 Slack으로 알림
Won't Have (이번 버전에서는)
- 실시간 자동 동기화 (Figma Plugin API 제약으로 불가)
- app_lokalise, server_lokalise 지원 (데이터 준비 후 추가 예정)
기술 선택
Figma REST API vs Plugin API
처음에는 Figma REST API로 Variables를 직접 등록하려 했다. 그런데 Variables API를 찾아보니 Enterprise 플랜 전용이라는 벽에 부딪혔다. 우리 팀은 Organization 플랜이었다.
방향을 바꿔 Figma Plugin API를 선택했다. Plugin API는 Organization 플랜에서도 Variables를 생성하고 수정할 수 있다.
기술 스택 확정
| 구성 요소 | 선택한 기술 | 이유 |
|---|---|---|
| 플러그인 런타임 | Figma Plugin API | Organization 플랜 지원 |
| 데이터 소스 | Google Sheets | 기존 언어팩 관리 도구 |
| 데이터 중간 레이어 | Google Apps Script | CORS 해결, 무료 |
| 알림 | Slack Incoming Webhook | 사내 커뮤니케이션 도구 |
| 코드 관리 | 사내 GitHub 레포 | 팀 접근 가능 |
아키텍처 설계
전체 흐름은 이렇다.
Google Sheets (언어팩 원본)
↓
Google Apps Script 웹앱
(CORS 해결 + JSON 변환)
↓
Figma Plugin
(ui.html + code.js)
↓
Figma Variables
(admin_lokalise 컬렉션)
↓
텍스트 레이어 바인딩
Figma Plugin은 두 파일로 구성된다.
- code.js — Figma Plugin API를 직접 호출하는 메인 로직. Variables 생성, 저장, 바인딩을 담당
- ui.html — 사용자가 보는 인터페이스. HTML/CSS/JS로 작성되며 code.js와 메시지로 통신
ui.html code.js
| |
|-- postMessage(sync) ---> |
| |-- Variables 등록
| |
|<-- postMessage(done) ----|
구현 상세
1단계: Google Apps Script 웹앱
Figma 플러그인에서 Google Sheets를 직접 호출하면 CORS 오류가 발생한다. Google Apps Script를 중간 레이어로 두어 이 문제를 해결했다.
Apps Script를 웹앱으로 배포하면 외부에서 HTTP 요청으로 데이터를 받아올 수 있다.
function doGet(e) {
const sheetName = e.parameter.sheet || 'admin_lokalise';
const sheet = SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName(sheetName);
const data = sheet.getDataRange().getValues();
const result = [];
for (let r = 1; r < data.length; r++) {
const key = data[r][1]; // B열 — key
const value = data[r][2]; // C열 — ko value
const tags2 = data[r][4]; // E열 — 그룹 경로
if (!key || !String(key).trim()) continue;
// Figma Variables는 . 미지원 → _ 로 변환
const variableName = String(key).trim().replace(/\./g, '_');
// 그룹 경로는 / 로 변환하여 폴더 구조로 표현
const groupPath = tags2 ? String(tags2).trim().replace(/\./g, '/') : '';
const fullName = groupPath ? `${groupPath}/${variableName}` : variableName;
result.push({
key: fullName,
value: String(value || '').trim(),
tag: groupPath
});
}
return ContentService
.createTextOutput(JSON.stringify(result))
.setMimeType(ContentService.MimeType.JSON);
}
여기서 key 네이밍 변환 규칙이 중요한 설계 포인트였다. Figma Variables는 이름에 .을 허용하지 않는다. 그래서 두 가지 변환을 적용했다.
B열 key — . → _ 변환 (Variable 이름)
공통.버튼.복사 → 공통_버튼_복사
E열 tags2 — . → / 변환 (그룹 경로)
공통.버튼 → 공통/버튼 (Figma Variables 패널에서 폴더로 표현)
| 구분 | Google Sheets | Figma Variables |
|---|---|---|
| key | 공간관리.모달.공간삭제실패.에러 | 공간관리_모달_공간삭제실패_에러 |
| 그룹 | 공간정보.공간관리 | 공간정보/공간관리 (폴더) |
manifest.json에 networkAccess를 설정하는 것도 잊으면 안 된다. Apps Script 도메인을 허용 목록에 추가해야 플러그인에서 fetch 요청이 통과된다.
{
"networkAccess": {
"allowedDomains": [
"https://script.google.com",
"https://script.googleusercontent.com"
]
}
}
2단계: Variables 일괄 등록 로직
Figma Plugin API에서 Variables를 다룰 때 가장 중요한 것은 반드시 Async 버전의 API를 사용해야 한다는 점이다. documentAccess: dynamic-page 설정을 사용하면 동기 버전을 호출할 수 없다.
// 이렇게 하면 오류 발생
const collections = figma.variables.getLocalVariableCollections();
// 반드시 이렇게 해야 한다
const collections = await figma.variables.getLocalVariableCollectionsAsync();
실제로 이 오류 때문에 초반에 한참 헤맸다. 에러 메시지는 이렇게 나온다.
Cannot call with documentAccess: dynamic-page.
Use figma.variables.getLocalVariableCollectionsAsync instead.
Variables 일괄 등록 전체 로직이다.
// 컬렉션 찾거나 새로 생성
const collections = await figma.variables.getLocalVariableCollectionsAsync();
let collection = collections.find(c => c.name === collectionName);
if (!collection) {
collection = figma.variables.createVariableCollection(collectionName);
}
// 모드 이름을 'ko'로 설정
const modeId = collection.modes[0].modeId;
collection.renameMode(modeId, 'ko');
// 기존 Variables 가져오기
const existingVariables = await figma.variables.getLocalVariablesAsync('STRING');
// 각 key에 대해 Variable 생성 또는 업데이트
for (const { key, value } of variables) {
let variable = existingVariables
.find(v => v.name === key && v.variableCollectionId === collection.id);
if (!variable) {
variable = figma.variables.createVariable(key, collection, 'STRING');
}
variable.setValueForMode(modeId, value);
}
이렇게 등록하면 Figma Variables 패널에서 아래처럼 그룹 폴더 구조로 정리된 언어팩을 확인할 수 있다.

3단계: 플러그인 UI 구성
UI 설계에서 가장 많이 고민한 부분은 최초 진입 경험과 탭 구조였다.
최초 진입 — Empty State
플러그인을 처음 실행하면 등록된 컬렉션이 없다. 이때 사용자가 다음에 무엇을 해야 하는지 명확히 알 수 있도록 Empty state 화면을 설계했다.

컬렉션 선택 팝업
"컬렉션 등록하기" 버튼을 누르면 팝업이 열린다. 현재 사용 가능한 컬렉션과 준비 중인 컬렉션을 명확히 구분해서 보여준다.

앞으로 app_lokalise, server_lokalise 데이터가 준비되면 코드에서 enabled: false를 true로 바꾸기만 하면 활성화된다.
const COLLECTION_CONFIG = [
{ name: 'admin_lokalise', sheet: 'admin_lokalise', enabled: true },
{ name: 'app_lokalise', sheet: 'app_lokalise', enabled: false }, // 준비 중
{ name: 'server_lokalise',sheet: 'server_lokalise',enabled: false }, // 준비 중
];
Keys 탭 — 언어팩 탐색
등록이 완료되면 Keys 탭으로 전환된다. 좌측 사이드바에는 등록된 컬렉션 목록이, 우측에는 key 목록이 표시된다. 각 key는 key명, 그룹 태그, 한국어 value 세 줄로 표현했다.

검색은 key 이름과 한국어 value를 통합 검색한다. 검색어에 해당하는 텍스트는 하이라이트 처리된다.

Apply 탭 — 텍스트 일괄 바인딩
Apply 탭은 실제 Figma 작업 시 가장 많이 쓰이는 기능이다. 현재 페이지에서 특정 텍스트를 찾아 Variable로 교체하고 바인딩한다.
동작 방식은 이렇다.
- 찾을 텍스트 입력 — 교체할 텍스트 입력 (예: "취소")
- 바꿀 key 검색 — key 이름 또는 value로 검색
- 원하는 key 선택 — 선택 시 하단 미리보기에 key → value 표시
- 현재 페이지에 적용 버튼 클릭


4단계: 데이터 영속성 해결
플러그인을 껐다 켜면 메모리가 초기화된다. 등록했던 컬렉션 데이터가 사라지고 매번 Empty state가 뜨는 문제가 있었다.
Figma Plugin API는 figma.clientStorage라는 영구 저장소를 제공한다. 파일별로 데이터를 저장할 수 있어서 플러그인을 껐다 켜도 데이터가 유지된다.
// 등록 완료 후 저장
await figma.clientStorage.setAsync('registeredCollections', data);
await figma.clientStorage.setAsync('lastSynced', timeString);
// 플러그인 시작 시 불러오기
const saved = await figma.clientStorage.getAsync('registeredCollections');
const lastSynced = await figma.clientStorage.getAsync('lastSynced');
추가로 한 가지 더 고민했다. 디자인 시스템 파일이 아닌 다른 Figma 파일에서 플러그인을 실행하면 어떻게 될까?
Library를 통해 Variables를 연결한 파일에서 플러그인을 켜면 clientStorage에 데이터가 없어서 Empty state가 표시됐다. 이를 해결하기 위해 현재 파일에 존재하는 Variables 컬렉션을 자동 감지하는 로직을 추가했다.
if (msg.type === 'load') {
const saved = await figma.clientStorage.getAsync('registeredCollections');
const lastSynced = await figma.clientStorage.getAsync('lastSynced');
// 현재 파일의 Variables 컬렉션 감지
const localCollections = await figma.variables.getLocalVariableCollectionsAsync();
const ALLOWED_NAMES = ['admin_lokalise', 'app_lokalise', 'server_lokalise'];
const additionalCollections = localCollections
.filter(c => !savedNames.includes(c.name) && ALLOWED_NAMES.includes(c.name))
.map(c => ({
name: c.name,
sheet: c.name,
variables: localVariables
.filter(v => v.variableCollectionId === c.id)
.map(v => ({ key: v.name, value: String(v.valuesByMode[...] || '') }))
}));
const merged = [...validSaved, ...additionalCollections];
figma.ui.postMessage({ type: 'loaded', data: merged, lastSynced });
}
ALLOWED_NAMES로 허용된 컬렉션만 필터링하는 것도 중요하다. 처음에는 이 필터 없이 구현했더니 디자인 토큰 컬렉션인 primitive, semantic까지 사이드바에 표시되는 문제가 있었다.
5단계: 텍스트 레이어 Variable 바인딩
Apply 탭에서 텍스트를 바인딩할 때 예상치 못한 오류를 만났다.
처음에는 setRangeBoundVariable로 구현했는데 이런 오류가 났다.
Property "field" failed validation: Invalid enum value.
Expected 'fontFamily' | 'fontSize' | 'fontStyle' | 'fontWeight' |
'letterSpacing' | 'lineHeight' | 'paragraphSpacing' | 'paragraphIndent',
received 'characters'
characters 필드는 setRangeBoundVariable에서 지원하지 않는 것이었다. setBoundVariable을 사용하는 방식으로 바꿨다.
// 오류 발생
node.setRangeBoundVariable(0, node.characters.length, 'characters', variable);
// 정상 작동
await figma.loadFontAsync(node.fontName); // 폰트 로드 필수
node.characters = value; // 텍스트 먼저 교체
node.setBoundVariable('characters', variable); // Variable 바인딩
주의할 점은 figma.loadFontAsync를 먼저 호출해야 한다는 것이다. 폰트를 로드하지 않고 텍스트를 수정하면 또 다른 오류가 발생한다.
바인딩이 완료되면 Figma 우측 패널 Content 영역에서 Variable이 연결된 상태로 확인할 수 있다.

6단계: Slack 알림봇 연동
플러그인으로 언어팩을 등록해두면 이후에는 자동으로 반영되지 않는다. Figma 플러그인은 실시간 동기화가 기술적으로 불가능하기 때문에, 언어팩이 변경되면 팀원이 플러그인에서 수동으로 재등록해야 한다.
이 문제를 보완하기 위해 Google Sheets에서 변경이 발생하면 Slack으로 알림을 보내는 봇을 만들었다. 알림을 받으면 플러그인에서 재등록하면 된다.
감지 항목은 세 가지다.
- key 변경 — B열 key 이름이 수정된 경우
- value 변경 — C열 한국어 값이 수정된 경우
- key 삭제 — 행 전체가 삭제된 경우

구현 방식은 Google Apps Script의 onChange 트리거를 활용한다. 시트가 변경될 때마다 이전 상태와 현재 상태를 비교해서 차이점을 Slack Webhook으로 전송한다.
여러 변경이 연달아 일어날 때 알림이 여러 번 오는 것을 방지하기 위해 1분 단위로 변경사항을 모아서 한 번에 발송하는 방식을 택했다. time-based 트리거로 sendPendingChanges 함수를 1분마다 실행하고, onSheetChange에서는 변경사항을 임시 저장소에 누적해두는 구조다.
onSheetChange (변경 시마다 실행)
→ 변경사항 감지 및 pendingChanges에 누적
sendPendingChanges (1분마다 실행)
→ pendingChanges 확인
→ 변경사항 있으면 Slack 발송
→ pendingChanges 삭제
예상치 못한 난관들
PropertiesService 용량 초과
알림봇에서 이전 상태와 현재 상태를 비교하려면 언어팩 전체를 어딘가에 저장해야 한다. 처음엔 CacheService를 썼는데 6시간이 지나면 캐시가 만료되어 전체 key가 새로 추가된 것처럼 오탐이 발생했다.
PropertiesService로 바꿨더니 이번엔 50KB 용량 초과 오류가 났다. 1500개 언어팩 전체를 하나의 속성에 저장하려니 용량이 부족했다.
해결책은 100개 단위 청크 분할 저장이었다.
const CHUNK_SIZE = 100;
function savePreviousMap(currentMap) {
const props = PropertiesService.getScriptProperties();
const keys = Object.keys(currentMap);
const chunkCount = Math.ceil(keys.length / CHUNK_SIZE);
// 기존 청크 초과분 삭제
const oldChunkCount = parseInt(props.getProperty('previousMapChunks') || '0');
for (let i = chunkCount; i < oldChunkCount; i++) {
props.deleteProperty(`previousMap_${i}`);
}
// 청크별로 저장
for (let i = 0; i < chunkCount; i++) {
const chunk = {};
keys.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE)
.forEach(k => { chunk[k] = currentMap[k]; });
props.setProperty(`previousMap_${i}`, JSON.stringify(chunk));
}
props.setProperty('previousMapChunks', String(chunkCount));
}
불러올 때는 청크를 순서대로 합쳐서 원래 맵을 복원한다.
function loadPreviousMap() {
const props = PropertiesService.getScriptProperties();
const chunkCount = parseInt(props.getProperty('previousMapChunks') || '0');
const previousMap = {};
for (let i = 0; i < chunkCount; i++) {
const chunkJson = props.getProperty(`previousMap_${i}`);
if (chunkJson) Object.assign(previousMap, JSON.parse(chunkJson));
}
return previousMap;
}
sendPendingChanges 무한 재시도
어느 날 Apps Script에서 오류 메일이 36통이나 왔다. sendPendingChanges에서 INTERNAL 오류가 반복 발생한 것이었다.
원인은 이랬다. Slack 발송이 성공했음에도 pendingChanges 삭제 과정에서 오류가 나면, 다음 1분에 또 pendingChanges가 남아있어 재시도하는 무한 루프에 빠졌다.
해결 방법은 간단했다. 성공 여부와 관계없이 pendingChanges를 무조건 삭제하는 것이다. 알림이 누락될 수 있지만, 무한 루프보다는 낫다.
// 기존 — 성공했을 때만 삭제
if (success) {
props.deleteProperty('pendingChanges');
}
// 변경 후 — 무조건 삭제 (무한 재시도 방지)
try {
props.deleteProperty('pendingChanges');
} catch (e) {
Logger.log('pendingChanges 삭제 실패: ' + e.message);
}
오탐 알림
어느 날 언어팩 key 3개를 추가했는데 900개가 추가됐다는 알림이 왔다. previousMap 청크 저장 중 일부가 누락되어 이전 상태와의 비교가 제대로 되지 않는 문제였다.
오탐 방지 로직을 추가했다. 감지된 추가 key 수가 전체의 50% 이상이면 이상한 상황으로 판단해 알림을 보내지 않고 현재 상태로 초기화만 한다.
const totalKeys = Object.keys(currentMap).length;
if (addedKeys.length > totalKeys * 0.5) {
Logger.log(`오탐 감지: 추가 ${addedKeys.length}개 / 전체 ${totalKeys}개`);
savePreviousMap(currentMap); // 현재 상태로 초기화만
return; // 알림 발송 없이 종료
}
배포
Figma Organization 플랜의 팀 플러그인 기능을 활용해 Private 플러그인으로 배포했다. 팀원들은 별도 파일 설치 없이 Figma Assets 패널에서 바로 플러그인을 찾아 실행할 수 있다.

코드는 사내 GitHub 레포의 sheets-to-variables 폴더에서 관리한다. 수정이 필요할 때는 main 브랜치에 push하고 Figma에서 Publish new version으로 재배포한다.
코드 수정
↓
git add, commit, push → main
↓
Figma → Development → Sheets to Variables
→ Publish new version
↓
팀원들에게 자동으로 최신 버전 반영
트러블슈팅 요약
| 오류 | 원인 | 해결 방법 |
|---|---|---|
Cannot call with documentAccess: dynamic-page | Async API 미사용 | 모든 Variables API를 Async 버전으로 교체 |
Invalid enum value received 'characters' | setRangeBoundVariable 미지원 필드 | setBoundVariable 사용으로 변경 |
| PropertiesService 50KB 초과 | 전체 언어팩을 단일 속성에 저장 | 100개 단위 청크 분할 저장 |
| CacheService 6시간 만료 | Cache 특성상 만료 불가피 | PropertiesService로 전환 (영구 저장) |
| sendPendingChanges 무한 재시도 | 삭제 실패 시 pendingChanges가 남아있음 | 성공 여부와 무관하게 무조건 삭제 |
| 오탐 알림 (전체 key 추가) | 청크 저장 불완전으로 비교 오류 | 추가 key 50% 초과 시 초기화만 수행 |
결과
플러그인 배포 후 언어팩 업데이트 작업이 크게 간소화됐다.
| 항목 | 기존 | 개선 후 |
|---|---|---|
| 언어팩 반영 방식 | 수동 텍스트 수정 | 플러그인 1회 실행으로 전체 등록 |
| 변경 감지 | 없음 (모르고 지나침) | Slack 자동 알림 |
| 개발-디자인 일치 | 불확실 | Figma Variables로 동기화 |
| 텍스트 바인딩 | 개별 수동 연결 | Apply 탭으로 일괄 처리 |
| 타 파일에서 사용 | 불가 | Library 연결 시 자동 감지 |
마치며
디자이너가 직접 플러그인을 만들면서 느낀 것이 몇 가지 있다.
완벽한 코드가 아니어도 된다. 팀의 실제 문제를 해결하는 것이 목적이라면 코드의 우아함은 부차적이다. 여러 번 오류가 나고, 고치고, 또 다른 오류가 나는 과정이 반복됐지만 결국 작동하는 도구를 만들었다.
API 문서를 읽는 능력이 중요하다. 코딩을 잘 몰라도 공식 문서와 에러 메시지를 읽고 이해하는 것만으로 많은 문제를 해결할 수 있었다. Figma Plugin API 공식 문서는 생각보다 친절하게 쓰여있다.
작은 문제부터 해결하다 보면 큰 그림이 만들어진다. 처음부터 완성된 설계를 가지고 시작하지 않았다. 가장 핵심적인 기능부터 만들고, 사용하면서 불편한 점을 하나씩 개선하는 방식으로 진행했다.
앞으로 추가할 기능도 있다.
app_lokalise,server_lokalise컬렉션 데이터 준비 완료 시 활성화- 텍스트 레이어를 직접 선택한 후 플러그인에서 key를 클릭해 바로 바인딩하는 방식 (Lokalise 플러그인과 유사)
비슷한 고민을 하는 디자이너가 있다면, 일단 시작해보는 것을 권한다.