발행일

워크플로우 엔진 DB 설계를 공부하고, 실제로 지으면서 바꾼 것들

워크플로우 엔진 DB 설계를 공부하고, 실제로 지으면서 바꾼 것들

표준데이터 등록 신청 → 심사 → 승인/반려 흐름을 만들어야 했습니다. 상태가 몇 개 안 되니 status 컬럼 하나로 시작할 수도 있었는데, 프로세스 종류가 늘어날 게 뻔해서(표준데이터·OID·기타) 범용 워크플로우 엔진 쪽을 먼저 공부하기로 했어요.

Exception Not Found의 8부작 시리즈가 가장 실용적이었습니다. 아래는 그 정리이고, 마지막 §9에 실제로 구현하면서 바꾼 것들을 대조해 적었습니다. 공부한 설계를 그대로 쓴 게 아니라서요.

참고한 문서 :

https://www.exceptionnotfound.net/designing-a-workflow-engine-database-part-1-introduction-and-purpose/


1. 워크플로우란?

  • 정의: 특정 요청에 대해 여러 사람이 순차적으로 내리는 결정들의 집합.
  • 예: 등록 신청 → 심사 → 반려/승인 → 완료

2. 주요 참여자 예시 (사용자 흐름)

  1. John이 요청 제출
  2. Victoria(상사)가 승인
  3. Nate(개발 리더)가 검토 및 승인
  4. Jenna(코디네이터)가 리서치
  5. 개발 진행 → QA(Charles) → 최종 승인(John)

3. 공통 요소 정리 (Lots of Similarity)

  • Request: 검토/승인 대상 요청 객체
  • Request Data: 요청별로 유동적인 사용자 정의 필드
  • Process: 요청이 따라야 할 승인 절차 정의
  • Notifications: 특정 시점마다 사용자에게 알림 전송
  • Process Owners: 프로세스를 설정/관리하는 소수 관리자

4. 핵심 테이블 설계

Process

  • 프로세스 정의 단위
  • 필드: ProcessID, Name

Users

  • 앱에 로그인 가능한 사용자
  • 필드: UserID, FirstName, LastName, DateOfBirth

Requests

  • 요청 객체 (하나의 프로세스에만 소속)
  • 필드: RequestID, Title, CreatedBy, CreatedDate, CurrentStateID, ProcessID

RequestData (Name-Value Pair)

  • 유연한 요청별 데이터 확장
  • 필드: RequestDataID, RequestID, Name, Value

Stakeholders (다대다 관계)

  • 필드: RequestID, UserID

Notes

  • 사용자 주석 저장
  • 필드: NoteID, RequestID, UserID, NoteText, CreatedAt

RequestFiles

  • 요청에 첨부된 바이너리 파일 저장
  • 필드: FileID, RequestID, FileName, MimeType, FileContent

5. 상태 및 전이

StateTypes

  • 미리 정의된 상태 종류 (Start, Normal, Complete, Denied, Cancelled)

States

  • Process별 요청이 가질 수 있는 상태 목록
  • 필드: StateID, ProcessID, Name, StateTypeID

Transitions

  • 상태 간 이동 정의
  • 필드: TransitionID, ProcessID, FromStateID, ToStateID

6. 사용자 동작과 시스템 반응

ActionTypes

  • 사용자 입력 종류 (Approve, Deny, Cancel, Restart, Resolve)

Actions

  • 요청에 대해 사용자가 직접 수행하는 작업
  • 필드: ActionID, Name, ActionTypeID

Activities

  • 상태/전이 변경 시 시스템이 자동으로 수행하는 작업
  • 예: 상태 변경 시 이메일 발송

RequestActions

  • 특정 요청에서 실행 가능한 액션 추적
  • 필드: RequestID, ActionID, TargetID, IsActive, IsCompleted
  • 모든 필수 Action 완료 시 Transition이 실행됨

7. 그룹 및 대상

Groups

  • 역할 기반 사용자 집합
  • 필드: GroupID, Name, ProcessID

GroupMembers

  • 그룹-사용자 관계
  • 필드: GroupID, UserID

Targets

  • 액션 수행자 또는 알림 수신자 지정
  • 값 예시: RequestCreator, Stakeholders, GroupMembers, ProcessAdmins

8. 참조 설계가 스스로 밝힌 한계점

  • 시간 기반 전이 미지원
  • 상태/전이 삭제 처리 없음
  • RequestFiles에 직접 저장 → 성능 저하 가능
  • 특정 개인 지정 기능 부족

9. 실제로 구현하면서 바꾼 것들

여기까지가 공부한 내용이고, 아래는 이 설계로 실제 심사 워크플로우를 지으면서 그대로 따른 것과 바꾼 것입니다.

9.1 그대로 따른 뼈대

핵심 구조는 거의 그대로 갔습니다. Process / State / Transition / Request / RequestData / Action / Activity / RequestAction / ProcessGroup. 상태 종류와 액션 종류도 참조 설계의 분류를 그대로 썼어요.

TYPE_STATE  = (('STR','Start'), ('NOM','Normal'), ('CMP','Complete'),
               ('DEN','Denied'), ('CAN','Cancelled'), ('NULL','Null'))
TYPE_ACTION = (('APR','Approve'), ('DNY','Deny'), ('CCL','Cancel'),
               ('RST','Restart'), ('RSV','Resolve'), ('NULL','Null'))

이 분류를 그대로 쓴 게 나중에 도움이 됐습니다. 상태 이름은 프로세스마다 다르지만 상태의 "종류"는 공통이라, 화면에서 "이 요청이 끝난 건가 반려된 건가"를 판정할 때 이름이 아니라 종류를 보면 됐어요. 프로세스가 늘어도 판정 코드는 안 늘어납니다.

