정찰 모델과 제한된 저장소 도구로 변경 파일 밖의 계약을 확인한다. AI 리뷰가 호출자·타입 정의·다른 모듈의 계약을 놓친다. 변경 파일 밖의 근거 없이 리뷰 결론을 낸다.
전제 환경 — n8n / Node.js / Python / Bitbucket Cloud / Jira
요약
- 정찰과 최종 리뷰를 두 호출로 분리
- needs 요청을 전체 12개로 제한
- 경로 오류를 결과 객체로 격리
- 빈 needs에서도 실행 체인 유지
- 배포 전 jsCode 문법 검사
원인
기존 리뷰 입력에는 PR 제목·설명, Jira 요구사항, 리포 규약, context=30 diff와 변경 후 파일 소스가 이미 포함돼 있었다. 실행 데이터에서 프롬프트는 14,126자, 13,642자, 10,002자, 7,934자로 측정됐고, 한 사례에서는 6,124자의 소스가 전체의 43%였다. 900줄 이하 파일은 전문을 넣었으며, 1,031줄 파일은 910~1031줄만 포함됐다. 따라서 문제는 diff 전용 입력이 아니라 변경된 파일 밖으로 이동하지 못하는 범위 제한이었다.
전체 저장소를 한 번에 주입하는 대신 첫 모델이 needs로 필요한 경로와 연산을 고르고, 저장소 도구가 get_file 또는 list_dir 결과를 두 번째 리뷰에 제공한다. 모델이 없는 경로를 추정할 수 있으므로 개별 실패는 { ok: false, op, path, error }로 합친다. 전체 요청은 12개, 변경 파일당 5개로 제한하고 노이즈 파일과 상위 경로 탈출을 차단한다. 빈 요청도 한 항목으로 전달해 n8n 체인을 보존하며, 활성 Code 노드 반영 전에는 정규식 조작 결과가 아닌 실제 jsCode 문법을 검사한다.
전체 그림
2단계 저장소 컨텍스트 수집
[PR created]
|
[게이트] -> [diff + 변경 후 소스]
| |
+-------> [1차 정찰: needs[]]
| 없음
+----------------------+
| 있음 |
[상한·경로·노이즈 필터] |
| |
[get_file | list_dir] |
| | |
[성공] [경로 오류: 격리] |
+--------------+---------------+
|
[2차 리뷰: 컨텍스트 병합]
|
[인라인 코멘트 + Markdown]
전후 비교
단일 리뷰와 정찰 기반 2단계 리뷰
고치기 전
const input = {
pr, jira, rules,
diff: diffWithContext(30),
changedSource
};
// 문제: 변경 파일 밖의 호출자·타입·계약을 읽지 못함
return reviewModel(input);
고친 뒤
const base = collectChangedContext(30);
const scout = await scoutModel(base);
const requests = limitAndNormalize(scout.needs, 12, 5);
const extra = await repositoryTool(requests);
// 변경: 선택된 다른 파일과 개별 경로 오류를 2차 입력에 병합
return reviewModel({ ...base, additionalContext: extra });
절차
1. 현재 프롬프트 범위를 측정한다
diff 전용 문제인지 변경 파일 경계 문제인지 구분한다.
점검 — Python 셸에서 프롬프트 길이와 범위 측정
import os
import re
prompt = os.environ['PROMPT']
src_m = re.search(r'\(전체 (\d+)줄 중 (\d+)~(\d+)줄만 표시\)', prompt)
full = '전체' if not src_m else (
f'{src_m.group(2)}~{src_m.group(3)} / 전체 {src_m.group(1)}줄'
)
print(f'프롬프트 총 {len(prompt):,}자')
print(f'소스 범위: {full}')
출력 예
프롬프트 총 7,934자
소스 범위: 910~1031 / 전체 1031줄
실패하면 — 실행 데이터에서 최종 프롬프트 문자열 추출 단계를 확인한다.
2. 정찰 응답을 제한된 요청으로 바꾼다
모델 선택권을 유지하면서 저장소 조회량을 제한한다.
점검 — n8n Code 노드에서 상한 확인
const totalLimit = 12;
const perFileLimit = 5;
const rows = $input.all();
console.log(`limits total=${totalLimit} per_file=${perFileLimit}`);
console.log(`scout_items=${rows.length}`);
return rows;
출력 예
limits total=12 per_file=5
실행 — n8n Code 노드에서 needs 요청 생성
const totalLimit = 12;
const perFileLimit = 5;
const files = $input.all().map((item) => item.json);
const changed = new Set(files.map((file) => file.path));
const noise = /(^|\/)(dist|build|out|target|node_modules|vendor|coverage|\.next|__snapshots__)\/|\.(lock|snap|min\.js|min\.css|map|png|jpe?g|svg|gif|ico|woff2?)$/i;
const seen = new Set();
const requests = [];
for (const file of files) {
let used = 0;
for (const need of file.needs ?? []) {
const path = String(need.path ?? '').trim();
if (!path || changed.has(path) || noise.test(path)) continue;
if (seen.has(path) || used >= perFileLimit) continue;
seen.add(path);
used += 1;
requests.push({
op: need.op === 'list_dir' ? 'list_dir' : 'get_file',
path
});
if (requests.length >= totalLimit) break;
}
if (requests.length >= totalLimit) break;
}
if (requests.length === 0) {
console.log('requests=1 placeholder=true');
return [{ json: { skip: true, requests: [] } }];
}
console.log(`requests=${requests.length} placeholder=false`);
return requests.map((request) => ({ json: request }));
출력 예
requests=1 placeholder=true
확인 — n8n Code 노드에서 빈 needs 분기 검증
const items = $input.all();
const first = items[0]?.json ?? {};
const kept = items.length >= 1;
console.log(`chain_items=${items.length}`);
console.log(`placeholder=${first.skip === true}`);
console.log(`chain_kept=${kept}`);
return items;
출력 예
chain_items=1
placeholder=true
chain_kept=true
실패하면 — needs JSON 형식과 변경 파일별 요청 집계를 확인한다.
3. 저장소 요청 경로를 정규화한다
추정 경로와 상위 탈출 문자를 도구 호출 전에 차단한다.
점검 — n8n Code 노드에서 원본 경로 검사
const rows = $input.all().map((item) => item.json ?? {});
for (const row of rows) {
console.log(`op=${row.op ?? ''} path=${row.path ?? ''}`);
}
console.log(`input_count=${rows.length}`);
return $input.all();
출력 예
op=get_file path=../<추정 경로>?raw=1
input_count=1
실행 — n8n Code 노드에서 경로와 요청 수 제한
const requestLimit = 12;
const out = [];
for (const item of $input.all().slice(0, requestLimit)) {
const request = item.json ?? {};
const op = request.op === 'list_dir' ? 'list_dir' : 'get_file';
let path = String(request.path ?? '')
.trim()
.replace(/^\/+/, '')
.replace(/[?#]/g, '');
if (path.split('/').some((segment) => segment === '..')) path = '';
const bad = !request.workspace || !request.repoSlug || !request.ref || !path;
if (bad) {
out.push({ json: { ok: false, op, path, error: 'invalid request' } });
continue;
}
out.push({ json: { ...request, op, path } });
}
console.log(`accepted=${out.filter((item) => item.json.ok !== false).length}`);
console.log(`rejected=${out.filter((item) => item.json.ok === false).length}`);
return out;
출력 예
accepted=0
rejected=1
확인 — n8n Code 노드에서 차단 결과 확인
const rows = $input.all().map((item) => item.json);
const invalid = rows.filter((row) => row.error === 'invalid request');
const escaped = rows.filter((row) => String(row.path).split('/').includes('..'));
console.log(`invalid=${invalid.length}`);
console.log(`parent_escape=${escaped.length}`);
console.log(`error=${invalid[0]?.error ?? ''}`);
return $input.all();
출력 예
invalid=1
parent_escape=0
error=invalid request
실패하면 — workspace·repoSlug·ref·path 누락과 쿼리 제거 결과를 확인한다.
4. 저장소 조회를 배치로 전달한다
한 서브워크플로우에서 두 연산과 여러 요청을 처리한다.
점검 — n8n Code 노드에서 연산 종류 확인
const operations = [...new Set(
$input.all().map((item) => item.json.op).filter(Boolean)
)].sort();
console.log(`operations=${operations.join(',')}`);
console.log(`request_count=${$input.all().length}`);
return $input.all();
출력 예
operations=get_file,list_dir
실행 — n8n Code 노드에서 요청 배치 구성
const requestLimit = 12;
const requests = $input.all()
.slice(0, requestLimit)
.map((item) => ({
op: item.json.op,
path: item.json.path,
workspace: item.json.workspace,
repoSlug: item.json.repoSlug,
ref: item.json.ref
}));
console.log(`batch_count=${requests.length}`);
console.log(`within_limit=${requests.length <= requestLimit}`);
return [{ json: { requests } }];
출력 예
within_limit=true
확인 — 저장소 결과에서 개별 실패 격리
const results = $input.all().flatMap((item) => item.json.results ?? []);
const normalized = results.map((result) => result.ok === false ? {
ok: false,
op: result.op,
path: result.path,
error: result.error
} : result);
console.log(`results=${normalized.length}`);
console.log(`isolated_errors=${normalized.filter((r) => r.ok === false).length}`);
console.log(`workflow_failed=false`);
return normalized.map((json) => ({ json }));
출력 예
workflow_failed=false
실패하면 — 정규화 결과가 12개를 넘거나 op가 변형됐는지 확인한다.
5. 추가 컨텍스트를 최종 리뷰에 합친다
성공한 파일과 경로 오류를 같은 최종 판단 근거로 전달한다.
점검 — n8n Code 노드에서 저장소 결과 분류
const rows = $input.all().map((item) => item.json);
const success = rows.filter((row) => row.ok !== false);
const errors = rows.filter((row) => row.ok === false);
console.log(`success=${success.length}`);
console.log(`errors=${errors.length}`);
console.log(`mergeable=${Array.isArray(rows)}`);
return $input.all();
출력 예
mergeable=true
실행 — n8n Code 노드에서 2차 입력 구성
const base = $('변경 컨텍스트').first().json;
const repositoryResults = $input.all().map((item) => item.json);
const additionalContext = repositoryResults.map((result) => ({
ok: result.ok !== false,
op: result.op,
path: result.path,
content: result.content ?? '',
error: result.error ?? ''
}));
const finalInput = {
...base,
additionalContext
};
console.log(`additional_context=${additionalContext.length}`);
console.log('review_pass=2');
return [{ json: finalInput }];
출력 예
review_pass=2
확인 — n8n Code 노드에서 오류 포함 여부 확인
const payload = $input.first().json;
const context = payload.additionalContext ?? [];
const malformed = context.filter((item) => item.ok === false && !item.error);
console.log(`context_items=${context.length}`);
console.log(`malformed_errors=${malformed.length}`);
console.log(`ready=${malformed.length === 0}`);
return $input.all();
출력 예
malformed_errors=0
ready=true
실패하면 — 1차 needs JSON 파싱과 저장소 결과 배열 형태를 확인한다.
6. 문법과 코멘트 렌더링을 검사한다
활성 Code 노드 오류와 Bitbucket HTML 제거를 배포 전에 잡는다.
점검 — 셸에서 생성 코드와 HTML 태그 검사
set -eu
node --check generated-code-node.js
grep -E '<details>|</details>|<sub>|</sub>' comment.md || true
printf 'diagnose_exit=%s\n' "$?"
출력 예
diagnose_exit=0
실행 — 셸에서 문법 게이트와 Markdown 적용
set -eu
node --check generated-code-node.js
node test_gate.mjs "<exported-workflow.json>"
cat > comment.md <<'MARKDOWN'
<!-- 중복 방지 마커 -->
## AI 코드 리뷰
| 구분 | 결과 |
|---|---|
| 추가 컨텍스트 | 저장소 조회 결과 포함 |
```text
경로 오류는 리뷰 실패와 분리
```
MARKDOWN
printf 'gate_exit=%s\n' "$?"
출력 예
gate_exit=0
확인 — 셸에서 Markdown과 마커 검증
set -eu
marker_count=$(grep -c '<!-- 중복 방지 마커 -->' comment.md)
html_count=$(grep -Ec '<details>|</details>|<sub>|</sub>' comment.md || true)
printf 'marker_count=%s\n' "$marker_count"
printf 'unsupported_html=%s\n' "$html_count"
node --check generated-code-node.js
printf 'syntax_exit=%s\n' "$?"
출력 예
marker_count=1
unsupported_html=0
syntax_exit=0
실패하면 — 생성된 jsCode와 Markdown 내 details·sub 태그를 확인한다.
마무리 점검
- 1차 응답의 needs가 JSON 배열
- 전체 요청 수가 12개 이하
- 변경 파일별 요청이 5개 이하
- 경로 오류가 리뷰를 중단하지 않음
- 빈 needs에서도 2차 리뷰 실행
- 코멘트가 표·헤딩·코드 블록으로 표시
파라미터
| 이름 | 값 | 무엇을 정하는가 |
|---|---|---|
context |
30 |
diff 전후 문맥 줄 수 |
needs |
JSON 배열 |
1차 정찰이 요청한 추가 파일 목록 |
MAX_TOTAL |
12 |
정찰 단계의 전체 요청 상한 |
MAX_PER_FILE |
5 |
변경 파일 하나당 요청 상한 |
MAX_REQUESTS |
12 |
저장소 도구 호출 직전 상한 |
op |
get_file | list_dir |
저장소 도구의 허용 연산 |
안 될 때
| 증상 | 원인 | 해결 |
|---|---|---|
| No such file or directory: <추정 경로> | 모델이 추정한 저장소 경로 | 실패를 ok:false 결과로 반환하고 리뷰를 계속하라 |
| 1차 응답 뒤 n8n 체인이 끊김 | needs 0건으로 출력 항목 소멸 | skip 표시가 있는 최소 한 항목을 전달하라 |
| Code 노드 문법 오류 | 주석 끝에 붙은 }; 또는 정규식 코드 조작 | PUT 전에 node --check와 test_gate를 실행하라 |
| PR 코멘트의 details·sub 표시가 사라짐 | Bitbucket Cloud의 임의 HTML 제거 | 표·헤딩·코드 블록 Markdown으로 교체하라 |
| 리뷰가 diff만 본다고 판단됨 | 프롬프트 실행 데이터 미측정 | 소스 범위와 총 글자 수를 측정해 경계를 다시 정하라 |
원상복구
직전 export를 복원하고 Code 노드 문법 게이트를 통과한 뒤 활성화한다.
set -eu
cp "<이전-exported-workflow.json>" "<exported-workflow.json>"
node test_gate.mjs "<exported-workflow.json>"
node --check generated-code-node.js
요점
- 프롬프트 범위는 diff 포함 여부보다 변경 파일 밖의 계약 접근 여부로 판단한다.
- 모델이 고른 경로는 신뢰하지 말고 정규화·상한·개별 실패 격리를 적용한다.
- 저장된 HTML 마커와 사용자에게 보이는 렌더링 결과를 별도로 검증한다.
- 설정 보존이나 정규식 조작은 유효한 JavaScript를 보장하지 않는다.
'시스템 > Devops' 카테고리의 다른 글
| LIMIT 6 집계 비용 분리 런북 (0) | 2026.09.23 |
|---|---|
| AI 런북을 숫자 계약으로 교정하는 법 (0) | 2026.09.21 |
| 최종 분포에 가려진 변화 방향 복원 (0) | 2026.09.09 |
| uv sync 후 pytest가 사라졌을 때 복구 방법 (0) | 2026.08.07 |
| Streamlit 리런 뒤 선택 탭을 유지하는 방법 (0) | 2026.08.03 |