teklog

Auto Resume

2025/03/01

n°64

category : Recap

img

배포된 사이트: 링크

깃헙 레포 : 링크

작업 소요 시간: 1.5일 (MVP 버전)

1. 목적

Auto Resume는 Figma 프로젝트를 원본 데이터(Source of Truth)로 삼는 정적 HTML 이력서 웹사이트입니다.

이번 프로젝트에는 다음과 같은 목적이 있었습니다.

  • Figma = Source of Truth 피그마 문서와 동기화된 웹사이트

  • 자동 배포: Figma 변경 → JSON 변환 → HTML 생성 → 배포까지 수동 개입 없이 처리.

    • Figma 변경점이 CI/CD의 기준점이 됨

    • 최소한의 UI 코드와 최대한의 동기화

동기 Motivation

최근 블로그에 Tiptap 에디터를 적용하면서 동적 마크업, 특히 직렬화된 HTML 파싱에 대해 고민 중이었습니다. 서버/클라이언트 컴포넌트와 같은 방식 말고 다른 방법은 없을까 궁금했습니다.

마침, 하드 코딩하는 것이 번거로워 업데이트를 안하게 된 제 이력서 웹사이트가 눈에 들어왔습니다. 실제로 지원할 때 사용하는 이력서와 꽤 큰 차이가 있었습니다. 바로 해봐야겠다 싶었습니다.


2. 설계

로직 흐름

img

노트에 설계와 과정, 필요한 부분을 정리했습니다.

Figma에서 문서가 바뀌었다는 신호만 서버에 전달할 수 있다면, 나머지는 가능하지 않을까 싶었습니다.


stateDiagram
state "Figma > Source Truth" as Truth {
이력서작성: 새로운 이력서를 작성한다
버전변경: 이력서 파일을 피그마에서 "Publish"한다
웹훅: 피그마에서 웹훅이 실행된다.
이력서작성 --> 버전변경
버전변경 --> 웹훅
}

Truth --> Server: 버전 변경을 서버에 알림

state "Workers(서버리스)" as Server {
Listen: 📡 웹훅으로부터 버전 변경을 감지한다.
FigmaAPI: 새로운 버전의 데이터를 피그마 API로 요청한다.
R2: 응답 받은 피그마 JSON을 버켓에 저장한다.
FIN: Cloudflare Pages의 deploy-hook을 트리거하고 Workers 종료.
Listen --> FigmaAPI
FigmaAPI --> R2
R2 --> FIN
}

Server --> Build: Pages Deploy Hook으로 빌드 트리거
state "빌드 Build" as Build {
INIT: Deploy Hook이 실행되면 Github 레포에 저장된 빌드 스크립트가 실행된다.
GET: R2에 저장된 JSON을 가져온다.
BACKUP: 메타 데이터를 추출해 저장한다.
PARSE: JSON을 파싱하여 HTML을 생성한다.
배포트리거: 생성이 완료된 HTML을 Pages가 배포하도록 한다.

INIT --> GET
GET --> BACKUP
GET --> PARSE
BACKUP --> 배포트리거
PARSE --> 배포트리거
}
Build --> StaticSite: Pages를 통한 배포
state "배포: 정적 사이트 호스팅" as StaticSite {
Pages: 빌드 완료 이후 새로 생성된 HTML을 감지한다.
Deploy: Pages는 새로운 HTML을 배포한다.
Pages --> Deploy
}

기술 스택

계획을 작성할 당시엔 다음과 같은 이유에서 스택을 결정했습니다.

Figma Webhook

Figma 프로젝트 Publish 시 웹훅 트리거 (FILE_UPDATE 이벤트 사용)

Cloudflare Worker

웹훅 이벤트 처리, Figma API 호출, JSON을 R2에 저장, Deploy Hook 실행

Cloudflare R2

최신 Figma JSON을 저장하는 클라우드 스토리지. 무료 플랜에서 Workers 실행은 30s 한도가 있다. R2에 JSON을 저장하고, 배포 스크립트가 실행될 때 JSON을 가져온다.

GitHub Repository

빌드 및 배포 스크립트 저장

Node.js + Handlebars

빌드 스크립트에서 사용된다. JSON을 기반으로 HTML 생성한다.

Cloudflare Pages

정적 HTML을 자동 배포하기 위해 CDN을 사용한다.


3. 워크 플로우 구체화

위 내용을 바탕으로 각 단계를 구체화했습니다.

