전이 통제

전이 통제

상태는 값이 아니다. 상태는 어떤 경로를 거쳤는지다.


왜 전이를 별도로 통제하는가

task.status = "completed"는 값을 바꾼다.

task.complete()는 다르다. 현재 상태가 전이를 허용하는지 확인하고, 허용된 경우에만 상태를 바꾸고, 전이에 따르는 부수 효과를 실행하고, 이벤트를 발행한다.

겉으로는 같아 보인다. 결과도 같다. 하지만 두 코드가 하는 일은 다르다.

이게 전이 통제가 존재하는 이유다. 값을 바꾸는 것과 상태가 이동하는 것은 다른 일이다. 상태 변경 우회(Atlas)와 상태-UI 바인딩 우회(Glif)는 모두 이 구분이 사라질 때 발생한다.


문제: AI는 상태를 값으로 취급한다

AI가 태그 기능에 색상을 추가하는 작업을 받으면, 가장 짧은 경로로 결과를 만든다.

// Atlas — AI가 생성한 코드
tag.Color = "#ff0000";
await _repository.SaveAsync(tag);
// Glif — AI가 생성한 코드
tag.color = "#ff0000";
dispatch({ type: "UPDATE_TAG", payload: tag });

기능은 동작한다. 테스트도 통과할 수 있다. 하지만 이 코드는 다음을 보장하지 않는다.

  • 색상 변경이 허용된 상태에서 일어났는가
  • validate → normalize → publish 절차를 통과했는가
  • 관련 이벤트가 발행됐는가
  • 다른 색상 변경과 동시에 발생할 때 어떻게 처리되는가

값은 바뀌었다. 하지만 상태가 이동한 것이 아니다.


1단계: 허용된 전이를 정의한다

전이 통제의 시작은 “어떤 상태에서 어떤 상태로 이동할 수 있는가"를 코드로 정의하는 것이다.

