UI 액션 우회

UI 액션 우회

이 사례의 핵심은 “이벤트 처리를 잘 분리하자"가 아니라
action 계층을 거치지 않으면 UI가 side effect를 실행할 수 없게 만드는 것이다.

개요

상태 바인딩과 레이아웃까지 통제해도
UI는 여전히 구조를 무너뜨릴 수 있다.

그 이유는 사용자 입력이 들어오는 순간
컴포넌트가 다시 가장 짧은 경로를 선택할 수 있기 때문이다.

대표적으로 다음과 같은 우회가 발생한다.

  • 버튼 클릭에서 바로 API 호출
  • 컴포넌트 내부에서 toast, navigation, analytics를 직접 실행
  • validation 없이 submit 수행
  • 상태 업데이트와 side effect를 한 함수 안에서 임의로 결합

즉 문제는 이벤트를 받는 것 자체가 아니라
이벤트 처리 경로가 action 계층 밖으로 새는 것이다.

Glif에서는 다음을 목표로 한다.

  • UI는 action만 호출한다
  • side effect는 action/effect 계층에서만 실행된다
  • 위반은 코드 리뷰가 아니라 정적 분석 또는 런타임에서 실패한다

경로 한눈에 보기

D2 diagram
핵심 차이는 "클릭이 무엇을 호출하느냐"가 아니라, 클릭 이후의 절차와 side effect 위치가 action 계층에 고정되어 있느냐에 있다.

이 문서의 질문

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

이벤트가 action 계층을 거친 같은 의미 경로로 처리되는가

문제

다음과 같은 코드가 반복적으로 생성된다.

function SaveButton() {
  const onClick = async () => {
    const response = await fetch("/api/save", { method: "POST" });
    if (response.ok) {
      alert("saved");
      location.href = "/docs";
    }
  };

  return <button onClick={onClick}>Save</button>;
}

또는

function PublishButton() {
  return (
    <button
      onClick={() => {
        analytics.track("publish_clicked");
        toast.success("published");
        store.setPublished(true);
      }}
    >
      Publish
    </button>
  );
}

이 방식은 빠르게 동작한다.
하지만 구조적으로는 다음 문제를 만든다.

  • side effect 위치가 컴포넌트마다 달라짐

  • validation, logging, analytics 순서가 깨짐

  • 동일 액션이 화면마다 다른 방식으로 동작함

  • 테스트가 UI 이벤트와 외부 의존성에 강하게 묶임

  • AI가 가장 짧은 이벤트 처리 경로를 계속 재생산함

즉 문제는 클릭 핸들러가 아니라
클릭 핸들러가 실행 절차를 직접 소유하는 것이다.

문제의 본질

사람과 AI는 이벤트를 시스템 경계가 아니라
즉시 처리 가능한 함수 호출 지점으로 본다.

기준은 단순하다.

  • 버튼을 누르면 바로 결과가 나오면 된다

  • 별도 action 구조를 따라갈 필요가 없다

  • validation, analytics, navigation을 한곳에 몰아넣는 것이 빠르다

그래서 다음과 같은 우회가 쉽게 발생한다.

  • onClick 안에서 직접 fetch

  • onSubmit 안에서 직접 validation + 저장 + 이동

  • UI 컴포넌트에서 toast, modal open, tracking 직접 실행

  • action 계층을 생략하고 store mutation 수행

즉 사람과 AI는 action 구조를 보존하기보다
이벤트를 받은 위치에서 바로 끝내는 경로를 만든다.

목표 구조

Glif에서는 이벤트 처리 경로를 다음처럼 고정한다.

D2 diagram
핵심 규칙은 단순하다.
  • UI는 이벤트를 action으로 전달한다

  • action이 실행 순서를 결정한다

  • side effect는 UI가 아니라 effect 계층이 수행한다

하네스 적용 위치

이 문제는 네 레벨에서 동시에 막는다.

구조 레벨

UI와 action 계층의 책임을 분리한다.

  • UI는 입력 수집과 action 호출만 담당

  • action은 validation, effect 호출, 상태 반영 순서를 담당

  • API, router, toast, analytics는 effect 계층 뒤로 숨긴다

