본문 바로가기
Backend/Architecture

[Server] 보상 작업으로 다단계 생성 흐름의 실패 범위 줄이기

미지시료 2026. 8. 15.
반응형

하나의 요청이 여러 저장소와 외부 시스템을 거칠 때, 완료된 단계를 기록하고 역순으로 보상하도록 구성한 과정과 그 한계를 정리했다.





시작하며

관리 화면에서 하나의 항목을 생성하더라도 서버 내부에서는 여러 작업이 이어질 수 있다.

  • 사용할 식별자를 예약한다.
  • 기본 정보를 저장한다.
  • 하위 자원을 생성한다.
  • 정책과 설정을 적용한다.
  • 사용자 접근 권한을 연결한다.
  • 외부 시스템에 결과를 동기화한다.

각 단계가 같은 데이터베이스와 연결만 사용한다면 하나의 트랜잭션으로 묶을 수 있다. 하지만 실제 작업에는 서로 다른 저장소, 파일 변경, 네트워크 호출이 섞여 있었다. 모든 단계를 하나의 데이터베이스 트랜잭션으로 감싸는 것만으로는 실패를 되돌릴 수 없었다.

이 글에서는 긴 생성 작업을 단계로 나누고, 중간 실패 시 이미 완료된 작업을 역순으로 취소하도록 구성한 과정을 살펴본다.





실제 구현은 네 번에 걸쳐 경계가 바뀌었다

처음부터 지금의 보상 흐름을 한 번에 설계한 것은 아니다. 생성 API를 구현하고 실패 경로를 반복해서 확인하면서, 무엇을 실패로 볼지와 실패 후 무엇을 남길지가 계속 바뀌었다.

처음 구현한 방식 실제로 드러난 문제 바꾼 내용
실패하면 생성 데이터를 논리 삭제 완성되지 않은 데이터가 남고 같은 식별자를 다시 쓰기 어려움 이번 흐름만 소유하는 신규 데이터는 물리 삭제
모든 오류를 하나의 생성 실패로 처리 화면과 로그에서 어느 입력 단계가 실패했는지 알기 어려움 실패 단계와 관련 입력 필드를 내부 결과에 기록하고 오류에 실패 단계를 포함
실패 후 예약과 작업 상태를 함께 제거 어떤 단계까지 끝났고 무엇을 보상했는지 확인할 정보도 사라짐 식별자 예약만 해제하고 작업 상태와 undo 기록은 만료 전까지 유지
외부 실행 장치 반영 실패도 핵심 실패로 처리 외부 장치가 잠시 응답하지 않으면 이미 만든 핵심 데이터까지 모두 삭제됨 핵심 저장은 완료하고 외부 반영은 경고와 재동기화 대상으로 분리

특히 마지막 변경은 “오류가 발생했는가”보다 그 오류 때문에 전체 결과를 없애야 하는가를 먼저 판단하게 만든 계기였다. 보상 범위를 넓게 잡는 것이 항상 안전한 것은 아니었다.





하나의 트랜잭션으로 해결할 수 없었던 이유

데이터베이스 트랜잭션은 같은 연결 안에서 실행되는 변경을 원자적으로 처리한다. 그러나 다음 작업까지 함께 되돌려 주지는 않는다.

  • 다른 데이터베이스에 저장한 데이터
  • 외부 API가 생성한 자원
  • 디스크에 쓴 설정 파일
  • 외부 시스템으로 전달한 요청이나 메시지

트랜잭션을 오래 유지하는 것도 부담이었다. 사용자의 입력 검증부터 여러 네트워크 요청이 끝날 때까지 잠금을 유지하면 처리 시간이 길어지고, 실패 가능성이 높은 외부 시스템에 데이터베이스 자원이 종속된다.

그래서 작업의 경계를 두 층으로 나눴다.

  1. 한 단계 안에서 함께 저장되어야 하는 데이터는 가능한 범위에서 로컬 트랜잭션으로 묶는다.
  2. 여러 단계에 걸친 전체 작업은 오케스트레이터가 순서를 관리하고 보상한다.

