콘텐츠 대표 이미지 - C#로 우리 회사만의 워크플로우 엔진 만들기: 개발자, 이제 칼퇴를 꿈꿔보자! 🚀

C#로 우리 회사만의 워크플로우 엔진 만들기: 개발자, 이제 칼퇴를 꿈꿔보자! 🚀

반복 업무는 이제 그만! C#으로 비즈니스 프로세스를 자동화하는 여정, 지금 바로 시작합니다.

들어가며: "이거 승인 좀 해주세요"의 굴레에서 벗어나기

개발자라면 누구나 한 번쯤은 이런 생각 해봤을 거예요.
"이놈의 반복 업무, 누가 대신 좀 안 해주나?" 😫
휴가 신청서, 경비 처리, 콘텐츠 발행 요청, 신규 입사자 온보딩...
회사에는 정해진 절차를 따라야 하는 수많은 업무가 존재하죠.

이 과정은 보통 이메일, 메신저, 혹은 구두로 이루어집니다.
"김대리, 기획안 검토 부탁해." ➡️ "네, 부장님께 전달하겠습니다." ➡️ "부장님, 기획안 검토 후 승인 부탁드립니다."
이 과정에서 누군가 휴가라도 가면 업무는 그대로 멈춰버리고, 누가 어떤 단계를 진행 중인지 파악하기도 어렵습니다. 한마디로 카오스 그 자체죠.

바로 이 지점에서 우리 개발자들의 슈퍼파워가 발휘될 수 있습니다. 바로 '자동화'라는 마법이죠! ✨
그리고 그 마법의 중심에는 '워크플로우 엔진(Workflow Engine)'이 있습니다.

이 글에서는 C#과 .NET 환경을 기반으로, 우리 회사만을 위한 맞춤형 워크플로우 엔진을 어떻게 설계하고 구현할 수 있는지, 그 핵심 개념부터 실제 코드 레벨의 아이디어까지 깊이 있게 파헤쳐 볼 겁니다.
마치 레고 블록을 조립하듯, 하나하나 개념을 쌓아 올려 우리만의 강력한 자동화 시스템을 만들어가는 여정! 생각만 해도 가슴이 뛰지 않나요? 😉

잠깐! 이 글은 누구를 위한 건가요? - C#에 익숙하고, .NET 환경에서 개발 경험이 있는 분
- 회사의 비효율적인 업무 프로세스를 개선하고 싶은 의욕 넘치는 개발자
- '워크플로우'나 'BPM(Business Process Management)'이 뭔지 궁금했지만, 너무 어렵게만 느껴졌던 분
- 나만의 프로젝트로 포트폴리오를 한 단계 업그레이드하고 싶은 분

자, 그럼 이제 커피 한 잔 진하게 타시고, C#으로 기업의 업무 흐름을 지휘하는 오케스트라의 마에스트로가 될 준비를 시작해봅시다!

1. 워크플로우 엔진, 대체 정체가 뭐야? (핵심 개념 파악하기)

'워크플로우 엔진'이라는 말이 좀 거창하게 들릴 수 있어요. 하지만 그 본질을 뜯어보면 생각보다 심플합니다.
쉽게 말해, '정해진 규칙(Rule)에 따라 업무의 흐름을 자동으로 관리하고 실행하는 소프트웨어'라고 할 수 있습니다.

우리가 흔히 사용하는 내비게이션을 떠올려보세요. 🗺️
출발지와 목적지를 입력하면, 내비게이션은 최적의 경로를 찾아주고, 각 경유지마다 "잠시 후 우회전입니다" 와 같이 무엇을 해야 할지 알려주죠. 교통 상황에 따라 다른 길을 추천해주기도 하고요.

워크플로우 엔진이 바로 '업무용 내비게이션'입니다.

  • 업무의 시작과 끝 (출발지와 목적지): "휴가 신청서 작성"부터 "휴가 최종 승인"까지.
  • 업무의 절차 (경로): 팀장 결재 ➡️ 부서장 결재 ➡️ 인사팀 전달.
  • 각 단계에서 할 일 (경유지 안내): 팀장에게 승인 요청 알림 발송.
  • 조건에 따른 분기 (교통 상황): "만약 신청자가 팀장이라면, 부서장 결재부터 시작한다."

이 모든 과정을 사람이 일일이 챙기는 대신, 엔진이 알아서 처리해주는 겁니다. 개발자는 이 '업무 지도'를 그려주는 역할을 하는 것이고요.

이 개념을 좀 더 '개발자스럽게' 분해해볼까요? 워크플로우 엔진은 크게 4가지 핵심 요소로 구성됩니다.

워크플로우 엔진 워크플로우 정의 (The Blueprint 📜) 워크플로우 인스턴스 (A Single Run 🏃) 액티비티 (Activity) (The Steps 🧱) 전환 (Transition) (The Paths 🛣️)

1. 워크플로우 정의 (Workflow Definition):
업무의 전체적인 청사진 또는 설계도입니다. 어떤 단계(Activity)들이 있고, 그 단계들이 어떤 순서와 조건(Transition)으로 연결되는지를 모두 담고 있죠. "휴가 신청서는 팀장 승인 후 부서장에게 간다"는 규칙 자체가 바로 워크플로우 정의의 일부입니다. 보통 XML이나 JSON 같은 형태로 저장됩니다.

2. 워크플로우 인스턴스 (Workflow Instance):
정의된 워크플로우가 실제로 한 번 실행되는 것을 말합니다. '홍길동'이 휴가 신청서를 제출했다면, 그 순간 '홍길동의 휴가 신청'이라는 하나의 인스턴스가 생성되는 거죠. 이 인스턴스는 자신이 지금 어떤 단계에 있는지, 누가 처리해야 하는지 등의 상태 정보를 가지고 있습니다. 수백 명이 동시에 휴가 신청을 해도, 각각의 인스턴스는 독립적으로 자신의 길을 갑니다.

3. 액티비티 (Activity):
워크플로우를 구성하는 가장 작은 작업 단위입니다. '결재 요청', '이메일 발송', '데이터베이스에 기록', '외부 API 호출' 등 구체적인 '행동' 하나하나가 모두 액티비티가 될 수 있습니다.

