상태 변경 우회

상태 변경 우회

개요

정의된 상태 변경 경로가 있음에도 불구하고 필드나 프로퍼티를 직접 수정하는 문제는 시스템의 흐름을 내부에서 무너뜨리며, 이는 하네스를 통해 차단되어야 한다.

이 문서의 질문

이 문서는 하네스 개요에서 정리한 다섯 공통 패턴 중 state & meaning bypass에 가장 가깝다. 핵심 질문은 다음과 같다.

값 변경이 정해진 상태 전이와 후속 절차를 함께 보존하는가

문제

구조는 잘 분리되어 있다.

  • UI는 ViewModel을 통해 동작한다
  • Backend는 Service를 통해 상태를 만든다
  • 상태 변경을 위한 Store 또는 Manager가 존재한다

하지만 실제 코드에서는 다음과 같은 우회가 발생한다.

_selectedDoc.IsDirty = true;
_currentState.Title = newTitle;
_items.Clear();
_items.Add(newItem);

또는

_viewModel.CurrentDoc.Metadata["title"] = title;

또는

_docStore.State.ActiveDocId = docId;

겉보기에는 단순한 값 변경이다.
하지만 이 코드는 공통적으로 같은 문제를 가진다.

상태가 바뀌었지만,
상태 변경 흐름은 실행되지 않았다

문제의 본질

이 문제는 단순한 캡슐화 위반이 아니다.
핵심은 다음이다.

값은 바뀌었지만
시스템은 그 변화를 모른다

흐름 없는 상태 변경

정상적인 상태 변경은 다음처럼 흘러야 한다.

intent → command / action → state transition → event / notify → render / follow-up

하지만 직접 수정은 이 흐름을 제거한다.

field write
D2 diagram
이 차이는 작아 보이지만 결과는 완전히 다르다.

후속 처리 소실

상태 변경 경로에는 보통 다음이 붙어 있다.

  • 변경 이력 기록

  • dirty 상태 갱신

  • 이벤트 발행

  • undo / redo 관리

  • validation

  • UI refresh

직접 수정이 허용되면 이런 처리가 전부 빠질 수 있다.

즉 값은 바뀌어도 시스템은 절반만 반응한다.

변경 지점 추적 불가능

상태는 바뀌었는데, 누가 바꿨는지 알 수 없다.

그 결과:

  • 디버깅이 어려워진다

  • 이벤트 순서가 꼬인다

  • 특정 조건에서만 재현되는 오류가 생긴다

이때 문제는 값 그 자체보다 변경 경로의 상실이다.

AI의 선택 패턴

AI는 상태 관리 구조가 존재해도 다음 상황에서 직접 수정을 선택한다.

  • 가장 짧은 해결책이 필요할 때

  • 기존 액션이나 커맨드 이름을 모르거나 찾기 어려울 때

  • 이벤트 흐름보다 즉시 반영을 우선할 때

즉 사람과 AI는 “상태를 바꾸는 방법"보다
“지금 값을 바꾸는 방법"을 고른다.

목표 구조

핵심은 다음이다.

상태는 수정 가능한 데이터가 아니라
지정된 흐름을 통해서만 바뀌는 시스템이어야 한다

사례 규칙 요약

  • 상태 객체 직접 수정 금지
  • 상태 변경은 Store / Manager / Command API를 통해서만 허용
  • 컬렉션 직접 변경 금지
  • public setter 최소화 또는 금지

자세한 rule/validator 설계 원칙은 규칙 설계검증기 설계를 따른다. 이 케이스에서는 위 금지 표면과 합법 경로가 바로 검사 대상이다.

하네스 적용 위치

이 케이스의 하네스는 write surface, collection mutation surface, allowed writer scope에 놓인다.

상태 surface 레벨

UI와 ViewModel은 읽기 API만 보고, 쓰기 권한은 Store나 Command 쪽에만 남긴다.

변경 레벨

property assignment와 collection mutation 모두 같은 write funnel 안으로 수렴시켜야 한다.

검증 레벨

state 타입 assignment, Add, Remove, Clear 같은 직접 변경, 허용되지 않은 파일의 write를 analyzer로 막는다.

실제 구현

핵심 구현은 세 가지다.

  • 상태를 읽는 표면과 바꾸는 표면을 분리한다.
  • 상태 변경은 store나 command API로만 수렴시킨다.
  • 컬렉션 변경도 같은 write funnel 안에 가둔다.
구현 예시 펼치기

상태는 데이터 묶음이 아니라 변경 표면이 닫힌 객체여야 한다

먼저 상태 모델은 외부에서 setter나 mutable collection으로 직접 비틀 수 없도록 두는 편이 좋다.

public sealed record DocState(
    string Id,
    string Title,
    bool IsDirty
);

핵심은 상태를 읽는 표면과 바꾸는 표면을 분리하는 것이다.

상태 변경은 store로만 수렴시킨다

public interface IDocStore
{
    DocState Current { get; }
    void SetTitle(string title);
    void MarkDirty();
    void Replace(DocState next);
}

구현은 내부에서만 새 상태를 만들고, 후속 처리도 그 안에 묶는다.

public sealed class DocStore : IDocStore
{
    public DocState Current { get; private set; } = new("", "", false);

    public void SetTitle(string title)
    {
        Current = Current with { Title = title, IsDirty = true };
        PublishChanged();
    }

