- 발행일
워크플로우에 "보완 요청(RVS)" 단계를 추가·철회한 의사결정 회고
워크플로우에 "보완 요청(RVS)" 단계를 넣었다가 하루 만에 뺀 이야기
심사 결정에 보완 요청(Revise, RVS) 단계를 추가하는 작업을 마치고 다음 날 전부 되돌렸습니다. 결과만 보면 "왜 했지?" 싶은 커밋 두 개가 남았지만, 그 과정에서 얻은 교훈이 있어 기록으로 남깁니다.
1. 원래 워크플로우 — 두 축의 단순한 상태 머신
기존 심사 프로세스는 단순했습니다.
신청(RECORD) → 심사(REVIEW) → 승인(APR) 또는 반려(REJ) → 종료
심사자는 승인(Approve) 과 반려(Reject) 두 가지 결정만 내릴 수 있고, 반려된 건은 신청자가 수정 후 재신청하는 구조였습니다.
# workflow/iso14817_factory.py — 기존 구조
class Action:
APR = 'approve' # 승인
REJ = 'reject' # 반려
2. 요구사항 — "반려하지 말고 보완해서 돌려달라"
현업에서 피드백이 왔습니다.
"반려되면 신청자가 처음부터 다시 작성해야 해서 불편해요. 사소한 수정만 필요한 경우, 심사자가 코멘트 달아서 돌려보내고 신청자가 수정해서 재제출하는 흐름이 있으면 좋겠어요."
설득력 있는 요청이라 세 번째 축 — 보완 요청(RVS) 을 추가하기로 했습니다.
신청(RECORD) → 심사(REVIEW) ┬→ 승인(APR) → 종료
├→ 반려(REJ) → 종료
└→ 보완 요청(RVS) → RECORD (self-loop)
3. 구현 — self-loop 전이와 backfill 명령
상태 머신에 self-loop 추가
# workflow/iso14817_factory.py
class Action:
APR = 'approve'
REJ = 'reject'
RVS = 'revise' # 신규 추가
class ISO14817Factory:
def build_transitions(self):
return [
(REVIEW, Action.APR, APPROVED),
(REVIEW, Action.REJ, REJECTED),
(REVIEW, Action.RVS, RECORD), # self-loop로 신청 단계로 복귀
]
기존 데이터 backfill 관리 명령
기존에 진행 중이던 신청 건들은 이 전이를 모르기 때문에, 상태 정의를 덮어쓰는 idempotent 커맨드를 따로 만들었습니다.
# workflow/management/commands/backfill_revise_flow.py
class Command(BaseCommand):
help = '기존 워크플로우에 RVS self-loop 전이를 주입'
def handle(self, *args, **options):
for wf in Workflow.objects.all():
if not wf.has_action(Action.RVS):
wf.add_transition(REVIEW, Action.RVS, RECORD)
신청자 화면 처리
RECORD 단계에서만 편집 가능했던 상세 뷰를, RVS로 돌아온 상태에서도 편집 가능하도록 게이트를 완화했습니다.
# 편집 게이트 (도입 시점)
if request.state == RECORD or has_active_revise(request):
allow_edit = True
그리고 보완 요청을 받은 신청자에게 알려주는 배너 매크로도 추가했습니다.
{% macro revise_received_banner(request) %}
<div class="alert alert-warning">
심사자가 보완을 요청했습니다: {{ request.latest_revise_comment }}
</div>
{% endmacro %}
4. 철회 — 하루 만에 전부 되돌린 이유
다음 날 팀 리뷰에서 세 가지 문제가 드러났습니다.
(1) 상태 머신이 급격히 복잡해졌다
RECORD ──→ REVIEW ──→ APPROVED
↑ │
│ ├──→ REJECTED
│ │
└───────────┘ (RVS self-loop)
self-loop는 그래프상으로는 한 줄 추가지만, "이 신청건이 지금 첫 신청이냐, 보완 후 재신청이냐"를 구분하는 분기가 도처에 생겼습니다. has_active_revise() 같은 상태 질의가 여기저기 번져나갔고 리스트 화면·알림·권한 체크마다 "RVS 상태인 경우는 예외"가 붙기 시작했습니다.
(2) 기존 "반려 후 재신청" 흐름과 사실상 중복
반려당한 건을 신청자가 수정해서 새로 신청하는 흐름이 이미 존재했습니다. RVS는 그 흐름의 UX만 조금 개선한 것이지 새 기능이 아니었습니다. "돌려보낸다"와 "반려한다"의 경계가 심사자 입장에서도 애매했고 실제 데이터로는 두 축이 거의 똑같이 쓰일 가능성이 컸습니다.
(3) 권한/취소 정책과 충돌
같은 PR에서 신청 취소 단계별 권한 분리(SELF_SERVE / APPROVAL_REQUIRED / FORBIDDEN) 작업도 같이 들어갔는데, RVS 상태에서는 이 세 단계가 어디에 속하는지 정의되지 않았습니다. "보완 요청 중인 건을 신청자가 혼자 취소할 수 있나?" 같은 질문에 팀 내 합의가 없었습니다.
5. 철회 커밋에서 지운 것들
- Action.type 에서 RVS 제거, ActionDecision 클래스 · backfill 커맨드 삭제
- ISO14817Factory · SimpleFactory 의 self-loop 전이 및 관련 파라미터 제거
- RequestActionManager.has_active_revise 및 self-loop 분기 제거
- 신청 상세 뷰의 편집 게이트를 RECORD 단계 전용으로 복원
- ActionSelectForm DEFAULT_MESSAGES · qualifyDecisionMessage.js 에서 revise 제거
- revise_received_banner 매크로 및 관련 템플릿 호출 제거
삭제 diff가 오히려 추가 diff보다 깔끔해서, 도입 전 코드로 100% 복귀했습니다.
6. "코드 100% 복귀"가 "상태 100% 복귀"는 아니다
이 글을 다시 읽으면서 걸린 부분입니다. 5절의 목록은 전부 코드 이야기예요. 그런데 도입할 때 코드만 넣은 게 아니었습니다.
# workflow/management/commands/backfill_revise_flow.py
for wf in Workflow.objects.all():
if not wf.has_action(Action.RVS):
wf.add_transition(REVIEW, Action.RVS, RECORD)
backfill 커맨드는 DB에 전이 행을 씁니다. 커맨드 파일을 지운다고 이미 쓰인 행이 사라지지는 않아요.
이 커맨드를 어디까지 돌렸는지에 따라 결과가 갈립니다.
| 실행 범위 | 롤백 후 상태 |
|---|---|
| 안 돌림 | 코드만 되돌리면 끝. 문제없음 |
| 로컬만 | 개발 DB에 고아 전이 행이 남음. 무해하지만 헷갈림 |
| 운영까지 | 운영 DB에 코드가 모르는 전이가 남음 |
마지막 경우가 고약합니다. 코드에 Action.RVS가 없으니 그 전이는 쓰이지 않지만, 워크플로우 정의 데이터에는 존재해요. 나중에 워크플로우를 화면에 그리거나 검증하는 코드가 생기면 "정의에 없는 액션을 가리키는 전이" 를 만나게 됩니다.
되돌리기 좋은 작업이었던 이유가 코드 변경이 깔끔해서였는데, 그건 코드에 한정된 이야기였어요. 도입 커맨드를 만들 때 되돌리는 커맨드를 짝으로 만들었어야 합니다. 마이그레이션에 backward()를 쓰는 것과 같은 이유예요 — 실제로 필수 플래그를 옮길 때는 backward()를 같이 짰고, 그래서 되돌릴 자리가 있었습니다.
데이터를 건드리는 변경은 코드 되돌리기만으로 롤백되지 않는다 — 이 글의 교훈 목록에 빠져 있던 항목입니다.
7. 그래서 원래 요구는 어떻게 됐나
이것도 짚어둬야 공정합니다. 철회로 끝난 게 아니라, 철회하면서 남긴 약속이 있었어요.
반려 사유를 더 잘 보여주고, 재신청 시 이전 폼을 자동 채워주는 식으로도 같은 목적을 달성할 수 있었습니다.
현업의 요구("처음부터 다시 쓰기 불편하다")는 롤백해도 그대로 남아 있습니다. RVS를 뺀 것은 해법을 뺀 것이지 문제를 없앤 게 아니에요. 이 글이 롤백의 정당성으로 끝나면, 정작 요청한 사람 입장에서는 아무것도 달라지지 않은 채 논의만 끝난 셈이 됩니다.
그리고 §4의 (3) 철회 사유 — "취소 정책과 충돌, 팀 내 합의 없음" — 는 이 작업만의 문제가 아니었습니다. 한 달 뒤 신청 취소 권한을 정하는 작업이 따로 왔고, 거기서도 "취소권은 누구의 권리인가" 를 처음부터 다시 논의했어요. 정책 공백 하나가 두 작업을 연달아 막은 셈입니다.
순서를 다시 짠다면 정책을 먼저 정하고 상태 축을 논의했을 겁니다. 이번엔 반대로 갔고, 그래서 하루치 작업이 통째로 롤백됐어요.
8. 배운 것
상태 머신에 축을 추가하는 것은 "+1"이 아니라 "×2"다
승인/반려 두 축에 세 번째 축을 넣는 건 "옵션 하나 추가"처럼 느껴졌지만, 실제로는 모든 분기·리스트·권한 체크가 곱절로 복잡해졌습니다. 상태 머신은 노드 수가 아니라 전이 수로 복잡도를 재야 합니다.
기존 흐름으로 해결 가능한지 먼저 검증한다
"반려 후 재신청" 흐름을 UX 개선으로 풀 수 있었다면, 새 축을 넣을 필요가 없었습니다. 반려 사유를 더 잘 보여주고, 재신청 시 이전 폼을 자동 채워주는 식으로도 같은 목적을 달성할 수 있었습니다.
권한·취소 정책과 함께 설계되지 않은 상태는 버그 온상이다
새 상태를 추가할 때는 그 상태에서의 모든 권한·액션·알림을 표로 미리 정리하고 들어가야 합니다. 이번에는 그 표가 비어 있었습니다.
정리
보완 요청(RVS) 단계 도입은 현업 요구에는 맞았지만, 기존 상태 머신과 권한 체계가 흡수할 수 있는 복잡도를 초과했습니다. 결국 하루 만에 롤백하고 승인/반려 두 축 구조를 유지하는 대신, 반려 UX 개선으로 같은 목적을 달성하기로 방향을 바꿨습니다.
코드는 얼마든지 되돌릴 수 있지만, "왜 넣었고 왜 뺐는가"를 기록으로 남기지 않으면 다음 분기에 또 같은 논의가 반복됩니다. 롤백 커밋 메시지와 함께 이 글을 남기는 이유입니다.