3.1 Figma → Worker

  • Figma 프로젝트가 Publish되면, 웹훅을 통해 Cloudflare Worker를 호출.

  • Worker는 Figma API를 사용하여 최신 JSON을 가져옴.

  • 가져온 JSON을 Cloudflare R2에 저장한 후, Pages Deploy Hook을 트리거.

3.2 빌드

  • Deploy Hook이 호출되면 빌드 스크립트를 실행.

  • 빌드가 실행되는 Node.js 환경에서:

    1. R2에서 Figma JSON을 가져옴.

    2. Handlebars를 사용해 JSON 기반 마크업 생성.

    3. 생성된 HTML을 dist/ 폴더에 저장.

3.3 배포

  • Cloudflare Pages의 dist/ 경로로 정적 사이트를 자동 배포.


4. 구현 과정

A. Figma → Worker

문제 : Desgin Mode에서 API 사용하기

알고보니 Figma Webhook은 Figma Pro만 가능했습니다. 고민 끝에 Figma Pro를 사용하지 않기로 결정했습니다. 이 결정에는 다음과 같은 이유가 있었습니다.

  1. 프로젝트 목적 자체가 Figma Design Mode만 사용하는 것이었다.

  2. Figma Pro (Dev Mode)가 제공하는 마크업과 CSS를 사용해야 한다.

  3. Figma JSON의 Raw Data가 아니라 Figma 내부의 마크업 생성 로직이 의존성 레이어로 추가된다.

  4. Figma API JSON이 변동사항이 적고, 대응도 쉽다.

  5. 또한 경험 상, Figma가 생성한 마크업과 CSS의 신뢰성이 높지 않았다.

  6. Figma Webhook의 대안으로 Figma 커스텀 플러그인을 만들면, 본래 목적을 이룰 수 있다.

Figma에서 디자인 작업을 한 뒤, 작업 변동 여부만 서버에 전달하는 기능만 필요한 것이라 충분히 가능해 보였습니다.

계획 수정

웹훅을 대체 할 커스텀 플러그인을 만들었습니다. 커스텀 플러그인 내부에서 Figma API를 미리 요청하고 Worker로 넘기면, Worker의 런타임 시간도 줄어들어 더욱 적절해 보였습니다.

plugin.js

피그마 커스텀 플러그인은 Figma Desktop App에서 열 수 있습니다. manifest.json으로 플러그인을 정의하고, ui.html, plugin.js로 간단한 플러그인을 개발했습니다. 여기서 가장 중요한 plugin.js만 살펴보자면:

// figma는 플러그인이 실행될 때 전역 변수로 제공된다.
// 플러그인의 ui를 html로 연다. ui.html
figma.showUI(__html__, { width: 300, height: 150 });  

// 플러그인에서 Figma API로 JSON을 요청한다.
async function fetchFigmaJSON() {  
    try {  
        console.log("Fetching Figma JSON...");  
        const response = await fetch(`https://api.figma.com/v1/files/${FIGMA_FILE_KEY}`, {  
            headers: { "X-Figma-Token": FIGMA_API_KEY }  
        });  
  
        if (!response.ok) throw new Error(`Failed to fetch Figma JSON: ${response.statusText}`);  
  
        const json = await response.json();  
        console.log("Fetched JSON successfully:", json);  
        return json;  
    } catch (error) {  
        console.error("Figma API Error:", error.message);  
        figma.notify("❌ Figma API Error: " + error.message);  
        return null;  
    }  
}  

// figma.ui에 onmessage 이벤트를 등록한다.
figma.ui.onmessage = async (msg) => {  
    if (msg.type === "send-update") { 
    // "send-update"라는 이벤트로 요청을 시작한다. 
        console.log("Send update triggered");  

	
        try {  
        // 1. Figma JSON 요청
        const jsonData = await fetchFigmaJSON();  
        if (!jsonData) {  
            figma.notify("❌[FIGMA_API]:Failed to fetch Figma data");  
        }  

        console.log("✅[FIGMA_API]:", JSON.stringify(jsonData)); 

	  // 2. R2로 Figma JSON을 저장한다.
            const response = await fetch("https://auto-resume-worker.leetekwoo.com", {  
                method: "POST",  
                headers: {  
                    "Content-Type": "application/json",  
                },  
                body: JSON.stringify(jsonData),  
            });  
  
            console.log("Worker response status:", response.status);  
  
            if (!response.ok) {  
                throw new Error(`❌[WORKER] Error: ${response.statusText}`);  
            }  
  
            const res = await response.json();  
  
            figma.notify("✅[DONE]: Resume update sent successfully!");  
        } catch (error) {  
            console.error("❌ Fetch Error:", error.message);  
            figma.notify("❌ Network error: " + error.message);  
        } finally {  
			// 정확히 figma.notify가 실행된 이후 닫히도록 하기 위해 추가
            await new Promise(r => setTimeout(r, 500)); 
            figma.closePlugin();  
        }  
    }  
};