4. 전환 (Transition):
하나의 액티비티에서 다음 액티비티로 넘어가는 '길'이자 '규칙'입니다. "팀장이 '승인' 버튼을 누르면" 다음 단계로, "'반려' 버튼을 누르면" 신청자에게 반려 알림을 보내는 단계로 가는 것처럼, 특정 조건을 만족해야만 길이 열립니다. 이 조건 로직이 워크플로우의 유연성을 결정하는 핵심입니다.

💡 핵심 정리: 워크플로우 엔진은 워크플로우 정의(설계도)를 읽어서,
수많은 워크플로우 인스턴스(실행 건)를 생성하고 관리하며,
각 인스턴스를 액티비티(단계) 사이에서 전환(규칙)에 따라 이동시키는 똑똑한 관리자인 셈입니다!

2. 왜 직접 만들까? (Buy vs. Build)

이쯤에서 현명한 개발자라면 이런 질문을 던질 겁니다. 🤔
"이미 좋은 오픈소스나 상용 워크플로우 엔진이 많은데, 왜 굳이 우리가 직접 만들어야 하죠?"

정말 좋은 질문입니다. 실제로 세상에는 훌륭한 워크플로우 엔진들이 많습니다.
.NET 생태계만 봐도 마이크로소프트가 직접 만들었던 WF(Windows Workflow Foundation)가 있었고, 최근에는 Elsa Core, Workflow Core 같은 강력한 오픈소스 프로젝트들이 큰 인기를 얻고 있죠. Java 진영에는 Camunda, jBPM 같은 전통의 강자들이 있고요.

이런 기성 제품을 사용하는 것(Buy)은 분명 장점이 많습니다.

  • 빠른 개발 속도: 이미 만들어진 기능을 가져다 쓰기만 하면 되니, 초기 개발 시간을 획기적으로 줄일 수 있습니다.
  • 안정성: 수많은 사용 사례를 통해 검증되었기 때문에(Battle-Tested), 우리가 미처 생각지 못한 엣지 케이스나 버그가 적습니다.
  • 풍부한 기능과 커뮤니티: 타이머, 이벤트 처리, 시각적 디자이너 등 고급 기능을 바로 사용할 수 있고, 막히는 부분이 있으면 커뮤니티의 도움을 받을 수 있습니다.

하지만, 그럼에도 불구하고 우리가 직접 만드는 것(Build)을 고려해야 하는 이유는 더욱 강력합니다.

1. 궁극의 커스터마이징 (Ultimate Customization) 🎨
기성 제품은 범용성을 위해 만들어졌습니다. 우리 회사의 아주 독특하고 기묘한(?) 결재 라인이나 예외 규칙을 적용하려면, 제품이 정해놓은 틀 안에서 어떻게든 '우겨넣어야' 합니다. 이는 종종 복잡한 설정이나 지저분한 우회 코드(Workaround)를 낳게 되죠.
하지만 우리가 직접 만든다면? 우리 회사 비즈니스 로직이 곧 엔진의 규칙이 됩니다. 군더더기 없이, 100% 우리에게 최적화된 엔진을 가질 수 있습니다.

2. 라이선스 비용과 종속성 탈피 (Freedom from Lock-in) ⛓️
상용 솔루션은 당연히 라이선스 비용이 발생합니다. 오픈소스라 할지라도, 특정 기능(예: 고급 대시보드, 기술 지원)은 유료인 경우가 많죠. 더 무서운 것은 '기술 종속성'입니다. 한번 특정 엔진에 맞춰 시스템을 구축하고 나면, 나중에 다른 기술로 전환하기가 매우 어렵습니다. 그 엔진의 업데이트 정책이나 회사의 운명에 우리 시스템의 미래가 좌우될 수 있는 것이죠.

3. 완벽한 통합과 성능 최적화 (Seamless Integration & Performance) ⚙️
우리 회사가 이미 사용 중인 인증 시스템, 데이터베이스, 레거시 시스템과 가장 자연스럽게 연동되는 코드를 작성할 수 있습니다. 불필요한 기능을 덜어내고 우리에게 꼭 필요한 부분만 구현함으로써, 시스템을 더 가볍고 빠르게 만들 수도 있습니다.

4. 내재화된 기술 역량 (In-house Expertise) 🧠
이것이 가장 중요할 수 있습니다. 워크플로우 엔진을 직접 만들어보는 경험은, 단순히 결과물을 얻는 것 이상의 가치를 가집니다. 비즈니스 프로세스 자동화에 대한 깊은 이해를 갖게 되고, 이는 회사의 핵심 기술 자산이 됩니다. 문제가 발생했을 때, 블랙박스 같은 외부 솔루션의 문서를 뒤지는 대신, 우리가 직접 만든 코드를 보고 즉시 해결할 수 있죠. 이러한 고급 기술 역량은 개발자 개인의 성장은 물론, 재능넷과 같은 플랫폼에서 자신의 가치를 높이는 데 큰 도움이 될 것입니다.

⚠️ 결론: 언제 만들고, 언제 사야 할까? - Buy (기성 제품 사용): 표준적인 워크플로우가 많고, 개발 일정이 매우 촉박하며, 핵심 비즈니스가 아닐 때.
- Build (직접 개발): 회사의 핵심 비즈니스 프로세스이며, 매우 독특한 요구사항이 많고, 장기적인 기술 독립성과 내재화가 중요할 때.

이 글에서는 'Build'의 길을 선택했습니다. 학습과 통제, 그리고 커스터마이징의 즐거움을 만끽하기 위해서죠! 자, 이제 이론은 충분하니 코드로 이야기해볼 시간입니다.

3. C#으로 뼈대 세우기: 핵심 모델 설계

드디어 C# 코드를 만날 시간입니다! 💻
워크플로우 엔진을 만든다는 건, 결국 앞에서 설명한 '워크플로우 정의', '인스턴스', '액티비티', '전환' 같은 개념들을 클래스로 모델링하는 과정입니다.

가장 먼저, 워크플로우의 설계도인 '워크플로우 정의' 관련 클래스부터 만들어 보겠습니다. 이 설계도는 보통 JSON이나 XML 형태로 관리되므로, 직렬화/역직렬화가 쉬운 POCO(Plain Old CLR Object) 클래스로 설계하는 것이 좋습니다.

3.1. 워크플로우 정의 (The Blueprint)

워크플로우 전체의 틀을 정의하는 클래스입니다.