모든 단계에 로컬 트랜잭션이 적용된 것은 아니다. 단일 저장으로 끝나는 단계가 있는 반면, 여러 저장 작업과 외부 호출이 섞여 부분 실패가 가능한 단계도 있었다. 따라서 이 구조는 완전한 분산 트랜잭션이라기보다 Saga의 오케스트레이션 방식을 참고한 동기식 보상 흐름에 가깝다.





작업을 명시적인 단계로 나누기

전체 흐름은 먼저 식별자를 예약한 뒤 핵심 생성 단계를 순서대로 수행한다. 이전 시스템 동기화처럼 핵심 결과를 만드는 데 필수적이지 않은 일부 작업은 성공 경로의 뒤쪽으로 옮겼다.

여러 단계 생성 작업의 전체 흐름

오케스트레이터는 각 단계를 호출할 뿐, 단계 내부의 상세 저장 방법까지 알지 않는다. 대신 다음 정보는 알고 있다.

  • 현재 실행 중인 단계
  • 완료된 단계
  • 실패한 단계
  • 완료 단계마다 필요한 보상 작업
  • 예약한 식별자

단계의 경계가 명확해지면 오류 메시지도 “생성에 실패했습니다”에서 끝나지 않는다. 현재 구현은 실패 단계와 관련 입력 필드를 내부 결과에 기록하고, 최종 오류 메시지에는 실패 단계를 포함한다.

type WorkflowStep =
  | 'validate'
  | 'createRoot'
  | 'createChildren'
  | 'applyPolicy'
  | 'grantAccess';

interface WorkflowResult {
  status: 'success' | 'failure';
  failedStep?: WorkflowStep;
  failedFields?: string[];
}

다만 failedFields까지 구조화된 오류 응답으로 전달되는 것은 아니다. 보상 후 예외로 전환하는 과정에서 최종 응답에는 주로 실패 단계와 원인 문구만 남는다. 화면이 입력 영역을 바로 강조하게 하려면 예외 필터까지 failedStepfailedFields를 보존하는 별도 오류 계약이 더 필요하다. 내부에서 값을 계산하는 것과 클라이언트가 실제로 받는 것은 다른 문제였다.





성공한 직후 보상 작업을 기록하기

각 단계가 성공하면 해당 단계를 되돌릴 작업을 undo log에 추가한다. 이후 단계에서 오류가 발생하면 이 기록을 역순으로 실행한다.

성공 단계 기록과 역순 보상

순서가 중요한 이유는 자원 사이에 의존성이 있기 때문이다. 상위 자원을 먼저 삭제하면 하위 자원의 정리 과정이 실패하거나, 어떤 데이터를 지워야 하는지 찾기 어려워질 수 있다. 생성 순서가 상위 → 하위 → 정책이었다면 보상은 정책 → 하위 → 상위로 수행한다.

const undoStack: UndoOperation[] = [];

try {
  const root = await createRoot(input.root);
  undoStack.push({ type: 'DELETE_ROOT', id: root.id });

  await createChildren(root.id, input.children);
  undoStack.push({ type: 'DELETE_CHILDREN', rootId: root.id });

  await applyPolicy(root.id, input.policy);
  undoStack.push({ type: 'REMOVE_POLICY', rootId: root.id });
} catch (cause) {
  await compensateInReverse(undoStack);
  throw new WorkflowError(currentStep, cause);
}

여기서 적용한 규칙은 단계가 성공한 직후 보상 작업을 기록하는 것이다. 다음 단계가 시작된 뒤 기록하면 그 사이에 발생한 오류를 되돌릴 정보가 없다.

하지만 이 방식만으로 단계 내부의 부분 실패까지 해결되지는 않는다. 예를 들어 createChildren()이 여러 항목을 순차 저장하다가 중간에 실패하면 메서드는 완료되지 않고, 그 뒤에 있는 undoStack.push()도 실행되지 않는다. 일부 항목은 저장됐지만 이를 정리할 undo 정보가 없는 상태가 될 수 있다.

await createChildren(input); // 내부에서 일부 저장 후 실패할 수 있다.
undoStack.push({
  // 메서드가 완료돼야 기록된다.
  type: 'DELETE_CHILDREN',
  rootId,
});

