워크플로우 문제 때문에 Glue Code를 신뢰하지 못하게 되었다
Source: Dev.to
몇 년 전…
나는 웹훅 핸들러가 같은 분 안에 고객의 카드를 두 번 청구하는 것을 보았다.
성공 경로가 커밋되었고, 재시도 경로도 커밋되었다. 두 코드는 서로 다른 엔지니어가 6개월 차이로 작성했으며, 서로 존재한다는 사실을 몰랐다. 수정 작업은 오후 한 번에 끝났지만, 시스템이 왜 이를 허용했는가에 대한 논의는 2주가 걸렸다.
수정은 멱등성 키였다. 그 2주는 그 외 모든 것에 관한 것이었다.
패턴
나는 이런 코드를 20 년 동안 작성해 왔으며, 그 사건이 처음도 아니고 최악도 아니었다. 컨설팅 프로젝트, 포춘 500 기업 통합, 그리고 내가 출시한 제품들 전반에 걸쳐 같은 형태가 계속 나타난다:
- 웹훅이 들어온다.
- 페이로드가 파싱된다.
- 고객 레코드와 함께 데이터를 보강한다.
- API를 호출한다.
- 알림을 전송한다.
- 행을 기록한다.
- 후속 작업을 큐에 넣는다.
- 문제가 발생하면 오류 경로를 처리한다.
처음엔 단순히 애플리케이션 코드일 뿐이다: 여기서는 함수, 저기서는 큐 컨슈머, 몇 번의 재시도, 몇 개의 로그, 어쩌면 데이터베이스 상태 컬럼 정도. 워크플로우 시스템처럼 보이지 않는다; 제품을 움직이게 해야 하기 때문에 실용적으로 작성하는 코드처럼 보인다.
그게 함정이다.
유용한 워크플로우는 작게 머물지 않는다. 브랜치, 승인 단계, 재시도, 취소가 늘어나며, 반드시:
- 카드를 두 번 청구하거나 같은 Slack 메시지를 세 번 게시하는 것을 방지한다.
- 배포, 크래시, 타임아웃, 혹은 핸들러 버그 이후에도 재개할 수 있어야 한다.
- 운영자가 기본적인 질문에 답할 수 있을 만큼의 히스토리를 제공한다: 이 실행에서는 무슨 일이 있었는가?
그 시점에서 워크플로우는 더 이상 단순한 글루 코드가 아니라 상태 머신이다. 유일한 질문은 그 상태 머신이 명시적인지, 아니면 조건문, 데이터베이스 필드, 큐 메시지, 부수 효과 등에 흩어져 있는지이다. 내 경험상 거의 항상 후자다.
버그의 형태
워크플로우 코드에서 가장 귀찮은 버그는 영리한 알고리즘 버그가 아니라 상태와 경계 버그입니다:
- 핸들러가 이미 단계가 진행됐다고 가정하지만, 영속된 상태가 이를 증명하지 못한다.
- 재시도가 한 번만 커밋되어야 할 부수 효과를 다시 실행한다.
- 실패가 문자열로 저장돼서, 재시도 정책이 원래 계약이 아니었던 텍스트에 의존한다.
- 함수가 순수 변환처럼 보이지만 조용히 데이터베이스, API, 혹은 알림 서비스를 호출한다.
- 전환이 단지 관례일 뿐이라 호출자가 예상하지 못한 상태에 워크플로우가 들어갈 수 있다.
- UI, 워커, API, 배포 대상마다 워크플로우에 대한 약간씩 다른 이해를 가지고 있다.
세 명의 엔지니어와 데이터베이스 쿼리가 얽힌 20분짜리 대화를 하면서 “이것이 어떤 상태가 될 수 있나요?”라고 물어본 적이 있습니다.
이 문제들은 특이한 것이 아니라, 바로 암시적인 워크플로우 모델에서 발생하는 일반적인 실패 모드입니다. 코드는 로컬에서는 깔끔해 보이고, 각 함수는 리뷰에서도 잘 읽히지만, 시스템 수준의 계약은 여전히 비공식적이며—바로 그 비공식적인 부분에서 프로덕션 사고가 발생합니다.
Source: …
숨겨진 상태 머신
해병대에서 작전 명령은 계약과 같습니다. 작전의 단계, 단계 사이를 이동하는 조건, 중단 기준, 각 단계에서 각 요소가 하는 일, 그리고 임무 완료가 무엇인지 정의합니다. 이것은 분위기가 아니라; 상황이 꼬이기 시작하면(꼭 그렇게 될 것이기 때문에) 모두가 참고하는 문서입니다.
워크플로우도 같은 종류의 계약입니다. 다만 보통은 그렇게 보이지 않을 뿐입니다.
위의 이중 청구 사건은 암묵적인 상태 머신을 가지고 있었습니다. 받음, 처리 중, 청구됨, 실패, 재시도와 같은 상태가 있었지만, 어느 곳에도 이름이 붙어 있지 않았습니다. 상태는 상태 컬럼, 큐 메시지, 그리고 몇 개의 조건문에 숨어 있었습니다. 재시도 경로가 추가되었을 때, “청구됨”이 종료 상태라는 규칙을 적어 놓은 사람은 없었습니다. 적을 필요도 없었죠. 코드는 작동했습니다—하지만 작동하지 않을 때까지는.
만약 그 상태들이 명시적이었다면, 배포 전에 스스로 답을 찾을 수 있었을 것입니다:
- 어떤 전이가 허용되는가?
- 어느 상태가 종료 상태인가?
- 어느 단계가 외부에 보이는 행동을 커밋하는가?
- 어떤 실패 상태가 재시도될 수 있는가?
상태가 암묵적이면, 여전히 그 질문에 답해야 합니다—하지만 2시 새벽에, 사후에, 코드 경로, 데이터베이스 컬럼, 로그 메시지, 그리고 3 스프린트 전에 코드를 작성한 사람들의 머릿속에 흩어져서 말이죠.
이때 접착 코드가 비싸게 됩니다. 함수가 나쁘기 때문이 아니라, JSON이 나쁘기 때문이 아니라, 워크플로우 계약이 구현 안에 숨겨져 있기 때문이며, 구현이 따라야 할 것이 아니라는 점이 문제입니다.
부작용 문제
워크플은 대부분 부작용에 관한 것입니다.
JSON을 파싱하는 것이 어려운 것이 아니라, 런타임이 반복해도 되는 작업을 결정하는 것이 어렵습니다.
다음 작업들 사이에는 실제 차이가 있습니다:
| 재생해도 안전함 | 재생하면 안전하지 않음 |
|---|---|
| 페이로드 검증 | 카드 결제 |
| 재시도 결정 계산 | 이메일 전송 |
| 메시지 포맷팅 | Slack에 포스트 |
| 캐시된 레코드 읽기 | 외부 시스템에 쓰기 |
일반적인 글루 코드에서는 “재생 안전”과 “커밋됨” 사이의 경계가 이름, 주석, 혹은 리뷰어의 규율에 의해 표현됩니다. 규율만으로는 상황이 꼬였을 때 여러분을 구해주지 못합니다. 런타임은 어떤 호출이 재생 안전하고 어떤 것이 외부에 보이는 커밋인지 알아야 하며, 함수 이름으로 추론해서는 안 됩니다.
따라서 워크플로 런타임은 논리적으로 판단할 수 있는 부작용 경계가 필요합니다. 계약에 커밋된 작업이 어디서 발생하는지 명시되지 않으면, 런타임은 구현 세부 사항을 추측하게 되고, 결국 고객이 그 결과를 알게 됩니다.
The Bet
좋은 워크플로 엔진은 이미 존재합니다. 저는 내구성 있는 실행 시스템이 쓸모없다고 회의하는 것이 아니라, 대부분의 엔진이 런타임을 중심축으로 삼는다는 점이 회의적입니다. 여러분은 그래프를 정의하거나 프레임워크에 맞춰 코드를 작성하고, 플랫폼이 실행을 해석하거나 오케스트레이션합니다.
이는 많은 팀에게는 괜찮은 답변입니다. 하지만 엔지니어인 제가 원하는 답은 아닙니다.
저는 워크플로 계약이 진실의 원천이 되길 원합니다. 명시적이고, 테스트 가능하며, 버전 관리가 가능한—그래서 런타임이 이를 강제하고, 팀이 흩어져 있는 조건문이나 데이터베이스 컬럼을 뒤져가며 찾지 않아도 이해할 수 있기를 바랍니다.
워크플로는 실행 가능하기 전에 읽을 수 있어야 합니다. 하나의 파일을 열면 워크플로가 가질 수 있는 상태, 허용되는 전이, 각 상태가 전달하는 데이터, 수행할 수 있는 부수 효과, 취할 수 있는 커밋 액션, 그리고 런타임에 노출되는 실패 형태를 모두 확인할 수 있어야 합니다. 그런 다음 생성된 코드와 운영 도구는 그 계약을 보존해야 하며, 다른 곳에서 다시 만들지 않아야 합니다.
그래서 저는 워크플로 접착 코드를 기본 추상화로 신뢰하는 것을 멈췄습니다. 접착 코드가 항상 나쁜 것은 아니지만, 워크플로는 접착 코드가 자연스럽게 제공하는 것보다 더 강력한 경계가 필요합니다—그리고 그 사실을 깨닫게 될 때쯤이면 이미 비용을 지불하고 있는 상황이 됩니다.
다음 포스트에서는 이를 해결하기 위해 제가 만들고 있는 것을 다룹니다.