로컬 LLM과 n8n으로 구축하는 에러 분석 자동화

  • n8n
  • LLM
  • 최적화
  • 설계
2026-02-19

들어가며

팀 개발 중 팀원의 코드에서 에러가 발생하면, 스택 트레이스를 분석하고 맥락을 재구성하는 초기 탐색에 반복적으로 시간이 들었다. 마침 워크플로우 자동화 도구인 n8n을 접하고 직접 경험해볼 겸, 이 불편함을 자동화로 풀어보기로 했다. 이전 프로젝트에서 Sentry로 에러 모니터링을 하며 스택 트레이스와 유저 액션 정보가 디버깅에 큰 도움이 된다는 걸 느꼈던 경험이 있어서, 에러가 발생하는 즉시 AI가 컨텍스트를 분석하고 초기 가이드라인을 잡아주면 어떨까 하는 아이디어로 이어졌다.

그렇게 만든 게, 프론트엔드에서 에러가 발생하면 n8n이 관련 소스 코드와 스택 트레이스를 수집하고 로컬 LLM(Ollama)이 원인 분석과 수정 제안을 생성해 Notion 데이터베이스에 자동으로 기록하는 파이프라인이다.

  • 에러 발생 후 Notion 리포트가 생성되기까지 평균 20~25초가 소요된다.
  • Git에 푸시되지 않은 로컬 개발 단계에서도 동작한다.
  • Ollama를 로컬에서 구동하기 때문에 코드가 외부로 전송되지 않는다.

기술 선택

n8n은 작업 단계를 노드로 이어 붙여 자동화하는 도구다. 마침 한번 써보고 싶던 참이었고, 내 컴퓨터에 직접 띄울 수 있어 에러 데이터가 외부 서비스를 거치지 않는다는 점도 좋았다. n8n 자체는 Docker 컨테이너로 올렸다.

에러를 분석할 LLM은 Ollama로 로컬에서 돌렸다. 코드가 내 머신 밖으로 나가지 않고 API 비용도 들지 않는다. 모델은 M4 Pro(24GB RAM)에서 분석 품질과 속도가 가장 무난했던 deepseek-coder-v2:16b를 골랐다. 다만 Ollama는 n8n과 달리 컨테이너에 넣지 않고 맥에 직접 설치했다. macOS용 Docker Desktop은 컨테이너에 GPU를 넘겨주지 못해서, 안에 두면 M4의 GPU를 전혀 못 쓰기 때문이다. 대신 n8n 컨테이너가 host.docker.internal, 컨테이너에서 호스트 머신을 가리키는 주소로 바깥의 Ollama를 호출하게 했다.

macOS 호스트 (M4 Pro) DOCKER n8n 워크플로우 Ollama 여기 두지 않음 Ollama deepseek-coder-v2:16b Metal GPU host.docker.internal 11434 포트 Docker Desktop이 GPU를 못 넘김 직접 접근 컨테이너 밖으로 호출 호스트 안에서 처리 막힌 경로
GPU로 가는 경로만 컨테이너 밖으로 빼낸 배치

파이프라인 구현

에러 하나가 Notion 리포트가 되기까지 8단계의 파이프라인을 거친다. 전체 흐름은 크게 세 구간으로 나뉜다. 1구간은 에러 수집과 정제(1-2단계), 2구간은 AI 분석(3-5단계), 3구간은 결과 저장(6-8단계)이다. 각 단계는 다음 단계에 필요한 데이터만 넘기도록 설계해 단계 간 결합도를 낮췄다.

에러 로거 정의

프론트엔드에서 에러가 발생하면 로거가 필요한 정보를 모아 n8n 웹훅으로 보낸다. 처음에는 Sentry를 거쳐 웹훅으로 넘기는 구조를 생각했지만, 어차피 에러 시점의 정보는 직접 정리해야 했다. 그럴 바엔 Sentry를 거칠 이유가 없었다. 직접 만들면 호출 지점 스택이나 error.cause 체인처럼 Sentry 페이로드 스펙에 없는 필드도 원하는 대로 담고, 중간 서버를 하나 덜 거치니 전송도 빨라진다.