따라서 단계의 완료 기록뿐 아니라 단계 내부의 원자성도 함께 설계해야 한다. 가능한 경우에는 DB 변경을 하나의 로컬 트랜잭션으로 묶고, 그러기 어려우면 생성 단위를 더 작게 나눠 undo 정보를 기록하거나 작업 상태를 영속화하는 방법을 고려할 수 있다.

기능을 확장하면서 한 단계가 새로운 보조 자원까지 함께 생성하도록 바뀐 적도 있었다. 생성 코드만 보면 작은 변경이지만, 보상 목록에도 해당 자원을 추가하지 않으면 이후 단계 실패 시 보조 데이터만 남는다. 그래서 생성 단계의 변경과 undo 대상 변경을 같은 작업으로 다뤘다. 단계가 무엇을 생성하는지와 무엇을 지우는지는 항상 함께 검토해야 한다.





삭제 방식은 자원의 성격에 따라 달라진다

첫 구현에서는 실패한 데이터를 논리 삭제했다. 기록을 남길 수 있다는 장점은 있었지만, 생성 도중 실패한 불완전한 데이터가 계속 쌓이고 동일한 식별자를 다시 사용하기 어려워지는 문제가 있었다.

이번 작업은 새로 생성되었고 다른 흐름과 공유되지 않은 데이터가 대부분이어서, 보상 시 물리 삭제하는 편이 더 명확했다. 다만 물리 삭제를 모든 보상의 기본값으로 삼아서는 안 된다.

작업과 데이터의 성격 적합한 보상 방식
이번 흐름만 소유하는 신규 데이터 물리 삭제
감사 이력이 필요한 데이터 논리 삭제
기존 값을 수정한 작업 변경 전 값 복원
외부 시스템에 전달한 요청 취소 API 또는 별도 상쇄 요청
되돌릴 수 없는 부수 효과 재처리·대사 대상으로 기록

보상은 원래 작업의 정반대 명령이 아니라, 비즈니스 관점에서 허용 가능한 상태로 되돌리는 작업이다.





모든 실패를 같은 방식으로 처리하지 않기

작업을 구현하면서 실패를 두 종류로 구분했다.

핵심 단계의 실패

기본 자원, 하위 자원, 정책, 권한처럼 생성 결과의 일관성을 결정하는 단계다. 하나라도 실패하면 이후 작업을 중단하고 보상을 시작한다.

부가 단계의 실패

상태 확인, 설정 파일 갱신, 별도 시스템 동기화처럼 핵심 생성 결과와 분리할 수 있는 작업이다. 이 실패 때문에 이미 완성된 핵심 데이터를 모두 삭제하면 오히려 사용자 경험과 운영 복구가 어려워질 수 있다.

따라서 일부 부가 작업은 오류를 기록하되 핵심 작업을 실패로 바꾸지 않는 fail-soft 방식으로 처리했다. 이전 시스템과의 동기화는 핵심 단계가 모두 성공한 뒤 실행하고, 실패 시 별도 운영 기능으로 다시 실행할 수 있게 했다.

다만 모든 부가 작업에 공통 재처리 체계를 구현한 것은 아니다. 파일 갱신과 일부 외부 호출은 실패 로그만 남는다. fail-soft는 실패를 해결한다는 뜻이 아니라 핵심 요청과 부가 작업의 성공 여부를 분리한다는 뜻에 가깝다. 예외를 로그에만 남기고 끝내면 불일치가 누적될 수 있으므로, 재시도 큐나 주기적인 대사, 운영 알림은 앞으로 보완할 부분이다.





식별자 예약도 워크플로의 일부다

사용자가 입력을 마치기 전에 식별자의 사용 가능 여부를 확인하면, 확인 시점과 실제 저장 시점 사이에 다른 요청이 같은 값을 선점할 수 있다.

이를 줄이기 위해 흐름마다 고유한 workflow ID를 발급하고, 선택한 식별자를 별도 키로 예약했다.

workflow:{workflowId}        -> 상태, 완료 단계, undo log, 예약 ID
reservation:{resourceId}     -> workflowId

