본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 16. 22:14

5단계가 실패했을 때, 1단계부터 4단계까지 되돌리는 방법

요약

외부 상태를 변경하는 효과적인 파이프라인(effectful pipelines)에서 부분적 실패가 발생했을 때, 이미 확정된 상태를 되돌리는 보상(compensation) 메커니즘을 다룹니다. Ruuk의 saga 키워드를 활용하여 각 전진 단계와 그에 대응하는 정리 작업을 쌍으로 묶는 설계 방식을 제안합니다.

핵심 포인트

  • 순수 변환과 달리 외부 상태를 확정하는 단계는 실패 시 오류 축적만으로 해결되지 않음
  • 부분적 확정 후 발생하는 실패를 관리하기 위해 보상(compensation) 로직이 필수적임
  • Ruuk의 saga 키워드를 통해 전진 단계와 보상 작업을 일급 요소로 선언하여 관리 가능
  • 보상 로직과 실행 로직의 불일치를 방지하는 구조적 설계가 중요함

이전 글들은 컴파일러가 개별 작업(operations)에 대해 무엇을 검증할 수 있는지에 대한 그림을 그려왔습니다: 작업의 결과(outcomes), 작업이 볼 수 있는 데이터, 그리고 작업에 필요한 상태(state)입니다. 작업(operations)에 관한 글에서는 모든 결과가 처리되고 그 과정에서 오류가 축적될 수 있는 N-트랙 파이프라인(N-track pipelines)을 소개했습니다. 만약 모든 단계가 순수 변환(pure transformation)이라면, 그 모델로 충분합니다. 오류는 수집되고, 호출자(caller)가 이를 처리하며, 외부의 어떤 것도 변경되지 않기 때문입니다.

하지만 실제 절차는 순수 변환이 아닙니다. 원정 임무(away mission)는 팀을 구성하고, 브리핑을 하고, 전송(beaming)하고, 접촉을 시도하고, 샘플을 수집하고, 다시 전송하여 복귀하는 과정을 포함합니다. 각 단계는 실제 효과(effects)를 확정(commit)합니다 — 승무원 배정이 변경되고, 전송 로그(transporter logs)가 업데이트되며, 통신 채널이 열립니다. 4단계가 실패했을 때, 1단계부터 3단계까지는 이미 일어난 일입니다. 축적된 오류는 무엇이 잘못되었는지 알려줄 뿐, 이미 일어난 일을 되돌려주지는 않습니다.

저는 데이터만 변환하는 것이 아니라 외부 상태(external state)를 확정하는 단계들, 즉 효과적인 파이프라인(effectful pipelines)을 위한 오류 복구(error recovery)를 탐구했습니다. 그 해답은 보상(compensation)이었습니다: 가능한 경우, 각 전진 단계(forward step)를 그것을 되돌리거나 정리하는 작업과 쌍으로 묶는 것입니다. Ruuk의 saga 키워드는 이러한 쌍을 선언의 일급 요소(first-class part)로 만들어 줍니다.

문제점: 분산된 확정 (Distributed Commitment)

도전 과제는 실패 그 자체가 아닙니다. 도전 과제는 부분적인 확정 이후에 발생하는 부분적인 실패입니다.

어떤 단계도 외부 상태를 확정하지 않았다면 파이프라인은 실패를 깔끔하게 처리할 수 있습니다. 만약 3단계가 실패 결과(failure outcome)를 반환한다면, 1단계와 2단계가 생성한 데이터는 여전히 로컬(local)에 머물러 있으며 — 외부의 어떤 것도 변경되지 않았습니다. 이 실패는 단지 하나의 결과일 뿐, 정리(cleanup)의 문제가 아닙니다.

