에이전틱 프로그래밍 패턴1-12026.10.20

상태마다 필요한 데이터를 타입으로 묶는다

시스템의 상태가 여러 개일 때, 모든 상태의 데이터를 하나의 구조체에 모아두면 문제가 생깁니다. 상태마다 실제로 필요한 필드가 다른데도 하나의 객체가 모든 필드를 선택적(Optional)으로 들고 있으면, 유효하지 않은 데이터 조합이 얼마든지 만들어질 수 있기 때문입니다.

음악 플레이어를 예로 들어보겠습니다. 음악 플레이어는 곡을 선택하지 않은 빈 상태(Empty), 곡을 재생 중인 상태(Playing), 재생을 멈춘 일시정지 상태(Paused)를 가집니다. 이를 단일 클래스로 표현하면 보통 다음과 같은 형태가 됩니다.

from dataclasses import dataclass


@dataclass
class Player:
    status: str  # "EMPTY", "PLAYING", "PAUSED"
    song_name: str | None = None

이 코드는 단순해 보이지만, 시스템이 표현할 수 있는 상태 공간을 지나치게 넓혀둡니다.

상태 플래그와 선택 필드가 엉뚱한 조합을 만든다

위 Player 모델에서 status가 가질 수 있는 값은 3가지이고, song_name은 문자열이거나 None인 2가지입니다. 따라서 시스템이 표현할 수 있는 전체 상태의 수는 3×2=63 \times 2 = 6가지입니다.

하지만 실제 음악 플레이어의 도메인 규칙을 따져보면 유효한 상태는 단 3가지뿐입니다:

  1. status == "EMPTY"이고 song_name is None (정상: 곡 없음)
  2. status == "PLAYING"이고 song_name이 유효한 문자열 (정상: 곡 재생 중)
  3. status == "PAUSED"이고 song_name이 유효한 문자열 (정상: 곡 일시정지)

나머지 3가지는 시스템에 결코 존재해서는 안 되는 불가능한 상태(Illegal States)입니다:

  • status == "EMPTY"인데 song_name에 곡 이름이 들어 있는 상태
  • status == "PLAYING"인데 song_name is None인 상태 (재생 중인데 곡이 없음)
  • status == "PAUSED"인데 song_name is None인 상태 (일시정지 중인데 곡이 없음)
statussong_name is Nonesong_name == "곡 이름"
"EMPTY"정상불가능
"PLAYING"불가능정상
"PAUSED"불가능정상

소비처마다 방어 코드가 늘어난다

불가능한 상태가 모델 수준에서 허용되면, 이 데이터를 사용하는 모든 함수가 그 대가를 치릅니다.

화면에 현재 재생 중인 곡 이름을 표시하는 함수를 작성한다고 해보겠습니다. 정적 타입 검사기 입장에서는 song_name이 str | None이므로, None일 가능성을 배제할 수 없습니다.

def playback_title(player: Player) -> str:
    # status가 "PLAYING"이어도 song_name이 None일 수 있으므로 방어 코드가 필요하다
    if player.status == "PLAYING":
        if player.song_name is not None:
            return player.song_name
        raise ValueError("재생 중인데 곡 이름이 없습니다")
    return "재생 중이 아닙니다"

알림창에 표시할 텍스트를 만드는 함수도 마찬가지입니다.

def notification_text(player: Player) -> str:
    if player.status in ("PLAYING", "PAUSED"):
        if player.song_name is not None:
            return f"선택한 곡: {player.song_name}"
        raise ValueError("곡 이름이 없습니다")
    return "선택한 곡 없음"

song_name을 읽는 모든 곳에서 if player.song_name is not None 검사를 반복해야 합니다. 만약 어느 한 곳이라도 이 검사를 빠뜨리면 런타임에 NoneType 오류가 발생합니다. 불완전한 모델 때문에 하류(Downstream) 코드 전체에 방어적 조건문이 증식하는 것입니다.

각 상태에 필요한 데이터만 타입으로 묶는다

해결책은 상태마다 필요한 데이터만 담는 독립된 타입을 만들고, 이들을 합집합(Union) 타입으로 묶는 것입니다.

from dataclasses import dataclass


@dataclass(frozen=True)
class Empty:
    pass


@dataclass(frozen=True)
class Playing:
    song_name: str


@dataclass(frozen=True)
class Paused:
    song_name: str


# 음악 플레이어가 가질 수 있는 모든 상태의 합집합
Player = Empty | Playing | Paused