같은 흐름이 같은 식별자를 다시 예약하는 요청은 성공으로 처리하고, 다른 흐름의 예약이 확인되면 충돌로 응답한다. 완료·취소 시에는 예약 키를 해제하고, 예약 데이터에는 만료 시간을 두어 클라이언트가 이탈했을 때 영구 점유되는 것도 방지한다.

다만 현재 구조는 인메모리 캐시에서 예약 여부를 조회한 다음 저장한다. 두 연산이 원자적으로 묶여 있지 않고 여러 서버 인스턴스가 상태를 공유하지도 않으므로, 이를 강한 동시성 제어로 볼 수는 없다. 예약은 사용자 흐름의 충돌을 줄이는 best-effort 장치이고, 마지막 동시성 방어선은 여전히 데이터베이스의 Primary Key나 Unique Constraint여야 한다.





반복 취소에 안전한 범위 확인하기

네트워크가 끊기면 클라이언트는 취소 요청의 성공 여부를 알 수 없다. 따라서 같은 취소가 다시 호출되는 상황을 고려해야 한다.

  • 존재하지 않는 흐름의 취소는 이미 취소된 것으로 처리한다.
  • 삭제 대상이 없을 때 발생하는 일부 오류는 성공으로 간주한다.
  • 예약 키는 완료 또는 취소 과정에서 제거한다.

하지만 현재 구현은 취소 후 워크플로 상태와 undo log를 즉시 제거하지 않으며, 예약을 해제할 때 저장된 소유자를 원자적으로 비교하지도 않는다. 반복 취소가 같은 undo 작업을 다시 실행할 수 있으므로, 워크플로 전체의 멱등성을 완전히 보장한다고 표현하기는 어렵다.

이를 보완하려면 취소 완료 상태를 명시적으로 저장하고 undo log를 비우거나, 예약 해제 시 workflowId를 비교한 후 삭제하는 원자적 연산이 필요하다. 생성 요청 역시 외부 호출이 성공한 직후 응답만 유실될 수 있으므로, 단계별 멱등성 키나 생성 결과 조회 수단을 함께 고려해야 한다.





보상 작업도 실패할 수 있다

원래 작업이 실패한 시점에는 데이터베이스나 외부 서비스가 이미 불안정할 수 있다. 따라서 보상 역시 실패할 가능성이 높다.

현재 구현에서는 최초로 실패한 단계와 보상 실패 여부를 확인할 수 있다. 그러나 하나의 undo 작업이 실패하면 이후 보상은 중단되고, 개별 undo의 구체적인 예외와 실행 결과를 구조화해 보존하지 않는다.

운영 중 실패 범위를 정확히 파악하려면 최소한 다음 정보를 함께 남기는 편이 좋다.

  • 최초로 실패한 단계와 오류
  • 성공한 단계 목록
  • 실행한 보상 작업과 결과
  • 실패한 보상 작업
  • workflow ID와 요청 추적 ID

이는 현재 구현이 모두 제공하는 정보가 아니라 개선 목표다. 규모가 커지면 워크플로 상태와 undo log를 영속 저장하고, 하나의 보상이 실패해도 나머지를 계속 시도한 뒤 각 결과를 기록하는 방식이 더 안전하다. 실패한 보상은 별도 작업자가 다시 실행할 수 있어야 한다.





단계 추가와 변경을 위한 체크리스트

보상 코드는 정상 경로의 반대편에 있어 기능을 추가할 때 빠뜨리기 쉽다. 이후에는 한 단계를 추가하거나 수정할 때 다음 항목을 함께 확인하도록 정리했다.