하지만 우주선 작업은 외부 시스템에 확정(commit)을 수행합니다. 팀을 전송하는 것은 함선 기록 내 그들의 위치를 변경합니다. 통신 채널을 여는 것은 서브스페이스 대역폭(subspace bandwidth)을 할당합니다. 과학 데이터베이스에 샘플을 기록하는 것은 다른 시스템이 의존하는 기록을 생성합니다. 이것들은 로컬 변환(local transformations)이 아닙니다. 절차가 성공하든 실패하든 지속되는 효과(effects)입니다.

beamDownTeam이 성공한 후 establishContact가 실패하면, 팀은 확인된 통신 링크가 없는 상태로 행성 표면에 남겨지게 됩니다. 올바른 대응은 그들을 다시 위로 비밍(beam back up)하는 것이지만, 해당 보상 로직(compensation logic)은 어딘가 catch 블록 안에 존재하며, 이 로직이 현재의 비밍 다운(beam-down) 절차와 일치하는지 여부는 절차가 변경되었을 때 누군가가 두 곳을 모두 업데이트했는지에 달려 있습니다.

표준적인 접근 방식은 각 단계를 실행하고, 결과를 확인하며, 실패 시 보상(compensate)하는 것입니다.

async function conductAwayMission(mission, team, planet) {
  const assigned = await assembleTeam(team, mission);
  if (!assigned.ok) return missionFailed(assigned.error);
...

이것은 유능하고 읽기 좋은 코드입니다. 각 실패를 처리하고 올바르게 보상합니다. 하지만 보상 목록은 단계가 추가될 때마다 늘어납니다. 각 실패 핸들러(failure handler)는 이전의 모든 보상 작업을 역순으로 반복해야 합니다. briefTeambeamDown 사이에 단계를 하나 추가하면, 이후의 모든 핸들러를 업데이트해야 합니다. 순방향 단계와 그에 따른 보상 사이의 관계는 암시적(implicit)입니다. 예를 들어 releaseTeamassembleTeam을 되돌린다는 사실은 단계 선언(step declaration)이 아니라 에러 핸들러를 읽어야 알 수 있습니다. 그리고 모든 효과적인(effectful) 단계에 상응하는 취소(undo) 작업이 있는지 검증하는 장치도 없습니다.

선언으로서의 Saga (Sagas as Declarations)

Saga는 단계들을 순서대로 선언합니다. 외부 상태를 커밋(commit)하는 각 단계는 자신의 보상 작업(compensating operation)을 함께 선언합니다.

pub saga ConductAwayMission =
    subject mission: Mission<Approved>
    payload team: List<CrewMember>
...

이를 미션 브리핑처럼 읽어보십시오: 팀을 소집하고(나중에 실패하면 해산), 브리핑을 수행하며(필요 시 디브리핑), 미션을 시작하고, 팀을 비밍 다운하며(문제가 생기면 다시 비밍 업), 접촉을 확립하고, 샘플을 수집하고, 비밍 업을 한 뒤, InProgress에서 Completed로의 상태 전이(state transition)와 함께 미션을 완료합니다.

establishContactcollectSamples에는 compensate 절(clause)이 없습니다. establishContact는 읽기/검증(read/verify) 단계이므로, 만약 실패하더라도 이전의 보상(compensation) 작업 외에는 되돌릴 것이 없습니다. collectSamples는 미션의 전진 페이로드(forward payload)입니다. 샘플 수집이 실패할 경우 이전 단계들을 되돌려야(unwinding) 하지만, 샘플을 "수집 취소"하는 것은 의미 있는 작업이 아닙니다.

마지막 beamUp 단계(보상이 아닌 계획된 복귀)와 completeMission 또한 보상이 누락되어 있습니다. 이 단계들에 도달했을 때 미션은 실질적으로 성공한 상태이기 때문입니다. completeMission은 제5조에 따라 타입 상태 전이(typestate transition)를 수행하여, 미션을 InProgress에서 Completed로 이동시킵니다.

사가(saga)의 outcomes 블록은 사가 수준의 결과, 즉 호출자(caller)가 처리해야 하는 결과(outcomes)를 선언합니다. 단계가 실패하면, 사가는 보상을 선언했던 완료된 단계들을 되돌리고(unwinds), 해당 실패를 적절한 사가 결과(saga outcome)로 드러냅니다.

실패 시 자동 보상 (Automatic Compensation on Failure)

beamDown, launchMission, briefTeam, assembleTeam이 모두 성공한 후 establishContact가 실패하면, 사가는 역순으로 자동 실행(unwinds)됩니다:

  1. beamUpbeamDown을 보상합니다 (팀이 함선으로 복귀함)
  2. abortMissionlaunchMission을 보상합니다 (미션이 중단된 것으로 정리됨)
  3. debriefTeambriefTeam을 보상합니다 (팀 상태가 초기화됨)
  4. releaseTeamassembleTeam을 보상합니다 (승무원 배정이 해제됨)

가장 마지막에 완료된 것이 가장 먼저 보상됩니다. 보상 순서는 실행 순서의 역순입니다. 이는 콜 스택(call stack)을 되감는(unwinding) 것과 동일한 원리이지만, 실제 세계에 영향을 미치는 도메인 작업(domain operations)에 적용된 것입니다.

개발자가 이 되감기(unwind) 로직을 직접 작성하지 않습니다. 사가 선언(saga declaration)이 이를 정의합니다. beamDown에 대한 보상은 동일한 라인에 선언된 beamUp입니다. 전진 단계(forward step)와 되돌리기(undo) 사이의 관계는 가시적이고 명시적이며, 한 곳에서 관리됩니다.

컴파일러가 검증하는 것

보상 완전성 (Compensation completeness). 돌연변이 연산 (mutating operation)을 호출하는 모든 단계는 보상 동작 (compensating action)을 선언해야 합니다. 컴파일러는 compensate 절 없이 외부 상태를 수정하는 단계에 대해 경고를 보냅니다. 이는 오류는 아닙니다. 일부 돌연변이는 진정으로 되돌릴 수 없기 때문입니다 (예: 전송된 메시지를 취소할 수 없음). 하지만 이는 개발자가 해당 판단을 명시적으로 내려야 한다는 신호입니다.

연산 존재 여부 (Operation existence). stepcompensate에 명시된 각 연산은 스코프 (scope) 내에 존재해야 합니다. 정의되지 않은 보상 연산을 참조할 수 없습니다.

수행 일관성 (Performs consistency). 만약 Saga 단계가 performs를 사용한다면, typestate 관련 문서에서 다룬 것과 동일한 검증 규칙이 적용됩니다. 즉, 피연산자 (subject) 파라미터는 소스 상태 (source state)와 일치해야 하며, 성공 결과 (success outcome)는 타겟 상태 (target state)와 일치해야 하며, 불일치하는 전이 (transition)가 없어야 합니다.

순서 보장 (Order guarantees). 보상은 정의상 역순 실행 순서 (reverse execution order)로 실행됩니다. Saga 선언은 이를 명시적으로 만듭니다. 단계를 위에서 아래로 읽으면, 보상 순서는 아래에서 위로 진행됨을 알 수 있습니다. 이를 통해 런타임 조정 로직 (runtime coordination logic)에서 실수가 발생할 여지를 줄여줍니다.

Saga를 생성하는 에이전트 (agent) 역시 동일한 가드레일 (guardrails)을 적용받습니다. 컴파일러는 선언을 작성한 주체가 사람이든 에이전트든 관계없이, 보상되지 않은 돌연변이에 대해 경고를 보냅니다. 구조적 검사 (structural check)는 코드를 작성한 이가 누구인지에 의존하지 않기 때문입니다.

더 풍부한 예시: 선박 수리 워크플로우 (Ship Repair Workflow)

원정 임무 (Away missions)는 극적이지만 선형적입니다. 선박 수리는 Saga가 여러 외부 시스템과의 상호작용이 포함된 더 복잡한 워크플로우를 어떻게 처리하는지 보여줍니다.

pub saga RepairCriticalSystem =
    payload system: ShipSystem
    payload damage: DamageReport
...

각 순방향 단계 (forward step)는 그에 따른 보상과 함께 자연스럽게 읽힙니다: 팀 할당 / 팀 해제, 부품 요청 / 부품 반납, 섹션 오프라인 전환 / 섹션 온라인 복구. 3번째 문서에서 다룬 파라미터 역할이 단계에 나타납니다 — requisitionParts from supplyStore — 이로 인해 Saga 선언은 마치 절차 매뉴얼처럼 읽힙니다.

만약 수리가 완료된 후 runDiagnostic이 실패하면, saga는 되감기(unwind)를 수행합니다: 해당 섹션을 다시 온라인 상태로 복구하고, 부품을 반납하며, 팀을 해산합니다. 수리 작업 자체는 수동 개입이 필요할 수도 있습니다 — "수리를 취소하는 것"은 의미 있는 동작이 아니기 때문에 performRepair에는 compensate가 존재하지 않습니다. 이는 선언부에서도 확인할 수 있는 의도적인 설계 선택입니다.

Sagas vs. Pipelines (Saga 대 파이프라인)

Saga와 파이프라인은 모두 순차적인 작업들을 구성합니다. 하지만 그 차이점은 매우 중요합니다:

**파이프라인 (pipeline)**은 데이터를 앞으로 전달합니다. 각 단계는 이전 단계의 결과물을 변환합니다. N-트랙 파이프라인은 진행 과정에서 여러 오류를 축적할 수 있지만, 이 모델은 어떤 단계도 외부 상태(external state)를 커밋하지 않았다고 가정합니다 — 즉, 실패는 보고해야 할 결과물일 뿐, 되돌려야 할 영향(effects)이 아닙니다.

Saga는 영향(effects)을 조정합니다. 각 단계는 운송, 데이터베이스 쓰기, 리소스 할당과 같이 외부 상태를 커밋할 수 있습니다. 만약 이후 단계가 실패하면, 이미 커밋된 상태를 반드시 되돌려야 합니다. Saga는 보상 스택(compensation stack)을 관리합니다.

단계들이 순수 변환(pure transformations)이거나 단일 시스템이 롤백(rollback)을 자동으로 처리하는 경우(예: 데이터베이스 트랜잭션)에는 파이프라인을 사용하세요. 운송 시스템, 인사 데이터베이스, 공급망, 통신 어레이와 같이 트랜잭션 경계를 공유하지 않는 여러 시스템을 조정해야 할 때는 Saga를 사용하세요.

Sagas가 완성하는 것

이전 글은 "무엇이 결합되는가(What Compounds)"로 끝을 맺었습니다 — 각 기능은 실질적인 완화를 위해 설계되었으며 구조적 정확성에 도달했습니다. Saga는 이 패턴을 한 번 더 확장합니다. 저는 영향이 있는 파이프라인(effectful pipelines)에서의 오류 복구를 탐구하고 있었고, 순방향 단계와 함께 보상(compensation)을 선언하는 방법으로 시작했던 것이 컴파일러에 의해 검증되는 워크플로 무결성(workflow integrity)으로 발전했습니다.

분산된 보상 로직 (compensation logic)을 사용할 경우, "미션 도중 운송 장치가 실패하면 어떻게 되는가?"라는 질문에 대한 답은 "우리의 에러 핸들러 (error handlers)가 적절한 정리 함수 (cleanup functions)를 호출한다"가 됩니다. 그 답변은 모든 핸들러가 정확하고, 완전하며, 순방향 경로 (forward path)와 동기화되어 있다는 전제에 의존합니다. 사가 (saga) 선언을 사용하면, 그 답변은 선언 자체에 있습니다: beamDown에는 compensate beamUp이 포함되어 있습니다. 누군가 새로운 단계를 추가하면, 구문 (syntax) 자체가 "이 단계에 보상이 필요한가?"라는 질문을 유도하며, 상태를 변경하는 (mutating) 단계에서 보상을 누락하면 컴파일러가 경고를 보냅니다. 순방향 경로와 보상 경로는 함께 존재하므로, 함께 진화합니다.

시리즈 결론

이 시리즈는 코드 에이전트가 작성하고 인간이 검토하는 코드에 대해 컴파일러가 무엇을 검증할 수 있는지에 대한 그림을 그려왔습니다.

작업 및 결과 (Operations and outcomes) (3번째 기사)는 컴파일러에게 작업이 무엇을 의미하는지에 대한 가시성을 제공합니다. 이는 단순히 반환 타입 (return type)뿐만 아니라, 도메인 특화된 결과 (domain-specific results)까지 포함합니다. 컴파일러는 모든 호출자가 모든 결과에 대해 책임을 갖도록 합니다.

투영 (Projections) (4번째 기사)는 컴파일러가 각 작업이 무엇을 볼 수 있는지 제어할 수 있게 합니다. 데이터 접근 경계 (data access boundaries)는 별도로 유지되는 런타임 필터 (runtime filters)가 아니라, 타입의 구조적 속성 (structural properties)입니다.

타입스테이트 (Typestate) (5번째 기사)는 컴파일러가 작업이 언제 실행될 수 있는지 제어할 수 있게 합니다. 상태 전제 조건 (state preconditions)은 런타임 가드 (runtime guards)가 아닌 타입 시스템 (type system) 내에 존재합니다. 유효하지 않은 전이 (invalid transitions)는 컴파일 에러가 됩니다.

사가 (Sagas) (본 기사)는 컴파일러에게 다단계 워크플로 (multi-step workflows)와 그 보상 (compensation)에 대한 가시성을 제공합니다. 순방향 경로와 되돌리기 경로 (undo path)는 함께 선언되고, 함께 유지되며, 함께 검증됩니다.

각 기능은 독립적으로 존재하지만, 핵심은 이들이 결합된다는 점에 있습니다. 타입이 지정된 결과(typed outcomes)를 가지며, 투영된 데이터 뷰(projected data view)로 범위가 제한되고, 타입 상태(typestate)에 의해 보호되며, 사가(saga) 내에서 조정되는 연산 — 이것은 테스트와 코드 리뷰만으로는 신뢰성 있게 달성할 수 없는 수준의 구조적 검증(structural verification)입니다. 컴파일러는 인간이 작업 기억(working memory)에 유지하기 어려워하는 불변량(invariants)을 보유합니다. 에이전트(agents)가 대량으로 코드를 작성하고 인간이 시간 압박 속에서 이를 검토하는 시대에, 이는 단순히 있으면 좋은 기능(nice-to-have)이 아니라 신뢰의 아키텍처(architecture of trust)의 일부처럼 보이기 시작합니다.

Ruuk은 현재 alpha 단계에 있습니다. 구문(syntax)은 계속 진화할 것이며 구현 과정에는 갈 길이 멉니다. 하지만 설계 기준 — 컴파일러가 인식할 수 있는 도메인 의미론(domain semantics), 행동 관습(behavioral convention)보다 구조적 강제(structural enforcement)를 우선시하는 것, 엄격함을 희생하지 않는 점진적 개발(progressive development) — 은 에이전트 시대(agentic era)에 새롭게 중요해진 속성들이라고 생각합니다. 만약 이 아이디어들이 공감을 불러일으킨다면, ruuk을 한 번 사용해 보세요. GitHub를 팔로우하고 토론에 참여해 의견을 나누어 주세요. 최고의 언어는 자신이 해결하고자 하는 문제에 관심을 가진 사람들에 의해 형성됩니다.

이 기사는 AI의 도움을 받아 작성되었습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0