// WorkflowDefinition.cs
public class WorkflowDefinition
{
    // 워크플로우의 고유 ID (e.g., "vacation-request")
    public string Id { get; set; }

    // 워크플로우 이름 (e.g., "휴가 신청 프로세스")
    public string Name { get; set; }

    // 버전 관리! 나중에 워크플로우가 변경될 때를 대비합니다.
    public int Version { get; set; }

    // 이 워크플로우를 구성하는 모든 액티비티(단계)들의 목록
    public List<ActivityDefinition> Activities { get; set; }

    // 액티비티들을 연결하는 모든 전환(규칙)들의 목록
    public List<TransitionDefinition> Transitions { get; set; }

    // 워크플로우가 처음 시작될 때, 어떤 액티비티부터 시작할지 지정
    public string InitialActivityId { get; set; }
}

이제 이 `WorkflowDefinition`을 구성하는 `ActivityDefinition`과 `TransitionDefinition`을 살펴볼까요?

3.2. 액티비티 정의 (The Steps)

각 단계에서 어떤 일이 벌어지는지를 정의합니다.


// ActivityDefinition.cs
public class ActivityDefinition
{
    // 액티비티의 고유 ID (e.g., "manager-approval")
    public string Id { get; set; }

    // 액티비티 이름 (e.g., "팀장 승인 대기")
    public string Name { get; set; }

    // 액티비티의 유형. 이 타입에 따라 엔진이 다른 동작을 수행합니다.
    // e.g., "UserTask", "AutomatedTask", "Notification"
    public string Type { get; set; }
    
    // 이 액티비티를 수행할 역할(Role)이나 담당자 정보
    // e.g., "TeamManager", "HR-Department"
    public string AssigneeRole { get; set; }

    // 자동화된 작업의 경우, 실행할 클래스나 스크립트 정보
    // e.g., "MyProject.Tasks.SendApprovalEmailTask"
    public string? AutomatedTaskHandler { get; set; }
}

`Type` 프로퍼티가 아주 중요합니다. 'UserTask'는 사람의 입력을 기다려야 하는 작업이고, 'AutomatedTask'는 시스템이 자동으로 처리하는 작업(ex: 이메일 발송)을 의미합니다. 엔진은 이 `Type`을 보고 "아, 이건 사람이 처리할 때까지 기다려야겠군!" 또는 "이건 내가 바로 처리해야지!"라고 판단하게 됩니다.

3.3. 전환 정의 (The Paths)

액티비티와 액티비티를 잇는 다리입니다. 여기에 '조건'이 들어가는 것이 핵심이죠.


// TransitionDefinition.cs
public class TransitionDefinition
{
    // 시작 액티비티 ID
    public string FromActivityId { get; set; }

    // 도착 액티비티 ID
    public string ToActivityId { get; set; }

    // 이 전환이 일어나기 위한 조건! (가장 흥미로운 부분)
    // e.g., "data.amount > 1000000", "data.result == 'approved'"
    // 조건이 없으면(null) 이전 액티비티가 끝나면 바로 전환됩니다.
    public string? Condition { get; set; }
}

`Condition`을 보세요! 여기에 C# 표현식이나 특정 DSL(Domain-Specific Language)을 문자열로 넣어두면, 엔진이 이 문자열을 동적으로 평가해서 `true`일 때만 `ToActivityId`로 워크플로우를 진행시킵니다. 예를 들어, 경비 신청 워크플로우에서 "신청 금액이 100만원을 초과하면" CEO 승인 단계로, "100만원 이하면" 바로 재무팀으로 보내는 분기 처리가 바로 이 `Condition`으로 구현됩니다. 🤯

3.4. 워크플로우 인스턴스 (The Runner)

자, 이제 설계도는 완성되었습니다. 이 설계도를 가지고 실제로 동작하는 '러너'를 모델링할 차례입니다. 바로 `WorkflowInstance` 클래스죠.


// WorkflowInstance.cs
public class WorkflowInstance
{
    // 인스턴스의 고유 ID (Guid를 사용하는 것이 일반적)
    public Guid Id { get; set; }

    // 이 인스턴스가 어떤 워크플로우 정의를 따르는지
    public string WorkflowDefinitionId { get; set; }
    public int WorkflowDefinitionVersion { get; set; }

    // 현재 머물러 있는 액티비티의 ID
    public string CurrentActivityId { get; set; }

    // 인스턴스의 현재 상태
    public WorkflowStatus Status { get; set; } // Running, Completed, Faulted, Suspended 등

    // 이 인스턴스만의 고유한 데이터 저장소!
    // 휴가 신청이라면 신청자, 기간, 사유 등이 여기에 저장됩니다.
    // Dictionary나 JSON 문자열 형태로 많이 사용합니다.
    public Dictionary<string, object> Payload { get; set; }

    public DateTime CreatedAt { get; set; }
    public DateTime? CompletedAt { get; set; }
}

public enum WorkflowStatus
{
    Running,
    Completed,
    Faulted,
    Suspended // 사람의 입력을 기다리는 등 '일시정지' 상태
}

`Payload`가 이 클래스의 심장입니다. '홍길동'의 휴가 신청 인스턴스와 '임꺽정'의 휴가 신청 인스턴스는 동일한 `WorkflowDefinition`을 사용하지만, `Payload`에 담긴 내용은 완전히 다릅니다. 그리고 `Transition`의 `Condition`을 평가할 때 바로 이 `Payload`에 있는 데이터를 사용하게 됩니다.

✅ 지금까지의 설계 요약: 1. `WorkflowDefinition`이라는 설계도를 JSON 등으로 미리 만들어 둡니다.
2. 사용자가 "휴가 신청" 버튼을 누르면, 이 설계도를 바탕으로 `WorkflowInstance`라는 실행 객체를 생성합니다.
3. 이 인스턴스는 `CurrentActivityId`를 바꾸어 가며 상태를 변경하고, `Payload`에 자신만의 데이터를 저장합니다.
4. 이 모든 과정을 조율하는 것이 바로 다음에 만들 '워크플로우 실행자(Executor)'입니다.

어떤가요? 클래스 몇 개만으로 복잡해 보이던 워크플로우 엔진의 뼈대가 세워졌습니다. 이제 이 뼈대에 살을 붙이고 심장을 뛰게 할 차례입니다!