    public void Replace(DocState next)
    {
        Current = next;
        PublishChanged();
    }
}

이 순간부터 상태 변경은 단순 값 대입이 아니라 알림, dirty sync, history 같은 후속 처리를 포함한 행위가 된다.

컬렉션 변경도 store 내부로 가둔다

컬렉션은 direct mutation이 더 쉽기 때문에 더 위험하다.

public interface IExplorerStore
{
    IReadOnlyList<DocItem> Items { get; }
    void SetItems(IReadOnlyList<DocItem> items);
    void AddItem(DocItem item);
    void RemoveItem(string id);
}

외부에서는 _items.Add(...) 같은 호출을 할 수 없고, 변경 이벤트도 store를 통해서만 발생한다.

ViewModel은 상태를 바꾸지 않고 요청만 한다

public sealed class DocViewModel
{
    private readonly IDocStore _docStore;

    public void Rename(string title)
    {
        _docStore.SetTitle(title);
    }
}

반대로 CurrentDoc.Title = title처럼 직접 쓰기를 허용하면 알림, validation, history가 빠진 채 상태만 흔들린다.

다른 언어에서도 기준은 같다

Rust 역시 상태 필드는 가능한 private으로 두고, 변경은 store나 command 메서드 안에서만 일어나게 하는 편이 강하다.

pub struct DocStore {
    current: DocState,
}

impl DocStore {
    pub fn set_title(&mut self, title: String) {
        self.current.title = title;
        self.current.is_dirty = true;
        self.publish_changed();
    }
}

중요한 것은 immutable 여부 자체보다, 외부 코드가 상태 참조를 잡고 임의로 수정할 수 없게 만드는 것이다.

위반 예시와 실패 결과

다음 코드는 반드시 실패해야 한다.

_state.Title = "Draft";
_state.OpenTabs.Add(tab);

실패 결과:

error ATL201: State member '_state.Title' must not be modified directly. Use store or state command instead.

상태가 바뀌었다고 해서 시스템이 그 변화를 이해한 것은 아니다.

보조 강제 수단

이 케이스는 direct assignment와 collection mutation을 함께 막아야 한다. setter만 막으면 목록과 사전이 새고, collection만 막으면 property write가 남는다.

상태 타입에 대한 외부 쓰기를 막는다

Analyzer 예시 펼치기
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class StateMutationBypassAnalyzer : DiagnosticAnalyzer
{
    public override void Initialize(AnalysisContext context)
    {
        context.RegisterOperationAction(AnalyzeAssignment, OperationKind.SimpleAssignment);
    }

    private static void AnalyzeAssignment(OperationAnalysisContext context)
    {
        var assignment = (ISimpleAssignmentOperation)context.Operation;
        var targetType = assignment.Target.Type;

        if (targetType?.Name.EndsWith("State") == true || targetType?.Name.EndsWith("ViewState") == true)
        {
            context.ReportDiagnostic(Diagnostic.Create(Rules.StateMutationBypass, assignment.Syntax.GetLocation(), assignment.Target.Syntax.ToString()));
        }
    }
}

실무에서는 여기에 store 내부 allowlist를 더해 “State 타입에 대한 외부 쓰기”만 잡는 편이 현실적이다.

컬렉션 mutation도 함께 막는다

Add, Remove, Clear 같은 호출이 상태 보유 컬렉션에 직접 들어가면 같은 문제가 생긴다. 그래서 ViewModel/UI/일반 서비스에서는 collection mutation도 금지하고, store 내부만 예외로 두는 편이 좋다.

허용 영역과 금지 영역을 분명히 둔다

허용 영역:

  • store 구현 내부
  • state factory / builder
  • 직렬화 전용 코드

금지 영역:

  • ViewModel
  • UI event handler
  • 일반 서비스
  • 테스트 편의 코드

빌드 실패로 고정한다

[*.cs]
dotnet_diagnostic.ATL201.severity = error

이제 상태 직접 수정은 조용한 실수가 아니라 즉시 막히는 구조 위반이 된다.

결과

하네스 적용 이후 변화는 명확하다.

  • 모든 변경이 Store 또는 Command를 통과하면서 event, dirty sync, history, validation이 다시 같은 경로에 묶인다.
  • 상태가 언제 어디서 어떤 API를 통해 바뀌었는지 추적할 수 있다.
  • 사람과 AI 모두 public setter나 컬렉션 direct mutation 대신 상태 변경 API를 먼저 찾게 된다.

실무 적용 팁

  • reader와 writer를 명시적으로 분리하면 UI가 우연히 write surface를 잡는 일을 줄일 수 있다.
  • 가능하면 immutable snapshot 교체 쪽으로 기울이고, mutable collection은 store 내부로만 가두는 편이 낫다.
  • 테스트도 상태 setter가 아니라 command나 store API를 통해 변화시키는 편이 실제 흐름을 보존한다.

요약

상태 관리 우회 문제는 하네스를 통해서만 안정적으로 제어된다.

  • 상태를 읽기 전용으로 노출

  • Store / Command를 통해서만 변경 허용

  • 컬렉션 변경도 중앙화

  • Roslyn Analyzer로 직접 수정을 차단

  • 빌드 에러로 강제

이 구조를 통해
상태는 더 이상 어디서나 바뀌는 값이 아니라,
정의된 흐름을 통해서만 움직이는 시스템이 된다.