즉 컴포넌트는 처리기가 아니라
이벤트를 위임하는 진입점이 된다.

검증 레벨

정적 분석으로 UI의 side effect 직접 실행을 탐지한다.

  • UI 파일에서 fetch, axios 금지

  • router.push, navigate 직접 호출 금지

  • toast.*, analytics.* 직접 호출 금지

  • store.set*, dispatchRaw* 직접 호출 금지

실행 레벨

런타임에서도 action 경계를 확인한다.

  • action context 밖 effect 호출 시 throw

  • action trace 없이 실행된 external effect 감지

  • 개발 모드에서 direct side effect 실행 경고 또는 실패

테스트 레벨

테스트도 허용된 이벤트 경로만 반복한다.

  • 컴포넌트 테스트는 action mock만 주입

  • API/analytics/toast를 UI에 직접 주입하는 테스트 금지

  • action 단위에서 순서와 부작용을 검증

실제 구현

이 문서에서 필요한 구현은 네 가지뿐이다.

  • UI는 action 이름만 안다
  • 실행 절차는 action 구현체에만 모은다
  • effect는 정적 규칙과 runtime scope로 다시 잠근다
  • writer 권한은 action 계층에만 남긴다
구현 예시 펼치기

UI는 action 이름만 알고 side effect는 모르게 만든다

이 패턴의 목표는 클릭 핸들러를 얇게 만드는 것이 아니라, 이벤트가 side effect에 닿기 전에 반드시 action 계층을 통과하게 만드는 것이다.

export interface EditorActions {
  saveDoc(docId: string): Promise<void>;
  publishDoc(docId: string): Promise<void>;
}

UI는 fetch, toast, router, analytics를 직접 알지 않고 action 이름만 안다.

클릭은 action 호출로만 끝난다

function SaveButton({ docId, actions }: { docId: string; actions: EditorActions }) {
  return <button onClick={() => actions.saveDoc(docId)}>Save</button>;
}

이 구조의 장점은 onClick 안에 새로운 side effect가 자라기 시작하는 순간 바로 눈에 띈다는 점이다.

실행 절차는 action 구현체에만 모은다

export function createEditorActions(deps: {
  validate: (docId: string) => Promise<void>;
  api: { save: (docId: string) => Promise<void> };
  toast: { success: (message: string) => void };
  analytics: { track: (event: string) => void };
  router: { goDoc: (docId: string) => void };
}): EditorActions {
  return {
    async saveDoc(docId: string) {
      await deps.validate(docId);
      await deps.api.save(docId);
      deps.analytics.track("doc_saved");
      deps.toast.success("saved");
      deps.router.goDoc(docId);
    },
  };
}

validation, API 호출, analytics, toast, navigation의 순서는 여기서만 정의된다. UI는 실행 절차를 다시 쓰지 못한다.

정적 규칙으로 direct effect import를 막는다

module.exports = {
  overrides: [
    {
      files: ["src/ui/**/*.tsx"],
      rules: {
        "no-restricted-imports": [
          "error",
          {
            patterns: [{ group: ["@/api/*", "@/services/*", "@/infra/*"], message: "UI must not call external effects directly. Use actions." }],
            paths: [{ name: "axios", message: "UI must not call axios directly. Use actions." }]
          }
        ]
      }
    }
  ]
};

핵심은 개별 기술을 금지하는 것이 아니라, UI에서 effect를 직접 호출하는 위치 자체를 금지하는 것이다.

runtime에서도 action scope를 확인한다

lint만으로는 helper 재수출이나 indirect call을 완전히 막기 어렵다. 그래서 effect adapter가 action scope 안에서만 실행되도록 한 번 더 잠글 수 있다.

let currentActionScope = 0;

export async function runInActionScope<T>(fn: () => Promise<T>): Promise<T> {
  currentActionScope += 1;
  try {
    return await fn();
  } finally {
    currentActionScope -= 1;
  }
}