4. 엔진의 심장, 실행자(Executor) 구현하기

지금까지 우리는 워크플로우의 '명사'들, 즉 데이터 모델을 정의했습니다. 이제는 '동사'를 만들 차례입니다. 워크플로우를 실제로 한 단계씩 앞으로 나아가게 하는 심장부, 바로 워크플로우 실행자(Workflow Executor)를 구현해 보겠습니다.

`WorkflowExecutor`는 서비스 클래스로, 다음과 같은 핵심적인 역할을 수행합니다.

  • 워크플로우를 처음 시작시키는 역할 (`StartWorkflow`)
  • 현재 단계를 실행하고, 다음 단계로 넘어갈지 결정하는 역할 (`ExecuteCurrentStep` 또는 `TriggerNextStep`)

아래는 `WorkflowExecutor`의 개념적인 코드 스케치입니다. 실제 구현에서는 의존성 주입(DI), 데이터베이스 접근 로직 등이 추가되겠지만, 핵심 로직에 집중해 봅시다.


// IWorkflowExecutor.cs (인터페이스 정의)
public interface IWorkflowExecutor
{
    // 새 워크플로우 인스턴스를 시작
    Task<WorkflowInstance> StartWorkflowAsync(string workflowDefinitionId, Dictionary<string, object> initialPayload);

    // 특정 인스턴스의 다음 단계를 진행 (e.g., 사용자가 '승인' 버튼을 눌렀을 때 호출)
    Task<WorkflowInstance> TriggerNextStepAsync(Guid instanceId, Dictionary<string, object> dataForNextStep);
}

// WorkflowExecutor.cs (구현체)
public class WorkflowExecutor : IWorkflowExecutor
{
    // 실제로는 DB에서 읽어오겠죠? 여기서는 간단히 In-Memory로 가정
    private readonly IWorkflowDefinitionRepository _definitionRepo;
    private readonly IWorkflowInstanceRepository _instanceRepo;

    public WorkflowExecutor(IWorkflowDefinitionRepository definitionRepo, IWorkflowInstanceRepository instanceRepo)
    {
        _definitionRepo = definitionRepo;
        _instanceRepo = instanceRepo;
    }

    public async Task<WorkflowInstance> StartWorkflowAsync(string workflowDefinitionId, Dictionary<string, object> initialPayload)
    {
        var definition = await _definitionRepo.GetByIdAsync(workflowDefinitionId);
        if (definition == null) throw new Exception("Workflow definition not found!");

        var instance = new WorkflowInstance
        {
            Id = Guid.NewGuid(),
            WorkflowDefinitionId = definition.Id,
            WorkflowDefinitionVersion = definition.Version,
            CurrentActivityId = definition.InitialActivityId, // 시작 액티비티로 설정
            Status = WorkflowStatus.Running,
            Payload = initialPayload ?? new Dictionary<string, object>(),
            CreatedAt = DateTime.UtcNow
        };

        await _instanceRepo.SaveAsync(instance);

        // 시작과 동시에 첫 단계를 바로 실행할 수도 있습니다.
        return await ExecuteCurrentStepAsync(instance);
    }
    
    // 이 메소드는 외부에서 직접 호출되기보다, 내부 로직이나 다른 트리거에 의해 호출될 수 있습니다.
    private async Task<WorkflowInstance> ExecuteCurrentStepAsync(WorkflowInstance instance)
    {
        // ... (핵심 로직은 여기에!)
        // 1. 현재 액티비티가 무엇인지 찾기
        // 2. 액티비티 타입에 따라 작업 수행 (자동 or 대기)
        // 3. 다음으로 갈 전환(Transition) 찾기
        // 4. 조건 평가 후 다음 액티비티로 상태 변경
        // ...
        return instance;
    }

    public async Task<WorkflowInstance> TriggerNextStepAsync(Guid instanceId, Dictionary<string, object> dataForNextStep)
    {
        var instance = await _instanceRepo.GetByIdAsync(instanceId);
        if (instance == null || instance.Status != WorkflowStatus.Suspended)
        {
            // 실행 중이 아니거나, 일시정지 상태가 아니면 진행 불가
            throw new InvalidOperationException("Instance is not in a valid state to be triggered.");
        }

        // 사용자가 입력한 데이터(e.g., 승인/반려 여부)를 Payload에 업데이트
        foreach (var item in dataForNextStep)
        {
            instance.Payload[item.Key] = item.Value;
        }

        // 이제 다시 실행!
        return await ExecuteCurrentStepAsync(instance);
    }
}

자, 이제 가장 중요한 `ExecuteCurrentStepAsync` 메소드의 내부를 채워볼 시간입니다. 이 로직의 흐름을 시각적으로 먼저 이해해볼까요?

C# 워크플로우 엔진 - 실행자(Executor) 로직 흐름도 시작 1. 워크플로우 인스턴스 & 정의 로드 데이터베이스에서 현재 상태 정보 조회 2. 현재 액티비티 정보 확인 활성 상태의 액티비티 노드 탐색 3. 액티비티 타입별 로직 실행 자동화 작업 처리 또는 사용자 입력 대기 4. 다음 전환(Transition) 탐색 가능한 경로 및 조건 확인 5. 조건 평가 조건 충족 (True) 6a. 다음 액티비티로 상태 변경 워크플로우 진행 계속 다음 스텝으로 계속 실행 조건 불충족 / 대기 6b. 상태 변경 일시정지 또는 완료 처리 종료 C# Engine

4.1. `ExecuteCurrentStepAsync` 상세 로직

이제 저 다이어그램을 코드로 옮겨보겠습니다.


