전이 통제
상태는 값이 아니다. 상태는 어떤 경로를 거쳤는지다.
왜 전이를 별도로 통제하는가
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단계: 오류가 방향을 가리키게 만든다
전이 실패 오류는 세 가지를 포함해야 한다.
- 현재 상태
- 요청한 전이
- 허용된 전이 목록
// 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 규칙)
- 전이 위반이 즉시 명확한 오류로 전환되는가?
- 오류 메시지에 현재 상태, 요청 전이, 허용 전이가 포함되는가?
- 실행 함수 안에서 전이 호출이 빠질 수 없는가?
- 테스트에서도 직접 상태 설정이 금지되어 있는가?
- (선택) 핵심 상태에 타입 레벨 전이 강제가 적용되어 있는가?