왜 gaji인가? - TS로 안전하게 GitHub Actions 작성하기

개발곰 @gaebalgom@hackers.pub

최근에 저는 TS로 GitHub Actions를 작성하기 위한 툴을 만들었습니다. 그 이름하여 GitHub Actions Justified Improvements, gaji 라는 툴입니다. 저는 왜 TS로 GitHub Actions를 작성하게 되었으며, 기존 툴들과 어떤 점이 다를까요? 같이 알아보시죠.

가지 공식 문서

Toss Client DevOps Team에서의 인턴 근무

올해 1월부터, 저는 Toss Client DevOps Team 에서 인턴 근무를 시작했습니다. Client DevOps Team을 가장 단순하게 표현하자면, 클라이언트 개발자가 빠르고 안전하게 배포할 수 있는 인프라 환경을 구축하는 팀입니다. 제가 주로 진행한 작업은 기존 워크플로우를 GitHub Actions로 전환하고, 새로운 검사를 위한 커스텀 액션을 만드는 일이었습니다. 수십 개의 워크플로우를 다루면서, 빠르고 안전한 배포 인프라를 만들어야 하는 팀에서 정작 그 인프라를 만드는 과정 자체는 느리고 불안전하다는 걸 체감했습니다. 오타 하나를 확인하려면 커밋 → 푸시 → CI 실행 → 실패 확인이라는 사이클을 반복해야 했고, 로컬에서 재현할 방법이 없으니 git 실력만 늘었습니다.

인턴을 하며 자리잡은 생각들

이런 인턴 근무를 하면서, 몇가지 생각이 자리잡았습니다. 철학까지는 아니고, 단순한 생각 정도입니다.

  1. 입출력이 명확해야 좋은 소프트웨어입니다.

  2. YAML은 동작을 표현할 언어가 아닙니다. Actions는 입출력과 사이드이펙트가 있는 동작입니다. 이걸 데이터를 표현하기 위한 언어인 YAML로 표현하는 것 자체가 언어의 사용처가 잘못된 것이 아닐까요? 선언적이지 않은 걸 선언적으로 표현하려다 보니, YAML 안에 셸 스크립트를 넣는 기형적인 구조가 되어버렸습니다.

  3. 어느 환경에서든 재현 가능해야 좋은 도구입니다.

gaji는 이 중 1, 2번에서 출발했고, 3번은 act 같은 도구의 영역입니다.

GitHub Actions의 3가지 구조적 문제

위 생각을 가지고 GitHub Actions를 보면, 다음과 같은 문제점이 있습니다.

  1. YAML은 데이터 표현 언어지, 동작을 표현하기에 적합하지 않습니다.
  2. 타입 검사가 없습니다. 외부 저장소에 의존할 일이 많은데(actions/checkout@v5조차 외부 저장소입니다), 이들이 요구하는 입력에 대한 검증이 전혀 없습니다. 사용자가 직접 문서를 보고 일일이 형식에 맞게 입력해야 합니다.
  3. 로컬에서 재현하기가 힘듭니다.

이 세 가지가 결합해 GitHub Actions는 실행하기 전까지 간단한 오타 하나도 못 찾는 플랫폼이 되었습니다.

name: CI
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - uses: actions/setup-node@v4
        with:
          node-versoin: '20'  # 키 이름 오타! 런타임까지 오류 없음 ❌
          cache: 'npm'

      - run: npm ci
      - run: npm test

gaji는 첫 번째와 두 번째 문제를 해결하는 데 집중합니다.

기존 도구들과의 비교

actionlint

솔직히 말하면, gaji를 만들 당시에는 actionlint의 존재를 몰랐습니다. 이후에 알게 되었는데, 훌륭한 도구입니다. ${{ }} 표현식의 타입 체크, 액션 입력 검증, shellcheck 통합 등 YAML 워크플로우의 오류를 상당히 잘 잡아줍니다.

다만 근본적인 접근 방식이 다릅니다. actionlint는 YAML을 유지하면서 사후에 오류를 잡는 린터이고, gaji는 YAML 자체를 벗어나서 작성 시점에 오류를 불가능하게 만드는 접근입니다. 린터는 "실수를 알려주고", 타입 시스템은 "실수를 하기 어렵게 만듭니다." 개발 경험 측면에서도, actionlint는 별도 CLI를 실행하거나 에디터 플러그인을 설치해야 하지만, gaji는 TypeScript 네이티브 자동완성과 인라인 타입 힌트가 에디터에서 즉시 동작합니다.

