결정적인 것과 비결정적인 것

프로그래밍에서 가장 기본적인 구분 중 하나다.

# 결정적 (Deterministic)
def add(a, b):
    return a + b
 
add(2, 3)  # 항상 5
 
# 비결정적 (Non-deterministic)
def get_random():
    return random.randint(1, 100)
 
get_random()  # 매번 다름

결정적 함수는 예측 가능하다. 테스트할 수 있고, 디버깅할 수 있고, 안심하고 조합할 수 있다. 비결정적 함수는 그렇지 않다.

LLM 기반 자동화를 설계할 때도 이 구분이 핵심이다. 문제는 LLM 자체가 비결정적이라는 것. 같은 프롬프트에 매번 다른 응답이 나온다. 그렇다면 LLM을 쓰는 자동화는 필연적으로 비결정적인가?

아니다. 어디가 비결정적인지를 구분하면, 나머지는 결정적으로 만들 수 있다.


이 글에서 말하는 “결정적 Workflow”

먼저 용어를 명확히 하자. 이 글에서 결정적 Workflow란:

  1. 수행할 단계가 사전에 정의됨 - 1→2→3→4 순서가 마크다운에 명시
  2. 분기 조건이 사전에 정의됨 - “실패하면 X, 성공하면 Y”가 명시
  3. 에러 처리가 사전에 정의됨 - 재시도 횟수, 타임아웃, 에스컬레이션 조건이 명시

단, 이것은 “결과의 결정성”과 다르다:

  • 각 단계에서 LLM이 생성하는 내용은 매번 다르다
  • 분기 조건의 실제 발생 여부는 실행 시점에 결정된다
  • 재시도가 몇 번 일어날지는 실행해봐야 안다
절차의 결정성: "무엇을 할지"가 사전에 정해짐 ✓
결과의 결정성: "어떤 결과가 나올지"가 사전에 정해짐 ✗

이 글은 절차의 결정성을 다룬다. 결과까지 결정적으로 만드는 것은 LLM의 본질상 불가능하다.


Workflow와 Contents: 두 개의 층위

하나의 작업을 분해하면 두 가지 층위가 보인다.

작업 = Workflow (어떤 절차로) + Contents (어떤 내용을)

예를 들어 커밋을 만드는 작업:

층위예시
Workflowgit diff → 변경사항 분석 → 커밋 메시지 작성 → git commit
Contents”feat: add retry logic for TCP reconnection”

Workflow는 절차다. 어떤 단계를, 어떤 순서로 수행하는가. Contents는 내용다. 각 단계에서 생성되는 구체적인 결과물.

핵심 통찰:
Contents의 비결정성은 LLM의 본질이다. 피할 수 없다.
Workflow의 결정성은 설계의 영역이다. 만들 수 있다.

왜 Workflow를 결정적으로 만들어야 하는가

1. 재사용할 수 있다

비결정적 workflow는 매번 다른 경로를 탄다. “이번엔 되더니 다음엔 안 된다”가 발생한다. 결정적 workflow는 같은 상황에서 같은 절차를 따른다. 한 번 검증하면 계속 쓸 수 있다.

2. 조합할 수 있다

각 부품의 절차가 정해져 있으면, 조합 결과의 절차도 정해진다. 부품이 비결정적이면? “이 부품이 이번엔 뭘 할지”를 알 수 없어서 조합이 어렵다.

3. 디버깅할 수 있다

문제가 생겼을 때, 결정적 workflow는 “3단계에서 실패했다”고 특정할 수 있다. 비결정적 workflow는 “이번엔 이 경로로 갔는데, 저번엔 저 경로로 갔고…”가 된다.

4. 테스트할 수 있다

결정적 workflow는 “이 입력에서 이 단계들이 실행되는가?”를 테스트할 수 있다. 비결정적 workflow는 매번 다른 경로를 타서 테스트 자체가 어렵다.


Skill: 결정적 Workflow의 단위

Claude Code에서 Skill은 .claude/commands/에 마크다운으로 정의하는 커스텀 명령이다. 핵심은 수행 절차가 텍스트로 고정되어 있다는 것.

<!-- .claude/commands/commit.md -->
# Commit Procedure
 
