코드를 짤 것인가, 프롬프트를 짤 것인가
결론부터
LLM 파이프라인을 만들면서 가장 많이 망설인 결정은 "이 부분을 코드로 짤까, 프롬프트로 둘까"였다. 6번쯤 부딪힌 끝에 답이 자리 잡았다 — 분기와 평가가 많은 부분은 자연어로, 결정론적이고 반복적인 부분은 코드로. 그 경계를 모르면, 코드는 매번 깨지고 프롬프트는 매번 흔들린다.
idea-cycles는 6단계의 행동 규칙을 전부 scripts/cycle-prompt.md 한 마크다운 파일에 두고, 코드는 PowerShell 래퍼와 디자인 린트에만 남겼다. 처음엔 단계별로 코드를 짜고 싶었는데, 참았던 게 결과적으로 옳았다. 이 글은 그 결정의 일반론이다.
코드와 프롬프트의 강점은 다르다
같은 일을 코드로도, 프롬프트로도 시킬 수 있을 때 둘은 다른 자리수에서 강하다.
코드의 강점:
- 결정론 — 같은 입력에 같은 출력. 디자인 린트(
lint-design.mjs)가 hex literal을 잡아내는 정규식은 매번 같은 동작이다. - 저비용 반복 — 1만 번 호출해도 비용이 거의 같다. LLM 호출은 비용이 따라 늘어난다.
- 속도 — 정규식 한 번이 토큰 1만 개를 뽑는 LLM 호출보다 1만 배 빠르다.
프롬프트의 강점:
- 분기와 판단 — "이 후보 5개 중 어떤 게 30분짜리로 적합한가"를 코드로 짜는 건 거의 불가능하다. LLM은 그걸 한 줄 rationale과 함께 답한다.
- 자연어 사양의 가독성 — 6단계의 행동 규칙이 마크다운 한 파일이면 6개월 뒤의 자기가 읽고 즉시 동작을 그려낼 수 있다. 같은 걸 if/else 트리로 짜면 따라가기 어렵다.
- 수정 비용 — 프롬프트 한 줄을 고치면 다음날 새벽 5시부터 동작이 바뀐다. 코드라면 빌드·테스트·재시작이 따라온다.
대칭적으로, 약점도 다르다. 코드는 결정 트리가 깊어지면 따라가기 어렵고, LLM의 "어느 정도 비슷한 것"을 처리하지 못한다. 프롬프트는 매 호출마다 결과가 흔들리고, 토큰 비용이 따라 늘어나며, 동일한 입력에도 출력이 미세하게 달라진다.
분기 vs 절차
경계를 짓는 가장 단순한 기준은 "분기·평가"인지 "절차"인지다.
idea-cycles의 6단계를 그 기준으로 잘라보자.
| 단계 | 성격 | 어디에 둘까 |
|---|---|---|
| Tech Scout | 신기술 후보를 찾고 평가 | 자연어 사양(프롬프트) |
| Ideator | 아이디어 5개를 발산 | 자연어 사양 |
| Picker | 5축 점수로 1개 선정 | 자연어 사양 + 강제 JSON 출력 |
| Implementer | 단일 페이지 데모 작성 | 자연어 사양 |
| QA Tester | Playwright MCP로 검증 | 자연어 사양 (동작 결정은 LLM, 검증 도구 호출만 코드) |
| Reviewer | 비평 + data/cycles.json 갱신 | 자연어 사양 |
여섯 단계 모두 분기·평가가 핵심이다. 그래서 자연어 사양에 둔다.
코드로 둔 건 다섯 가지뿐이다 — lint-design.mjs(고정 정규식 검사), run-cycle.ps1(claude CLI 호출 + 종료 코드 fallback alert), data/cycles.json의 fenced JSON 파싱, Stage 마커 stream-json 파싱, 그리고 텔레그램 webhook POST. 결정론적이고 반복적인 영역만 코드.
코드로 짜고 싶은 충동은 거의 항상 "이거 LLM 한테 매번 시키면 비용 들잖아"에서 온다. 비용이 정말 문제일 만큼 호출이 잦다면 그건 옳다. 그런데 매일 한 번 도는 파이프라인이라면 비용 비교가 거의 의미 없다 — LLM의 안정성이 코드의 결정 트리 디버깅 비용보다 싸다.
하이브리드 — 자연어 + 강제 JSON 출력
가장 자주 쓰는 패턴은 자연어 사양 + 강제 JSON 출력이다.
LLM이 free-form 분석을 한 뒤 마지막에 fenced JSON 블록을 emit하도록 강제한다. 다음 단계는 그 JSON을 파싱해서 읽는다. 추론은 자연어로, handoff는 structured로.
Stage 3 (Picker) 출력 끝부분에 강제 emit:
```json
{
"picked": "Pomodoro Garden",
"slug": "pomodoro-garden",
"scores": [
{ "title": "...", "engagement": 5, ... }
],
"rationale": "..."
}
```이 패턴이 강한 이유는 두 자리수에서 강점을 다 쓰기 때문이다. 추론 과정은 LLM의 자연어 능력으로, 다음 단계로 넘어가는 데이터는 코드가 파싱할 수 있는 JSON으로. JSON 안에 모든 후보의 점수가 들어가니까 사후에 "왜 이게 뽑혔지?"를 audit할 수 있는 추적성도 같이 얻는다.
비슷한 하이브리드를 텔레그램 봇에서도 쓴다. 사용자 메시지의 의도 분석은 LLM이 하지만, 그 결과로 호출되는 도구(Read, Edit, Bash, MCP)는 코드 인터페이스다. 의사결정은 자연어, 실행은 코드.
강제 JSON 출력의 핵심은 "파싱 실패 = 단계 실패"로 처리하는 것. JSON이 안 나오면 다음 단계가 못 받으니, 자연스럽게 LLM이 매번 그 형식을 지키게 된다. 명시적 패널티를 안 줘도 자기강제가 된다.
코드로 갈아탈 신호
자연어 사양으로 시작했다가 코드로 갈아타야 할 신호가 몇 가지 있다.
- 호출이 절대적으로 잦다 — 분당 100회 이상 같은 결정을 시킬 거면 비용이 폭주한다. 코드 쪽으로.
- 출력이 항상 같은 형식이고, 분기가 단순하다 — "X면 Y, 아니면 Z" 류의 한두 가지 분기는 if/else가 더 빠르고 안정적이다.
- 결정의 가역성이 낮다 — DELETE / 결제 / 외부 API 호출 같은 부분은 LLM의 미세한 흔들림이 큰 비용으로 이어진다. 코드로 명시하고 LLM은 호출만.
거꾸로, 코드로 짰다가 프롬프트로 갈아타야 할 신호도 있다.
- 분기 트리가 깊어지고, "어느 정도 비슷한 것"을 처리해야 한다 — 자연어 사양이 더 안정적이다.
- 수정이 잦은데 매번 코드 변경 + 빌드 + 재시작이 부담 — 프롬프트 한 줄 고치는 게 1초.
- 같은 결정을 여러 모델이 반복할 거라면 — 코드는 모델 무관하지만, 모델 특화 분기가 들어가면 자연어 사양이 더 깔끔하다.
닫는 글
LLM 파이프라인을 짤 때 가장 흔한 실수는 모든 걸 코드로 짜고 싶어하는 것, 또는 거꾸로 모든 걸 프롬프트에 떠넘기는 것이다. 둘 다 한 자리수만 쓰는 셈이다.
기준 한 줄: 분기와 평가는 자연어로, 결정론과 반복은 코드로. 둘이 만나는 곳에 강제 JSON 출력 패턴을 박으면, LLM의 추론 능력과 코드의 결정론을 동시에 쓸 수 있다.
코드로 옮기고 싶을 때마다 한 번 더 묻자 — "이게 분기인가, 절차인가?" 분기라면 자연어로 두는 게 거의 항상 옳다.