확인 대상 확인할 질문 놓치면 생기는 문제
단계 경계 한 단계가 성공했다고 판단하는 정확한 시점은 언제인가 일부만 저장됐는데 완료 단계가 기록됨
로컬 트랜잭션 같은 저장소의 여러 변경을 하나로 묶을 수 있는가 메서드 중간 실패 데이터가 undo 기록 없이 남음
생성 자원 목록 새로 생기는 본 데이터와 보조 데이터를 모두 적었는가 보상 후 일부 관계 데이터만 잔존함
보상 순서 참조 관계의 반대 순서로 정리하는가 상위 데이터가 먼저 사라져 하위 삭제가 실패함
실패 분류 이 실패가 핵심 결과를 무효화하는가, 나중에 재처리할 수 있는가 부가 장애 때문에 전체 결과를 불필요하게 삭제함
식별자 예약 성공·실패·취소에서 예약이 해제되는가 사용하지 않는 식별자가 계속 점유됨
반복 호출 생성과 취소를 다시 호출해도 결과가 달라지지 않는가 네트워크 재시도로 중복 생성·중복 삭제가 발생함
관측 정보 실패 단계, 원인, 실행한 undo와 결과가 남는가 운영자가 남은 데이터를 판단하지 못함

현재는 실패 단계와 완료 단계, undo 목록을 캐시에 기록하지만 보상 흐름 전용 자동화 테스트가 충분하지 않다. 특히 각 단계 직후 오류를 강제로 발생시켜 잔존 데이터를 비교하는 실패 주입 테스트와, 하나의 undo가 실패해도 나머지 보상을 계속하는지 확인하는 테스트가 필요하다. 체크리스트는 코드 리뷰 누락을 줄여 주지만 자동화된 검증을 대신하지는 못한다.





이 구조에도 남아 있는 한계

보상 트랜잭션은 데이터베이스의 rollback과 동일하지 않다.

첫째, 한 단계가 여러 저장 작업을 수행하다가 중간에 실패하면 일부 데이터는 저장됐지만 undo 정보는 등록되지 않을 수 있다. 단계가 성공한 뒤 undo log를 기록하기 전에 프로세스가 종료되는 경우에도 같은 공백이 생긴다. 작업 실행과 기록을 원자적으로 연결할 수 없다면 단계별 멱등성과 정기 대사가 필요하다.

둘째, 캐시에만 저장한 워크플로 상태는 만료나 장애로 사라질 수 있다. 긴 작업과 높은 복구 요구사항에는 데이터베이스 테이블이나 내구성 있는 메시지 저장소가 더 적합하다.

셋째, 외부 API 호출과 파일 변경은 완전히 되돌릴 수 없을 수 있다. 가능한 작업은 뒤쪽으로 배치했지만 일부 외부 호출은 핵심 단계 내부에도 남아 있다. 실행 결과를 식별할 키와 재처리 경로가 추가로 필요하다.

넷째, 물리 삭제는 신규 전용 데이터에만 안전하다. 기존 데이터 수정과 공유 자원이 추가되면 이전 값 복원이나 참조 수 기반 정리 등 별도의 보상 전략이 필요하다.





마치며

여러 저장소와 외부 시스템을 거치는 생성 작업에서 중요한 것은 모든 것을 하나의 트랜잭션처럼 보이게 만드는 일이 아니었다. 실패했을 때 어디까지 성공했는지 확인하고, 기록된 범위 안에서 안전한 순서로 되돌리는 일이었다.

정리하면 다음 원칙이 도움이 됐다.

  1. 전체 작업을 관찰 가능한 단계로 나눈다.
  2. 가능한 단계는 로컬 트랜잭션으로 묶고, 그렇지 못한 단계의 부분 실패를 확인한다.
  3. 성공 직후 보상 작업을 기록하되, 기록 전 실패 가능성도 고려한다.
  4. 보상은 생성의 역순으로 실행한다.
  5. 핵심 실패와 fail-soft로 처리할 부가 실패를 구분한다.
  6. 예약 캐시와 DB 제약 조건의 책임을 구분한다.
  7. 반복 취소와 보상 실패의 결과를 관측할 수 있게 만든다.

보상 작업이 존재한다는 사실만으로 전체 일관성이 보장되지는 않는다. 단계 내부의 원자성, undo 기록 시점, 반복 실행의 안전성, 보상 실패의 관측 가능성까지 함께 설계해야 한다. 현재 구조는 동기식 보상 흐름의 출발점이며, 더 강한 복구 보장이 필요하다면 상태와 undo log를 영속화하는 방향으로 발전시켜야 한다.

반응형