다만 같은 에러가 짧은 시간에 연달아 터지면 똑같은 기록이 끝없이 쌓인다. 같은 에러 키가 5초 안에 다시 들어오면 중복으로 보고 버리도록 TTL 기반 메모리 캐시를 두었다.

// 중복 판정 기준 시간 (5초)
const RECENT_ERROR_TTL = 5000
 
// errorKey -> 마지막 발생 타임스탬프
const recentErrors = new Map()
 
/** 동일 에러가 TTL 내에 재발생하면 true 반환 */
function isDuplicate(errorKey) {
  const now = Date.now()
  const lastTimestamp = recentErrors.get(errorKey)
 
  // TTL 이내 동일 키 재진입 시, 중복으로 판정
  if (lastTimestamp && now - lastTimestamp < RECENT_ERROR_TTL) {
    return true
  }
 
  recentErrors.set(errorKey, now)
 
  // TTL 만료 후 해당 키만 제거해 Map이 무한히 커지는 것을 방지
  setTimeout(() => recentErrors.delete(errorKey), RECENT_ERROR_TTL)
 
  return false
}

에러 객체를 그대로 넘기면 AI가 모르는 부분을 지어내기 때문에, 추측할 여지가 없도록 맥락을 미리 갈라 담았다. 스택을 error와 callSite로 나눈 것도 그래서다. 에러가 터진 지점과 그 코드를 호출한 지점은 자주 어긋난다. 유틸 함수 안에서 에러가 났더라도 잘못된 값을 넘긴 쪽은 그 함수를 부른 컴포넌트인 식이다. 둘을 함께 보내면 AI가 양쪽 스택을 오가며 원인을 좁힌다.

ProfileCard 잘못된 값을 넘긴 곳 formatDate() 에러가 터진 곳 undefined 전달 callSite.traces 호출한 지점의 스택 error.traces 터진 지점의 스택 둘을 함께 보내야 원인까지 되짚는다
스택 하나만 보면 값을 잘못 넘긴 쪽이 기록에서 사라진다
function createPayload(error, callSiteStack) {
  return {
    // 에러 객체: 메시지, 스택, cause 체인을 구조화해 담음
    error: {
      message: error.message,
      name: error.name,
      stack: error.stack || '',
      traces: parseStackTrace(error.stack),
      causes: collectCauseChain(error),
    },
    // 호출 지점: 에러 발생 위치와 별도로 실제 호출한 스택을 함께 기록
    callSite: {
      stack: callSiteStack,
      traces: parseStackTrace(callSiteStack),
    },
    // 부가 정보: 발생 페이지, 브라우저, 환경을 리포트에서 바로 파악할 수 있도록 담음
    meta: {
      url: window.location.href,
      userAgent: navigator.userAgent.slice(0, 500),
      timestamp: new Date().toISOString(),
      environment: isDev ? 'development' : 'production',
    },
  }
}

페이지가 닫히거나 전환되는 찰나의 에러도 놓치지 않도록 fetch API의 keepalive: true 옵션을 적용했다. MDN 문서는 이 옵션을 켜면 요청을 시작한 페이지가 언로드되더라도 브라우저가 해당 요청을 중단하지 않는다고 명시한다. 웹훅 전송은 .catch()로 예외를 격리해 네트워크 오류나 n8n 장애가 발생해도 실제 서비스 동작에는 영향을 주지 않도록 했다.

// n8n 웹훅으로 페이로드 전송
fetch(N8N_WEBHOOK_URL, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(payload),
  keepalive: true, // 페이지 언로드 중에도 요청이 완료될 때까지 유지
}).catch((err) => {
  // 개발 환경에서만 콘솔 출력 — 프로덕션에서는 조용히 실패
  if (isDev) console.warn('[logger] Webhook 전송 실패', err.message)
})

