워크플로우 표현식 엔진을 직접 만든 이유 — 그리고 후회한 적 없는 결정
GitHub Actions 표현식의 "거의 비슷한" 80% 를 받아들이지 못해서 PEG 파서를 직접 짠 6개월. 무엇을 깎아냈고, 무엇을 추가했고, 후속 비용이 어떻게 됐는지.
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개월 누적 LOC | 2,800 (parser 포함) | 약 800 (어댑터) |
| 6개월 누적 버그 보고 | 11건 | 측정 불가 (외부 라이브러리 버그도 우리에게 옴) |
빌려 썼을 때보다 3배 코드 를 짰다. 하지만 그 3배가 우리가 통제 가능한 코드 다. 외부 라이브러리의 우리가 못 고치는 버그 가 7개월차에 한 번이라도 터지면 그 결정은 정당화된다.
안 했으면 어땠을까
이 글을 쓰는 시점, 6개월간 단 한 번 도 그 결정을 후회하지 않았다. 후회의 단서가 있었다면 — 예를 들어 외부 라이브러리에 우리가 못 박는 기능을 매번 사람이 우회 하는 패턴이 보였다면 — 다시 검토했을 거다. 그런 단서는 없었다.
다만 한 가지는 인정한다 — 처음 3일짜리 prototype 의 빠른 동작 이 우리에게 "빨리 끝낸다" 라는 잘못된 인상을 준 시점이 있었다. 진짜 작업은 그 이후 6주 였다.
누구에게 이 결정이 의미 있는가
- 워크플로우 도메인 을 직접 만드는 팀에게 — 표현식 엔진은 거의 항상 비싸 보이지만 사실 가장 싼 결정이다. 외부 라이브러리의 가정과 우리 도메인의 가정 이 어긋나는 순간 발생하는 비용이 누적적으로 더 크다.
- 호환성을 영업 포인트 로 쓰려는 팀에게 — 100% 호환은 기술 결정이 아니라 마케팅 결정 이다. 그 결정의 비용을 알고 들어가야 한다.
다음 글에선 타입 시스템을 어떻게 가볍게 짰는지, 그리고 MCP 의 도구 스키마와 어떻게 정렬 시켰는지 얘기한다.
비슷한 글
에이전틱 DevOps 12개월 후 — 첫 가설 중 무엇이 *맞았고* 무엇이 *틀렸나*
12개월 전 다음 10년의 DevOps는 에이전틱이다 의 가설들. 12개월의 데이터로 어느 가설이 맞고 어느 게 틀렸는지의 정직한 평가.
백재민
3 pillars 그 후 — 4 추가 신호의 *6개월 후* 운영 노트
3 pillars 가 더 이상 충분하지 않은 이유 발행 후 6개월. 4 추가 신호 (events / user journeys / deploy correlation / similarity) 가 운영에서 어떻게 작동했는지의 후속.
백재민
GitHub Actions vs 자체 호스팅 — *진짜 비용* 비교 (12개월 데이터)
GitHub Actions 가 *비싸 보임* 은 표면. 12개월 자체 호스팅 vs SaaS 비교 — 단순 *분당 비용* 이 아니라 *총 운영 비용* 으로.
백재민