이 둘을 같이 쓰면 더욱 좋습니다. gaji가 생성한 YAML을 actionlint로 검증하면 가장 이상적인 조합이 됩니다. gaji가 TypeScript 단에서 액션 입력의 타입을 잡고, actionlint가 ${{ }} 표현식 검증 같은 YAML 단의 검사를 보완합니다.

emmanuelnk/github-actions-workflow-ts

github-actions-workflow-ts는 TS로 GitHub Actions를 표기한다는 아이디어의 출발점이 된 프로젝트입니다. action.yml에서 타입을 자동 생성한다는 아이디어 자체는 gaji와 동일합니다. 다만 코드젠의 주체가 다릅니다. github-actions-workflow-ts는 메인테이너가 trackedActions 목록을 관리하여 npm 패키지로 배포하는 방식이고, gaji는 사용자가 참조하는 모든 액션에 대해 즉시 로컬에서 타입을 생성합니다.

github-actions-workflow-ts의 장점은 명확합니다. npm 패키지를 설치하면 바로 사용 가능하고, 사용자가 별도의 코드젠을 실행할 필요가 없습니다. step outputs에 대한 타입 안전성도 지원합니다.

반면 단점도 있습니다. 메인테이너가 관리하는 목록에 있는 액션만 타입이 지원되므로, 커스텀 액션이나 GHE 내부 액션은 사용할 수 없습니다. 새 액션이나 버전 추가도 메인테이너에 의존하고, 외부 JS 런타임이 필요합니다.

gaji의 장점은 사용자가 참조하는 어떤 액션이든 즉시 타입을 생성한다는 점입니다. 커스텀 액션이든 GHE 내부 액션이든 상관없습니다. Rust 바이너리로 동작하므로 JS 런타임도 불필요합니다. 반면 단점으로는 사용자가 gaji dev를 실행해야 타입이 생성되고, 타입이 로컬에서 생성되므로 프로젝트마다 세팅이 필요하다는 점이 있습니다.

저는 GHE 환경에서 수많은 커스텀 액션을 다뤘던 경험 때문에, gaji 쪽의 접근이 필요하다고 판단했습니다.

gaji의 접근법

왜 이렇게 만들었는가

왜 Rust인가? 빠르기 때문입니다. 단순히 빌드된 바이너리의 속도를 이야기하는 것이 아닙니다. clippy, rustfmt 등 여러 검사 도구가 내장되어 있어서 LLM을 이용한 개발 속도를 크게 줄여주었습니다. 덕분에 인턴을 하면서도 빠르게 만들 수 있었습니다. 또한 oxc 등 Rust로 작성된 TypeScript 지원 도구들이 이미 성숙해 있어서, Rust에서 TypeScript를 다루는 것 또한 편했습니다.

왜 TypeScript인가? 우선 제가 JS/TS 개발자입니다. TypeScript의 타입 시스템은 강력하면서도 보편적이라 많은 개발자가 이미 익숙합니다. 그리고 GitHub Actions의 YAML 구조가 본질적으로 JSON과 유사하므로, TS/JS에서 JSON-like 객체로 표현하기가 매우 자연스럽습니다. 이를 단적으로 보여주는 것은 모든 gaji 워크플로우 파일이 그 자체로 유효한 TS 파일이라는 점입니다. Deno처럼 TS를 바로 실행할 수 있는 환경에서 gaji로 작성된 워크플로우를 실행하면, 해당 워크플로우를 JSON으로 표현한 결과를 출력합니다.

왜 action.yml 자동 코드젠인가? Client DevOps Team에서 커스텀 액션을 작성하는 일을 했고, 이미 상당히 많은 커스텀 액션이 존재했습니다. 이들의 문서를 일일이 보며 작성하는 것이 매우 힘들었던 경험이 직접적인 동기였습니다. Hackers.pub에 기여하면서 GraphQL 자동 코드젠의 개념을 접했고, 같은 접근을 GitHub Actions에 적용할 수 있겠다고 판단했습니다.

핵심 구조

gaji 워크플로우는 getAction()JobWorkflow.build()의 흐름으로 구성됩니다. gaji dev --watch를 실행하면 새 액션 참조를 감지하여 자동으로 타입을 생성합니다.

import { getAction, Job, Workflow } from "../generated/index.js";

const checkout = getAction("actions/checkout@v5");
const setupNode = getAction("actions/setup-node@v4");

const build = new Job("ubuntu-latest")
  .addStep(checkout({}))
  .addStep(setupNode({
    with: {
      "node-version": "20",
      cache: "npm",
    },
  }))
  .addStep({ run: "npm ci" })
  .addStep({ run: "npm test" });

const workflow = new Workflow({
  name: "CI",
  on: { push: { branches: ["main"] } },
}).addJob("build", build);