Atlas (C#)

public enum DocumentStatus { Draft, Review, Published, Archived, Rejected }

public static class DocumentTransitions
{
    private static readonly IReadOnlyDictionary<DocumentStatus, DocumentStatus[]> Allowed =
        new Dictionary<DocumentStatus, DocumentStatus[]>
        {
            [DocumentStatus.Draft]      = [DocumentStatus.Review],
            [DocumentStatus.Review]     = [DocumentStatus.Draft,
                                           DocumentStatus.Published,
                                           DocumentStatus.Rejected],
            [DocumentStatus.Published]  = [DocumentStatus.Archived],
            [DocumentStatus.Rejected]   = [DocumentStatus.Draft],
            [DocumentStatus.Archived]   = [],
        };

    public static void Assert(DocumentStatus from, DocumentStatus to)
    {
        if (!Allowed[from].Contains(to))
            throw new InvalidTransitionException(from, to, Allowed[from]);
    }
}

Glif (TypeScript)

type DocumentStatus = "draft" | "review" | "published" | "archived" | "rejected";

const allowedTransitions: Record<DocumentStatus, DocumentStatus[]> = {
  draft:     ["review"],
  review:    ["draft", "published", "rejected"],
  published: ["archived"],
  rejected:  ["draft"],
  archived:  [],
};

function assertTransition(from: DocumentStatus, to: DocumentStatus): void {
  if (!allowedTransitions[from].includes(to)) {
    throw new TransitionViolationError({
      from,
      to,
      allowed: allowedTransitions[from],
      message: `Cannot transition from '${from}' to '${to}'.`,
    });
  }
}

이 정의가 없으면 어떤 상태에서 어떤 상태로도 이동 가능하다. 제약이 없다.


2단계: 직접 변경 경로를 닫는다

정의만으로는 부족하다. status 필드를 직접 바꿀 수 있는 한, 정의는 우회된다.

Atlas (C#)

public sealed class Document
{
    private DocumentStatus _status;

    // getter만 공개 — setter 없음
    public DocumentStatus Status => _status;

    public void TransitionTo(DocumentStatus next)
    {
        DocumentTransitions.Assert(_status, next);

        var previous = _status;
        _status = next;

        // 전이에 따르는 처리
        RaiseDomainEvent(new DocumentStatusChanged(Id, previous, next));
    }
}

setter가 없다. document.Status = DocumentStatus.Published는 컴파일 오류다. 유일한 경로는 TransitionTo다.

Glif (TypeScript)

class Document {
  readonly #status: DocumentStatus;

  get status(): DocumentStatus { return this.#status; }

  transition(to: DocumentStatus): Document {
    assertTransition(this.#status, to);
    // 불변 객체로 반환 — 기존 객체를 바꾸지 않음
    return new Document({ ...this.toPlain(), status: to });
  }
}

ESLint 규칙으로 .status 직접 할당도 차단한다.

{
  "no-restricted-syntax": [
    "error",
    {
      "selector": "AssignmentExpression[left.property.name='status']",
      "message": "Direct status mutation is forbidden. Use transition()."
    }
  ]
}

정적 분석이 작성 시점에 잡고, 런타임 가드가 최종 방어선을 담당한다.


3단계: 실행과 전이를 결합한다

전이만 고정하는 것으로는 아직 부족하다. 실행 함수 안에서 전이가 빠질 수 있다.

// 취약한 구조: 전이가 실행 바깥에 있음
async function publishDocument(id: string) {
  const doc = await repository.get(id);
  // transition() 호출 없이 저장 가능
  await repository.save({ ...doc.toPlain(), status: "published" });
}

실행 자체가 전이를 포함하도록 만든다.

Atlas (C#)

public sealed class PublishDocumentUseCase : IPublishDocumentUseCase
{
    public async Task ExecuteAsync(
        PublishDocumentCommand command,
        CancellationToken ct = default)
    {
        _validator.Validate(command);

        var doc = await _repository.LoadAsync(
            new ValidDocId(command.DocId), ct);

        // 전이가 실행의 일부 — 빠질 수 없음
        doc.TransitionTo(DocumentStatus.Published);

        await _repository.SaveAsync(doc, ct);
        // TransitionTo 내에서 이미 발행된 이벤트가 여기서 처리됨
    }
}

Glif (TypeScript)

async function publishDocument(id: string): Promise<void> {
  const doc = await repository.get(id);

  // 전이가 실행의 일부
  const published = doc.transition("published");

  await repository.save(published);
  await eventBus.emit("document.published", { id: published.id });
}

doc.transition("published")가 실패하면 이후 코드는 실행되지 않는다. 전이 검증과 실행이 분리될 수 없다.


4단계: 오류가 방향을 가리키게 만든다

전이 실패 오류는 세 가지를 포함해야 한다.

  1. 현재 상태
  2. 요청한 전이
  3. 허용된 전이 목록
// Atlas — InvalidTransitionException
public sealed class InvalidTransitionException : DomainException
{
    public InvalidTransitionException(
        DocumentStatus from,
        DocumentStatus to,
        DocumentStatus[] allowed)
        : base(
            $"Cannot transition from '{from}' to '{to}'. " +
            $"Allowed: [{string.Join(", ", allowed)}]. " +
            $"See: harness/atlas/state-mutation-bypass")
    { }
}
// Glif — TransitionViolationError
class TransitionViolationError extends Error {
  constructor({ from, to, allowed }: TransitionError) {
    super(
      `Cannot transition from '${from}' to '${to}'. ` +
      `Allowed: [${allowed.join(", ")}]. ` +
      `See: harness/glif/state-ui-binding-bypass`
    );
  }
}

방향이 없는 오류 메시지는 다시 추측을 만든다. AI도, 사람도 무엇을 해야 하는지 모른 채 다른 방식으로 시도한다.


테스트에서도 같은 경로를 따른다

테스트에서 “편의용 상태 점프"를 허용하면 두 가지 문제가 생긴다.

첫째, 테스트가 실제 전이 경로를 검증하지 않는다. published 상태를 초기값으로 넣은 테스트는 draft → review → published 경로가 올바른지 보장하지 않는다.

둘째, AI가 테스트 코드를 정본 예시로 참조한다. 테스트에서 doc.status = "published"가 허용되면 AI는 production 코드에서도 같은 패턴을 생성한다.

// 위반: 테스트에서 상태 직접 설정
const doc = createDocument({ status: "published" });  // 전이 없이 초기화

// 합법: 테스트도 동일한 전이 경로
const doc = createDocument({ status: "draft" });
const reviewed = await submitForReview(doc.id);
const published = await publish(reviewed.id);
// 위반: C# 테스트에서 reflection으로 상태 직접 변경
var field = typeof(Document).GetField("_status",
    BindingFlags.NonPublic | BindingFlags.Instance);
field.SetValue(doc, DocumentStatus.Published);

// 합법: 실제 전이 사용
doc.TransitionTo(DocumentStatus.Review);
doc.TransitionTo(DocumentStatus.Published);

더 강하게: 타입으로 경로를 고정한다

전이 통제를 한 단계 더 강하게 가져가려면 상태 자체를 타입으로 분리한다.

Atlas (C#)

// 상태마다 다른 타입
public sealed record DraftDocument(string Id, string Content);
public sealed record ReviewDocument(string Id, string Content, DateTimeOffset SubmittedAt);
public sealed record PublishedDocument(string Id, string Content, DateTimeOffset PublishedAt);

// 함수 시그니처가 경로를 강제
public Task<ReviewDocument> SubmitForReviewAsync(DraftDocument doc);
public Task<PublishedDocument> PublishAsync(ReviewDocument doc);
// DraftDocument를 직접 publish하는 함수는 존재하지 않음

Glif (TypeScript)

type DraftDoc    = { state: "draft";     id: string; content: string };
type ReviewDoc   = { state: "review";    id: string; content: string; submittedAt: Date };
type PublishedDoc = { state: "published"; id: string; content: string; publishedAt: Date };

// 함수 시그니처가 경로를 강제
function submitForReview(doc: DraftDoc): ReviewDoc { ... }
function publish(doc: ReviewDoc): PublishedDoc { ... }
// publish(draftDoc)은 컴파일 오류

이 방식에서는 런타임 가드가 없어도 잘못된 전이가 컴파일 오류가 된다. 타입 시스템 자체가 전이 경로를 강제한다.

다만 상태가 많아질수록 타입 수가 늘어나는 트레이드오프가 있다. 핵심 상태 몇 개에 먼저 적용하고 점진적으로 확장하는 게 현실적이다.


체크리스트

  • 허용된 전이 목록이 코드로 정의되어 있는가?
  • 상태 필드가 직접 변경될 수 없도록 보호되어 있는가? (setter 없음 / private field)
  • 빌드 타임 규칙이 직접 변경을 차단하는가? (Roslyn ATL003 / ESLint 규칙)
  • 전이 위반이 즉시 명확한 오류로 전환되는가?
  • 오류 메시지에 현재 상태, 요청 전이, 허용 전이가 포함되는가?
  • 실행 함수 안에서 전이 호출이 빠질 수 없는가?
  • 테스트에서도 직접 상태 설정이 금지되어 있는가?
  • (선택) 핵심 상태에 타입 레벨 전이 강제가 적용되어 있는가?