에러 정보 파싱

로거가 보낸 스택 트레이스에는 내가 고칠 수 없는 파일이 잔뜩 섞여 있다. node_modules 안쪽이나 번들러가 만들어낸 결과물까지 전부 넘기면 토큰만 쓰고 정작 봐야 할 코드에서 초점이 흐려진다. 그래서 경로에 src/가 들어간 파일만 남기고, 그중에서도 에러와 가장 가까운 3개만 골라 넘겼다.

// 모든 트레이스 합치기 (에러 발생 지점이 앞서도록 순서 유지)
const allTraces = [...(error.traces || []), ...(callSite.traces || [])].slice(0, 3)
 
const seen = new Set()
const srcFiles = []
 
for (const trace of allTraces) {
  if (!trace.file) continue
 
  // 'src/' 경로 포함 여부 확인 후 추출
  const match = trace.file.match(/(src\/.*)$/)
  if (!match) continue
 
  // 중복 제거: 동일한 파일 경로는 한 번만 기록
  const filePath = match[1]
  if (seen.has(filePath)) continue
  seen.add(filePath)
 
  srcFiles.push({
    file: filePath,
    fn: trace.function,
    location: `${trace.line}:${trace.column}`,
    full: `${filePath}:${trace.line}:${trace.column}`,
  })
}

가공된 정보는 프롬프트 설계를 염두에 두고 구조화했다. 제목, 에러 상세, 호출 스택, 환경 정보, 타임스탬프를 명시적으로 분리해두면 이후 프롬프트에서 각 섹션에 필요한 데이터만 골라 주입할 수 있다.

return [
  {
    json: {
      // 노션 페이지 제목 및 Ollama 프롬프트 상단에 사용
      title: `[${error.name || 'Error'}] ${error.message}`,
      // 호출 지점 원본 스택 — 프롬프트의 호출 흐름 섹션에 주입
      callSiteStack: callSite.stack,
      // 에러 상세 — srcFiles, primaryLocation, stackSummary는 파싱 단계에서 추가된 필드
      error: {
        ...error,
        srcFiles, // 분석 대상 파일 목록 (최대 3개)
        primaryLocation, // 에러 발생 주요 위치 (file:line:column)
        stackSummary, // 스택 트레이스 한 줄 요약
      },
      // 발생 환경 — 프롬프트의 환경 섹션 및 노션 속성에 사용
      context: {
        url: meta.url,
        path: getUrlPath(meta.url), // 쿼리스트링 제거한 경로만 추출
        browser: uaInfo.browser,
        os: uaInfo.os,
        userAgent: meta.userAgent,
        env: meta.environment,
      },
      timestamp: meta.timestamp || new Date().toISOString(),
    },
  },
]

에러가 발생한 파일 읽기

고른 파일을 어디까지 읽어 보낼지는 한 번에 정해지지 않았다. 처음에는 에러가 난 줄을 기준으로 위아래 10줄만 잘랐는데, 함수가 길거나 컴포넌트 구조가 복잡하면 맥락이 중간에서 끊겼다. 20줄로 넓히자 함수의 흐름과 변수가 어디서 쓰이는지까지 40줄 안에 대체로 들어왔다.

위아래 10줄 위아래 20줄 에러가 난 줄 함수 앞뒤가 창 밖으로 밀려남 함수 전체가 창 안에 들어옴 함수 전체 범위
창을 좁히면 잘리는 건 에러 줄이 아니라 그 줄을 설명하는 앞뒤다

AI가 어느 줄이 문제인지 바로 짚게 하려고 두 가지를 덧붙였다. 줄마다 앞에 라인 번호를 붙여 몇 번째 줄이라고 답할 수 있게 했고, 에러가 난 줄에는 > 마커를 세워 그 지점을 따로 가리켰다.