workflow.build("ci");

이렇게 작성하면 모든 액션 입력에 대한 자동완성, 컴파일 시점 타입 체크, action.yml 문서의 IDE 힌트 표시, 기본값 표시가 모두 동작합니다. CompositeJob으로 공통 로직을 클래스로 추출하거나, CallJob으로 재사용 가능한 워크플로우를 호출하는 것도 TS 코드상에선 자연스럽습니다.

실제 사례: gaji 자체의 릴리즈 CD

gaji는 모든 ci/cd를 gaji로 작성하고 있습니다. 그중에서 제일 복잡한 release.ts는 4개의 Job으로 구성되어 있습니다.

  • build: 5개 플랫폼(linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64) 크로스 빌드
  • upload-release-assets: GitHub Release에 바이너리와 체크섬 업로드
  • publish-npm: npm에 플랫폼별 패키지 퍼블리시
  • publish-crates: crates.io에 OIDC 기반 퍼블리시

이 워크플로우가 YAML로 컴파일되면 약 180줄의 평탄한 구조가 됩니다. 주석 없이는 Job 간 경계나 의존관계를 파악하기 어렵습니다. TypeScript 버전에서는 build, uploadReleaseAssets, publishNpm, publishCrates라는 변수명만으로 구조가 즉시 파악됩니다. 6개의 외부 액션을 타입 안전하게 사용하고, 복잡한 매트릭스 빌드와 OS별 분기가 코드 구조 안에서 가독성 있게 표현됩니다.

gaji의 한계

gaji에는 여전히 한계가 존재합니다.

근본적으로, 최종 산출물이 여전히 YAML입니다. GitHub Actions 플랫폼의 입력 형식이 YAML인 이상, gaji도 그 제약 안에 갇힙니다. gaji는 이 플랫폼 위에서의 최선이지, 이상적인 해답은 아닙니다. 원본 action.yml의 inputs가 문자열이나 숫자 정도로만 표현되기 때문에, "npm" | "yarn" | "pnpm" 같은 세밀한 값 수준의 타입까지는 제공할 수 없습니다. ${{ matrix.target.rust_target }} 같은 GitHub Actions 표현식도 여전히 순수 문자열이라 타입 검증이 불가능합니다.

기술적인 제한도 있습니다. gaji devgetAction()을 정적 분석해서 실행되기 때문에 문자열 리터럴만 지원하고, 변수나 템플릿 리터럴은 사용할 수 없습니다. 문자열 값 자체의 오타(cache: "npn" vs cache: "npm")도 잡을 수 없고요.

앞으로의 방향

gaji의 현재 아키텍처는 TypeScript → Parse (oxc) → Execute (QuickJS) → YAML입니다. 코드젠을 만들면서 한 가지 아이디어를 얻었는데, 프론트엔드(사용자가 작성하는 언어)와 백엔드(YAML 생성)를 잘 분리하고, 중간 언어를 잘 정의하면 TypeScript 외의 다른 언어로도 워크플로우를 작성할 수 있겠다는 것입니다.

1.0에서는 플러그인 시스템을 도입해 다른 언어 지원을 확장할 수 있는 구조를 만들 계획입니다. gaji의 핵심 가치인 action.yml 자동 타입 생성을 TypeScript에 국한하지 않고 확장하고 싶습니다.

Special Thanks

gaji 브랜드

이름 제안을 해 주신 kiwiyou 님, RanolP 님, 로고 제작을 해 주신 sij411 님께 감사드립니다. Client DevOps Team에게도 감사합니다. 이 팀에서 겪은 경험이 아니었으면 YAML과 GitHub Actions에 대해 생각해 보지 않았을 겁니다. emmanuelnk/github-actions-workflow-ts에게도 감사를 표합니다. TS로 GitHub Actions를 표기한다는 아이디어와 기본적인 TS API 설계는 여기서 가져왔습니다.

Read more →
2

❤️

2 people reacted.

어느 한 개발자입니다.

Hi, I'm who's behind Fedify, Hollo, BotKit, and this website, Hackers' Pub! My main account is at @hongminhee洪 民憙 (Hong Minhee) :nonbinary:.

Fedify, Hollo, BotKit, 그리고 보고 계신 이 사이트 Hackers' Pub을 만들고 있습니다. 제 메인 계정은: @hongminhee洪 民憙 (Hong Minhee) :nonbinary:.

FedifyHolloBotKit、そしてこのサイト、Hackers' Pubを作っています。私のメインアカウントは「@hongminhee洪 民憙 (Hong Minhee) :nonbinary:」に。