1. Run `git diff --staged` to see changes
2. If no changes → exit with "Nothing to commit"
3. Analyze changes: categorize as feat/fix/refactor/docs/test/chore
4. Write commit message in Conventional Commits format
5. Run `git commit -m "{message}"`
6. If commit fails → report error and exit
7. Output commit hash

이 Skill은:

  • 절차가 고정 - 1→2→3→4→5→6→7 순서는 변하지 않음
  • 분기 조건도 고정 - “변경사항 없으면”, “실패하면”이 사전 정의됨
  • 내용만 가변 - 커밋 메시지는 매번 다르지만, 형식은 고정

이것이 Skill의 정체다: 결정적 Workflow + 비결정적 Contents.


Skill의 조합

작은 Skill을 조합해서 상위 Skill을 만들 수 있다.

<!-- .claude/commands/pr.md -->
# Pull Request Procedure
 
1. Check for uncommitted changes
2. If changes exist → execute /commit
3. Run `npm run lint`
4. If lint fails → report error and exit
5. Run `git push origin HEAD`
6. If push fails → report error and exit
7. Generate PR description based on recent commits
8. Run `gh pr create --title "{title}" --body "{body}"`
9. Output PR URL

주의: “lint 실패 시 자동으로 fix하고 재시도”같은 동작은 Skill이 아니라 Agent의 영역이다. Skill은 판단 없이 절차를 따르는 것에 집중한다. 복잡한 판단이 필요하면 Agent에게 맡긴다.

/pr (결정적)
 ├── /commit (결정적)
 └── 자체 절차 (결정적, 실패 시 종료)

에러 처리의 구체화

“결정적”이라는 것은 “에러가 없다”는 뜻이 아니다. 에러 상황에서 어떻게 할지도 사전에 정의한다는 뜻이다. 여기서 중요한 것은 구체성이다.

# Deploy Skill
 
1. Run `make build`
2. If build fails:
   - Capture error log
   - Report: "Build failed: {error}"
   - EXIT (do not proceed)
 
3. Run `scp artifacts remote:/deploy/`
4. If transfer fails:
   - Retry up to 3 times with 5-second delay
   - If still fails after 3 retries:
     - Report: "Transfer failed after 3 retries"
     - EXIT
 
5. Run `ssh remote 'systemctl restart app'`
6. Run health check (GET /health, expect 200)
7. If health check fails:
   - Wait 10 seconds, retry health check
   - Max 3 retries
   - If still fails:
     - Run `ssh remote 'systemctl rollback app'`
     - Report: "Deploy failed, rolled back to previous version"
     - EXIT
 
8. Report: "Deploy successful"

구체적으로 명시해야 할 것들:

  • 재시도 횟수 (3번)
  • 재시도 간격 (5초, 10초)
  • 타임아웃 (몇 초 기다릴지)
  • 실패 시 행동 (종료? 롤백? 에스컬레이션?)

Agent: 판단이 필요할 때

모든 workflow를 사전에 정의할 수 있는 건 아니다.

JIRA 티켓 → 분석 → 계획 → 구현 → 테스트 → PR

현실:

  • 계획이 마음에 안 들면? → 다시 계획
  • 테스트 실패하면? → 구현으로 돌아감, 아니면 계획부터 다시?
  • 계획 하나에 커밋 몇 개? → 모름

어떤 경로를 탈지, 몇 번 반복할지, 언제 멈출지를 사전에 정의할 수 없다. 이것이 Agent의 영역이다.

Agent (orchestrator)
  ├─ /analyze (Skill)
  ├─ /plan (Skill)
  ├─ /implement (Skill)
  ├─ (판단) 테스트 실패 → /implement 다시? /plan부터?
  └─ /pr (Skill)

Agent의 역할:

  • 언제 어떤 Skill을 호출할지 판단
  • 결과가 만족스러운지 평가
  • 다음 행동을 결정 (재시도, 다른 접근, 종료)

원칙:

  1. 가능한 많은 것을 Skill로 만들어 결정적으로
  2. 정말 판단이 필요한 부분만 Agent에게
  3. Agent도 무한 루프 방지 조건을 명시
# Agent Guidelines
 
