지난 글에서는 ChatGPT의 대화를 Memory에만 맡기지 않고, 살아남은 프로젝트 지식을 GitHub와 Obsidian에 남기는 개인 LLM Wiki를 만들었다.
그런데 프로젝트의 상태를 GitHub에 남길 수 있게 되면 곧 다음 질문이 생긴다.
ChatGPT가 GitHub에 변경을 Commit했다면, 그다음 작업도 자동으로 실행할 수 없을까?
예를 들어 ChatGPT가 설정 파일을 수정했다. 사람이 서버에 SSH로 들어가 git pull을 하고, 테스트 스크립트를 실행하고, 결과를 확인한다면 지식 관리는 자동화됐지만 실행은 여전히 사람이 담당하고 있는 셈이다.
가장 단순한 생각은 AI에게 서버 Shell을 직접 열어주는 것이다.
ChatGPT ↓ SSH / Shell ↓ Server
기술적으로는 가능하다.
하지만 운영 환경에서는 다른 문제가 생긴다.
- AI가 정확히 어떤 명령을 실행했는가?
- 어떤 버전의 코드를 기준으로 실행했는가?
- 같은 요청이 두 번 들어오면 어떻게 되는가?
- 실패한 명령을 다시 실행해도 안전한가?
- AI가 허용하지 않은 명령까지 생성하면 어떻게 막을 것인가?
그래서 이번에는 반대로 설계해 본다.
AI에게 실행 권한을 주지 않는다. AI는 변경을 Git에 남기고, Runtime이 그 변경을 해석해 미리 정의된 동작만 실행한다.
Git Commit을 Execution Contract로 사용한다

이번에 만들 구조는 다음과 같다.
ChatGPT ↓ GitHub Commit ↓ GitHub Push Event ↓ n8n ↓ Policy / Routing ↓ FastAPI ↓ OCI Runtime ↓ Test / Validation ↓ PASS / FAIL
여기서 각 컴포넌트의 역할을 명확하게 나눈다.
| Component | Role |
|---|---|
| ChatGPT | 무엇을 변경해야 하는지 판단하고 Git 변경을 생성 |
| GitHub | 변경 이력과 Commit SHA를 보존하는 Control Plane |
| n8n | Event를 받아 정책을 검사하고 적절한 작업으로 Routing |
| FastAPI | 허용된 Action을 실제 Runtime 명령으로 변환 |
| OCI | Test, Validation, Build 같은 결정론적 작업 실행 |
핵심은 ChatGPT가 서버에서 실행할 명령을 만드는 것이 아니라는 점이다.
ChatGPT가 만드는 것은 Change다.
실행 방법은 Runtime이 결정한다.
이번에 만들어 볼 Demo
실습에서는 단순한 시나리오 하나만 구현한다.
ChatGPT 또는 개발자가 특정 GitHub Repository에 변경을 Commit한다.
GitHub의 Push Event가 n8n Workflow를 Trigger하고, n8n은 다음 조건을 검사한다.
- 허용된 Repository인가?
- 허용된 Branch인가?
- 실제 Commit SHA가 존재하는가?
- 변경된 파일이 허용된 경로 안에 있는가?
조건이 맞으면 n8n이 OCI에서 실행 중인 FastAPI에 작업을 요청한다.
FastAPI는 전달받은 문자열을 그대로 Shell에서 실행하지 않는다.
대신 미리 등록한 Action을 실행한다.
validate → pytest build → predefined build script lint → predefined lint command
이번 예제에서는 validate 하나만 사용한다.