if (file.location) {
  // "22:15" 형식에서 라인 번호만 추출
  const targetLine = parseInt(file.location.split(':')[0])
  const lines = sourceCode.split('\n')
 
  // 에러 발생 라인 기준 상하 20줄 — 파일 경계를 벗어나지 않도록 clamp
  const start = Math.max(0, targetLine - 21)
  const end = Math.min(lines.length, targetLine + 20)
 
  codeSnippet = lines
    .slice(start, end)
    .map((line, index) => {
      const currLineNum = start + index + 1
      // 에러 발생 지점에 시각적 마커 추가 — AI가 해당 라인에 집중하도록 유도
      const marker = currLineNum === targetLine ? ' > ' : '   '
      return `${marker}${currLineNum} | ${line}`
    })
    .join('\n')
}

Ollama 프롬프트 작성

프롬프트에는 먼저 시니어 프론트엔드 개발자이자 디버깅 전문가라는 역할을 줬다. 증상만 짚고 끝내는 답 대신 근본 원인부터 찾게 하려는 의도였다. 응답은 원인 분석, 수정 전략, 변경 사항, 수정 코드, 테스트 단계, 위험도까지 필드를 지정해 JSON 하나로 받았다.

그중 fixCode는 파일 전체가 아니라 고쳐야 할 함수나 블록만 쓰게 했다. 멀쩡한 코드까지 다시 뱉으면 출력 토큰만 늘어나고, 정작 리포트에서 어디가 바뀌었는지 찾기 어려워진다.

당신은 시니어 프론트엔드 개발자이자 디버깅 전문가입니다.
제공된 에러 맥락과 소스 코드를 교차 분석하여 근본 원인을 찾고, 노션(Notion) 리포트용 JSON 응답을 생성하세요.
 
## 1. 에러 맥락
 
- 에러명: ${data.title}
- 메시지: ${data.error.message}
- 발생 위치: ${data.error.primaryLocation}
- 호출 흐름: ${data.error.stackSummary}
- 환경: ${data.context.os} / ${data.context.browser} (${data.context.env})
 
## 2. 원인 체인 (error.cause)
 
${causesSection}
 
## 3. 관련 소스 코드 (에러 지점 주변부)
 
${codeContextSection}
 
## 4. 상세 분석 및 응답 요구사항 (중요)
 
- **problemAnalysis**: 단순 현상이 아닌 '왜' 발생했는지 분석하세요. 핵심 키워드에 **볼드**를 사용하세요.
- **fixStrategy**: 어떤 방향으로 고칠지 1~2문장으로 요약하세요.
- **fixContent**: 구체적인 변경 사항을 **반드시 한 줄에 하나씩 글머리 기호('-')를 사용하여 리스트 형태로** 작성하세요. 각 줄 사이에는 줄바꿈을 포함하세요.
- **fixCode**: 수정이 필요한 부분만 **작업 단위(함수 또는 블록)**별로 작성하세요. 전체 파일을 작성하지 마세요. 반드시 변경 전/후의 맥락을 알 수 있는 주석을 포함하세요. 줄 번호는 반드시 포함하지 마세요.
- **riskLevel**: LOW, MEDIUM, HIGH 중 하나를 선택하세요.
- **testSteps**: 검증을 위해 필요한 단계를 배열 형태로 작성하세요.
 
## 5. 응답 형식 (JSON만 출력, 코드 블록 기호 \`\`\`json 금지)
 
{
"summary": "한 줄 요약",
"problemAnalysis": "근본 원인 상세 분석",
"fixStrategy": "해결을 위한 접근 방식",
"fixContent": "- 변경 사항 1\\n- 변경 사항 2\\n- 변경 사항 3",
"fixFilePath": "${primaryFile?.file || "src/..."}",
"fixCode": "// [수정된 함수/블록명]\\nfunction example() {\\n // ... 기존 코드\\n // FIXED: 에러 방지를 위해 null 체크 추가\\n if (data) { \\n // ... 수정된 로직\\n }\\n}",
"testSteps": ["테스트 단계 1", "단계 2"],
"riskLevel": "LOW | MEDIUM | HIGH",
}

