런타임 강제

런타임 강제

빌드 타임에서 잡지 못한 것을 런타임에서 잡는다. 잘못된 상태는 한 발짝도 진행하지 못한다.


빌드 타임 강제는 강력하지만 완전하지 않다. 동적 데이터, 런타임 조건, 외부 입력, 타입 캐스팅으로 우회된 코드는 정적 분석을 통과할 수 있다.

런타임 강제는 최후의 방어선이다. 실행이 시작되는 순간, 잘못된 상태면 즉시 실패한다.


런타임 강제는 모든 곳에 거는 것이 아니다

런타임 강제가 가장 비싼 층이라는 사실은 숨길 필요가 없다. 그래서 핵심은 모든 함수에 guard를 붙이는 것이 아니라, 동적 사실이 실제 손상을 만들기 직전의 경계에 집중하는 것이다.

보통 런타임 강제를 우선 두는 곳은 다음과 같다.

  • public entrypoint
  • 상태 전이 직전
  • 외부 입력, plugin, provider 같은 동적 경계
  • planner, executor, capability 같은 실행 권한 경계

반대로 순수 함수, hot path 내부 루프, 정적으로 충분히 막을 수 있는 구조 위반은 런타임보다 빌드 타임에서 처리하는 편이 낫다.

즉 구조 위반은 정적으로, 동적 입력과 최종 불변조건은 런타임에서 닫는 것이 기본 전략이다.


Guard를 진입점 앞에 배치한다

핵심 원칙은 하나다. 내부가 아니라 입구에서 막는다. 진입점 안쪽 어딘가에서 실패하는 것보다 진입점에서 바로 실패하는 것이 수정 범위가 좁고 오류 메시지가 명확하다.

Atlas (C#)

public async Task<OpenDocResult> ExecuteAsync(
    OpenDocCommand command,
    CancellationToken ct = default)
{
    _validator.Validate(command);  // 진입점 첫 번째 줄
    
    var doc = await _repository.LoadAsync(command.DocId, ct);
    return new OpenDocResult(doc.Id, doc.Content);
}

Validator가 실패하면 즉시 예외가 던져지고 이후 코드는 실행되지 않는다.

Glif (TypeScript)

function useTheme() {
  const value = useContext(ThemeContext);
  
  if (!value) {
    throw new Error(
      "ThemeProvider is required but not found in component tree. " +
      "Wrap the component with <AppProviders>."
    );
  }
  
  return value;
}

Provider 없이 hook을 사용하면 즉시 명확한 오류가 발생한다. ?? defaultTheme 같은 silent fallback은 문제를 숨긴다.


Silent fallback을 제거한다

// 위반: 문제를 숨기는 fallback
const theme = useContext(ThemeContext) ?? defaultTheme;

// 합법: 문제를 드러내는 guard
const theme = useContext(ThemeContext);
if (!theme) throw new Error("ThemeProvider required");

Fallback은 “대체"가 아니라 “중단"이어야 한다. AI가 생성하는 코드에서 ?? defaultValue 패턴은 항상 위험하다. guard로 대체한다.


Assertion으로 전제 조건을 강제한다

Atlas (C#)

private static void AssertValidated(Task task)
{
    if (task.State != TaskState.Validated)
    {
        throw new InvalidOperationException(
            $"Task must be in Validated state before execution. " +
            $"Current: {task.State}. Call Validate() first.");
    }
}

public async Task RunAsync(Task task)
{
    AssertValidated(task);
    // 이 아래는 task가 Validated 상태임이 보장됨
}

Glif (TypeScript)

function invariant(condition: unknown, message: string): asserts condition {
  if (!condition) {
    throw new Error(message);
  }
}

function runTask(task: Task) {
  invariant(
    task.state === "validated",
    `Task must be validated before running. Current: ${task.state}`
  );
  
  // 이 아래는 task.state === "validated"가 보장됨
}

invariant 패턴은 두 가지를 동시에 달성한다. 런타임 검증과 TypeScript type narrowing. asserts condition으로 선언하면 이후 코드에서 타입이 좁혀진다.


실행 컨텍스트를 검증한다

Planner를 거쳐야만 실행 가능한 코드에서, 직접 호출이 들어오면 차단한다.

function executeInternal(
  plan: ExecutionPlan,
  ctx: ExecutionContext
) {
  if (ctx.source !== "planner") {
    throw new Error(
      "Execution must originate from Planner. " +
      "Direct executor calls are forbidden."
    );
  }
  
  // planner를 통한 호출만 이 아래로 진행
}

Side-effect 전에 반드시 검증한다

async function publishDocument(doc: Document) {
  // side-effect 전에 먼저 검증
  invariant(doc.status === "reviewed", "Document must be reviewed before publishing");
  
  // 검증 통과 후에만 side-effect 실행
  await storage.save({ ...doc, status: "published" });
  await eventBus.emit("document.published", { id: doc.id });
}

DB write, 네트워크 호출, 파일 쓰기 같은 side-effect는 되돌리기 어렵다. 검증은 항상 그 앞에 위치한다.


비용과 난이도를 현실적으로 본다

런타임 강제의 비용은 대체로 세 군데에서 나온다.

  • 입력과 상태를 확인하는 추가 검증
  • 실패 객체와 메시지를 만드는 비용
  • trace ID와 운영 로그를 남기는 비용

이 비용은 대개 경계에 집중할 때는 감당 가능하지만, 내부 전체로 퍼뜨리면 hot path를 빠르게 오염시킨다.

실무적으로는 보통 이렇게 시작하는 편이 좋다.

  • 진입점 하나에 invariant 하나를 둔다
  • 위반 메시지 형식을 하나로 통일한다
  • trace ID를 붙여 운영 로그와 연결한다
  • 반복 위반이 많은 경계부터 넓힌다

런타임 강제의 목적은 모든 함수를 감시하는 것이 아니라, 잘못된 상태가 side-effect를 만들기 전에 한 발짝도 더 나가지 못하게 하는 것이다.

적용 강도와 비용 판단은 강도 조정, 비용 원천에서 이어서 다룬다.


오류 메시지의 형태

런타임 강제의 오류 메시지는 빌드 타임보다 더 중요하다. 이미 실행이 진행된 상태이기 때문이다. 무엇이 잘못됐는지, 어떻게 해야 하는지를 즉시 알 수 있어야 한다.

{
  "error": "TransitionViolationError",
  "message": "Cannot transition document from 'draft' to 'published'",
  "current_status": "draft",
  "requested_transition": "published",
  "allowed_transitions": ["review", "archived"],
  "legal_path": "submitForReview() → approve() → publish()",
  "trace_id": "abc-123"
}

legal_path 필드가 합법 경로를 직접 가리킨다. AI 작업 흐름에서 이 형식은 수정으로 수렴하게 만든다.


런타임 강제 체크리스트

  • Guard가 진입점의 첫 번째 코드인가?
  • Silent fallback(?? default)이 guard로 교체됐는가?
  • Assertion이 전제 조건을 명시적으로 표현하는가?
  • Side-effect 전에 검증이 위치하는가?
  • 오류 메시지에 현재 상태, 위반 내용, 합법 경로가 포함되는가?
  • 런타임 실패가 trace ID와 함께 기록되는가?