Step 1. GitHub Push를 n8n의 Event로 받는다
n8n에서는 GitHub Trigger Node를 사용한다.
GitHub Credential을 연결하고 다음과 같이 설정한다.
Repository Owner: YOUR_GITHUB_ID Repository Name: demo-agent Events: Push
이 Workflow를 활성화한 뒤 Repository에 Commit을 Push하면 Workflow가 실행된다.
Push Event에서는 우리가 Execution Contract를 만드는 데 필요한 몇 가지 값이 전달된다.
repository.full_name ref after commits[]
after는 Push가 완료된 이후 해당 ref가 가리키는 Commit SHA다.
이 값을 사용하는 이유는 중요하다.
“현재 main Branch를 실행하라”가 아니라 “이 Commit을 실행하라”라고 요청해야 실행 결과를 재현할 수 있다.
Step 2. n8n은 단순 Relay가 아니라 Policy Layer가 된다
Webhook이 왔다고 바로 서버를 호출하면 아직 충분하지 않다.
n8n에서 최소한의 Policy를 적용한다.
GitHub Trigger
↓
Normalize / Policy
↓
Allowed?
↙ ↘
NO YES
Stop FastAPI
GitHub Trigger 다음에 Code Node를 하나 추가한다.
예를 들어 다음과 같이 Repository, Branch, Commit SHA와 변경 파일을 검사할 수 있다.
const payload = $json.body ?? $json;
const ALLOWED_REPOSITORY = 'YOUR_GITHUB_ID/demo-agent';
const ALLOWED_REF = 'refs/heads/main';
const repository = payload.repository?.full_name;
const ref = payload.ref;
const commitSha = payload.after;
const commits = payload.commits ?? [];
const changedFiles = [
...new Set(
commits.flatMap((commit) => [
...(commit.added ?? []),
...(commit.modified ?? []),
...(commit.removed ?? []),
])
),
];
const shaIsValid =
typeof commitSha === 'string' &&
/^[0-9a-f]{40}$/i.test(commitSha) &&
!/^0{40}$/.test(commitSha);
const allowedPaths = [
'config/',
'automation/',
'tests/',
];
const filesAreAllowed =
changedFiles.length > 0 &&
changedFiles.every((file) =>
allowedPaths.some((prefix) => file.startsWith(prefix))
);
const allowed =
repository === ALLOWED_REPOSITORY &&
ref === ALLOWED_REF &&
shaIsValid &&
filesAreAllowed;
return [
{
json: {
allowed,
repository,
ref,
commit_sha: commitSha,
changed_files: changedFiles,
action: 'validate',
},
},
];
여기에는 LLM 판단이 하나도 없다.
오히려 그래야 한다.
Repository 이름이나 Branch가 허용되는지 판단하는 것은 Reasoning 문제가 아니라 Policy 문제이기 때문이다.
Agent가 판단해야 할 것과 Code가 판단해야 할 것을 나눈다
Agentic Workflow를 설계하면서 자주 하는 실수 중 하나는 모든 판단을 LLM에 넘기는 것이다.
하지만 다음 조건에 LLM은 필요하지 않다.
repository == allowed repository branch == main commit SHA == valid SHA path startsWith config/ action in allowlist
이런 규칙은 Code가 더 싸고, 빠르고, 예측 가능하다.
반대로 다음과 같은 일에는 AI가 가치가 있다.
- 어떤 설정을 바꿔야 하는가?
- 어떤 코드를 수정해야 하는가?
- 현재 요구사항을 만족하려면 어떤 접근이 적절한가?
즉 이번 구조에서 AI는 Intent와 Change를 담당하고, 시스템은 Policy와 Execution을 담당한다.
Step 3. 허용된 Event만 FastAPI에 전달한다
Code Node 다음에 IF Node를 추가한다.
{{ $json.allowed }} is true
TRUE 경로에서 HTTP Request Node를 연결한다.
FastAPI에는 다음 정도의 데이터만 전달하면 된다.
{
"action": "validate",
"repository": "YOUR_GITHUB_ID/demo-agent",
"commit_sha": "3d7f5e8...",
"changed_files": [
"config/app.json",
"tests/test_config.py"
]
}
여기서 중요한 필드가 하나 빠져 있다.
command다.
다음처럼 설계하지 않는다.
{
"command": "git pull && pytest && docker restart ..."
}
외부 시스템이 Server에서 실행할 Shell 명령을 결정하게 만들면 FastAPI는 사실상 Remote Shell API가 된다.
대신 외부에서는 의미만 전달한다.
{
"action": "validate"
}
그리고 Server가 validate가 실제로 무엇을 의미하는지 결정한다.
Step 4. FastAPI에 Allowlisted Executor를 만든다
FastAPI에서는 Action을 Enum으로 제한한다.
from enum import Enum
from pydantic import BaseModel
class JobAction(str, Enum):
validate = "validate"
class JobRequest(BaseModel):
action: JobAction
repository: str
commit_sha: str
changed_files: list[str] = []
그리고 Repository 역시 서버 쪽 Allowlist에서 다시 검사한다.
ALLOWED_REPOSITORIES = {
"YOUR_GITHUB_ID/demo-agent": {
"path": "/srv/repos/demo-agent",
"actions": {"validate"},
}
}
n8n에서 이미 검사했는데 FastAPI에서 다시 검사하는 이유는 무엇일까?
신뢰 경계를 분리하기 위해서다.
n8n Workflow가 실수로 변경되거나 다른 Client가 FastAPI를 직접 호출하더라도 Execution Layer 자체가 마지막 방어선을 가져야 한다.
Step 5. Branch가 아니라 정확한 Commit을 실행한다