- 같은 Skill을 연속 3번 실패하면 → 사람에게 에스컬레이션
- 전체 작업 시간이 30분을 초과하면 → 중간 결과 보고 후 대기
- 판단이 불확실할 때 → 사람에게 선택지 제시

Contents 다루기: 형태와 품질

여기까지는 Workflow의 결정성을 다뤘다. 하지만 Workflow가 결정적이어도, LLM이 생성하는 Contents가 잘못되면 문제다.

빌드가 안 되는 코드, 틀린 커밋 메시지, 잘못된 분석 결과. 이건 Workflow의 문제가 아니라 Contents 품질의 문제다.

Contents 품질은 두 가지 차원에서 다룬다.

1. 형태의 규격화 (Guardrail)

내용은 매번 다르지만, **형태(format)**는 규격화할 수 있다.

# Commit Skill
 
...
커밋 메시지 형식:
- Type: feat|fix|refactor|docs|test|chore
- Scope: optional, in parentheses
- Description: imperative mood, lowercase, no period
- Example: "feat(auth): add JWT token refresh"
...

Guardrail의 효용:

  • 파싱 가능 - 정해진 형식이니 자동 분석 가능
  • 일관성 - 사람이 읽을 때 예측 가능
  • 형식 오류 조기 감지

Guardrail의 한계:

  • LLM이 항상 형식을 따르리라는 보장 없음
  • 별도 검증 로직(regex, JSON schema)으로 형식을 체크해야 함
  • 형식이 맞아도 내용이 틀릴 수 있음
# Guardrail 검증 예시
import re
 
def validate_commit_message(message):
    pattern = r'^(feat|fix|refactor|docs|test|chore)(\(.+\))?: .+$'
    if not re.match(pattern, message):
        raise ValueError(f"Invalid commit message format: {message}")

2. 의미의 검증 (Verification)

형태가 맞아도 내용이 틀릴 수 있다. 코드가 Conventional Commits 형식이어도, 실제로 빌드가 안 될 수 있다.

이건 실행해서 확인하는 수밖에 없다.

# Implement Skill
 
...
5. Write the implementation code
6. Run `npm run build`
7. If build fails:
   - Analyze error message
   - Fix the code
   - Go to step 6 (max 3 retries)
   - If still fails → report error and EXIT
8. Run `npm test`
9. If tests fail:
   - Analyze failure
   - Fix the code
   - Go to step 6 (restart from build, max 3 retries total)
   - If still fails → report error and EXIT
...

검증의 현실적 한계:

  • 빌드/테스트도 100% 결정적이진 않다 (flaky tests, 환경 의존성)
  • 외부 API, 네트워크 상태에 따라 결과가 달라질 수 있음
  • 그래도 대부분의 경우 빌드/테스트는 결정적으로 동작함

검증 단계를 Workflow의 일부로 포함시킨다. LLM이 생성한 Contents를 즉시 검증하고, 실패하면 재시도한다. 단, 재시도 횟수를 제한해서 무한 루프를 방지한다.


비용과 지연: 실용적 고려

Skill을 잘게 나눌수록:

장점단점
재사용성 높아짐LLM 호출 횟수 증가
테스트 쉬워짐토큰 비용 증가
디버깅 쉬워짐지연 증가
조합 가능컨텍스트 전환 오버헤드

실용적 기준:

  • 3회 이상 반복되는 절차 → Skill로 추출
  • 한 번 쓰고 마는 절차 → 굳이 Skill로 만들 필요 없음
  • 5단계 이하의 짧은 절차 → 하나의 Skill로
  • 10단계 이상의 긴 절차 → 여러 Skill로 분리 후 조합

Skill 테스트하기

Skill의 절차가 올바른지 어떻게 테스트하는가?

1. 절차 테스트 (Workflow 검증)

특정 입력에서 예상한 단계들이 실행되는지 확인:

def test_commit_skill_no_changes():
    """변경사항이 없을 때 early exit하는지 테스트"""
    result = run_skill("/commit", staged_changes=None)
    assert result.status == "nothing_to_commit"
    assert result.steps_executed == ["git diff", "exit"]
 
