Showing Posts From
학습
- 19 Jun, 2026
Sentry 에러를 AI가 자동으로 수정하는 파이프라인 설계하기
서비스를 운영하다 보면 Sentry 알림을 받고, 코드를 열고, 스택트레이스를 보고, 원인을 파악하고, 수정하고, PR을 올리는 과정을 반복하게 된다. 이 흐름 자체는 단순하지만 반복적이다. Claude Code가 이 과정을 대신할 수 있지 않을까? 라는 생각으로 설계를 해보았다. 이 글은 Sentry 에러 발생 → 자동 분석 → PR 생성까지 이어지는 파이프라인을 설계하고 구축한 경험을 다룬다. 구현 세부사항보다는 왜 이런 구조를 선택했는지, 각 설계 결정의 과정을 다루고 있다. 기존 흐름: Sentry 알림 → Slack → 사람이 수동으로 수정 자동화를 떠올린 건 결국 기존 흐름이 불편했기 때문이다. 원래 대응 과정은 이랬다. stage 환경에서 프론트엔드 에러가 발생하면 Sentry가 이를 잡아내고, Alert 연동을 통해 Slack 채널(#코인_오류_front_end_stage)로 알림을 보낸다. 채널에는 ReferenceError, TypeError 같은 에러들이 시간순으로 쌓인다.여기까지는 자동이다. 문제는 그다음부터다. 개발자는 Slack 알림을 보고 → Sentry 이슈 페이지로 들어가 → 스택트레이스와 브레드크럼을 읽고 → 원인이 될 만한 소스 파일을 찾고 → 수정하고 → PR을 올린다.위 이슈(ReferenceError: dtrum is not defined)처럼, 한 건을 처리하려면 Sentry 화면에서 스택트레이스(app:///gtm.js:739:429 …), 발생 URL, 환경, 브레드크럼을 일일이 눈으로 훑어야 한다. 흐름 자체는 단순하지만 알림을 받는 일만 자동이고, 그 뒤의 분석과 수정은 전부 사람 몫이다. 그래서 stage처럼 에러가 자주 쌓이는 환경에서는 알림은 빠르게 차오르는데 대응은 계속 밀린다. 이 "Slack 알림 이후의 반복 노동"을 누가 대신할 수 있을까? 그게 이 파이프라인의 출발점이었다. Sentry가 Slack까지 보내주듯, 그 알림을 사람 대신 Claude Code가 받아서 분석하고 PR까지 만들면 되지 않을까 생각이 들었다. 시작 - 복잡한 설계 처음에는 이런 구조로 시작했다. Sentry → Vercel (webhook receiver) → GitHub repository_dispatch → GitHub Actions → Oracle Instance → PR각 단계가 생겨난 데는 나름의 이유가 있었다. Vercel이 webhook receiver가 된 이유. Sentry는 webhook을 보낼 public HTTPS endpoint가 필요하다. 가장 빠른 선택은 Vercel이었다. 배포만 하면 자동으로 HTTPS가 붙고, 서버 관리가 필요 없다. Oracle 인스턴스에 직접 포트를 열고 SSL을 설정하는 것보다 훨씬 빠르게 시작할 수 있었다. GitHub Actions가 끼어든 이유. Vercel이 webhook을 받고 나서 Oracle을 직접 호출하려면 Oracle에도 public endpoint가 있어야 한다. 하지만 Oracle 인스턴스는 외부에 노출돼 있지 않았다. 이 문제를 해결하는 방법으로 역방향 연결 패턴을 선택했다. Vercel이 GitHub에 repository_dispatch 이벤트를 발행하면, Oracle의 GitHub Actions runner가 GitHub를 polling하다가 이벤트를 감지해서 실행하는 구조다. Vercel ──→ GitHub (이벤트 발행) ↑ Oracle ────────── GitHub Actions runner가 pollingOracle이 외부에 노출되지 않아도, GitHub를 중계 채널로 써서 트리거를 받을 수 있었다. 각 단계는 나름의 이유로 추가됐지만, 결과적으로 아무런 비즈니스 로직 없이 데이터를 전달하기만 하는 단계들이 쌓였다. 최종: Oracle 인스턴스 하나로 줄이기 불필요한 중간 단계를 제거하면 답은 명확했다. Sentry → Oracle Instance → PROracle 인스턴스가 webhook을 직접 받아서 Claude Code를 실행하면 된다. Vercel도, GitHub Actions도 필요 없다. 이 구조가 가능한 이유는 단순하다. Claude Code CLI는 결국 로컬(혹은 서버)에서 실행되는 프로세스다. 굳이 GitHub Actions라는 CI 환경을 거칠 이유가 없었다. webhook을 받는 주체와 Claude Code를 실행하는 주체를 하나로 합치자, 중계만 하던 단계들이 통째로 사라졌다. 설계 결정 1: Oracle 인스턴스에 직접 public endpoint 열기 Sentry가 webhook을 보내려면 공개적으로 접근 가능한 HTTPS endpoint가 필요하다. 방법은 두 가지였다. Option A: Oracle 인스턴스에 직접 HTTPS 열기nginx + Let's Encrypt SSL Oracle Security List와 OS 방화벽(nftables)에서 443 포트 오픈 SSL 갱신, 포트 관리 등 직접 운영Option B: Cloudflare Tunnel인스턴스에서 cloudflared 데몬 하나만 실행 포트 오픈, SSL 설정 불필요 Cloudflare 의존성 추가A를 선택한 이유. 외부 서비스 의존성을 추가하지 않으면서 직접 제어할 수 있는 구조가 더 낫다고 판단했다. SSL 갱신은 certbot이 자동으로 처리해주기 때문에 실제 운영 부담은 크지 않다. 설계 결정 2: webhook을 어디까지 믿을 것인가 public endpoint를 열었다는 것은, 그 주소를 아는 누구나 POST 요청을 보낼 수 있다는 뜻이다. 이 요청 하나가 곧바로 Claude Code 프로세스를 띄우고 git push까지 이어지기 때문에, 입력을 검증하는 단계가 생각보다 중요했다. 시크릿을 두 경로로 받기. Sentry의 webhook은 강력한 서명(HMAC) 검증을 강제하지 않는다. 그래서 공유 시크릿 방식을 택했는데, 이걸 헤더(x-webhook-secret)와 쿼리 파라미터(?secret=...) 두 경로 모두에서 받도록 했다. Sentry의 Alert 연동 UI는 버전과 연동 방식에 따라 커스텀 헤더를 못 넣는 경우가 있어서, 헤더가 막히면 URL에 시크릿을 붙이는 쪽으로 우회할 수 있게 한 것이다. 둘 중 하나라도 일치하지 않으면 401을 반환한다. body 읽기에 타임아웃 걸기. 요청 본문을 읽는 단계에 Promise.race로 8초 타임아웃을 걸었다. 외부에 열린 endpoint이다 보니, 느리거나 중간에 끊긴 연결이 핸들러를 무한정 붙잡고 있을 수 있다. 8초를 넘기면 408로 끊어내고 다음 요청을 받을 수 있게 했다. 이렇게 검증 단계를 실패 사유별로 다른 상태 코드(401 인증 실패, 408 수신 지연, 400 JSON 파싱 실패)로 나눠두면, Sentry 쪽 전송 로그만 봐도 어디서 막혔는지 바로 구분된다. 설계 결정 3: Webhook 수신 즉시 응답하기 (fire-and-forget) Sentry는 webhook을 보낸 후 응답을 일정 시간 안에 받지 못하면 실패로 처리한다. Claude Code가 코드를 분석하고 PR을 만들기까지는 수분이 걸릴 수 있다. 이 두 가지는 양립하기 어렵다. 해결책은 fire-and-forget 패턴이다. Sentry webhook 수신 ↓ payload 파싱 및 검증 ↓ Claude Code를 백그라운드 프로세스로 spawn (detached) ↓ 즉시 200 응답 반환 ← Sentry는 여기서 완료로 처리 ↓ (비동기) Claude가 백그라운드에서 분석 → 수정 → PR 생성Node.js에서는 child_process.spawn에 detached: true와 proc.unref()를 조합하면 부모 프로세스와 완전히 분리된 백그라운드 프로세스를 만들 수 있다. 핵심은 핸들러 안에서 runClaudeCode(issue, aiContext)를 await 없이 호출한다는 점이다. spawn만 던져두고 곧장 응답으로 넘어가므로, Claude Code가 몇 분을 돌든 HTTP 응답은 그와 무관하게 즉시 반환된다. 응답에 무엇을 담을 것인가. 즉시 반환하는 200 응답은 "잘 받았다"는 신호 이상의 역할을 하기 어렵다. 그래도 디버깅에 도움이 되도록, 응답 body에 처리에 필요한 최소한의 메타데이터를 실어 보낸다.issueId / eventId — 어떤 이슈를 받았는지 스택트레이스 프레임 개수 — 컨텍스트가 비어 있지 않은지 브레드크럼 존재 여부, top_in_app_frame 추출 성공 여부이렇게 해두면 PR이 만들어지기 한참 전이라도, Sentry 전송 로그에 찍힌 응답만으로 "컨텍스트가 제대로 구성됐는지"를 즉시 확인할 수 있다. 정작 결과물(PR)은 비동기로 만들어지므로, 동기 응답이 줄 수 있는 정보를 최대한 끌어모은 셈이다. 트레이드오프는 있다. Sentry 입장에서는 PR이 실제로 만들어졌는지 알 수 없고, Claude Code 실패 여부도 별도로 모니터링해야 한다. 하지만 "일단 받고 처리한다"는 단순함이 더 중요했다. 설계 결정 4: AI에게 전달할 컨텍스트 설계 Claude Code가 에러를 제대로 분석하려면 Sentry의 raw payload를 그대로 넘기는 것보다 구조화된 컨텍스트를 만들어서 전달하는 것이 낫다. 이 변환을 한 번에 하지 않고 두 단계로 나눴다.normalizeSentryPayload() — Sentry의 중첩된 payload에서 핵심 식별자(이슈 ID, 이벤트 ID, 제목, URL, 환경, 프로젝트)만 평평하게 뽑아낸다. buildAiContext() — 정규화된 이슈와 원본 payload를 함께 받아, Claude에게 줄 AiDebugContext를 조립한다.굳이 나눈 이유는, 식별자 추출과 분석용 컨텍스트 조립의 성격이 다르기 때문이다. 식별자는 브랜치 이름(fix/sentry-{issueId})이나 임시 파일 경로처럼 시스템이 쓰는 값이라 안정성이 중요하고, 컨텍스트는 Claude가 읽을 내용이라 풍부함이 중요하다. 둘을 한 함수에 섞으면 책임이 뒤엉킨다. ID는 반드시 문자열로. 정규화 과정에서 가장 신경 쓴 부분이다. Sentry의 이슈/이벤트 ID는 큰 정수라서, 그대로 두면 후속 단계(특히 셸/CI 환경)에서 1.23e+15 같은 지수 표기로 변질될 수 있다. ID가 변질되면 브랜치 이름도, 임시 파일 경로도 어긋난다. 그래서 ID는 추출 시점에 명시적으로 문자열로 변환한다. 누락에 대비한 fallback chain. webhook payload는 환경과 SDK 버전에 따라 필드가 들쭉날쭉하다. 그래서 핵심 필드마다 우선순위 사슬을 뒀다.제목: issue.title → event.title → event.message → 기본값 프로젝트 슬러그: webhook 슬러그 → event 슬러그 → 프로젝트 이름 → 환경 변수 → 숫자 ID어느 한 경로가 비어도 다음 경로로 넘어가므로, 필드 하나가 없다고 파이프라인 전체가 멈추지 않는다. 이렇게 정리한 정보를 AiDebugContext 스키마로 묶는다. AiDebugContext { issue: { id, title, url, level, environment, project } event: { id, message, culprit, transaction, timestamp } stacktrace: { frames: [...], // 최근 20개 프레임 top_in_app_frame // 가장 관련성 높은 in-app 프레임 } breadcrumbs: { last_items: [...] // 최근 20개 브레드크럼 } runtime: { browser, os, device, url, user_agent } tags: { ... } ai_instructions: { // Claude에게 직접 전달하는 지시사항 goal, constraints, expected_output } }스택트레이스 처리. Sentry의 frames 배열은 오래된 것부터 최근 순으로 정렬된다. 최근 20개만 유지하고, in_app: true인 프레임 중 마지막 것을 top_in_app_frame으로 추출한다. Claude가 node_modules나 프레임워크 내부보다 실제 앱 코드에 집중하도록 유도하기 위해서다. 민감정보 마스킹. 브레드크럼의 data 필드에는 요청 헤더, 쿠키, 토큰이 포함될 수 있다. key 이름 기반으로 민감한 필드를 [REDACTED]로 치환한다. 설계 결정 5: Claude Code CLI 호출 방식 Claude Code CLI를 서버에서 프로그래매틱하게 실행할 때 고려한 것들. 프롬프트 전달. --print 모드에서 긴 프롬프트는 stdin으로 전달하는 것이 안정적이다. const proc = spawn( "claude", [ "--print", "--dangerously-skip-permissions", "--allowedTools", "Bash,Read,Edit,Write", ], { cwd: REPO_PATH, detached: true, stdio: ["pipe", "pipe", "pipe"] }, );proc.stdin.write(prompt); proc.stdin.end();working directory. Claude가 REPO_PATH(대상 레포)를 cwd로 실행되어야 파일 탐색과 수정이 올바르게 동작한다. 허용 도구. Bash(git 명령어, gh CLI), Read(파일 읽기), Edit/Write(코드 수정). 이 4가지면 분석 → 수정 → 커밋 → PR 생성 전체가 가능하다. 권한 프롬프트가 끼어들면 백그라운드 프로세스가 멈춰버리므로 --dangerously-skip-permissions로 비대화형 실행을 보장하되, 그만큼 허용 도구를 위 4가지로 좁혀 위험 범위를 제한했다. 프롬프트는 절차로 적는다. Claude에게 주는 프롬프트는 추상적인 목표("이 에러 고쳐줘") 대신, 따라야 할 절차를 순서대로 적었다.@/tmp/sentry-{issueId}.json의 스택트레이스와 브레드크럼을 분석한다 관련 소스 파일을 찾아 수정한다 fix/sentry-{issueId} 브랜치를 만든다 커밋하고 푸시한다 gh로 PR을 생성한다컨텍스트 파일을 프롬프트에 직접 붙이지 않고 @경로 참조로 넘기는 이유는, payload가 크면 프롬프트가 비대해지고, Claude가 파일을 직접 Read하게 두는 편이 토큰 측면에서도 깔끔하기 때문이다. 프로세스의 뒷정리. spawn한 프로세스의 close 이벤트에서 두 가지를 한다. 하나는 /tmp/sentry-{issueId}.json 임시 파일 삭제(실패하더라도 try-catch로 흘려보낸다), 다른 하나는 stdout/stderr와 종료 코드 로깅이다. 다만 Claude의 출력은 길어질 수 있어 로그는 500자로 잘라 남긴다. 백그라운드라 화면에 아무것도 안 뜨는 만큼, 나중에 "무슨 일이 있었는지" 추적할 최소한의 단서는 남겨두는 것이다. 설계 결정 6: 왜 PR까지만 만들고 멈추는가 파이프라인의 마지막 단계는 gh pr create다. 마음만 먹으면 gh pr merge까지 붙여서 "에러 발생 → 자동 수정 → 자동 배포"로 완전 무인화할 수도 있었다. 하지만 의도적으로 PR 생성에서 멈추도록 설계했다. 이유는 단순하다. AI의 수정은 틀릴 수 있다. 스택트레이스만 보고 증상을 가린 채 근본 원인을 놓칠 수도 있고, 엉뚱한 파일을 건드릴 수도 있다. 자동 merge까지 열어두면, 잘못된 수정이 아무 검토 없이 메인 브랜치로 들어가 또 다른 장애를 만든다. 그래서 이 시스템의 역할을 "고치는 주체"가 아니라 "리뷰할 거리를 만들어두는 주체"로 한정했다. Claude가 하는 일은 사람이 처음부터 해야 했을 일(에러를 분석하고, 원인을 추정하고, 수정안을 코드로 만들어 PR에 정리하는 것) 까지다. 그 PR을 받아들일지 말지는 사람이 결정한다. 반복적인 앞단을 자동화하되, 마지막 판단 게이트는 사람에게 남긴다는 것이 이 설계의 핵심 안전장치다. 전체 흐름 정리 [Sentry] 에러 발생 │ │ POST /webhooks/sentry?secret=... ▼ [nginx] TLS termination │ │ proxy_pass localhost:3000 ▼ [Node.js - Hono] ├─ secret 검증 ├─ payload 파싱 ├─ AiDebugContext 빌드 ├─ /tmp/sentry-{id}.json 저장 └─ claude CLI spawn (background) │ │ 즉시 200 응답 ▼ [Sentry] 완료 처리 (백그라운드) ▼ [Claude Code CLI] ├─ /tmp/sentry-{id}.json 분석 ├─ KOIN_WEB_RECODE 탐색 ├─ 코드 수정 ├─ git checkout -b fix/sentry-{id} ├─ git commit & push └─ gh pr create ▼ [GitHub] PR 생성 완료남은 과제: 실패와 중복을 어떻게 다룰 것인가 지금까지의 구조는 "에러 하나가 정상적으로 들어와서, Claude가 한 번에 잘 고치는" 행복한 경로를 가정한다. 하지만 실제 운영에서는 그렇지 않은 경우가 더 많다. 현재 설계가 아직 충분히 답하지 못하는 두 가지 문제가 있다. 실패가 보이지 않는다. fire-and-forget의 대가다. Claude가 중간에 죽거나, 수정이 엉뚱하거나, push 권한 문제로 PR 생성에 실패해도 그 사실이 어디에도 드러나지 않는다. 지금은 프로세스의 close 이벤트에서 종료 코드와 출력 일부를 로그로 남기는 정도가 전부라, 서버에 직접 들어가 로그를 봐야 한다. webhook을 즉시 끊어버린 대신, 결과를 되돌려 알릴 채널이 사라진 셈이다. 자연스러운 다음 수순은 처리 결과(성공/실패, 생성된 PR 링크, 실패 사유)를 Slack이나 Sentry 이슈 코멘트로 다시 쏘는 것이다. 비동기로 일을 떠넘긴 만큼, 결과를 회수할 경로를 따로 만들어야 한다. 같은 에러가 반복해서 들어온다. Sentry는 동일한 이슈에 대해 alert을 여러 번 보낼 수 있다. 그런데 브랜치 이름은 fix/sentry-{issueId}로 이슈 ID에 고정돼 있다. 같은 이슈가 두 번 들어오면 두 번째 실행은 이미 존재하는 브랜치와 충돌하거나, 같은 내용의 PR을 중복으로 만들려다 깨진다. 결국 멱등성(idempotency) 문제다. "이 이슈는 이미 처리했는가"를 판단할 상태가 어디에도 없다. 처리한 이슈 ID를 기록해두고 중복 요청을 앞단에서 스킵하거나, 브랜치·PR의 존재 여부를 먼저 확인하는 가드가 필요하다. 여기에 더해, 장애 상황에서 알림이 한꺼번에 쏟아지면 Claude 프로세스가 무제한으로 spawn되는 동시성 문제도 같은 뿌리에서 나온다. 세 문제 모두 "받은 요청에 상태를 부여하지 않았다"는 한 지점으로 모인다. 지금 구조는 요청을 받는 즉시 잊어버리기 때문에, 신뢰성을 한 단계 끌어올리려면 결국 요청에 식별자와 상태(접수됨 / 처리 중 / 완료 / 실패)를 부여하는 최소한의 상태 저장소가 필요해진다. 에러 처리 다시 보기: 실패를 상태와 알림으로 바꾸기 위에서 남겨둔 과제를 실제로 손보면서, 이 서버의 에러 처리를 처음부터 다시 들여다봤다. 관점은 하나로 모였다. 이 서버는 외부(Sentry) → 외부(Claude CLI, GitHub, Sentry API)를 잇는 중계자라서, 내가 통제할 수 없는 입력과 통제할 수 없는 실패가 사방에 깔려 있다. 그래서 에러 처리를 "예외 잡기"가 아니라 흐름의 각 단계마다 실패를 어디서 끊고, 어떤 상태로 남기고, 누구에게 알릴지의 문제로 보기로 했다. 1. 요청 처리 흐름을 단계별 "관문"으로 나누기 webhook 핸들러는 순서가 곧 방어선이다. 각 단계가 실패하면 그 단계에 맞는 HTTP 상태 코드로 즉시 끊는다. POST /webhooks/sentry ├─ 1. 시크릿 검증 불일치 → 401 ├─ 2. body 읽기(타임아웃) 초과 → 408 ├─ 3. JSON 파싱 실패 → 400 ├─ 4. issueId 추출 없음 → 400 ├─ 5. 멱등성 체크 중복 → 200 (skipped) └─ 6. 처리 수락 → 200 (accepted)핵심은 "잘못된 입력(4xx)"과 "내 잘못(5xx)"을 분리한 것이다. 시크릿 불일치, 깨진 JSON, issueId 누락은 전부 클라이언트 책임(4xx)이라 재시도해도 의미가 없다. Sentry가 불필요하게 재발송하지 않도록 명확히 거절한다. 2. "body가 안 끝나는" 문제 — 타임아웃으로 멈춰버리는 현상 차단 가장 골치 아팠던 건 서버리스(Vercel) 환경에서 요청 body 읽기가 끝나지 않고 멈춰버리는 현상이었다. 이건 일반 try/catch로는 못 잡는다. 멈춘 Promise는 예외를 던지지 않기 때문이다. 그래서 Promise.race로 타임아웃을 강제로 씌웠다. function withTimeout<T>( promise: Promise<T>, ms: number, label: string, ): Promise<T> { return Promise.race([ promise, new Promise<never>((_, reject) => setTimeout( () => reject(new Error(`${label} timed out after ${ms}ms`)), ms, ), ), ]); }raw = await withTimeout(c.req.text(), BODY_READ_TIMEOUT_MS, "body-read"); // 8초"응답이 없는 실패"는 "에러를 던지는 실패"보다 위험하다. 타임아웃은 그 무응답을 다시 명시적 에러(408)로 바꿔주는 장치다. 3. 멱등성 — 같은 에러로 PR을 두 번 만들지 않기 앞서 과제로 남겼던 멱등성 문제를 여기서 푼다. Sentry webhook은 재시도되거나 중복 발송될 수 있다. 그대로 두면 하나의 에러에 PR이 여러 개 생긴다. 그래서 issueId 기준으로 처리 상태를 파일에 기록하고, 이미 processing/completed면 건너뛴다. const existing = processedStore.get(issue.issueId); if (existing?.status === "processing" || existing?.status === "completed") { return c.json({ ok: true, status: "skipped", ... }); // 중복은 정상 응답으로 흡수 }상태는 processing → completed | failed 세 가지로 관리한다. 주목할 두 가지 디테일이 있다.원자적 쓰기. 임시 파일에 쓴 뒤 rename으로 교체해서, 쓰는 도중 죽어도 스토어가 깨지지 않는다. 쓰기 직렬화. 동시 요청이 같은 파일을 덮어쓰지 않도록 writeChain Promise로 순차 처리한다.const tmp = `${path}.tmp`; writeFileSync(tmp, JSON.stringify(store, null, 2)); renameSync(tmp, path); // 원자적 교체4. 비동기 백그라운드 작업 — 응답은 먼저, 실패는 따로 알림 Claude Code 실행은 수 분이 걸린다. webhook 응답을 그때까지 붙잡고 있으면 Sentry 쪽이 타임아웃된다. 그래서 응답을 먼저 202 accepted 성격으로 돌려주고, 실제 작업은 detached 프로세스로 분리했다(앞서 설명한 fire-and-forget). await processedStore.markProcessing(issue.issueId); runClaudeCode(issue, aiContext); // await 하지 않음 — fire and forget return c.json({ ok: true, accepted: true, issueId: issue.issueId, ... });여기서 문제는 "응답을 이미 보낸 뒤의 실패는 어떻게 알리나"이다. HTTP로는 못 알린다. 그래서 결과를 두 곳(상태 저장소 + Sentry 코멘트)에 남긴다. proc.on("close", (code) => { if (code === 0) { processedStore.markCompleted(issueId, prUrl); postIssueComment(issueId, `🤖 수정 PR을 생성했습니다: ${prUrl}`); } else { processedStore.markFailed(issueId, `exit code ${code}`); postIssueComment(issueId, `⚠️ 자동 수정에 실패했습니다 (exit ${code}).`); } });성공/실패가 Sentry 이슈 코멘트로 다시 돌아가서, 개발자가 원래 보던 화면에서 결과를 확인한다. 앞 섹션에서 "결과를 회수할 경로가 없다"고 적었던 빈자리가 이걸로 메워졌다. 백그라운드 작업의 에러 처리는 "로그를 남기는 것"이 아니라 "사람에게 닿게 하는 것"이 핵심이다. 5. 알림 자체가 실패해도 본체는 안 죽는다 마지막 방어선. Sentry 코멘트 API 호출이 실패하더라도 그건 부가 기능이라 전체를 무너뜨리면 안 된다. 그래서 알림은 전부 "삼키는(swallow)" 에러 처리로 감쌌다. if (!env.SENTRY_API_TOKEN) { logger.warn("토큰 없음, 코멘트 스킵"); return; // 토큰 없어도 throw 안 함 } try { const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); if (!res.ok) { logger.warn(...); return; } } catch (err) { logger.warn("Failed to post Sentry comment", ...); // 실패해도 warn만 }AbortSignal.timeout(10_000)으로 외부 API 호출에도 타임아웃을 걸어, 느린 외부 의존성이 백그라운드 프로세스를 잡아두지 못하게 했다. 정리 — 에러 처리 5가지 원칙 이번 작업에서 일관되게 적용한 원칙을 추리면 다섯 가지다.단계마다 끊어라 — 실패 지점에 맞는 상태 코드로 즉시 거절 (4xx vs 5xx 분리) 무응답도 에러다 — hang은 타임아웃으로 명시적 에러로 변환 (Promise.race, AbortSignal.timeout) 중복을 흡수하라 — 멱등성으로 재시도/중복 발송을 안전하게 무시 응답 이후의 실패는 다른 채널로 알려라 — HTTP 대신 Sentry 코멘트로 결과 회신 부가 기능 실패가 본체를 죽이면 안 된다 — 알림은 swallow + 로깅한 줄 요약: 중계 서버의 에러 처리는 예외를 잡는 게 아니라, 통제 불가능한 외부 실패를 통제 가능한 상태와 알림으로 바꾸는 일이다.이렇게 해서 앞에서 남겨둔 세 과제(실패 가시성, 멱등성, 동시성)가운데 앞의 둘은 상태 저장소와 코멘트 회신으로 메워졌다. 동시성 제어(같은 이슈에 대한 프로세스 spawn 상한)는 멱등성 체크가 1차 방어선 역할을 하지만, 서로 다른 이슈가 한꺼번에 쏟아지는 경우의 큐잉은 아직 더 다듬을 여지가 남아 있다. 마치며 이 파이프라인의 핵심은 화려한 기술이 아니라, 불필요한 중간 단계를 걷어내는 것이었다. 처음엔 Vercel과 GitHub Actions를 거치는 5단계 구조였지만, "누가 webhook을 받고 누가 Claude를 실행하는가"를 다시 묻자 Oracle 하나로 충분하다는 답이 나왔다. 나머지 설계 결정들(직접 HTTPS, fire-and-forget, 구조화된 컨텍스트, CLI 호출 방식)은 모두 그 단순한 골격 위에서 각자의 트레이드오프를 따져 내린 선택이다.코드는 dooohun/koin-sentry-webhook에서 확인할 수 있다.
- 29 May, 2026
문자열 +=는 정말 O(N²)일까? V8의 ConsString 들여다보기
문자열이 여러 개 있을 때 모든 문자열들을 합쳐야 한다. 그럴 때 우리는 이렇게 코드를 짤 수 있다. function joinWords(words) { let sentence = ""; for (const word of words) { sentence += word; } return sentence; }JavaScript에 익숙하다면 join 메서드를 사용할 수 있다. function joinWords(words) { return words.join(""); }첫 번째 방식을 보면 "문자열은 불변(immutable)이라서, += 는 매번 새 문자열을 통째로 복사하기 때문에 N개를 이어 붙이면 O(N²)이 된다. 그렇기 때문에 Array.prototype.join()을 써야 한다." 라는 생각이 우선 든다. 문자열은 실제로 불변이고, sentence += word 는 기존 문자열을 고치는 게 아니라 기존 내용과 새 단어를 합친 새 문자열을 만들어 대입한다. 이론적으로는 단계마다 누적 길이만큼 복사가 일어나니 O(N²)이 맞다. 그런데 실제로 시간을 측정해보면 이야기가 달라진다. 그 이유를 만드는 V8 내부 장치인 ConsString을 들여다보고, 직접 만든 모델 코드와 실제 벤치마크로 확인해보려고 한다. ConsString - 일단 합치지 않고 미룬다. V8(Chrome과 Node.js의 JavaScript 엔진)은 두 문자열을 연결할 때 곧바로 문자를 복사하지 않는다. 대신 "이 둘이 이어져 있다"는 사실만 기록한 노드를 만든다. 이 노드가 ConsString(concatenated string) 이다. 여러 번 이어붙이면 이 노드들이 트리 구조로 쌓인다. 실제 문자 복사는 그 문자열의 내용이 정말로 필요해지는 순간까지 미뤄진다. 이것을 지연 평가(lazy evaluation)라고 부른다. 원리를 코드로 확인해보기 위해, ConsString의 핵심 동작만 흉내 낸 모델을 만들어보았다. (실제 V8 코드는 아니다. 어디까지나 멘탈 모델을 코드로 표현한 것이다.) // 평탄화 횟수 let flattenCount = 0;class ConsString { constructor(left, right) { // 즉시 합치지 않고 두 조각의 참조만 들고 있는다. => O(1) this.left = left; this.right = right; this.length = left.length + right.length; this._flat = null; } concat(other) { return new ConsString(this, other); } flatten() { if (this._flat !== null) return this._flat; flattenCount++; const l = this.left instanceof ConsString ? this.left.flatten() : this.left; const r = this.right instanceof ConsString ? this.right.flatten() : this.right; return (this._flat = l + r); } toString() { return this.flatten(); } }let s = ""; for (const w of ["hello", "my", "name", "dohun"]) { s = s === "" ? w : new ConsString(s, w); }console.log(flattenCount); // 0 console.log(s.toString()); // "hellomynamedohun" console.log(flattenCount); // 2 s.toString(); console.log(flattenCount); // 2연결을 끝낸 시점의 평탄화 횟수는 0이다. 노드만 쌓았을 뿐 실제 합치기는 한 번도 일어나지 않았다. 이게 연결 자체는 O(1)의 시간복잡도를 가지게 된다. toString() 으로 내용을 요구하는 순간 비로소 트리가 펼쳐지고, 한 번 펼친 결과는 캐싱되어 다음 요구부터는 다시 펼쳐지지 않는다. 결과적으로 l + r 도 복사가 아닌가? flatten() 안의 this._flat = l + r은 분명히 문자열 연결이고, 내부적으로 문자를 복사해 새 문자열을 만든다. 결과적으로 ConsString이 복사를 없앤 게 아니지 않은가? 맞다. ConsString이 없애는 건 복사 자체가 아니라 중간 단계마다 반복되는 복사이다. 예시로 [a, b, c, d]가 합쳐지는 과정을 보자. s = a (복사 없음) s = s + b → "a" + "b", 길이 2 복사 s = s + c → "ab" + "c", 길이 3 복사 s = s + d → "abc" + "d", 길이 4 복사매 단계에서 그때까지 누적된 전체를 다시 복사한다. 복사량이 쌓여 O(N²)이 된다. 같은 앞부분을 거듭 복사하는 것이 중요한 내용이다. 하지만 ConsString은 연결 단계에서 단 한번 마지막에 복사를 해서 합쳐질 뿐 매번 누적해서 복사하지 않는다. 그렇기에 O(N)으로 줄어든다. 언제 평탄화가 되는가? ConsString은 두 조각이 이어져 있다라는 지연된 표현이다. 임의 위치의 문자에 실제로 접근해야 하는 연산이 들어오면 그 시점에 트리를 하나의 연속된 버퍼로 펼친다. 대표적인 트리거는 다음과 같다.인덱싱, 문자 접근: str[i], charAt, CharCodeAt 다수의 내장 메서드: 정규식 매칭, indexOf, slice 등 연속 버퍼가 필요한 연산 엔진 외부로 데이터를 넘길 때: console.log 출력, JSON 직렬화 등한 번 펼쳐지면 결과가 보관되어 이후 접근은 빠르다. 모델과 실제 V8의 차이 위 모델은 원리를 보여줄 뿐, 실제 V8과는 다음 점에서 다르다.V8은 결과 문자열 길이가 약 13자 미만이면 ConsString을 만들지 않고 즉시 복사한다. 노드 객체 자체의 메모리 오버헤드가 짧은 문자열에는 손해이기 때문이다. 실제 평탄화는 위 모델처럼 재귀로 + 하는 대신, 총 길이만큼 버퍼를 미리 할당해 한 번에 채운다. 캐싱도 별도 프로퍼티가 아니라 노드 자체를 평탄화된 표현으로 교체하는 방식이다.성능 측정 세 가지 방식을 여러 N에서 비교했다.+= 누적 배열을 join("") 빈 배열에 push 한 뒤 join("")Node v22.22.2, 각 케이스를 20회 워밍업 후 11회 측정한 중앙값(ms)이다. 단어는 2~4자 길이를 사용했다. V8은 처음엔 코드를 느린 인터프리터로 실행하다가, 충분히 반복되면 최적화 컴파일러(JIT)가 빠른 기계어로 다시 컴파일한다. 그래서 처음 몇 번은 느리다. 측정 전에 함수를 미리 여러 번 돌려 이 최적화를 끝내 두는 것이 워밍업이다. 안 하면 "데워지기 전"의 느린 상태를 측정하게 된다. 짧은 단어 (2~4자)N (단어 수) += 배열 join() push + join()1,000 0.01 0.01 0.0210,000 0.06 0.16 0.2150,000 0.31 1.16 1.80100,000 0.62 2.6 4.1500,000 3.85 16.5 25.91,000,000 33.5 34.8 565,000,000 329 231 597긴 단어 (~31자)N (단어 수) += 배열 join() push + join()10,000 0.06 0.4 0.48100,000 0.62 4.0 5.4500,000 3.9 27.4 37.41,000,000 33.4 52.7 76.85,000,000 289 270 579+= 는 O(N²)이 아니다. ConsString이 올바르게 작동하고 있다는 직접적인 증거이다. 소~중 규모에서는 오히려 += 가 빠르다. join() 은 별도 배열을 순회하고 평탄화하는 비용을 치르기 때문이다. 역전은 수백만 단어 규모에서 발생한다. 즉 join()이 빠르다는 건 큰 입력에서 성립한다. 단어가 길수록 역전이 더 늦는다. 긴 단어 500만에서는 둘이 거의 비슷하다. 단어가 길면 다뤄야 할 노드 수는 같은데 복사할 총 바이트가 늘어, join()의 버퍼 선할당 이점이 상대적으로 덜 두드러진다.결론 "문자열은 불변이니 +=는 O(N²), 무조건 join()"이라는 통념은 알고리즘 수업에서는 맞지만, 실제 V8 런타임에서는 ConsString 덕분에 둘 다 O(N)에 가깝게 동작한다. 그리고 측정해 보면 일상적 규모에서는 오히려 +=가 빠른 경우가 많다. 실무적으로 정리하면.대부분의 경우(수천~수십만 단어) +=로 충분하고, 종종 더 빠르다. 그러니 읽기 쉽고 의도가 분명한 쪽을 고르면 된다. 단어들을 이미 배열로 들고 있다면 words.join("")이 가장 간결하다. 두 방식의 성능이 역전되는 건 수백만 단어 이상의 큰 입력에서다. 그런 규모이거나 메모리 사용량을 예측 가능하게 두고 싶다면 join()이 더 안정적이다. 무엇보다 ConsString의 13자 임계값 같은 내부 동작에 코드를 의존시키지 말 것. 그건 엔진이 알아서 잘하라고 있는 최적화이지, 우리가 기대고 설계할 대상이 아니다.참고V8's ConsString Optimization in JavaScript v8/v8 — src/objects/string.h How we made JSON.stringify more than twice as fast — V8 Blog V8. Working with Strings. Expanding Vocabulary — Frontend Almanac