이제 FastAPI가 GitHub에서 전달받은 Commit을 기준으로 작업한다.
현재 Repository 폴더에서 단순히 다음 명령을 실행하는 것만으로는 부족하다.
git pull pytest
git pull을 실행하는 사이 다른 Commit이 main에 들어왔다면 처음 Event를 발생시킨 코드와 다른 코드를 테스트할 수 있기 때문이다.
그래서 실행 기준은 Branch가 아니라 Commit SHA로 고정한다.
Git Worktree를 이용하면 기존 Working Tree를 건드리지 않고 특정 Commit을 별도의 디렉터리에 Checkout할 수 있다.
git -C /srv/repos/demo-agent fetch origin
git -C /srv/repos/demo-agent worktree add
--detach
/tmp/jobs/job-001
3d7f5e8...
이제 Validation은 이 디렉터리에서만 실행한다.
/tmp/jobs/job-001
↓
pytest
작업이 끝나면 Worktree를 제거할 수 있다.
git -C /srv/repos/demo-agent worktree remove
--force
/tmp/jobs/job-001
실제 FastAPI Executor
최소 PoC 형태로 합치면 다음과 같은 구조가 된다.
import re
import shutil
import subprocess
import uuid
from enum import Enum
from pathlib import Path
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class JobAction(str, Enum):
validate = "validate"
class JobRequest(BaseModel):
action: JobAction
repository: str
commit_sha: str
changed_files: list[str] = []
ALLOWED_REPOSITORIES = {
"YOUR_GITHUB_ID/demo-agent": {
"path": Path("/srv/repos/demo-agent"),
"actions": {"validate"},
}
}
@app.post("/jobs")
def create_job(request: JobRequest):
config = ALLOWED_REPOSITORIES.get(request.repository)
if not config:
raise HTTPException(
status_code=403,
detail="repository_not_allowed",
)
if request.action.value not in config["actions"]:
raise HTTPException(
status_code=403,
detail="action_not_allowed",
)
if not re.fullmatch(r"[0-9a-fA-F]{40}", request.commit_sha):
raise HTTPException(
status_code=400,
detail="invalid_commit_sha",
)
job_id = f"job_{uuid.uuid4().hex[:12]}"
repository_path = config["path"]
worktree_path = Path("/tmp/jobs") / job_id
try:
worktree_path.parent.mkdir(
parents=True,
exist_ok=True,
)
subprocess.run(
[
"git",
"-C",
str(repository_path),
"fetch",
"origin",
],
check=True,
timeout=60,
)
subprocess.run(
[
"git",
"-C",
str(repository_path),
"worktree",
"add",
"--detach",
str(worktree_path),
request.commit_sha,
],
check=True,
timeout=60,
)
result = subprocess.run(
[
"python",
"-m",
"pytest",
"-q",
],
cwd=worktree_path,
capture_output=True,
text=True,
timeout=300,
shell=False,
)
return {
"job_id": job_id,
"repository": request.repository,
"commit_sha": request.commit_sha,
"action": request.action,
"status": (
"succeeded"
if result.returncode == 0
else "failed"
),
"exit_code": result.returncode,
"stdout": result.stdout[-10000:],
"stderr": result.stderr[-10000:],
}
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=408,
detail="execution_timeout",
)
except subprocess.CalledProcessError as exc:
raise HTTPException(
status_code=500,
detail=f"execution_failed:{exc.returncode}",
)
finally:
if worktree_path.exists():
subprocess.run(
[
"git",
"-C",
str(repository_path),
"worktree",
"remove",
"--force",
str(worktree_path),
],
check=False,
timeout=30,
)
if worktree_path.exists():
shutil.rmtree(
worktree_path,
ignore_errors=True,
)
이 구현에서 중요한 것은 Python이나 FastAPI 자체가 아니다.
Execution Boundary다.
shell=False- 허용된 Repository만 실행
- 허용된 Action만 실행
- Commit SHA 검증
- 실행 Timeout 설정
- 고정된 테스트 명령 사용
- 격리된 Worktree에서 실행
즉 외부에서 들어오는 데이터가 Shell Command로 변환되는 경로 자체를 차단한다.
Step 6. HTTP 200보다 중요한 것은 Job State다
여기까지 만들면 Demo는 동작한다.
하지만 운영 가능한 시스템으로 가려면 하나를 더 생각해야 한다.
Execution State다.
API가 200 OK를 반환했다는 사실만으로는 충분하지 않다.
최소한 다음 상태를 남길 필요가 있다.
{
"job_id": "job_a83c91f275e1",
"repository": "YOUR_GITHUB_ID/demo-agent",
"commit_sha": "3d7f5e8...",
"action": "validate",
"state": "succeeded",
"started_at": "2026-09-11T12:01:02Z",
"finished_at": "2026-09-11T12:01:18Z",
"exit_code": 0
}
운영 버전에서는 이 정보를 DB나 Job Store에 저장하는 것이 좋다.
그러면 다음 질문에 답할 수 있다.
- 이 Commit은 실제로 실행됐는가?
- 어떤 Validation을 통과했는가?
- 언제 실패했는가?
- 같은 작업이 중복 실행됐는가?
같은 Push Event가 두 번 들어오면?
Event-Driven Architecture에서는 중복 전달도 고려해야 한다.
가장 단순한 방법은 다음 세 값을 하나의 Idempotency Key로 사용하는 것이다.
repository + commit_sha + action
예를 들어:
YOUR_GITHUB_ID/demo-agent
:3d7f5e8...
:validate
이미 같은 Key가 성공 상태로 존재한다면 다시 실행하지 않는다.
이 작은 장치 하나가 자동화를 상당히 안정적으로 만든다.