private async Task<WorkflowInstance> ExecuteCurrentStepAsync(WorkflowInstance instance)
{
    var definition = await _definitionRepo.GetByIdAsync(instance.WorkflowDefinitionId);
    var currentActivity = definition.Activities.First(a => a.Id == instance.CurrentActivityId);

    // 1. 액티비티 타입에 따른 로직 실행
    bool canContinueAutomatically = false;
    if (currentActivity.Type == "AutomatedTask")
    {
        // 자동화 작업 실행 (e.g., 리플렉션을 사용해 핸들러 클래스 실행)
        // IAutomatedTask handler = CreateTaskHandler(currentActivity.AutomatedTaskHandler);
        // await handler.ExecuteAsync(instance.Payload);
        Console.WriteLine($"자동화 작업 '{currentActivity.Name}' 실행 완료!");
        canContinueAutomatically = true; // 자동화 작업은 바로 다음으로 넘어갈 수 있음
    }
    else if (currentActivity.Type == "UserTask")
    {
        // 사용자 작업: 상태를 '일시정지'로 바꾸고 외부 입력을 기다림
        instance.Status = WorkflowStatus.Suspended;
        Console.WriteLine($"사용자 작업 '{currentActivity.Name}' 대기 중...");
        canContinueAutomatically = false;
    }
    // ... 다른 타입의 액티비티들 (e.g., EndEvent)

    // 2. 다음으로 진행할지 결정
    if (!canContinueAutomatically)
    {
        await _instanceRepo.SaveAsync(instance); // 상태 저장 후 함수 종료
        return instance;
    }

    // 3. 현재 액티비티에서 나가는 전환(Transition)들을 모두 찾음
    var possibleTransitions = definition.Transitions.Where(t => t.FromActivityId == currentActivity.Id);

    // 4. 조건 평가 후 다음 액티비티 찾기
    string? nextActivityId = null;
    foreach (var transition in possibleTransitions)
    {
        if (string.IsNullOrEmpty(transition.Condition) || EvaluateCondition(transition.Condition, instance.Payload))
        {
            // 조건이 없거나, 조건을 만족하는 첫 번째 전환을 선택
            nextActivityId = transition.ToActivityId;
            break;
        }
    }

    // 5. 다음 액티비티로 상태 변경 또는 워크플로우 종료
    if (nextActivityId != null)
    {
        instance.CurrentActivityId = nextActivityId;
        Console.WriteLine($"다음 단계 '{nextActivityId}'로 이동합니다.");
        // 재귀 호출을 통해 다음 스텝을 연쇄적으로 실행
        return await ExecuteCurrentStepAsync(instance); 
    }
    else
    {
        // 더 이상 진행할 전환이 없으면 워크플로우 종료
        instance.Status = WorkflowStatus.Completed;
        instance.CompletedAt = DateTime.UtcNow;
        Console.WriteLine("워크플로우가 성공적으로 완료되었습니다.");
    }

    await _instanceRepo.SaveAsync(instance);
    return instance;
}

private bool EvaluateCondition(string condition, Dictionary<string, object> payload)
{
    // 여기가 마법이 일어나는 곳!
    // 실제 프로덕션에서는 안전하고 강력한 표현식 평가 라이브러리 사용을 권장합니다.
    // 예: DynamicExpresso, Roslyn Scripting API 등
    // 여기서는 간단한 예시만 보여줍니다.
    if (condition == "data.result == 'approved'")
    {
        return payload.ContainsKey("result") && payload["result"].ToString() == "approved";
    }
    if (condition == "data.amount > 100000")
    {
        return payload.ContainsKey("amount") && Convert.ToInt32(payload["amount"]) > 100000;
    }
    // ...
    Console.WriteLine($"조건 '{condition}' 평가 결과: true"); // 예시로 무조건 true
    return true; 
}
⚠️ 중요: 조건 평가(Condition Evaluation)의 함정 위 예제의 `EvaluateCondition` 함수는 매우 단순화되어 있습니다. 실제로는 문자열로 된 표현식을 안전하게 실행해야 합니다. `eval`과 같은 함수를 잘못 사용하면 보안 취약점(Code Injection)이 발생할 수 있습니다. 따라서 반드시 검증된 라이브러리를 사용해야 합니다.

추천 라이브러리:
- DynamicExpresso: C# 구문을 안전하게 파싱하고 실행해주는 훌륭한 라이브러리입니다. 람다 표현식처럼 동적 코드를 실행할 수 있게 해줍니다. - Roslyn Scripting API: C# 컴파일러(Roslyn)를 직접 사용하여 코드를 동적으로 컴파일하고 실행합니다. 가장 강력하지만 약간의 학습 곡선이 있습니다.

이제 우리의 워크플로우 엔진은 심장을 갖게 되었습니다! `StartWorkflowAsync`를 호출하면 워크플로우가 시작되고, 자동화 작업들은 연쇄적으로 실행되다가 사용자 작업을 만나면 `Suspended` 상태로 멈춥니다. 그리고 사용자가 웹 UI 등에서 '승인' 버튼을 누르면 백엔드에서는 `TriggerNextStepAsync`를 호출하여 멈췄던 워크플로우를 다시 깨우는 거죠.

하지만 지금 우리의 인스턴스 정보는 프로그램이 종료되면 모두 사라집니다. 진정한 기업용 애플리케이션이 되려면 이 상태를 어딘가에 영구적으로 저장해야겠죠? 다음 장에서 그 방법을 알아봅시다.

5. 상태를 기억하라: 데이터베이스 연동과 영속성

"서버 재부팅했더니 진행 중이던 결재가 다 날아갔어요!" 😱
만약 이런 일이 벌어진다면, 그건 '엔진'이 아니라 '장난감'일 뿐입니다. 기업용 시스템의 핵심은 '상태의 영속성(Persistence)'입니다. 즉, 워크플로우 인스턴스의 현재 상태(`CurrentActivityId`, `Status`, `Payload` 등)를 프로그램이 꺼져도 안전하게 보관할 수 있어야 합니다.

가장 일반적이고 안정적인 방법은 바로 데이터베이스(Database)를 사용하는 것입니다.

.NET 개발자에게 가장 친숙한 도구는 단연 Entity Framework Core (EF Core)일 겁니다. 우리가 앞에서 설계한 C# 클래스들을 약간만 손보면, 아주 쉽게 데이터베이스 테이블과 매핑할 수 있습니다.

5.1. EF Core를 위한 모델 클래스 수정

`WorkflowInstance` 클래스를 EF Core가 인식할 수 있도록 약간의 어노테이션을 추가하고, 복잡한 타입(`Dictionary`)을 DB에 저장하기 쉬운 형태로 바꿔봅시다.


// WorkflowInstance.cs (EF Core 버전)
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;

public class WorkflowInstance
{
    [Key] // Primary Key
    public Guid Id { get; set; }

    [Required]
    public string WorkflowDefinitionId { get; set; }
    
    public int WorkflowDefinitionVersion { get; set; }