def test_commit_skill_with_changes():
    """변경사항이 있을 때 전체 절차를 따르는지 테스트"""
    result = run_skill("/commit", staged_changes="some diff")
    assert "analyze" in result.steps_executed
    assert "git commit" in result.steps_executed

2. 형태 테스트 (Guardrail 검증)

출력이 정해진 형식을 따르는지 확인:

def test_commit_message_format():
    """커밋 메시지가 Conventional Commits 형식인지 테스트"""
    message = generate_commit_message(diff="added login feature")
    pattern = r'^(feat|fix|refactor|docs|test|chore)(\(.+\))?: .+$'
    assert re.match(pattern, message), f"Invalid format: {message}"

3. 부수 효과 테스트

예상한 동작이 실제로 일어났는지 확인:

def test_commit_creates_commit():
    """실제로 git commit이 생성되는지 테스트"""
    before_count = get_commit_count()
    run_skill("/commit", staged_changes="some diff")
    after_count = get_commit_count()
    assert after_count == before_count + 1

계층 구조

┌─────────────────────────────────────────────────────────────┐
│  Agent (비결정적 Workflow)                                   │
│  - 언제 뭘 호출할지 판단                                      │
│  - 무한 루프 방지 조건 명시                                   │
│  - 에스컬레이션 조건 명시 (3번 실패 → 사람에게)                │
│                                                             │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  Skill 조합 (결정적 Workflow)                          │  │
│  │  - /pr = /commit + lint + push                        │  │
│  │  - 실패 시 종료 (판단은 Agent에게)                      │  │
│  │                                                       │  │
│  │  ┌─────────────────────────────────────────────────┐  │  │
│  │  │  단일 Skill (결정적 Workflow)                    │  │  │
│  │  │  - 절차/분기/에러처리 고정                        │  │  │
│  │  │  - 재시도 횟수, 타임아웃 명시                     │  │  │
│  │  │  - Contents만 비결정적                          │  │  │
│  │  │  - 형태 규격화 + 검증으로 품질 확보              │  │  │
│  │  └─────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

설계 원칙 정리

  1. Workflow와 Contents를 구분한다

    • Contents의 비결정성은 LLM의 본질
    • Workflow의 결정성은 설계로 확보
  2. “결정적”의 의미를 명확히 한다

    • 절차의 결정성: 무엇을 할지 사전 정의
    • 결과의 결정성: 이건 보장 불가
  3. 반복되는 절차는 Skill로 추출한다

    • 절차 + 분기 + 에러 처리를 사전 정의
    • 재시도 횟수, 타임아웃 구체적으로 명시
  4. Skill은 판단하지 않는다

    • 실패 시 종료하거나, 정해진 횟수만큼 재시도
    • 복잡한 판단이 필요하면 Agent에게
  5. Contents는 형태와 품질 두 가지로 관리한다

    • 형태: Guardrail로 규격화 (+ 별도 검증 로직)
    • 품질: 빌드/테스트로 검증 (재시도 횟수 제한)
  6. Agent는 무한 루프 방지 조건을 명시한다

    • N번 실패 시 에스컬레이션
    • 시간 초과 시 중간 보고
    • 불확실할 때 사람에게 선택지 제시
  7. 비용과 복잡성을 고려한다

    • 모든 걸 Skill로 만들 필요 없음
    • 3회 이상 반복될 때 추출

결론

LLM은 본질적으로 비결정적이다. 하지만 그렇다고 LLM 기반 자동화 전체가 비결정적일 필요는 없다.

층위속성대응
Workflow결정적으로 만들 수 있다Skill로 절차 고정, 구체적 에러 처리
Contents 형태규격화할 수 있다Guardrail + 검증 로직
Contents 내용비결정적이다빌드/테스트로 품질 확보 (재시도 제한)
Agent 판단비결정적이다최소화, 무한 루프 방지 조건 명시

절차의 결정성을 확보하면, 자동화는 재사용 가능하고, 조합 가능하고, 디버깅 가능하고, 테스트 가능해진다.

결과의 결정성은 LLM의 본질상 보장할 수 없지만, 형태를 규격화하고 검증을 포함시켜 품질을 관리할 수 있다.

이것이 LLM 시대에 신뢰할 수 있는 자동화를 만드는 방법이다.