같은 에러를 다시 넣었는데 매번 다른 분석이 나오면 리포트를 믿기 어려워진다. 그래서 파라미터 세 개를 조정했다. temperature는 0.1까지 낮췄고, DeepSeek 공식 문서도 코딩과 수학 태스크에는 0.0에 가까운 값을 권장한다. top_p는 0.9로 남겨 표현이 완전히 굳어버리지 않을 만큼의 여지는 뒀다. num_ctx는 8192로 잡았는데, 파일 3개의 코드 스니펫과 스택 트레이스를 함께 넣어도 앞쪽 정보가 잘리지 않는 크기다.

// 정확성과 재현성 우선 — 코딩 태스크에는 0에 가까운 값 권장
const AI_TEMPERATURE = 0.1
 
// 낮은 temperature 보완 — 제한된 범위 내에서 다양성 유지
const AI_TOP_P = 0.9
 
// 코드 스니펫 3개 + 스택 트레이스가 잘리지 않을 충분한 컨텍스트 크기
const AI_NUM_CTX = 8192
 
const body = {
  model: 'deepseek-coder-v2:16b',
  prompt,
  stream: false, // 응답 완료 후 한 번에 수신
  format: 'json', // JSON 형식 강제 — 파싱 실패 방지
  options: {
    temperature: AI_TEMPERATURE,
    top_p: AI_TOP_P,
    num_ctx: AI_NUM_CTX,
  },
}

Ollama 실행

가공한 데이터는 n8n 컨테이너에서 host.docker.internal의 11434 포트로 넘어가, 호스트에 떠 있는 Ollama가 받는다. Ollama를 컨테이너 밖에 둔 이유가 GPU였으니, 모델이 실제로 Metal GPU에 올라가는지도 확인하고 넘어갔다.

GPU 사용 확인
GPU 사용 확인

모델 크기와 프롬프트 길이에 따라 응답까지 수십 초에서 몇 분이 걸린다. 기본 타임아웃으로는 분석이 끝나기 전에 요청이 끊길 수 있어, HTTP Request 노드의 타임아웃을 5분(300000ms)으로 늘려 요청 한 번에 결과를 다 받도록 했다.

"options": {
  "timeout": 300000
}

Ollama 데이터 검증 및 가공

프롬프트에서 JSON 형식을 지정해도 LLM이 항상 그대로 지키지는 않는다. 필드 하나가 통째로 빠지거나, 배열로 와야 할 testSteps가 문자열로 오기도 한다. 그대로 다음 노드에 넘기면 노션 페이지를 만드는 단계에서 터지기 때문에, 필드마다 기본값을 채우고 배열이어야 하는 값은 타입까지 확인한 뒤에 넘겼다.

analysis = {
  summary: parsed.summary,
  problemAnalysis: parsed.problemAnalysis ?? '',
  fixStrategy: parsed.fixStrategy ?? '',
  fixContent: parsed.fixContent ?? '',
  fixCode: parsed.fixCode,
  fixFilePath: parsed.fixFilePath ?? promptData.error?.primaryLocation?.split(':')[0] ?? 'unknown',
  testSteps: Array.isArray(parsed.testSteps) ? parsed.testSteps : [],
  riskLevel: parsed.riskLevel,
}

Notion API 객체 모델로 변환

Notion API는 마크다운을 그대로 받지 않고 자체 블록 구조로 바꿔서 넘겨야 한다. Ollama가 볼드와 불릿을 섞어 돌려준 텍스트를 Notion rich_text 구조로 옮기는 변환 유틸을 직접 만들었다.

Status(진행 상태), Risk Level(위험도), Environment(발생 환경)는 일반 텍스트 대신 select 타입으로 정의했다. 텍스트로 두면 리포트를 하나씩 열어봐야 하지만, select면 노션 대시보드에서 위험도가 높은 것만 걸러 보거나 아직 처리하지 않은 에러만 모아 볼 수 있다.