    [Required]
    public string CurrentActivityId { get; set; }

    public WorkflowStatus Status { get; set; }

    // Dictionary<string, object>는 직접 매핑이 까다롭다.
    // 대신 JSON 문자열로 직렬화하여 저장하는 것이 일반적인 패턴!
    private string _payloadAsJson { get; set; } = "{}";

    [NotMapped] // 이 프로퍼티는 DB 컬럼으로 만들지 않음
    public Dictionary<string, object> Payload
    {
        get => JsonSerializer.Deserialize<Dictionary<string, object>>(_payloadAsJson);
        set => _payloadAsJson = JsonSerializer.Serialize(value);
    }

    public DateTime CreatedAt { get; set; }
    public DateTime? CompletedAt { get; set; }
}

`Payload` 프로퍼티를 처리하는 방식에 주목해주세요. `Dictionary` 타입은 관계형 데이터베이스에 그대로 저장하기 어렵습니다. 그래서 실제 DB에는 `_payloadAsJson`이라는 `string` 타입의 컬럼을 만들고, C# 코드에서는 `Payload` 프로퍼티를 통해 이 JSON 문자열을 `Dictionary` 객체로 변환하여 사용하는 'Proxy Property' 패턴을 사용했습니다. EF Core의 `[NotMapped]` 어트리뷰트는 `Payload` 프로퍼티가 DB 컬럼이 아님을 알려주는 역할을 합니다.

5.2. DbContext 설정

다음은 EF Core의 핵심인 `DbContext`를 설정할 차례입니다.


// WorkflowDbContext.cs
using Microsoft.EntityFrameworkCore;

public class WorkflowDbContext : DbContext
{
    public WorkflowDbContext(DbContextOptions<WorkflowDbContext> options) : base(options) { }

    // 이 DbSet이 DB의 'WorkflowInstances' 테이블과 매핑됩니다.
    public DbSet<WorkflowInstance> WorkflowInstances { get; set; }
    
    // 워크플로우 정의도 DB에 저장하고 싶다면?
    // public DbSet<WorkflowDefinition> WorkflowDefinitions { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Payload를 저장할 private field를 명시적으로 알려줌
        modelBuilder.Entity<WorkflowInstance>()
            .Property("_payloadAsJson")
            .HasColumnName("PayloadJson"); // DB 컬럼 이름 지정
    }
}

5.3. 리포지토리(Repository) 패턴 구현

이제 이 `DbContext`를 사용하여 실제로 데이터를 읽고 쓰는 `WorkflowInstanceRepository`를 구현합니다.


// IWorkflowInstanceRepository.cs
public interface IWorkflowInstanceRepository
{
    Task<WorkflowInstance?> GetByIdAsync(Guid id);
    Task SaveAsync(WorkflowInstance instance);
}

// EfWorkflowInstanceRepository.cs
public class EfWorkflowInstanceRepository : IWorkflowInstanceRepository
{
    private readonly WorkflowDbContext _context;

    public EfWorkflowInstanceRepository(WorkflowDbContext context)
    {
        _context = context;
    }

    public async Task<WorkflowInstance?> GetByIdAsync(Guid id)
    {
        return await _context.WorkflowInstances.FindAsync(id);
    }

    public async Task SaveAsync(WorkflowInstance instance)
    {
        var existing = await _context.WorkflowInstances.FindAsync(instance.Id);
        if (existing == null)
        {
            _context.WorkflowInstances.Add(instance);
        }
        else
        {
            // EF Core의 변경 추적 기능 덕분에, 엔티티를 업데이트하고 SaveChangesAsync만 호출하면 됨
            _context.Entry(existing).CurrentValues.SetValues(instance);
        }
        await _context.SaveChangesAsync();
    }
}
💡 트랜잭션의 중요성 (The Importance of Transactions) 워크플로우의 한 단계를 처리하는 것은 여러 작업의 묶음입니다. 예를 들어,
1. 외부 API를 호출하고,
2. 그 결과를 `Payload`에 저장하고,
3. `CurrentActivityId`를 다음 단계로 변경합니다.

만약 1, 2번은 성공했는데 3번에서 DB 오류가 발생하면 어떻게 될까요? 데이터가 뒤죽박죽되는 '불일치' 상태에 빠지게 됩니다. 이를 방지하기 위해 이 모든 과정을 하나의 트랜잭션(Transaction)으로 묶어야 합니다. EF Core는 `SaveChangesAsync` 호출을 기본적으로 트랜잭션으로 처리하지만, 여러 서비스에 걸친 복잡한 작업의 경우 `await _context.Database.BeginTransactionAsync()`를 사용하여 명시적으로 트랜잭션 범위를 지정하는 것이 안전합니다. "모두 성공하거나, 모두 실패하거나(All or Nothing)!"

이제 `WorkflowExecutor`는 `IWorkflowInstanceRepository`를 주입받아 사용하기만 하면 됩니다. 더 이상 인스턴스 데이터가 메모리에만 머무르지 않고, 데이터베이스에 안전하게 기록되고 업데이트됩니다. 서버를 100번 재부팅해도, '홍길동'의 휴가 신청서는 어제 멈췄던 바로 그 자리, '부서장 승인 대기' 단계에서 우리를 기다리고 있을 겁니다. 든든하죠? 💪

6. 진짜 '기업용'으로 거듭나기: 고급 기능 뽀개기

지금까지 만든 것만으로도 간단한 워크플로우는 충분히 자동화할 수 있습니다. 하지만 실제 기업 환경은 훨씬 더 복잡하고 예측 불가능한 상황들로 가득 차 있죠. 우리의 엔진이 이런 '실전'에서 살아남으려면 몇 가지 고급 무기를 더 장착해야 합니다.

6.1. 장기 실행 워크플로우와 유휴(Idling) 상태

'팀장 승인'은 5분 만에 끝날 수도 있지만, 5일이 걸릴 수도 있습니다. 만약 엔진이 5일 동안 이 워크플로우 하나만 붙들고 있다면 엄청난 자원 낭비겠죠? CPU와 메모리는 한정적이니까요.

그래서 '사람의 개입'이나 '정해진 시간'을 기다려야 하는 워크플로우는 잠시 실행을 멈추고 잠을 재워야 합니다. 이를 '유휴 상태(Idling)' 또는 '일시 중단(Suspension)'이라고 합니다.

