명령 출력의 라벨과 실패 후 확인 경로가 70자 제한으로 잘리던 문제를 재현 필드 상한 확대와 wrongFirst 구조 추가로 고쳤다. 처음 생성된 런북을 열었을 때 기대 출력에는 `37 50.6 731.8`처럼 라벨 없는 숫자만 남아 있었고, 명령이 실패한 뒤 어디를 확인해야 하는지도 알 수 없었다.
전제 환경 — JSON Schema / Python 3 / HTML
한 줄 답 — 런북의 실제 출력이 잘린 원인은 `expect`를 산문처럼 70자로 제한한 것이었고, 출력 상한을 400자로 늘리고 산문 총량 검사를 분리해 해결했다.
화면에 이렇게 찍혔다면
가치가 별로 없는 콘텐츠
어떻게 얽혀 있나
런북 생성과 출력 손실 지점
[작업 노트]
↓
[LLM 구조화 출력]
↓
[필드별 글자 수 제한]
├─ expect > 70자 → 실제 출력과 라벨 손실
├─ wrongFirst 필드 없음 → 오답 기록 폐기
└─ 수정: expect 400자 + wrongFirst 배열
↓
[HTML 렌더링]
↓
[초안의 구조·숫자 검사]
정리하면
- `expect` 상한을 70자에서 400자로 늘린다.
- `why`와 `onFail` 상한을 각각 150자로 늘린다.
- `wrongFirst` 배열을 필수 필드로 추가한다.
- 산문은 재현 필드가 아니라 전체 산문 총량으로 제한한다.
배경
expect는 산문이 아니라 명령 실행 결과를 보존하는 필드다. 이 필드를 70자로 제한하자 현재값, 14일 평균, 최댓값을 구분하던 라벨과 줄바꿈이 잘리고 숫자만 남았다. 처음에는 글이 길어지는 문제를 막으려고 둔 제한이었지만, 초안을 노트와 대조하면서 산문이 아니라 재현 근거를 자르고 있다는 걸 확인했다.
why와 onFail도 70자로 묶여 진단 이유와 실패 후 이동 경로가 사라졌다. 산문 증가는 background와 전체 산문 총량으로 검사하고, 재현에 필요한 필드는 실제 출력이 들어갈 만큼 열어 두는 방식으로 분리했다.
숫자 검사는 길이 검사와 별개다. 기존 초안에서는 노트에 없는 숫자 8건이 탐지됐으며, 계산으로 얻은 값도 명령이 직접 출력하지 않는다면 기대 출력에 넣을 수 없었다.
먼저 의심한 곳
답부터 보면 당연해 보이지만, 처음 몇 시간은 여기에 썼습니다.
| 처음 의심한 것 | 그렇게 본 이유 | 아니라고 판단한 근거 |
|---|---|---|
| 산문화를 막으려면 각 필드를 짧게 제한해야 한다. | 필드마다 70자 상한을 두면 생성된 글이 불필요하게 길어지지 않을 것으로 봤다. | `expect`의 70자 제한은 실제 출력을 `37 50.6 731.8`로 잘랐고, `why`와 `onFail`에서는 진단 이유와 다음 확인 경로가 사라졌다. |
| 전화번호 복호화가 주된 병목이다. | 행마다 수행되는 복호화 작업이 전체 처리 시간을 차지할 것으로 봤다. | 단계별 측정에서 복호화는 전체 54.6초 중 0.546초로 약 1%였다. |
| 유휴 파드의 차가운 캐시가 지연 원인이다. | 첫 실행이 느리면 캐시 워밍 전 상태를 먼저 의심하기 쉽다. | 워밍 후에도 100,000행 처리가 54.6초였고 요청 제한 시간 60초까지 계산상 여유는 5.4초뿐이었다. |
| 에세이 27편을 지우거나 얇은 글 8편을 합치면 애드센스 문제에 대응할 수 있다. | 콘텐츠 수와 글 길이가 `가치가 별로 없는 콘텐츠` 문구의 원인으로 보였다. | 에세이 27편 삭제와 얇은 글 8편 통합을 해도 `가치가 별로 없는 콘텐츠` 문구가 요구하는 내용은 바뀌지 않았다. |
설정 참조
| 키 | 값 | 설명 |
|---|---|---|
why |
70 → 150 |
해당 진단 단계가 필요한 이유의 최대 길이 |
expect |
70 → 400 |
명령 실행 결과와 라벨을 보존하는 최대 길이 |
onFail |
70 → 150 |
실패 후 다음 확인 지점을 적는 최대 길이 |
proseLen |
3200 → 6000 |
전체 산문 총량의 경고 기준 |
wrongFirst |
필수 배열 |
처음 의심한 원인과 관측으로 배제한 근거 |
단계별 실행
1. 스키마에 wrongFirst를 추가하고 재현 필드 상한을 늘린다
이걸 먼저 고치는 이유는 렌더러보다 앞단의 스키마에서 출력과 실패 후 경로가 이미 잘리고 있었기 때문이다. 필드 역할에 맞게 상한을 분리해야 뒤 단계에서도 원문이 남는다.
적용 — 작업 디렉터리에서 A-schema.json 수정본 생성
cd (작업디렉터리)
python3 - <<'PY'
import json
sch=json.load(open('A-schema.json'))
P=sch['properties']
# 1) 「처음엔 이렇게 봤다」 절 신설
P['wrongFirst']={
"type":"array",
"description":"처음에 의심했다가 아니었던 것. 노트의 '처음 틀렸던 판단'을 그대로 옮긴다. 2~4개. 독자가 가장 얻어가는 부분이므로 비우지 말 것.",
"items":{"type":"object","properties":{
"guess":{"type":"string","description":"처음에 무엇을 의심했나. 60자 이내."},
"why":{"type":"string","description":"왜 그렇게 보였나. 경보 문구, 첫인상, 흔한 통념 등. 90자 이내."},
"ruledOut":{"type":"string","description":"무엇을 보고 아니라고 판단했나. 반드시 관측한 수치나 출력을 포함. 160자 이내."}
},"required":["guess","ruledOut"]}}
# 2) 칸 상한을 실제 출력이 들어갈 수 있게 올린다
st=P['steps']['items']['properties']
st['why']['description']="왜 이 단계가 필요한지. 1~2문장, 150자 이내. 배경을 반복하지 말고 이 단계만의 이유를 쓴다."
st['onFail']['description']="실패하면 무엇을 의심하고 어디를 보는가. 1~2문장, 150자 이내. 다음에 볼 곳을 구체적으로."
for phase in ('diagnose','verify'):
st[phase]['properties']['expect']['description']=(
"실행하면 화면에 그대로 찍히는 출력. **명령어마다 반드시 채운다.** 여러 줄이면 줄바꿈 그대로. "
"숫자만 나열하지 말고 무엇을 보는 값인지 알아볼 수 있는 형태로. 400자까지.")
if 'required' in sch and 'wrongFirst' not in sch['required']:
sch['required'].append('wrongFirst')
open('A-schema.new.json','w',encoding='utf-8').write(json.dumps(sch,ensure_ascii=False,indent=2))
print('스키마 필드:', ', '.join(P.keys()))
print('required :', sch.get('required'))
PY
이렇게 나오면 정상
# '스키마 필드:' 줄에 wrongFirst가 포함됐는지 확인
# 'required :' 줄에 wrongFirst가 포함됐는지 확인
실패하면 — `A-schema.new.json`이 생성되지 않으면 `properties.steps.items.properties` 경로와 원본 JSON 구문을 확인한다. 출력의 `required`에 `wrongFirst`가 없으면 기존 required 배열 처리 부분을 확인한다.
2. 생성된 HTML의 절과 코드 구성을 검사한다
`A-schema.new.json`을 생성 입력으로 사용해 새 HTML이 나온 뒤에는 최종 렌더링 결과를 다시 봐야 한다. 스키마에 필드가 있어도 절이 누락되거나 출력이 다시 축약될 수 있기 때문이다.
검증 — 초안 디렉터리에서 최신 HTML 구조 검사
cd ~/private/n8n/files/drafts
f=$(ls -t *.html | head -1); echo "파일: $f"
python3 - "$f" <<'PY'
import sys,re,html as H
h=open(sys.argv[1],encoding='utf-8').read()
art=re.search(r'<article>([\s\S]*?)</article>',h)
a=art.group(1) if art else h
print('h2:', ' > '.join(re.findall(r'<h2>([^<]*)</h2>',a)))
print('h3:', len(re.findall(r'<h3>',a)),'개 pre:',len(re.findall(r'<pre',a)),' table:',len(re.findall(r'<table',a)))
prose=re.sub(r'(?is)<pre.*?</pre>','',a); prose=re.sub(r'<[^>]+>','',prose)
code=re.sub(r'<[^>]+>','',''.join(re.findall(r'(?is)<pre[^>]*>(.*?)</pre>',a)))
print('산문',len(re.sub(r'\s','',prose)),'/ 코드',len(re.sub(r'\s','',code)))
print()
print('── 절차 단계별 구성 ──')
for m in re.finditer(r'<h3>([^<]*)</h3>([\s\S]*?)(?=<h3>|<h2>|$)',a):
body=m.group(2)
tags=re.findall(r'<strong>([^<—]{1,12})',body)
pres=len(re.findall(r'<pre',body))
print(f' {m.group(1)[:30]:<32} 코드 {pres}개 {tags[:5]}')
PY
이렇게 나오면 정상
# 파일명이 표시되는지 확인
# h2 목록에 처음 틀렸던 판단을 렌더링한 절이 있는지 확인
# 각 절차 단계의 코드 수와 diagnose·apply·verify 구성이 표시되는지 확인
실패하면 — `wrongFirst`에 해당하는 절이 없으면 새 스키마가 생성 입력에 사용됐는지 확인한다. 단계별 출력에서 코드가 있어야 할 diagnose·apply·verify가 빠졌으면 해당 필드의 렌더링 부분을 확인한다.
전후 비교
기존 필드 제한과 수정한 스키마 설명
고치기 전
const CAPS = { why: 70, expect: 70, onFail: 70, ... } // 산문화 막으려고 걸었던 것
if (proseLen > 3200) warnings.push(...)
고친 뒤
st['why']['description']="왜 이 단계가 필요한지. 1~2문장, 150자 이내. 배경을 반복하지 말고 이 단계만의 이유를 쓴다."
st['onFail']['description']="실패하면 무엇을 의심하고 어디를 보는가. 1~2문장, 150자 이내. 다음에 볼 곳을 구체적으로."
for phase in ('diagnose','verify'):
st[phase]['properties']['expect']['description']=(
"실행하면 화면에 그대로 찍히는 출력. **명령어마다 반드시 채운다.** 여러 줄이면 줄바꿈 그대로. "
"숫자만 나열하지 말고 무엇을 보는 값인지 알아볼 수 있는 형태로. 400자까지.")
완료 확인
- `A-schema.new.json`의 required 배열에 `wrongFirst`가 있다.
- `expect` 설명에 400자와 명령어마다 반드시 채운다는 조건이 있다.
- `why`와 `onFail`의 상한이 각각 150자다.
- 최종 HTML의 기대 출력에 값의 의미를 구분하는 라벨과 줄바꿈이 남아 있다.
- 초안의 숫자를 노트와 대조했을 때 출처 없는 숫자가 없다.
문제 해결
| 증상 | 원인 | 해결 |
|---|---|---|
| 37 50.6 731.8 | `expect: 70자` 제한으로 라벨과 줄바꿈이 잘림 | `expect` 상한을 400자로 늘리고 화면에 찍히는 출력을 줄바꿈 그대로 넣는다. |
| 실패하면 무엇을 의심하고 어디를 보는가. 한 문장, 70자 이내. | `onFail` 제한으로 다음 확인 지점이 생략됨 | `onFail` 상한을 150자로 늘리고 다음에 볼 곳을 구체적으로 적는다. |
| 0.546, 5.4, 1.0, 118200000, 112.7, 9.5 | 노트 원문과 계산값 또는 생성된 숫자를 구분하지 않음 | 초안의 숫자를 노트와 대조하고 명령이 출력하지 않는 계산값은 expect에서 제거한다. |
되돌리기
문제가 생기면 필드 상한과 산문 총량 경고를 기존 값으로 복원하고 wrongFirst 필수 조건을 제거한다.
const CAPS = { why: 70, expect: 70, onFail: 70, ... } // 산문화 막으려고 걸었던 것
if (proseLen > 3200) warnings.push(...)
정리
- 명령 출력 필드의 품질은 글자 수가 아니라 독자가 각 값의 의미를 식별할 수 있는지로 판단한다.
- 산문 총량 제한과 재현 필드 제한을 분리한다.
- 계산 가능한 숫자라도 명령이 직접 출력하지 않으면 기대 출력에 넣지 않는다.
- 코드 블록 수와 전체 글자 수는 실패 후 다음 확인 지점이 보존됐는지를 대신하지 못한다.
숫자로 보면
- 70자 — 기존 스키마는 `why`, `expect`, `onFail`을 각각 70자로 제한했다.
- 150자, 400자 — 수정 후 `why`와 `onFail`은 150자, `expect`는 400자로 늘어났다.
- 3,200자 → 6,000자 — 산문 총량 경고 기준은 3,200자에서 6,000자로 바뀌었다.
- 9,280자, 28개 — 기존 초안은 산문 9,280자와 코드 블록 28개였지만 실제 출력의 라벨이 잘려 있었다.
- 8건 — 초안 검증에서 노트에 없는 숫자 8건이 탐지됐다.
- 0.546초 — 복호화는 전체 처리 시간 54.6초 중 0.546초로 약 1%였다.
- 54.6초, 60초 — 워밍 후에도 100,000행 처리에 54.6초가 걸렸고 요청 제한 시간은 60초였다.
- 500.15, 783, 2.4GiB, 94, 0.57, 731.8, 50.6 — major fault 관련 기록과 초안 대조에 사용한 실측값은 500.15, 783, 2.4GiB, 94, 0.57, 731.8, 50.6이었다.
자주 묻는 것
런북 expect에 숫자만 남고 무슨 값인지 알 수 없을 때 어떻게 고치나요?
`expect`의 70자 제한을 400자로 늘립니다. 화면에 찍히는 라벨과 줄바꿈을 그대로 보존하고 숫자만 나열하지 않도록 스키마 설명도 함께 바꿉니다.
런북 필드를 짧게 제한하면 왜 문제가 되나요?
재현 필드는 산문이 아니라 실행 근거를 담기 때문입니다. 이 사례에서는 70자 제한이 출력 라벨과 실패 후 다음 확인 경로를 제거했습니다.
계산한 숫자를 기대 출력에 넣어도 되나요?
이 런북의 기대 출력에는 넣지 않습니다. `60 − 54.6 = 5.4`처럼 계산 가능한 값이라도 명령이 직접 출력하지 않으면 독자가 실행해서 같은 출력을 얻을 수 없습니다.
런북 품질을 코드 블록 수나 글자 수로 판단해도 되나요?
판단할 수 없습니다. 산문 9,280자와 코드 블록 28개가 있던 초안도 출력 라벨이 잘렸고 흔한 오답 기록이 없어서 따라가기 어려웠습니다.
'시스템 > Devops' 카테고리의 다른 글
| LIMIT 6 집계 비용 분리 런북 (0) | 2026.09.23 |
|---|---|
| AI 런북을 숫자 계약으로 교정하는 법 (0) | 2026.09.21 |
| AI 코드리뷰에 다른 파일을 읽히는 2단계 (0) | 2026.09.18 |
| 최종 분포에 가려진 변화 방향 복원 (0) | 2026.09.09 |
| uv sync 후 pytest가 사라졌을 때 복구 방법 (0) | 2026.08.07 |