본문 바로가기

시스템/Devops

AI 코드리뷰에 다른 파일을 읽히는 2단계

정찰 모델과 제한된 저장소 도구로 변경 파일 밖의 계약을 확인한다. AI 리뷰가 호출자·타입 정의·다른 모듈의 계약을 놓친다. 변경 파일 밖의 근거 없이 리뷰 결론을 낸다.

전제 환경 — n8n / Node.js / Python / Bitbucket Cloud / Jira

요약

  1. 정찰과 최종 리뷰를 두 호출로 분리
  2. needs 요청을 전체 12개로 제한
  3. 경로 오류를 결과 객체로 격리
  4. 빈 needs에서도 실행 체인 유지
  5. 배포 전 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를 보장하지 않는다.