우리는 이미 `WorkflowStatus.Suspended` 상태를 만들어 이 개념을 구현했습니다. `UserTask`를 만나면 상태를 `Suspended`로 바꾸고 DB에 저장한 뒤, 실행 로직은 완전히 종료됩니다. 엔진은 이제 다른 워크플로우를 처리하러 갈 수 있죠.

그럼 잠든 워크플로우는 어떻게 다시 깨울까요?

  • 외부 이벤트: 사용자가 웹사이트에서 '승인' 버튼을 클릭하면, 이 요청이 API를 통해 `WorkflowExecutor`의 `TriggerNextStepAsync(instanceId, ...)` 메소드를 호출합니다. 이 메소드는 DB에서 잠자고 있던 인스턴스를 로드하여 실행을 재개합니다.
  • 타이머 이벤트: "결재 요청 후 3일이 지나도 응답이 없으면, 상위 관리자에게 자동으로 에스컬레이션한다"와 같은 요구사항이 있을 수 있습니다. 이를 위해서는 별도의 '스케줄러(Scheduler)'가 필요합니다.
    • 워크플로우가 타이머 액티비티를 만나면, `Suspended` 상태가 되면서 '깨어나야 할 시간'과 '인스턴스 ID'를 스케줄러에 등록합니다.
    • 스케줄러(예: Hangfire, Quartz.NET 같은 라이브러리)는 정해진 시간이 되면 `TriggerNextStepAsync`를 호출하여 워크플로우를 깨웁니다.
💡 핵심은 '상태 없음(Stateless)' 워크플로우 실행자 자체는 상태를 가지지 않는 것이 이상적입니다. 모든 상태 정보는 `WorkflowInstance` 객체에 담겨 DB에 저장되어야 합니다. 이렇게 해야 여러 대의 서버에서 실행자를 동시에 실행하는 '스케일 아웃(Scale-out)'이 가능해집니다. 어떤 서버가 요청을 받든, DB에서 최신 상태를 읽어와 작업을 처리할 수 있기 때문입니다.

6.2. 오류 처리와 보상(Compensation)

"여행 경비 신청 워크플로우"를 상상해봅시다.
[항공권 예약 API 호출] ➡️ [호텔 예약 API 호출] ➡️ [렌터카 예약 API 호출]

만약 항공권과 호텔은 성공적으로 예약했는데, 렌터카 예약에서 오류가 발생하면 어떻게 해야 할까요? 이미 예약된 항공권과 호텔을 그대로 두면 안 되겠죠. 이럴 때 필요한 것이 '오류 처리'와 '보상 트랜잭션'입니다.

  • 오류 처리: `ExecuteCurrentStepAsync` 로직 전체를 `try-catch` 블록으로 감싸야 합니다. 자동화 작업 실행 중 예외가 발생하면, `catch` 블록에서 인스턴스의 상태를 `WorkflowStatus.Faulted`로 변경하고, 오류 정보를 `Payload`에 기록합니다. 관리자는 `Faulted` 상태의 워크플로우들을 모니터링하고, 원인 파악 후 수동으로 재시도하거나 취소 처리를 할 수 있습니다.
  • 보상 (Compensation): 더 나아가, 실패했을 때 이전에 성공했던 단계를 '취소'하는 로직을 실행할 수 있습니다. 이를 '보상'이라고 합니다.
    • `ActivityDefinition`에 `CompensationHandler` 같은 프로퍼티를 추가합니다. (e.g., "MyProject.Tasks.CancelFlightBookingTask")
    • 워크플로우가 `Faulted` 상태가 되면, 엔진은 지금까지 거쳐온 액티비티들의 `CompensationHandler`를 역순으로 실행합니다. (렌터카 예약 실패 시 -> 호텔 예약 취소 -> 항공권 예약 취소)

보상 로직은 구현이 매우 복잡하기 때문에, 비즈니스적으로 정말 중요한 핵심 워크플로우에만 선택적으로 적용하는 것이 좋습니다.

6.3. 버전 관리 (Versioning)

비즈니스 프로세스는 살아있는 생물과 같아서 계속해서 변합니다. "내일부터 결재 라인에 재무팀 검토 단계를 추가해주세요." 라는 요청은 아주 흔하죠.

이때 기존 `WorkflowDefinition`을 그냥 수정해버리면 큰일 납니다! 이미 옛날 규칙으로 진행 중이던 워크플로우들이 새 규칙과 섞여버려 대혼란이 발생할 수 있습니다.

해결책은 '버전 관리'입니다.

  • `WorkflowDefinition`에 `Version` 프로퍼티를 두었던 것을 기억하시나요? 프로세스를 수정할 때는 기존 정의를 바꾸는 대신, `Version`을 2로 올린 새로운 `WorkflowDefinition` 문서를 만듭니다.
  • 신규 워크플로우 시작 시: 항상 가장 최신 버전의 `WorkflowDefinition`을 사용합니다.
  • 기존에 진행 중인 워크플로우: 시작될 당시의 버전(예: `WorkflowInstance`에 저장된 `WorkflowDefinitionVersion`이 1)을 끝까지 따라가도록 합니다.

이렇게 하면, 구 버전의 프로세스는 안전하게 마무리될 수 있고, 신규 프로세스는 새로운 규칙을 따르게 되어 안정적인 운영이 가능해집니다.

이 외에도 병렬로 여러 단계를 동시에 진행하고 하나로 합치는 '병렬 실행/조인(Parallel Execution/Join)', 특정 이벤트가 발생할 때까지 기다리는 '이벤트 기반 게이트웨이' 등 수많은 고급 패턴들이 있습니다. 하지만 오늘 우리가 다룬 핵심 개념만 제대로 구현해도, 웬만한 기업의 업무 자동화 요구는 훌륭하게 소화할 수 있을 겁니다.

7. 사용자 인터페이스(UI): 엔진에 날개를 달아주기

아무리 강력한 엔진을 만들어도, 사용자가 편하게 쓸 수 없다면 무용지물입니다. 워크플로우 엔진은 보통 눈에 보이지 않는 백엔드에서 동작하지만, 그 힘을 제대로 발휘하려면 사용자와 상호작용할 수 있는 UI가 반드시 필요합니다.

