JINAH YEOM

BLOG

디자이너가 직접 만든 Figma 플러그인 — 언어팩 자동화 개발기

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

들어가며

이 글은 코딩을 전공하지 않은 프로덕트 디자이너가 팀의 반복 작업을 줄이기 위해 Figma 플러그인을 직접 만든 경험을 담고 있다. 완벽한 코드를 작성한 이야기가 아니라, 문제를 정의하고 시도하고 실패하고 다시 고치는 과정의 기록이다.

기술적으로 완벽하지 않더라도 팀의 실제 문제를 해결할 수 있다면 그것으로 충분하다고 생각했다. 비슷한 고민을 하는 디자이너에게 조금이라도 도움이 됐으면 한다.

문제 인식 — 왜 만들게 됐나

반복되는 수작업

B2B SaaS 서비스의 디자인 시스템을 운영하면서 언어팩 관리는 늘 번거로운 작업이었다. 서비스에서 사용하는 모든 텍스트는 Google Sheets에서 key-value 형태로 관리되고 있었는데, 구조는 이렇다.

A열 No., B열 key, C열 ko(한국어 value), D열 tags(날짜), E열 tags2(그룹 경로) 구조로 관리되는 언어팩

개발 팀은 이 Sheets를 소스 오브 트루스로 사용해 코드에 언어팩을 반영한다. 문제는 디자인이었다. 언어팩이 변경될 때마다 디자이너가 Figma 파일을 열어 하나씩 텍스트를 수동으로 업데이트해야 했다.

기존 방식

Google Sheets 언어팩 변경 (개발팀 작업)
       ↓
디자이너가 변경 사항 파악
       ↓
Figma 파일에서 해당 텍스트 레이어 찾기
       ↓
하나씩 수동으로 텍스트 수정
       ↓
누락 발생, 개발-디자인 불일치

실제로 발생했던 문제들이다.


왜 기존 솔루션으로 해결이 안 됐나

Lokalise 같은 기존 플러그인도 있었다. 하지만 우리 팀의 언어팩 구조는 Lokalise가 아닌 자체 Google Sheets로 관리되고 있었고, API 토큰 방식의 서드파티 연동은 보안 정책상 적합하지 않았다.

결국 자체 플러그인을 만드는 방향으로 결론을 냈다.

목표 정의

플러그인이 해결해야 할 문제를 명확히 정의했다. 범위를 너무 넓게 잡으면 완성하지 못할 것 같아서 핵심 기능에만 집중했다.

Must Have

Should Have

Won't Have (이번 버전에서는)

기술 선택

Figma REST API vs Plugin API

처음에는 Figma REST API로 Variables를 직접 등록하려 했다. 그런데 Variables API를 찾아보니 Enterprise 플랜 전용이라는 벽에 부딪혔다. 우리 팀은 Organization 플랜이었다.

방향을 바꿔 Figma Plugin API를 선택했다. Plugin API는 Organization 플랜에서도 Variables를 생성하고 수정할 수 있다.

기술 스택 확정

구성 요소선택한 기술이유
플러그인 런타임Figma Plugin APIOrganization 플랜 지원
데이터 소스Google Sheets기존 언어팩 관리 도구
데이터 중간 레이어Google Apps ScriptCORS 해결, 무료
알림Slack Incoming Webhook사내 커뮤니케이션 도구
코드 관리사내 GitHub 레포팀 접근 가능

아키텍처 설계

전체 흐름은 이렇다.

Google Sheets (언어팩 원본)
       ↓
Google Apps Script 웹앱
(CORS 해결 + JSON 변환)
       ↓
Figma Plugin
(ui.html + code.js)
       ↓
Figma Variables
(admin_lokalise 컬렉션)
       ↓
텍스트 레이어 바인딩

Figma Plugin은 두 파일로 구성된다.

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 SheetsFigma 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 패널에서 아래처럼 그룹 폴더 구조로 정리된 언어팩을 확인할 수 있다.

admin_lokalise 컬렉션에 1519개 Variables가 등록된 상태. 좌측 Groups에서 공통/공간관리 등 폴더 구조로 정리됨


3단계: 플러그인 UI 구성

UI 설계에서 가장 많이 고민한 부분은 최초 진입 경험과 탭 구조였다.

최초 진입 — Empty State

플러그인을 처음 실행하면 등록된 컬렉션이 없다. 이때 사용자가 다음에 무엇을 해야 하는지 명확히 알 수 있도록 Empty state 화면을 설계했다.

컬렉션이 없을 때 표시되는 초기 화면. 클립보드 아이콘과 "컬렉션 등록하기" 버튼으로 다음 행동을 유도

컬렉션 선택 팝업

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

admin_lokalise는 "등록 가능" 상태로 선택 가능, app_lokalise와 server_lokalise는 "준비 중"으로 비활성화

앞으로 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 세 줄로 표현했다.

등록 완료 후 사이드바에 admin_lokalise 컬렉션, 우측에 1519개 key 목록. 각 항목에 key명, 그룹 태그(배지), value가 표시됨

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

"공간 수정 실패" 검색 시 해당 value를 가진 1개 key만 필터링된 결과

Apply 탭 — 텍스트 일괄 바인딩

Apply 탭은 실제 Figma 작업 시 가장 많이 쓰이는 기능이다. 현재 페이지에서 특정 텍스트를 찾아 Variable로 교체하고 바인딩한다.

동작 방식은 이렇다.

  1. 찾을 텍스트 입력 — 교체할 텍스트 입력 (예: "취소")
  2. 바꿀 key 검색 — key 이름 또는 value로 검색
  3. 원하는 key 선택 — 선택 시 하단 미리보기에 key → value 표시
  4. 현재 페이지에 적용 버튼 클릭

찾을 텍스트 "취소", 바꿀 key "공통/버튼/공통_버튼_닫기 → 닫기" 선택 상태. 미리보기에 선택된 key와 value가 표시됨

현재 페이지에 적용 후 "✓ 완료! 1개 레이어에 바인딩됨" 결과 메시지 표시


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이 연결된 상태로 확인할 수 있다.

우측 패널 Content 영역에 공통/버튼/공통_버튼_닫기 Variable이 연결된 상태. 변수 아이콘이 표시됨


6단계: Slack 알림봇 연동

플러그인으로 언어팩을 등록해두면 이후에는 자동으로 반영되지 않는다. Figma 플러그인은 실시간 동기화가 기술적으로 불가능하기 때문에, 언어팩이 변경되면 팀원이 플러그인에서 수동으로 재등록해야 한다.

이 문제를 보완하기 위해 Google Sheets에서 변경이 발생하면 Slack으로 알림을 보내는 봇을 만들었다. 알림을 받으면 플러그인에서 재등록하면 된다.

감지 항목은 세 가지다.

#열한시-언어팩-알림 채널에 발송된 알림. value 변경과 key 추가 내역이 No., key명, as-is/to-be 형태로 표시됨

구현 방식은 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 패널에서 바로 플러그인을 찾아 실행할 수 있다.

Figma Assets → Plugins & widgets → From 열한시 필터에서 Sheets to Variables 플러그인 확인 가능

코드는 사내 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-pageAsync 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 공식 문서는 생각보다 친절하게 쓰여있다.

작은 문제부터 해결하다 보면 큰 그림이 만들어진다. 처음부터 완성된 설계를 가지고 시작하지 않았다. 가장 핵심적인 기능부터 만들고, 사용하면서 불편한 점을 하나씩 개선하는 방식으로 진행했다.

앞으로 추가할 기능도 있다.

비슷한 고민을 하는 디자이너가 있다면, 일단 시작해보는 것을 권한다.