플러그인이 실행되면, ui.html의 버튼 이벤트로 실행합니다. workers와의 통신에서 CORS 때문에 꽤 오랫동안 트러블 슛팅을 했는데, 이후 살펴보도록 하겠습니다.

Figma JSON Interface

JSON으로 마크업을 구성하기 위해 피그마 JSON을 분석했습니다. Figma API의 GET 요청은 각 그래픽 요소를 노드로 나타냅니다. 그래픽 정보는 키-값으로 표현되고, children의 배열로 계층이 표현됩니다.

마크업을 표현하는 일반적인 자료 구조(ex:DOM api, ReactElement)와 유사하지만, Figma 고유의 필드명과 프로퍼티가 다르기 때문에 별도의 맵핑과 파싱이 필요합니다.

이번 프로젝트에서 중요하게 사용한 필드들은 다음과 같습니다:

{
	// 부모:n번째 자식 - 으로 노드 연결관계를 표현한다  
    "id": "0:1",  
    // 피그마 레이어의 이름이 설정된다. 
    // NOTE: 파싱에서 로직을 단순화하기 위한 인덱스로 사용했다.
	"name": "truth-resume",  
	// NOTE: 파싱에서 사용. 타입 구분이 중요하다. 
	"type": "DOCUMENT | CANVAS | FRAME | LAYOUT | TEXT | IMAGE | ...",
    // NOTE: HTTP 캐시 헤더처럼 활용할 수 있다    
    "lastModified": "2025-03-01T03:10:10Z", 
	// NOTE: 재귀적으로 계층 구조를 나타낸다.
	"children": [  
	//	... 동일한 구조의 객체(노드)로 구성
	],

	// NOTE: 각 노드의 스타일 정보를 포함한다.
	"styles": {
		"fontFamily": "Noto Sans KR",  
		"fontPostScriptName": null,  
		"fontStyle": "Bold",  
		"fontWeight": 700, 
		// NOTE: hyperlink 필드에는 링크 주소등이 포함된다. 
		"hyperlink": {  
		  "type": "URL",  
		  "url": "https://resume.com"  
		},  
		"textAutoResize": "WIDTH_AND_HEIGHT",  
		"textDecoration": "UNDERLINE",  
		"fontSize": 11,  
		"textAlignHorizontal": "RIGHT",  
		"textAlignVertical": "TOP",  
		"letterSpacing": 0,  
		"lineHeightPx": 15.927999496459961,  
		"lineHeightPercent": 100,  
		"lineHeightUnit": "INTRINSIC_%"
		// ....기타 수 많은 필드가 TYPE에 따라 추가/제거된다.
	},  

	// NOTE: type: 타입이 TEXT인 노드에서 특히 많이 사용된다. 
	// characters 내부에 특정 영역만 볼드, 언더라인, 링크가 적용됐을 때 사용된다.
	// 숫자로 스타일 오버라이드가 적용된 텍스트의 인덱스를 나타낸다.   
	"characterStyleOverrides": [  
	  18, 16, 16, 16, 16, 16, 16  
	],
	"styleOverrideTable": {  
	// 위의 인덱스를 문자열 키로 나타낸다. 
	  "16": {  
	  // 텍스트의 일부에만 링크가 적용된 경우, 이 필드를 사용해야 한다.
	    "hyperlink": {  
	      "type": "URL",  
	      "url": "https://0teklee.github.io/resume/cv.html"  
	    },  
	    "textDecoration": "UNDERLINE",  
	    "fills": [  
	      {  
	        "blendMode": "NORMAL",  
	        "type": "SOLID",  
	        "color": {  
	          "r": 0.11101888120174408,  
	          "g": 0.11018768697977066,  
	          "b": 0.11018768697977066,  
	          "a": 1  
	        }  
	      }  
	    ]  
	  },  
	  "18": { "textDecoration": "UNDERLINE" }  
},

	characters: "string", // TYPE TEXT 인 노드들의 텍스트 컨텐츠. 실제 텍스트 내용이된다.
	
  // NOTE: Figma Auto Layout에서 정렬을 나타내는 필드들
  "constraints": {  
    "vertical": "TOP", // align, justify 방향과 매치될 수 있다.  
    "horizontal": "LEFT"  
  },  
  "layoutAlign": "INHERIT",  
  "layoutGrow": 0,  
  "layoutSizingHorizontal": "HUG", // figma auto layout의 크기 설정
  "layoutSizingVertical": "HUG",
  "components": {},  // 피그마 컴포넌트
  "componentSets": {},  
  "version": "2190358777510355977",   // 피그마 내부 버전
  "role": "owner",  // Figma API 요청의 권한
}