워크플로우 시스템의 UI는 크게 세 가지로 나눌 수 있습니다.

1. 워크플로우 디자이너 (Workflow Designer)
개발자나 현업 담당자가 코딩 없이, 마우스로 끌어다 놓는 것만으로 워크플로우를 설계할 수 있게 해주는 시각적 도구입니다.

  • 기능:
    • 도형(액티비티)을 캔버스에 추가
    • 도형과 도형을 선(전환)으로 연결
    • 각 도형과 선의 속성(이름, 타입, 조건 등)을 설정
    • 설계가 끝나면 `WorkflowDefinition`에 해당하는 JSON이나 XML을 생성하여 서버에 저장
  • 구현 기술:
    • Blazor: C# 개발자에게 가장 매력적인 선택지입니다. C#만으로 인터랙티브한 웹 UI를 만들 수 있습니다.
    • JavaScript 라이브러리: GoJS, JointJS, Rappid, Drawflow 등 다이어그램 UI를 만드는 데 특화된 강력한 라이브러리들이 많습니다. 프론트엔드 개발자와의 협업이 필요합니다.

시각적 디자이너는 구현 난이도가 높지만, 한번 만들어두면 비개발자도 직접 업무 프로세스를 정의하고 수정할 수 있게 되어 생산성이 폭발적으로 증가합니다. 🚀

2. 태스크 리스트 / 대시보드 (Task List / Dashboard)
일반 사용자들이 가장 많이 보게 될 화면입니다. "나에게 할당된 업무" 목록을 보여주는 곳이죠.

  • 기능:
    • "내가 처리해야 할 결재 문서: 3건" 과 같이 할 일 목록 표시
    • 각 항목을 클릭하면 상세 내용(e.g., 휴가 신청서의 상세 정보) 확인
    • '승인', '반려' 같은 버튼을 통해 워크플로우를 다음 단계로 진행
    • 내가 올린 신청 건들의 현재 진행 상태 추적
  • 구현 기술:
    • ASP.NET Core MVC/Razor Pages, Blazor, 혹은 React/Vue/Angular 같은 SPA 프레임워크와 Web API의 조합으로 구현할 수 있습니다.
    • 백엔드에서는 현재 로그인한 사용자의 역할(Role)을 기반으로, `WorkflowInstance` 테이블에서 `Status`가 `Suspended`이고 `AssigneeRole`이 자신에게 해당하는 인스턴스들을 조회하여 보여주는 API가 필요합니다.

3. 관리 및 모니터링 (Admin & Monitoring)
시스템 관리자가 전체 워크플로우의 현황을 파악하고 문제를 해결하기 위한 화면입니다.

  • 기능:
    • 현재 실행 중인 모든 워크플로우 인스턴스 목록 조회
    • 각 인스턴스의 상세 정보(현재 상태, Payload 데이터, 실행 이력) 확인
    • 오류가 발생한(`Faulted`) 인스턴스를 강제로 재시도하거나 종료
    • 워크플로우별 통계(평균 처리 시간, 병목 구간 등) 분석
  • 구현 기술:
    • 태스크 리스트와 유사한 기술 스택으로 구현합니다.
    • 인스턴스의 실행 이력을 남기기 위해, 상태가 변경될 때마다 별도의 `WorkflowHistory` 테이블에 로그를 기록하는 로직을 추가하면 좋습니다.

이러한 UI 컴포넌트들은 우리의 C# 워크플로우 엔진을 단순한 '코드 덩어리'에서 살아 숨 쉬는 '업무 자동화 플랫폼'으로 완성시켜주는 마지막 퍼즐 조각입니다.

마치며: 이제 당신이 마에스트로가 될 시간

정말 긴 여정이었습니다! 😅
우리는 오늘 '워크플로우 엔진'이라는, 어찌 보면 막막하게 느껴졌던 주제를 C#이라는 친숙한 언어로 하나씩 분해하고 조립해보았습니다.

단순한 개념 정의에서 시작해, 핵심 데이터 모델을 설계하고, 엔진의 심장인 실행자를 코드로 구현했으며, 데이터베이스 연동으로 영속성을 확보하고, 나아가 실제 기업 환경에서 필요한 고급 기능들까지 살펴보았습니다.

물론 이 글에서 다룬 내용은 거대한 워크플로우 시스템의 '빙산의 일각'일 수 있습니다. 하지만 가장 중요한 핵심 원리와 뼈대는 모두 담겨 있습니다. 이 기반 위에 여러분의 비즈니스에 필요한 살을 붙여 나간다면, 그 어떤 상용 솔루션보다 우리 회사에 꼭 맞는 최고의 맞춤형 엔진을 만들 수 있을 겁니다.

더 이상 이메일과 메신저를 오가며 "그거 어떻게 됐어요?"라고 묻지 않아도 됩니다.
누가 휴가를 가든, 자리를 비우든, 시스템은 정해진 규칙에 따라 묵묵히 자신의 일을 처리할 겁니다.
개발자는 반복적인 업무 확인에서 해방되어 더 창의적이고 가치 있는 일에 집중할 수 있게 되죠. 이것이 바로 우리가 기술을 통해 꿈꾸는 '스마트한 업무 환경' 아닐까요?

🚀 당신의 다음 스텝은? - 오늘 배운 내용을 바탕으로, 아주 간단한 '휴가 신청 워크플로우' 미니 프로젝트를 시작해보세요.
- Elsa Core, Workflow Core 같은 .NET 오픈소스 워크플로우 엔진의 소스 코드를 한번 열어보세요. 오늘 배운 개념들이 실제 코드로 어떻게 구현되어 있는지 비교해보는 것만으로도 엄청난 공부가 될 겁니다.
- 만약 혼자서 이 모든 것을 구현하기 벅차다면, 재능넷과 같은 플랫폼에서 당신의 프로젝트를 도와줄 동료나 전문가를 찾아보는 것도 좋은 방법입니다. 협업은 위대한 결과물을 만드는 지름길이니까요!

이제 여러분은 C#이라는 지휘봉으로 회사의 복잡한 업무 프로세스를 아름다운 오케스트라처럼 조율할 수 있는 마에스트로가 될 준비를 마쳤습니다. 반복 업무의 자동화를 통해 당신과 동료들에게 '칼퇴'의 기쁨을 선물해주세요!

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.