9.2 바꾼 것 ① — 첨부 파일을 별도 테이블로 두지 않았다

참조 설계는 RequestFiles 테이블에 바이너리를 직접 담고, §8에서 스스로 성능 문제를 지적합니다. 이건 공부 단계에서 이미 알고 있던 지뢰라 처음부터 피해 갔어요.

별도 테이블 대신 RequestData에 파일 필드를 붙였습니다.

class RequestData(models.Model):
    ...
    attach_file = models.FileField(upload_to='requestdata_uploads/%Y/%m/%d/',
                                   null=True, blank=True)

두 가지가 달라집니다. 바이너리가 DB가 아니라 파일시스템에 저장되고(Django FileField의 기본 동작), 첨부가 별도 개념이 아니라 요청 데이터의 한 종류가 됩니다. 후자가 더 중요했어요. 심사 화면에서 "첨부 파일"과 "입력 값"을 따로 다룰 이유가 없었거든요. 둘 다 신청자가 낸 것입니다.

참조 설계의 한계점을 읽어두면 그 지점을 피해서 지을 수 있다는 걸 이때 배웠습니다. 8부까지 안 읽고 4부에서 멈췄으면 그대로 따라 만들었을 거예요.

9.3 바꾼 것 ② — Action/Activity의 연결을 조인 테이블로 명시

참조 설계는 액션·액티비티가 전이에 붙는 관계를 느슨하게 다룹니다. 구현에서는 TransitionAction / TransitionActivity 조인 모델을 명시적으로 뒀어요.

같은 액션(승인)이 여러 전이에서 재사용되고, 반대로 한 전이에 여러 액티비티(메일 발송 + 상태 로그)가 붙습니다. 다대다인데 이걸 암묵적으로 두면 "이 전이에서 뭐가 실행되나"를 코드로 찾아야 해요. 조인 테이블로 두니 데이터를 보면 답이 나옵니다.

대상 지정도 ActionTarget / ActivityTarget으로 나눴습니다. 참조 설계는 Targets 하나인데, 액션의 대상(누가 승인하나)과 액티비티의 대상(누구에게 알리나)은 의미가 다릅니다. 한 테이블에 두면 그 구분이 사라져요. 앞서 속성 range를 FK 둘로 쪼갠 작업과 같은 판단입니다 — 의미가 다르면 칸을 나눕니다.

9.4 바꾼 것 ③ — 도메인 특화는 프록시 모델로

표준데이터 신청과 OID 신청은 흐름이 같지만 다루는 대상이 다릅니다. OID 신청에는 "이 요청이 가리키는 OID 객체"를 꺼내는 일이 자주 있었어요.

여기서 테이블을 나누고 싶은 유혹이 있었는데, 프록시 모델로 갔습니다.

class RequestOid(Request):
    """OID 전용 Request 프록시 모델

    Note: Request와 동일한 테이블 사용하지만 OID 전용 메서드 추가
    """
    class Meta:
        proxy = True

    def get_oid_info(self):
        """OID 정보 조회 (RequestData에서 'oid_id' 키로 저장)"""
        oid_data = self.request_data.filter(key='oid_id').first()
        ...

테이블은 하나, 도메인 메서드만 분리입니다. 워크플로우 엔진 입장에서 OID 신청은 그냥 요청 하나예요 — 상태 전이도 액션도 똑같습니다. 다른 건 "요청에서 무엇을 꺼내 보느냐"뿐이고, 그건 스키마가 아니라 코드의 문제입니다.

테이블을 나눴으면 전이·액션·타깃 로직이 전부 두 벌이 됐을 겁니다. 프로세스 종류가 늘어날 걸 알고 시작한 작업이라, 늘어나는 축이 테이블이 되면 안 된다는 게 기준이었어요.

9.5 안 만든 것 — Notes

참조 설계의 Notes(사용자 주석)는 구현하지 않았습니다. 심사 의견은 이미 반려 사유로 RequestData에 들어가고 있어서, 별도의 자유 코멘트 기능이 요구사항에 없었어요. 설계서에 있다고 다 만들 필요는 없습니다. 필요해지면 그때 붙이면 되고, 지금 만들면 안 쓰이는 테이블이 하나 늘 뿐입니다.

9.6 그대로 남은 한계

참조 설계의 §8 한계 중 시간 기반 전이 미지원은 우리도 그대로입니다. "7일 내 미처리 시 자동 반려" 같은 게 아직 안 돼요. 상태 전이를 트리거하는 주체가 항상 사람의 액션이라, 스케줄러가 전이를 일으키려면 RequestAction 흐름 밖으로 나가야 합니다. 이건 붙일 때 구조를 한 번 더 봐야 하는 부분으로 남겨뒀습니다.


10. 정리

  • 범용 엔진을 공부하고 시작한 게 맞았습니다. status 컬럼으로 시작했으면 프로세스가 두 번째로 늘어나는 시점에 갈아엎었을 거예요.
  • 참조 설계의 "한계점" 장이 제일 실용적이었습니다. 남이 이미 겪은 문제를 미리 피할 수 있는 부분이라, 설계 문서를 읽을 땐 결론보다 이 절을 먼저 봅니다.
  • 설계를 그대로 따르는 게 존중이 아닙니다. 첨부 파일, 조인 테이블, 프록시 모델 세 군데는 우리 요구에 맞게 바꿨고, 그 판단 근거가 이 글의 실제 내용입니다.
  • 안 만든 것도 판단입니다. Notes를 안 만든 걸 적어두지 않으면, 나중에 누군가 "설계서엔 있는데 왜 없지"부터 다시 조사하게 돼요.