Note에서 이번 작업에 중요하게 사용한 필드들을 정리했습니다. 생각보다 구조가 복잡하여, 레이어 이름 (name 필드)를 사용해 파싱 로직을 단순화하게 되었습니다. Figma 편집에서 적절한 레이어 이름을 추가해야 하는 단점이 있었지만, MVP 단계임을 감안했습니다.

Cloudflare Worker

Worker 역할:

  1. 피그마로부터 JSON을 넘겨받아 R2에 저장

  2. Deploy Hook을 트리거

Worker에서 더 많은 작업을 처리할 수도 있습니다. 하지만 무료 플랜에서 30초 런타임 시간 제한과 비용 관리도 신경써야 하기에, 빌드 스크립트에서 마크업을 변환했습니다. 따라서 Worker의 책임을 JSON을 R2에 저장하고, Deploy Hook URL로 POST 요청을 보내는 것으로 정했습니다.

// Cloudflare의 엣지 환경에서 실행될 때 사용되는 환경변수
// wrangler secret으로 설정하여 코드에는 포함하지 않음
export interface Env {  
  //@ts-expect-error  
  RESUME_STORAGE: R2Bucket;  
  DEPLOY_HOOK_URL: any;  
}  
export default {  
  async fetch(request: Request, env: Env): Promise<Response> {  
    // NOTE: CORS의 Preflight 처리 (CORS)    
    if (request.method === "OPTIONS") {  
      return new Response(null, {  
        status: 204,  
        headers: {  
....
    }  
  
    if (request.method !== "POST") {  
	    // worker의 CORS 설정을 POST만 가능하게 설정했음
      return new Response("❌[WORKER]:Method Not Allowed", { status: 405 });  
    }  
  
    try {  
      const jsonData = await request.json();  
      // NOTE: JSON 데이터의 변경 시점을 저장
      const dateStr = jsonData?.lastModified ||
      new Date().toISOString(); 
  
      // 1. R2에서 최신 파일 목록 가져오기  
      const existingFiles = await env.RESUME_STORAGE.list({  
        prefix: `v`,  
        limit: 100,  
      });  
  
      // 2. R2 리스트에서 가장 최신 버전 찾기  
      let maxVersion = 0;  
      if (existingFiles.objects.length > 0) {  
  existingFiles.objects.forEach((obj: any) => {  
          const match = obj.key.match(/^v(\d+)-(\d{4}-\d{2}-\d{2})\T\((\d+):\(\d+):\(\d).json$/);  
          if (match && match[2] === dateStr) {  
            maxVersion = Math.max(maxVersion, parseInt(match[1], 10));  
          }  
        });  
      }  
  
      // 3. 파일 없을 시, 버전을 1로 설정  
      const newVersion = maxVersion + 1;  
      const versionedKey = `v${newVersion}-${dateStr}.json`;  
      const fileName = `v${newVersion}-${dateStr}.json`;  
  
      //4. R2에 새 버전 저장 (버저닝 기록을 위해) 
      await env.RESUME_STORAGE.put(fileName, JSON.stringify(jsonData), {  
        httpMetadata: { contentType: "application/json" },  
      });  
  
      // NOTE: Build 스크립트에서 가장 최신 버전을 "latest.json" 저장하여 포인터로 사용 
      await env.RESUME_STORAGE.put("latest.json", JSON.stringify(jsonData), {  
        httpMetadata: { contentType: "application/json" },  
      });  

	 // 5. Pages Deploy Hook에 POST 요청을 보내면 빌드-배포가 트리거됨
      let deploy_url = env.DEPLOY_HOOK_URL;  
      const deployResponse = await fetch(`${deploy_url}`, {  
        method: "POST",  
      });  
  
      if (deployResponse.ok) {  
        console.info("✅[WORKER]:Deploy hook 호출: Cloudflare Pages Rebuild");  
      } 
      // 5. Figma 플러그인으로 Ok 응답 보내어 플러그인 종료 
      return new Response(  
        JSON.stringify({  
          success: true,  
          message: `✅[WORKER]:JSON saved as ${versionedKey}`,  
        }),  
        {  
          headers: {  
            "Content-Type": "application/json",  
            "Access-Control-Allow-Origin": "*",  
            "Access-Control-Allow-Methods": "GET, POST, OPTIONS",  
            "Access-Control-Allow-Headers": "Content-Type",  
          },  
        },  
      );  
    } catch (error) {  
      console.error("❌[Worker]: Error", error);  
      return new Response("❌[Worker]: Error", { status: 500 });  
    }  
  },  
};

빌드 스크립트와 버저닝을 위한 데이터를 저장하고 Deploy Hook을 요청하며 마칩니다. 이 때 중요한 것은 Worker의 응답을 받는 쪽은 Figma 플러그인입니다. CORS 에러를 해결하며 약간의 작업 지연이 있었습니다.

Trouble Shoot: CORS 에러 처리하기

피그마 커스텀 플러그인은 Webview 환경인 데스크탑 앱에서 구동되기 때문에, 플러그인 실행 이후 Worker 요청에서 계속 CORS에러가 발생했습니다.

알고보니Figma 플러그인이 실행되는 Desktop App의 환경에서 Origin, Referrer가 null이었습니다. 요청에서 Origin 헤더를 넣어도 하여도, 제거하고 원본을 보내기 때문에, 적절한 조치가 필요했습니다.

문제의 원인은 Workers가 응답으로 적절한 CORS 헤더를 보내고 있지 않았던 것입니다. 하지만 와일드 카드를 사용하자니, 제 Figma 문서와 배포된 웹사이트 모두 취약점이 노출될 수 있었습니다.

// workers
// Authorization 헤더 설정 - .env.local, figma client storage, cloudflare secret에 저장됨
    const authHeader = request.headers.get("Authorization");
    const isAuthorized = authHeader === `Bearer ${env.WORKER_API_KEY}`;

    const corsHeaders = {
      "Access-Control-Allow-Origin": "null",
      "Access-Control-Allow-Methods": "POST, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
      // 요청의 authorization 헤더를 받기 위해 true 설정
      "Access-Control-Allow-Credentials": "true",
    };

// WORKER_API_KEY 토큰으로 권환 확인
      if (request.method !== "POST" || !isAuthorized) {
      return new Response("❌[WORKER]:Method Not Allowed", { status: 405 });
    }


// plugin.js
      // 피그마 클라이언트 스토리지에서 환경변수 불러오기
      // Promise.all 사용 시 RuntimeError: memory access out of bounds
      const PLUGIN_API_KEY =
        await figma.clientStorage.getAsync("PLUGIN_API_KEY");
      const WORKER_URL = await figma.clientStorage.getAsync("WORKER_URL");
      const FIGMA_FILE_KEY =
        await figma.clientStorage.getAsync("FIGMA_FILE_KEY");
      const FIGMA_API_KEY = await figma.clientStorage.getAsync("FIGMA_API_KEY");

      figma.notify("✅ [ENV]: WORKER_URL SET");

// Worker로 Figma JSON 전달
      const response = await fetch(WORKER_URL, {
        method: "POST",
// 외부 주소인 worker로 요청을 보낼 때 인증 헤더를 포함한다
        credentials: "include",
        headers: {
          "Content-Type": "application/json",
// Bearer Token을 보낸다
          Authorization: `Bearer ${PLUGIN_API_KEY}`,
        },
        body: JSON.stringify(jsonData),
      });

서버리스 워커, 웹뷰, 빌드 모두 다른 환경에서 실행되는 스크립트여서 혼동이 많았습니다. 특히 플러그인의 경우 웹뷰 환경이어서 node나 web api를 활용할 수 없는 것이 어려웠습니다. 이를 해결하기 위해 figma clientStorage, wrangler secret, 로컬 개발 환경에선 .env.local을 적절히 사용하여 공개된 레포에 올리면서 환경변수를 분리할 수 있었습니다.

우선은 서버와 클라이언트의 적절한 CORS 헤더 설정으로 이를 방지할 수 있었습니다. credentials: "same-origin"이어도 origin이 null이면 어디서든 허용된 헤더를 추가할 수 있습니다.

B. CI

wrangler.toml

Cloudflare의 Worker, Pages는 서버리스 환경에서 실행됩니다. 이 환경에 필요한 변수들과 경로를 wrangler.toml에서 설정해야합니다. Secret을 사용해 퍼블릭 레포에 노출되어서 안되는 환경 변수들은 wrangler secret put {name} 명령어나 클라우드플레어 대시보드에서 설정할 수 있습니다.

name = "auto-resume-worker"  
main = "./cloudflare/worker.ts"  
compatibility_date = "2025-02-27"  

  %% NOTE: 중요! R2의 버켓과 워커의 환경변수를 바인딩해야 한다. %%
[[r2_buckets]]  
binding = "RESUME_STORAGE"  
bucket_name = "auto-bucket"  
preview_bucket_name= "auto-bucket-preview"  
  
[observability.logs]  
enabled = true  
  
%% Pages 관련 환경 변수 %% 
[build]  
command = "npm run build"  
%% 
pages_build_output_dir를 대시보드나 .toml에서 정의해야 한다. 
wrangler.toml에서 Pages 설정을 포함한다면 pages_build_output_dir가 
필요하다. (wrangler cli 에러 발생)
%%

pages_build_output_dir = "dist"

wrangler를 통해 서버리스 환경에서 실행될 함수를 명령어로 따로 배포할 수도 있습니다.

build.js

Handlerbars를 이용해 JSON을 마크업으로 변환합니다. build.js는 Deploy Hook으로 POST 요청을 받으면 실행됩니다. 빌드 → 배포 순서를 보장하기 위해 적절한 스크립트 설정이 필요합니다.

/*
Pages 설정 혹은 wrangler.toml에서 
"npm run build"를 포함해야 @/src/scripts/build.js 해당 스크립트가 실행됨
*/
// auto-resume/package.json
{
	"scripts": {  
	  "build": "node src/scripts/build.js",  
	}
}

이렇게 실행되는 빌드 스크립트는 다음과 같습니다.

/*  
## Cloudflare Pages Build Script  
    목적: 최신 버전의 JSON을 R2로부터 받은 뒤, dist/index.html 생성  
    실행 시점: Deploy Hook 요청 받을 시 즉시 실행.  
*/  
// ...   
// 즉시 실행 함수로 빌드 시작
(async () => {  
  try {  

    // 1. 가장 최신 버전의 URL 포인터로 접근  
    const response = await fetch(process.env.LATEST_FIGMA);  
    if (!response.ok) {  
      throw new Error(  
        `❌[BUILD]: R2 request - Failed to get resume JSON : ${response.status} ${response.statusText}`,  
      );  
    } else {  
      console.info(["[BUILD]:get latest json"]);  
    }  
    const resumeData = await response.json();  
  
    // 2. template.hbs를 배포된 파일로부터 가져옴
    const templatePath = path.join(__dirname, "..", "template", "template.hbs");  
    const templateSource = fs.readFileSync(templatePath, "utf-8"); 

	// 3. NOTE: Handlerbars 인스턴스에 파싱 로직을 Helper로 등록 
    Handlebars.registerHelper(  
      recursiveChildren.key,  
      recursiveChildren.function,  
    );  
  
    // 4. NOTE: HandlerBars 인스턴스를 이용해 template.hbs를 컴파일
    const template = Handlebars.compile(templateSource);  
  
    // 5. NOTE: Figma JSON을 template.hbs에 주입  
    const outputHtml = template(resumeData);  
  
    // 6. output 경로 확인  
    const distDir = path.join(__dirname, "..", "..", "dist");  
    if (!fs.existsSync(distDir)) {  
      fs.mkdirSync(distDir);  
    }  
  
    // 7. 생성된 HTML을 경로 dist/index.html에 저장  
    const outputPath = path.join(distDir, "index.html");  
    fs.writeFileSync(outputPath, outputHtml);  
  
    console.log("✅[BUILD]: HTML 생성 완료 dist/index.html");  
  } catch (err) {  
    console.error("❌[BUILD]: Build script error:", err);  
    process.exit(1); // Fail the build if an error occurs  
  }  
})();

주석에서 3번부터 5번까지가 실제 변환이 일어나는 구간입니다. 이 부분을 순서대로 설명하면,

  1. Handlebars 인스턴스에 파싱 로직을 주입

  2. Handlerbars로 template.hbs를 컴파일 = 콜백 함수 리턴

  3. 콜백 함수에 Figma JSON을 인자로 넘겨 직렬화된 html을 생성

과 같습니다. 이 단계를 조금 더 명확하게 하기 위해 파일을 분리했습니다. 특히 실질적인 파싱이 일어나는 helper.js에서 로직이 길어져 분리가 필요했습니다.

template.hbs & helper.js : Figma API JSON 파싱하기

// helper.js
// Figma 노드는 재귀적으로 구성된 그래프임으로, DFS가 필요합니다.
const recursiveChildren = {  
  key: "recursiveChildren",  
  function(children, opts, parentMaxDepth = null) {  
    let result = "";  
  
    children.forEach((child) => {  
      // 그래프 높이 계산 유틸 적용
      const maxDepth = parentMaxDepth ?? getMaxDepth({ children });  
  
      // NOTE: child.name = Figma 레이어 이름 
      // 재귀 함수 실행 중 변경되면 영향을 줄 수 있어서 주의
      const safeClassName = child.name ?? "";  
  
      // list-items를 ul > li 태그로 전환  
      if (safeClassName.includes("list-items")) {  
        const lines = child.characters  
          .split("\n")  
          .map((line) => line.trim())  
          .filter(Boolean);  
  
        let href = Object.values(child?.styleOverrideTable)[0]?.hyperlink?.url;  
  
        const liClass = safeClassName  
          .split(" ")  
          .filter((item) => item !== "list-items");  
  
        const listItems = lines  
          .map((item, i) => {  
          // NOTE: 리스트 내부에 부분적으로 링크가 적용된 부분이 있었습니다.
            const isLinkList = liClass.includes("link");  
            const isLinkFirstItem = isLinkList && i === 0;  
            if (isLinkFirstItem && href) {  
              return `<li class=""><a class="link" href="${href}">${item}</a></li>`;  
            } else if (isLinkList && !isLinkFirstItem) {  
              const filtered = liClass  
                .filter((item) => item !== "link")  
                .join("");  
              return `<li class="${filtered}">${item}</li>`;  
            }  
            return `<li class="${liClass}">${item}</li>`;  
          })  
          .join("");  
        result += `<ul class="list-items">${listItems}</ul>`;  
      }  
  
      // NOTE: 텍스트 노드를 적절한 시멘틱 태그로 변경합니다.
      if (child.type === "TEXT" && !safeClassName.includes("list-items")) {  
        let tag = "p";  
        let attr = "";  
        if (child.name.includes("h1")) tag = "h1";
        // 피그마에서 link는 텍스트 + style.hyperlink로 저장되어 
        // a 태그 변환이 필요합니다.  
        else if (child.name.includes("link")) {  
          tag = "a";  
          attr = child?.style?.hyperlink?.url  
            ? `href="${child.style.hyperlink.url}"`  
            : "";  
        } else if (child.name.includes("h2")) tag = "h2";  
        else if (child.name.includes("h3")) tag = "h3";  
        else if (child.name.includes("h4")) tag = "h4";  
        else if (child.name.includes("h5")) tag = "h5";  
        else if (child.name.includes("bold")) tag = "strong";  
        else if (child.name.includes("span")) tag = "span";  

		// NOTE: "link" 레이어의 overrideStyle 사용 시, a 태그 스타일과 충돌이 일어납니다.
        const textStyleOverride =  
          child.name !== "link" ? applyTextStyles(child) : child.characters;  
          
	   // NOTE: result에 직렬화된 태그를 추가합니다. 
        result += `<${tag} ${attr || ""} class="${child.name}">${textStyleOverride}</${tag}>`;  
      }  
  
      // COL/ROW 레이아웃 프레임 노드  
      if (  
        (child.type === "FRAME" || child.type === "GROUP") &&  
        child.name.match(/\b(col-\d+|row-\d+)\b/g)  
      ) {  
      // Figma JSON의 레이아웃 필드를 사용합니다.
        let { layoutMode, itemSpacing = 4 } = child;  

		// layer 이름의 col | row로 layoutMode가 정확하지 않을 시
        let tempClass = child.name;  
        let classNames = tempClass.replace(/\b(col-\d+|row-\d+)\b/g, "").trim();  
        const groupType = layoutMode === "VERTICAL" ? "col" : "row";  
        let gapMatch = child.name.match(/(\d+)/);  
  
        let styleAttrs = itemSpacing ?? 4;  
        styleAttrs = gapMatch ? gapMatch[0] : styleAttrs;  
  
        result += `<div class="${groupType}${classNames ? " " + classNames : ""}" style="gap:${styleAttrs}px">`;  

	// NOTE: 자식 노드가 모두 닫힌 후 자신의 태그를 닫아야함
        if (child.children && child.children.length > 0) {  
          result += Handlebars.helpers.recursiveChildren(  
            child.children,  
            opts,  
            maxDepth,  
          );  
        } 
        result += `</div>`;  
      } 
      // 도형, 줄바꿈 노드인 경우 CSS 클래스로 스타일링 처리
      else if (  
        child.type === "RECTANGLE" ||  
        child.type === "ELLIPSE" ||  
        child.name === "br"  
      ) {  
        result += `<div class="${safeClassName}"></div>`;  
      }  
    });  
  
    return new Handlebars.SafeString(result);  
  },  
};  
  
module.exports = { if_eq, recursiveChildren };

recursiveChildren에서 신경썼던 부분은 그래프 탐색입니다. 특히 자식이 있는 노드는 모든 자식 노드의 태그가 닫힌 다음에서야 자신의 노드가 닫혀야합니다. 후위 순회에서 DOM 트리의 직렬화-역직렬화에 사용된다는 예시가 이제 이해됩니다.

// NOTE: 자식 노드가 모두 닫힌 후 자신의 태그를 닫아야함
        if (child.children && child.children.length > 0) {  
          result += Handlebars.helpers.recursiveChildren(  
            child.children,  
            opts,  
            maxDepth,  
          );  
        } 
        // 후위 순회 
        result += `</div>`;  
      } 

utils.js

helpers가 너무 길어져 함수 분리가 필요했습니다:

function getMaxDepth(node, depth = 1) {  
  if (!node.children || node.children.length === 0) return depth;  
  return Math.max(  
    ...node.children.map((child) => getMaxDepth(child, depth + 1)),  
  );  
}  

// NOTE: Figma API의 styleOverrride 필드, 텍스트의 부분적인 스타일링을 위한 별도의 함수입니다.
function applyTextStyles(node) {  
  let text = node.characters;  
  if (!text || !node?.characterStyleOverrides) return "";  
  let styles = node.characterStyleOverrides || [];  
  let overrideTable = node.styleOverrideTable || {};  
  let styledText = "";  
  let openTags = [];  

  for (let i = 0; i < text.length; i++) {  
    let styleKey = styles[i] || 0;  
    let style = overrideTable[styleKey] || {};  
  
    let tagStart = "";  
    let tagEnd = "";  
  
    if (style.fontWeight === 700) {  
      tagStart += "<b class='bold'>";  
      tagEnd = "</b>" + tagEnd;  
    }  
    if (style.textDecoration === "UNDERLINE") {  
      tagStart += "<u class='underline'>";  
      tagEnd = "</u>" + tagEnd;  
    }  
  
    if (openTags.length > 0 && tagStart !== openTags[openTags.length - 1]) {  
      styledText += openTags.pop();  
    }  
  
    if (tagStart) {  
      styledText += tagStart;  
      openTags.push(tagEnd);  
    }  
  
    styledText += text[i];  
  }  
  
  while (openTags.length) {  
    styledText += openTags.pop();  
  }  
  
  return styledText;  
}  

C. CD

클라우드플레어 Pages는 Deploy Hook을 제공합니다. Deploy Hook URL로 POST 요청이 들어오면, Pages에서 설정한 대로 CI/CD가 트리거됩니다. 빌드와 배포 순서를 보장하기 위해 앞서 빌드 파일을 구성했습니다.


5. 배포

프로젝트 구조

├── README.md
├── cloudflare
│   └── worker.ts
├── dist
│   ├── index.html
│   └── template.css
├── package-lock.json
├── package.json
├── src
│   ├── plugin // 피그마 플러그인 
│   │   ├── manifest.json
│   │   ├── plugin.js
│   │   └── ui.html
│   ├── scripts // 빌드 스크립트
│   │   └── build.js
│   ├── template // HTML 생성 hbs 관련 파일
│   │   ├── helper.js
│   │   ├── template.hbs
│   │   └── utils.js
│   └── test 
│       └── template // 로컬 스크립트 실행으로 html 생성 
│           ├── generate.js
│           └── test:gen.js
├── test
├── tsconfig.json
└── wrangler.toml

개선점

아직 손을 댈 부분이 많은 것 같습니다. 페이지 추가, 피그마 데이터를 바탕으로 CSS 생성하기, 지원 이력 A/B 테스트를 위한 추가 기록, 버저닝 개선 등등. 차근 차근 해나갈 예정입니다. 이력서를 시작으로 여러 정적 웹사이트 빌드, 배포에도 사용할 수 있을 것 같습니다.