Webhook을 직접 받을 때는 Signature를 검증한다
이번 예제에서는 n8n의 GitHub Trigger를 사용했다.
하지만 Generic Webhook Node나 자체 FastAPI Endpoint에서 GitHub Webhook을 직접 받는다면 Webhook Secret 검증을 생략하면 안 된다.
GitHub는 Webhook Secret을 이용해 Payload의 HMAC-SHA256 Signature를 생성하고 X-Hub-Signature-256 Header에 전달한다.
따라서 Payload를 처리하기 전에 Signature가 일치하는지 검증하는 경계를 둔다.
GitHub ↓ Signature Validation ↓ Policy Validation ↓ Execution
보안은 실행 직전에 한 번 검사하는 것이 아니라 여러 Trust Boundary에서 반복해서 좁혀가는 방식이 더 안전하다.
왜 그냥 GitHub Actions를 쓰지 않았나?
여기까지 읽으면 당연한 질문이 하나 생긴다.
“Git Push가 Trigger라면 GitHub Actions로 하면 되는 것 아닌가?”
맞다.
단순 CI/CD라면 GitHub Actions가 더 자연스럽다.
이번 구조에서 굳이 n8n을 중간에 둔 이유는 이후 Workflow가 Git 실행만으로 끝나지 않기 때문이다.
예를 들어 다음과 같은 흐름으로 확장할 수 있다.
Git Push ↓ Repository / Path 검사 ↓ Validation ↓ 결과 분기 ├─ PASS → Deploy Workflow ├─ FAIL → Slack / Telegram └─ REVIEW → Human Approval
또는:
Git Event ↓ CRM ↓ Document Pipeline ↓ LLM ↓ Human Approval ↓ External System
즉 GitHub Actions가 Repository Automation에 강하다면, n8n은 Git Event를 더 넓은 업무 Workflow의 일부로 연결할 때 유리하다.
둘 중 하나만 선택해야 하는 것도 아니다.
CI는 GitHub Actions가 담당하고, Business Workflow는 n8n이 담당하는 식으로 경계를 나눌 수도 있다.
AI Automation에서 중요한 것은 Agent 수가 아니다
요즘 AI 시스템을 설계하다 보면 무엇이든 Agent로 만들고 싶은 유혹이 있다.
하지만 이번 Workflow에는 실행 단계에서 LLM이 하나도 없다.
오히려 그것이 이 Architecture의 중요한 특징이다.
Reasoning → LLM State → Git Policy → Code Routing → n8n Execution → Runtime Evidence → Job State
각 문제를 가장 적합한 컴포넌트에게 맡긴다.
이 구조에서는 LLM이 조금 다른 답을 내더라도 Runtime의 행동 범위는 쉽게 바뀌지 않는다.
이것이 내가 Enterprise AI Architecture에서 중요하게 보는 Deterministic Control과 Probabilistic Reasoning의 분리다.
Part 1과 연결하면 구조가 보인다
이전 글에서는 다음 구조를 만들었다.
ChatGPT ↓ Knowledge Promotion ↓ GitHub ↓ Durable Project State
이번에는 한 단계 더 나갔다.
ChatGPT ↓ Change ↓ Git Commit ↓ Event ↓ Deterministic Runtime
두 구조는 사실 같은 원칙을 공유한다.
Memory보다 먼저 State를 설계하고, Agent보다 먼저 Execution Boundary를 설계한다.
AI가 더 많은 권한을 갖게 만드는 것보다, AI의 비결정적 판단과 시스템의 결정적 실행 사이에 명확한 Contract를 만드는 것이 먼저다.
그리고 여기서부터 진짜 Agent가 들어간다
이번 글에서는 의도적으로 Runtime에서 AI를 사용하지 않았다.
하지만 다음 단계에서는 상황이 달라진다.
지금까지 FastAPI가 실행한 것은 미리 정의된 pytest 같은 결정론적 작업이었다.
그 자리에 Codex CLI 같은 Coding Agent를 넣는다면 어떨까?
n8n / Web UI
↓
FastAPI Job
↓
Codex CLI
↓
Git Worktree
↓
Code Change
↓
Tests
↓
Diff / Commit
이제 단순 실행기가 아니라 여러 단계를 스스로 수행하는 Agent가 된다.
그러나 그때도 오늘 만든 경계는 그대로 필요하다.
- Job State
- Worktree Isolation
- Timeout
- Allowed Repository
- Test Gate
- Diff
- Commit
다음 글에서는 이 구조 위에 실제 Coding Agent를 올려볼 예정이다.
Takeaway
AI에게 서버를 주지 않는다.
AI는 Intent와 Change를 만든다.
Git은 State를 남긴다.
n8n은 Event를 Routing한다.
Runtime은 허용된 Contract만 실행한다.
Agentic Workflow의 핵심은 모든 것을 Agent에게 맡기는 것이 아니다.
어디까지 Agent에게 맡기고, 어디서부터 결정론적으로 통제할 것인지 설계하는 것이 운영 가능한 AI 시스템의 시작이다.