// 노션 페이지 생성용 JSON 구조
const notionBody = {
  properties: {
    Name: {
      title: [{ text: { content: pageTitle } }],
    },
    Status: {
      select: { name: 'Open' },
    },
    'Risk Level': {
      select: { name: data.riskLevel || 'MEDIUM' },
    },
    Environment: {
      select: { name: data.context?.env || 'unknown' },
    },
    // ...
  },
  // ...
}

노션 페이지 생성 및 기록

마지막 노드는 Notion API를 호출해 페이지를 만든다. 인증 토큰은 n8n에 Credential로 등록해두고 노드에서는 참조만 해서, 워크플로우 어디에도 토큰을 직접 적어두지 않는다. 에러 한 건이 노션 데이터베이스의 페이지 한 장이 되고, 앞 단계에서 만든 블록이 그대로 본문에 들어간다.

{
  "parameters": {
    "method": "POST",
    "url": "https://api.notion.com/v1/pages",
 
    // n8n Credential에 등록된 Notion API 키를 참조 — 토큰 직접 노출 없음
    "authentication": "predefinedCredentialType",
    "genericAuthType": "notionApi",
    "sendHeaders": true,
    "headerParameters": {
      "parameters": [ ... ] // Notion-Version 헤더 등 추가 설정
    },
    "sendBody": true,
    "specifyBody": "json",
 
    // 이전 단계에서 구성한 Notion 블록 구조를 JSON 직렬화해 전달
    "jsonBody": "={{ JSON.stringify($json.notionBody) }}",
    "options": {}
  },
  "name": "Create Notion Issue",
  "type": "n8n-nodes-base.httpRequest",
  "typeVersion": 4.2,
}

최종 결과

에러 한 건이 발생하면 파이프라인이 자동으로 실행되어 Notion 데이터베이스에 새로운 페이지가 생성된다. 리포트는 크게 네 부분으로 구성된다. 에러 요약과 위험도·환경 속성, 상세 스택 트레이스, AI가 제안하는 수정 코드, 검증 체크리스트다.

정렬 및 필터링을 위한 속성들
정렬 및 필터링을 위한 속성들
AI가 분석한 에러 요약본
AI가 분석한 에러 요약본
발생한 에러의 상세 로그와 트레이스
발생한 에러의 상세 로그와 트레이스
AI가 제안하는 코드 수정 방향과 실제 적용 코드
AI가 제안하는 코드 수정 방향과 실제 적용 코드
코드 수정 후 동작 검증 체크리스트
코드 수정 후 동작 검증 체크리스트

정리하며

현재는 에러 종류와 상관없이 20줄로 코드를 잘라 넘기고 있어, 분석 난이도에 따라 컨텍스트 범위를 조절하는 방향을 고민하고 있다. 로컬 모델의 정확도가 아쉬워 유료 API도 생각해봤지만, 코드가 밖으로 나가지 않는다는 전제가 무너지는 만큼 코드 마스킹이나 선택적 전송을 먼저 검토할 생각이다. 운영 측면에서도 Ollama 로딩 지연이나 네트워크 타임아웃 같은 예외 처리가 아직 미흡해, 재시도 전략과 에러 알림 워크플로우를 단계적으로 도입하려 한다.

그럼에도 적은 설정만으로 트리거부터 LLM 분석, Notion 페이지 생성까지 이어지는 흐름을 만들 수 있다는 점은 인상적이었다. 에러 초기 탐색에 5분 이상 걸리던 과정이 20~25초로 줄었고, 그만큼 실제 문제 해결에 쓸 시간이 늘었다. 자동화가 반복 작업을 줄이는 데 그치지 않고 사람이 집중해야 할 곳을 명확하게 만들어준다는 걸 직접 체감했다.