워크플로우 표현식 엔진을 직접 만든 이유 — 그리고 후회한 적 없는 결정

GitHub Actions 표현식의 "거의 비슷한" 80% 를 받아들이지 못해서 PEG 파서를 직접 짠 6개월. 무엇을 깎아냈고, 무엇을 추가했고, 후속 비용이 어떻게 됐는지.

백재민
백재민
CollabOps 창업자
워크플로우 표현식 엔진을 직접 만든 이유 — 그리고 후회한 적 없는 결정

CollabOps 베타 첫 주, 한 고객사 엔지니어가 슬랙으로 한 줄을 보냈다.

"왜 ${{ steps.x.outputs.y }} 가 안 되나요? GitHub 에선 됐는데."

그 한 줄이 그날 내 일정의 절반을 잡아먹었고, 그 주의 모든 결정 을 바꿨다. 우리는 그날부터 표현식 엔진을 직접 짜기 시작했다.

왜 빌려 쓰지 않았나

처음엔 빌려 쓸 생각이었다. 후보 셋:

  • GitHub Actions 의 expression 라이브러리 포팅 — 라이선스(MIT) 는 OK 인데, Go 로 짠 걸 우리 Rust 런타임에 들고 들어오는 게 무거웠다.
  • Tengo / Starlark / CEL 같은 임베더블 언어 — 너무 많다. 우리는 표현식 80개 함수 를 원하는 게 아니라, 6개 면 충분했다.
  • JSON-e / yq — DSL 에 가깝지 산술/조건이 약했다.

그래서 PEG (Parsing Expression Grammar) 로 직접 짜는 결정. 첫 prototype 이 3일 만에 굴러갔다. 그게 함정이었다.

첫 함정 — "거의 비슷한" 80%

GitHub Actions 표현식 문법을 완벽 복제 할 생각은 없었다. 하지만 대부분의 기존 워크플로우가 그대로 동작 해야 한다고 생각했다. 이게 함정이었다.

# 우리 첫 버전에서 안 됐던 것들
steps.build.outputs['package-name']     # bracket access
toJson(github.event)                    # nested function call with object literal
contains(fromJson(steps.x.outputs), 'a') # higher-order

각각이 다른 파서 결정 을 요구했다. bracket access 는 lexer rule, nested function 은 grammar 우선순위, higher-order 는 type system. 우리가 80% 호환을 광고 하지 않았는데도, 고객은 기존 워크플로우 70개 중 8개가 안 된다 는 보고서를 보냈다.

8/70 = 11.4%. 그게 우리가 자처한 마이그레이션 비용 이었다.

그래서 뭘 했나

세 가지 결정.

첫째, 호환성을 목표가 아닌 부산물 로 다시 정의했다. 우리는 우리 표현식 언어 를 만들고 있다. GitHub 호환은 우리 언어가 그것의 superset 이 되도록 짜면 자연히 따라온다. 이 한 줄의 멘탈 모델 변경이 6개월의 작업 방향을 정렬시켰다.

둘째, 함수 화이트리스트를 공개 했다. v1 에서는 6 개 (contains, startsWith, endsWith, format, join, toJson). v2 에서 8 개 (fromJson, hashFiles 추가). 함수 추가 는 토론을 거치고, 함수 제거 는 deprecation 6개월 후. 이게 조용히 깨지는 것을 막는다.

셋째, 타입 시스템을 가볍게 박았다. GitHub 표현식은 동적 이다. 런타임에 깨진다. 우리는 워크플로우 정의 시점에 타입 검사 를 강제했다. 이게 가장 큰 차별점이 됐다 — 빌드를 돌려보기 전에 표현식 오류를 안다.

사용자가 작성:    ${{ contains(steps.x.outputs.foo, 42) }}

타입 검사 출력:   contains() 의 두 번째 인자는 string. 42 (number) 가 들어왔음.
                  steps.x.outputs.foo 가 항상 string 인지 확인 필요.

6개월 후의 비용 표

직접 짠 결정의 진짜 비용을 표로:

항목직접 짤 때빌려 썼을 때 (가정)
초기 6주100% (PEG, lexer, eval)30% (적응 코드)
첫 고객 호환성 이슈우리 책임 — 빠르게 수정외부 라이브러리 책임 — 우회 또는 PR
차별 기능 (타입 검사)추가 100시간불가능에 가까움
6개월 누적 LOC2,800 (parser 포함)약 800 (어댑터)
6개월 누적 버그 보고11건측정 불가 (외부 라이브러리 버그도 우리에게 옴)

빌려 썼을 때보다 3배 코드 를 짰다. 하지만 그 3배가 우리가 통제 가능한 코드 다. 외부 라이브러리의 우리가 못 고치는 버그 가 7개월차에 한 번이라도 터지면 그 결정은 정당화된다.

안 했으면 어땠을까

이 글을 쓰는 시점, 6개월간 단 한 번 도 그 결정을 후회하지 않았다. 후회의 단서가 있었다면 — 예를 들어 외부 라이브러리에 우리가 못 박는 기능을 매번 사람이 우회 하는 패턴이 보였다면 — 다시 검토했을 거다. 그런 단서는 없었다.

다만 한 가지는 인정한다 — 처음 3일짜리 prototype 의 빠른 동작 이 우리에게 "빨리 끝낸다" 라는 잘못된 인상을 준 시점이 있었다. 진짜 작업은 그 이후 6주 였다.

누구에게 이 결정이 의미 있는가

  • 워크플로우 도메인 을 직접 만드는 팀에게 — 표현식 엔진은 거의 항상 비싸 보이지만 사실 가장 싼 결정이다. 외부 라이브러리의 가정과 우리 도메인의 가정 이 어긋나는 순간 발생하는 비용이 누적적으로 더 크다.
  • 호환성을 영업 포인트 로 쓰려는 팀에게 — 100% 호환은 기술 결정이 아니라 마케팅 결정 이다. 그 결정의 비용을 알고 들어가야 한다.

다음 글에선 타입 시스템을 어떻게 가볍게 짰는지, 그리고 MCP 의 도구 스키마와 어떻게 정렬 시켰는지 얘기한다.

태그#cicd#parser#peg#expression#workflow#infrastructure