export function assertInActionScope() {
  if (currentActionScope <= 0) {
    throw new Error("Side effects must run within action scope");
  }
}

이 장치가 있으면 toast helper나 analytics wrapper가 다시 아무 데서나 호출되는 우회 경로가 되기 어렵다.

mutation 권한도 action 계층에 묶는다

store writer를 UI에 직접 넘기면 action은 이름만 남은 wrapper가 된다. 그래서 UI에는 reader만, writer 권한은 action 계층에만 남기는 편이 좋다.

export function createEditorStore() {
  let state = { published: false };

  return {
    reader: { getState: () => state },
    writer: {
      setPublished(value: boolean) {
        state = { ...state, published: value };
      },
    },
  };
}

테스트도 같은 경계를 따라야 한다

컴포넌트 테스트는 action mock만 사용하고, side effect 순서는 action 테스트에서 검증한다. 테스트가 UI direct effect를 허용하면 production 코드도 그 경로를 다시 배운다.

위반 예시와 실패 결과

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

function PublishButton() {
  return (
    <button
      onClick={() => {
        toast.success("published");
      }}
    >
      Publish
    </button>
  );
}

실패 결과:

error: UI must not call toast directly. Use actions.

또는

function SaveButton() {
  return (
    <button
      onClick={() => {
        fetch("/api/save");
      }}
    >
      Save
    </button>
  );
}

실패 결과:

error: UI must not call external effects directly. Use actions.

또는 action scope 밖 effect 호출:

Error: Side effects must run within action scope

이 순간부터 이벤트 처리 규칙은 문서가 아니라
실행 가능한 제약이 된다.

보조 강제 수단

이 케이스의 보조 강제 수단은 event to effect path를 action 계층 안에 가두는 데 있다. action interface만 두면 helper와 util이 다시 새고, lint만 두면 runtime helper call이 남고, test 분리가 없으면 UI가 side effect를 다시 학습한다.

  • action interface → UI 진입점 고정
  • ESLint → UI direct effect 차단
  • action scope guard → runtime 실행 경계 확인
  • reader/writer 분리 → direct mutation 차단
  • layered tests → 우회 없는 실행 절차 검증

핵심은 클릭 핸들러를 똑똑하게 만드는 것이 아니라, 클릭이 side effect에 닿기 전 반드시 action을 통과하게 만드는 것이다.

결과

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

  • UI는 action 계층에 입력을 위임하고 direct side effect 경로를 잃는다.
  • effect 순서가 testable하고 repeatable한 구조로 고정된다.
  • router, toast, analytics, API 호출이 action과 effect 계층으로 다시 모인다.

실무 적용 팁

  • UI 이벤트는 시스템 실행의 시작점일 뿐이다. 클릭 핸들러 안에서 모든 것을 끝내기 시작하면 action 계층은 바로 무너진다.
  • effect를 편한 유틸처럼 export하지 않는다. toast, router, analytics helper를 아무 데서나 부를 수 있으면 AI는 반드시 그쪽으로 간다.
  • writer 권한은 UI에 주지 않는다. UI가 store를 직접 바꿀 수 있으면 action은 naming wrapper로 전락한다.
  • fallback으로 복구하지 않는다. action 경계가 깨졌는데도 조용히 실행되면 우회는 정상 경로로 굳어진다.

요약

이 케이스에서 하네스는 다음과 같이 구성된다.

  • UI는 action 인터페이스만 호출한다

  • API, router, toast, analytics 직접 호출은 정적 분석으로 차단된다

  • effect는 action scope 안에서만 실행된다

  • store mutation 권한은 action 계층에만 남긴다

  • 테스트도 UI와 action의 책임을 분리해서 검증한다

결과적으로

  • 이벤트 처리 경로는 action 중심으로 고정되고

  • UI는 입력을 위임하는 역할로 제한되며

  • direct side effect 실행이라는 우회 경로는 제거된다

“이벤트 처리를 잘 분리하라"가 아니라
“action 계층을 거치지 않으면 UI는 아무 side effect도 실행할 수 없다"가 하네스다.