이 설계는 다음 세 가지를 보장합니다:

  1. Empty 타입에는 song_name 필드 자체가 없습니다. 곡이 없는 상태에서 곡 이름을 넣는 실수 자체가 불가능합니다.
  2. Playing과 Paused 타입의 song_name은 str | None이 아니라 순수한 str입니다. 유효한 곡 이름 없이는 객체 자체를 생성할 수 없습니다.
  3. @dataclass(frozen=True) 옵션을 지정하여 객체를 불변(Immutable)으로 만듭니다. 인스턴스가 한 번 생성되면 필드 값을 수정할 수 없어(FrozenInstanceError 발생), 상태 객체의 일관성이 도중에 깨지지 않습니다.

이제 모델이 표현할 수 있는 상태 공간은 정확히 우리가 정의한 3가지뿐입니다. 불가능한 상태 3가지는 타입 정의 수준에서 물리적으로 제거되었습니다.

소비처가 단순해진다

상태가 분리되면 소비 함수의 시그니처와 구현이 극적으로 단순해집니다.

def playback_title(state: Playing) -> str:
    return state.song_name


def notification_text(state: Playing | Paused) -> str:
    return f"선택한 곡: {state.song_name}"

두 함수의 본문에서 None 여부를 검사하던 조건문이 완전히 사라졌습니다. 인자 타입이 이미 Playing | Paused로 제한되어 있으므로, 함수 내부에서는 곡 이름이 반드시 존재한다는 사실을 의심하지 않고 바로 사용할 수 있습니다.

이 설계는 에이전틱 코딩 환경에서 결정적인 차이를 만듭니다:

  • 완전한 로컬 추론: 에이전트는 프로젝트 전체의 주석이나 이전 실행 흐름을 뒤질 필요 없이, playback_title의 시그니처만 보고도 어떤 데이터를 준비해야 하는지 즉시 파악합니다.
  • 기계적 자가수정(Self-Correction) 피드백: 에이전트가 곡이 선택되지 않은 Empty 객체를 playback_title(Empty())처럼 전달하더라도, 정적 타입 검사기가 타입 검사 단계에서 즉시 오류를 지적합니다. 프롬프트 지침에 의존하지 않고도 에이전트가 도구의 정적 에러 메시지를 바탕으로 올바른 분기를 스스로 보완하는 폐루프가 작동합니다.

"재생이나 일시정지 상태에는 반드시 문자열 곡 이름이 존재해야 한다"처럼 시스템이 동작하는 동안 언제나 참이어야 하는 데이터 규칙을 불변식(Invariant)이라고 부릅니다. 이 불변식을 사용하는 곳마다 조건문으로 땜질하는 대신, 유효하지 않은 상태를 아예 만들 수 없도록 모델에 녹여내는 원칙이 바로 불가능한 상태는 표현할 수 없게(Make illegal states unrepresentable)입니다.

원칙의 유래

야론 민스키(Yaron Minsky)는 2010년 하버드 강연 Effective ML과 2011년 Jane Street 글 Effective ML Revisited에서 이 표어를 알렸습니다. 합집합 타입으로 유효한 데이터 조합만 정확히 표현하는 정적 모델링의 중요성을 강조했습니다. 불변식 개념은 C. A. R. 호어(Hoare)의 공리적 접근법과 버트란드 마이어(Bertrand Meyer)의 계약에 의한 설계(Design by Contract)와도 연결됩니다. 민스키의 문헌 정보는 참고문헌에서 확인할 수 있습니다.

하네스에 넣을 규칙

  • 설계 원칙 (상태 분리와 필드 한정):
    • 단일 구조체에 상태 구분 플래그와 모든 상태의 선택적(nullable/optional) 필드를 평면적으로 함께 두지 않는다.
    • 각 상태에 꼭 필요한 데이터만을 담는 불변 구조체를 각각 정의하고, 이들을 합집합(Union) 또는 대수적 데이터 타입(ADT)으로 묶어 표현 가능한 상태 공간을 유효한 조합으로만 한정한다.
  • 기계적 게이트 (정적 타입 검사 강제):
    • 상태 합집합 타입을 처리하는 함수는 명시적인 타입 좁히기(Type narrowing)나 조건 분기를 거치지 않고 특정 상태의 속성에 직접 접근할 수 없도록 정적 타입 검사기나 린터를 통해 검증한다.
  • 거절 조건 (코드 리뷰 체크리스트):
    • "이 상태에서는 이 필드가 항상 존재할 것이다"라는 암묵적 전제에 의존하여 널(null) 검사나 상태 확인을 건너뛰는 코드는 반려한다.