← posts/b.log()

blog92@web:~$ cat posts/yoyak-sentry-observability.md

PROJECTS10 min read

Sentry는 어떻게 에러를 잡아내는가 — yoyak 관측 설계 노트

AI 문서 요약 서비스 yoyak에 Sentry를 도입하기로 한 결정을 정리한다. 전역 핸들러·빌드 타임 래핑·이벤트 전송이라는 동작 원리부터, 이 프로젝트에서는 전제 조건인 개인정보 스크러빙 설계까지.

Sentry SDK를 설치하고 DSN 한 줄만 넣으면, 프로덕션에서 터진 에러가 스택 트레이스와 함께 대시보드에 나타난다. 편리하지만, 내 코드에 try/catch를 쓴 적도 없는데 어떻게 에러가 잡히는지 설명할 수 있을까. 도구를 붙여서 쓰는 것과, 그 도구가 내 요청 경로의 어디에 끼어드는지 아는 것은 완전히 다른 이야기다.

이 글은 지금 만들고 있는 AI 문서 요약 서비스 yoyak에 에러 트래킹 도구로 Sentry를 도입하기로 한 결정을 정리하는 설계 노트다. 먼저 정직하게 현재 상태를 밝히면 — 아직 패키지(@sentry/nextjs)만 설치했고 실제 연동은 로드맵 후반(Phase 5)이다. 코드에는 "Sentry 연동은 Phase 5"라는 주석만 있다. 그런데도 지금 글로 정리하는 이유는, 이 프로젝트에서 Sentry는 "붙이면 끝"이 아니라 지켜야 할 전제 조건(개인정보 스크러빙)이 있는 도구이고, 그 전제는 연동하는 날이 아니라 설계하는 날 정해야 하기 때문이다.

이 글의 가장 중요한 한 줄을 미리 말하면 이렇다.

Sentry는 마법이 아니다. 처리되지 않은 에러가 마지막으로 도달하는 길목에 미리 걸어둔 리스너와, 빌드 타임에 코드를 감싸는 래퍼와, 잡은 것을 서버로 보내는 HTTP 전송 — 세 가지 평범한 메커니즘의 조합이다.

1. 왜 에러 트래킹인가: 프로덕션은 깜깜하다

yoyak은 사용자가 문서를 올리면 AI가 요약해 주는 서비스다. 배포 후 사용자가 겪을 수 있는 에러는 이미 명확하다 — PDF 파싱 실패, Gemini API 쿼터 초과, DB 오류. 문제는 그 에러를 개발자가 알 방법이다.

로컬에서는 터미널이 있다. 에러가 나면 스택 트레이스가 바로 눈앞에 찍힌다. 그런데 프로덕션에서는 그 터미널이 없다. 관측 도구 없이 배포한 서비스는 깜깜한 방과 같아서, 사용자가 에러 화면을 보고 조용히 떠나도 개발자는 아무것도 모른다.

console.error로 로그를 남기면 되지 않을까? 남기고는 있다. 하지만 로그와 에러 트래킹은 성격이 다르다.

  • 로그는 쌓이기만 한다. 무슨 일이 있었는지 알려면 개발자가 직접 뒤져야 하고, 같은 에러가 천 번 나면 천 줄이 쌓인다.
  • 에러 트래킹은 에러만 골라 수집하고, 같은 에러끼리 묶고(그룹핑), 새 에러가 나타나면 알림까지 보낸다.

한 문장으로 압축하면 이렇다.

로그는 "기록을 남긴다"이고, 에러 트래킹은 "에러를 관리한다"이다.

그래서 설계 단계에서 "관측: Sentry 무료 티어 (개인정보 스크러빙 전제)"로 결정했고, 프로젝트 셋업 커밋에서 @sentry/nextjs를 미리 설치해 뒀다. 설정 파일은 아직 하나도 없다 — 그 자리는 4장에서 미리 그려 둔다.

2. Sentry의 동작 원리: 세 가지 메커니즘의 조합

"설치만 하면 에러가 잡힌다"는 마법처럼 보이지만, 뜯어 보면 세 가지 메커니즘이 각자 할 일을 하는 것뿐이다.

메커니즘 ① 전역 에러 핸들러 가로채기

처리되지 않은(unhandled) 에러는 그냥 사라지지 않는다. 런타임마다 마지막으로 도달하는 길목이 정해져 있다.

런타임처리되지 않은 에러가 도달하는 곳
브라우저window.onerror, window.onunhandledrejection
Node.jsprocess.on('uncaughtException'), process.on('unhandledRejection')
React컴포넌트 트리의 Error Boundary (SDK가 제공)

Sentry SDK가 init() 시점에 하는 일이 바로 이 길목들에 미리 리스너를 걸어두는 것이다. 내 코드 어디에도 try/catch가 없어도, 에러가 이 길목을 지나는 순간 SDK 손에 들어간다.

메커니즘 ② 빌드 타임 코드 래핑 — @sentry/nextjs가 특별한 이유

전역 핸들러만으로는 부족한 영역이 있다. Next.js 같은 프레임워크는 Route Handler에서 던져진 에러를 프레임워크가 먼저 잡아서 500 응답으로 바꿔버리기 때문에, 전역 핸들러까지 에러가 도달하지 않는 경우가 있다.

그래서 @sentry/nextjs는 일반 브라우저 SDK와 다르게 빌드 타임에 개입한다. withSentryConfig로 next.config를 감싸면, 빌드 과정에서 Route Handler·페이지·미들웨어가 자동으로 try/catch 래퍼 안에 들어간다. Next.js가 500 응답을 만들기 전에, 래퍼가 먼저 에러를 낚아채 Sentry로 보내고 나서 다시 던지는 구조다.

다만 Server Actions는 이 빌드 타임 자동 계측 대상이 아니다. 별도 처리가 필요한데, instrumentation.ts에 export const onRequestError = Sentry.captureRequestError 훅(Next.js 15+)을 등록하거나, 액션 본문을 withServerActionInstrumentation으로 수동 래핑해야 한다. yoyak은 요약 히스토리 CRUD를 전부 Server Actions로 만들 계획이라, 이 구분을 놓치면 히스토리 쪽 에러만 조용히 새는 사각지대가 생긴다.

메커니즘 ③ HTTP 전송 + 서버 처리

잡은 에러는 스택 트레이스에 부가 정보(URL, 브라우저 정보, 에러 직전 사용자의 행동 이력)를 더해 JSON 이벤트로 만들고, 프로젝트 식별자인 DSN 주소로 비동기 전송한다. 여기까지가 SDK의 일이고, 나머지는 Sentry 서버가 한다.

  • 그룹핑: 스택 트레이스로부터 지문(fingerprint)을 만들어 같은 에러를 하나로 묶는다. 같은 에러가 만 번 나도 대시보드에는 "이슈 1개 + 발생 횟수 10,000"으로 보인다.
  • 알림: 새 지문이 등장했을 때만 알린다. 이게 로그와의 결정적 차이다 — 노이즈가 아니라 신호만 온다.
  • 소스맵 복원: 프로덕션 스택 트레이스는 a.js:1:48291처럼 압축된 위치를 가리킨다. 빌드 시 업로드해 둔 소스맵으로 이걸 route.ts:62 같은 원본 위치로 복원해 보여준다.

성능 모니터링은 같은 원리의 확장

Sentry의 성능 모니터링(트레이싱)도 별개 기술이 아니다. 요청의 시작부터 끝까지를 트랜잭션으로 계측해 같은 경로로 전송하는, 같은 메커니즘의 확장이다. 모든 요청을 다 보내면 양이 많으니 tracesSampleRate로 일부만 샘플링한다.

3. 개인정보 스크러빙 — 이 프로젝트에서는 전제 조건이다

여기서부터가 이 글의 진짜 주제다. yoyak에서 Sentry는 "도입하면 좋은 것"이 아니라 "스크러빙 없이는 도입할 수 없는 것"으로 결정했다.

왜 전제인가

Sentry는 에러를 잘 잡기 위해 에러 시점의 요청 본문과 변수를 함께 보낼 수 있다. 일반적인 서비스라면 디버깅에 유용한 문맥이지만, yoyak이 다루는 요청 본문은 사용자가 업로드한 문서의 텍스트다. 이게 에러 이벤트에 실려 Sentry로 흘러가는 순간, yoyak의 핵심 설계 원칙인 최소 수집 — "원본 문서는 파싱 후 즉시 폐기한다" — 이 깨진다. 내 DB에는 안 남긴 데이터가 서드파티 서버에 남는, 뒷문으로 새는 구조가 되는 것이다.

지워야 할 대상은 세 가지다: 문서 텍스트(요청 본문), 요약 결과, 이메일(Auth.js 세션에서 나올 수 있음).

3겹 방어선

스크러빙은 한 곳에서 하지 않고 세 겹으로 쌓는다.

① SDK 기본값 — 애초에 수집하지 않기. Sentry SDK의 sendDefaultPii는 기본값이 false이고, 이 상태에서는 IP 주소·쿠키·인증 헤더를 애초에 수집하지 않는다. yoyak은 이 기본값을 그대로 유지한다. 여기서 이 글의 두 번째 핵심 문장이 나온다.

가장 좋은 스크러빙은 지우는 것이 아니라, 처음부터 수집하지 않는 것이다.

② beforeSend 훅 — 전송 직전 마지막 검문소. SDK가 이벤트를 전송하기 직전에 호출하는 훅으로, 이벤트 객체를 받아 수정하거나 null을 반환해 전송 자체를 취소할 수 있다. yoyak에서는 여기서 event.request.data(문서 텍스트가 담기는 자리)와 쿠키를 삭제하고, event.user는 id만 남긴다.

ts
function scrubEvent(event: Sentry.ErrorEvent): Sentry.ErrorEvent {
  // 요청 본문(문서 텍스트가 담기는 자리)과 쿠키는 통째로 제거
  if (event.request) {
    delete event.request.data;
    delete event.request.cookies;
  }
  // 사용자 식별은 id만 — 이메일은 남기지 않는다
  if (event.user) {
    event.user = { id: event.user.id };
  }
  return event;
}

③ 서버 사이드 스크러빙 — Sentry 쪽 안전망. Sentry 대시보드의 Data Scrubbing 설정은 password·token·카드번호 패턴을 [Filtered]로 치환하고, Advanced 규칙으로 커스텀 필드를 마스킹할 수 있다. 다만 이건 데이터가 이미 네트워크를 타고 Sentry에 도착한 뒤의 처리라는 점을 잊으면 안 된다. ③은 안전망일 뿐이고, 진짜 방어선은 내 코드 안의 ①과 ②다.

스크러빙 규칙이 못 잡는 구멍

세 겹을 쌓아도 새는 곳이 있다. 규칙은 필드를 지우지, 습관을 지우지 못한다.

첫 번째 구멍은 에러 메시지 자체에 데이터를 넣는 코드다.

ts
// ❌ 에러 메시지에 문서 내용이 실려 나간다 — 어떤 스크러빙 규칙도 못 잡는다
throw new Error(`파싱 실패: ${text.slice(0, 100)}`);
 
// ✅ 에러 메시지에는 데이터가 아니라 상황만 쓴다
throw new Error("PDF 텍스트 추출 실패 (본문 없음 또는 손상)");

event.request.data를 아무리 지워도, 메시지 문자열에 박힌 데이터는 스택 트레이스와 함께 그대로 전송된다. 그래서 "에러 메시지에는 데이터가 아니라 상황만 쓴다"를 코딩 규칙으로 삼는다.

두 번째 구멍은 세션 리플레이다. Sentry의 세션 리플레이는 에러 전후의 화면을 녹화해 보여주는 기능인데, yoyak의 화면에는 사용자의 문서 내용이 그대로 떠 있다. 그래서 이 기능은 켜지 않는다. 주의할 점은 샘플링 옵션이 두 개라는 것 — 평상시 녹화는 replaysSessionSampleRate, 에러 발생 시점 녹화는 replaysOnErrorSampleRate가 따로 제어하고, wizard가 생성하는 골격은 후자를 1.0으로 넣는 경우가 일반적이다. 앞의 것만 0으로 두면 정작 에러 순간의 화면은 녹화된다. 가장 확실한 차단은 두 값을 모두 0으로 두는 것을 넘어, replayIntegration 자체를 init에 추가하지 않는 것이다.

4. 설정이 들어갈 자리 — Phase 5에서 만들 구조

연동은 나중이지만, 파일이 어디에 어떻게 갈라질지는 지금 그려 둘 수 있다. Next.js에서 Sentry 설정 파일이 3개로 갈라지는 이유는 Next.js 앱이 사실 브라우저·Node·엣지라는 3개의 번들로 빌드되고, 각 번들이 자기 진입점에서 따로 Sentry.init()을 해야 하기 때문이다.

파일런타임비고
instrumentation-client.ts브라우저Next.js 파일 컨벤션으로 자동 로드
sentry.server.config.tsNode 서버yoyak에서 가장 중요 — 문서 텍스트가 담긴 요청 본문이 붙는 건 서버 이벤트다
sentry.edge.config.ts엣지(미들웨어)

서버 쪽 2개는 instrumentation.ts의 register()에서 런타임을 분기해 불러온다.

ts
// instrumentation.ts
import * as Sentry from "@sentry/nextjs";
 
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    await import("./sentry.server.config");
  }
  if (process.env.NEXT_RUNTIME === "edge") {
    await import("./sentry.edge.config");
  }
}
 
// Server Actions 등 자동 래핑 밖의 서버 에러를 잡는 훅 (2장 ② 참고)
export const onRequestError = Sentry.captureRequestError;

beforeSend는 각 파일의 init() 옵션이다. 여기서 함정 하나 — 스크러빙 로직을 세 파일에 복사해 붙이면, 나중에 한 곳만 고치고 두 곳을 잊는 사고가 반드시 난다. 그래서 3장의 scrubEvent 같은 공용 스크러빙 함수 하나를 만들어 세 config가 모두 import하는 패턴으로 간다. 스크러빙 규칙의 단일 진실 공급원을 만드는 것이다.

골격 자체는 npx @sentry/wizard@latest -i nextjs가 자동으로 만들어 준다. Phase 5의 실제 작업은 wizard가 만든 골격에 yoyak 맞춤 beforeSend(공용 스크러빙 함수)를 채우는 흐름이 된다.

마지막으로 하나 더 적어 둘 주의점. yoyak의 요약 API(/api/summarize)는 스트리밍 응답이라, 응답이 시작된 뒤 스트림 안에서 터지는 에러는 2장 ②의 빌드 타임 래퍼 try/catch 바깥일 수 있다. 이 경로는 자동 수집을 믿지 말고, 스트림 내부에서 Sentry.captureException()으로 수동 보고가 필요할 수 있다 — Phase 5 연동 시 확인할 항목으로 남겨 둔다.

마치며

이 글의 핵심을 한 줄로 압축하면 이렇다.

Sentry 도입에서 어려운 것은 연동이 아니라 경계선이다 — 에러의 문맥은 최대한 가져오되, 사용자의 데이터는 한 글자도 넘기지 않는 선을 코드로 긋는 것.

연동 자체는 wizard가 몇 분 만에 해 준다. 하지만 "무엇을 보내지 않을 것인가"는 도구가 정해 주지 않는다. yoyak처럼 사용자 데이터를 다루는 서비스라면, 그 경계선을 연동하는 날이 아니라 설계하는 날 정해 두는 편이 안전하다. 이 글이 그 기록이고, Phase 5에서 연동할 때 이 글이 체크리스트가 된다.

핵심 요약

개념핵심
로그 vs 에러 트래킹로그는 쌓이고, 에러 트래킹은 수집·그룹핑·알림으로 "관리"한다
동작 원리전역 핸들러 가로채기 + 빌드 타임 래핑(withSentryConfig) + JSON 이벤트 전송·지문 그룹핑·소스맵 복원
3겹 방어선① sendDefaultPii: false(미수집) → ② beforeSend(전송 직전 삭제) → ③ 서버 Data Scrubbing(안전망)
규칙 밖의 구멍에러 메시지에 데이터 넣지 않기, 세션 리플레이는 replayIntegration 미등록으로 차단(샘플링 옵션 2개를 0으로 두는 것보다 확실)
설정 구조브라우저/Node/엣지 3개 config + instrumentation.ts 분기, 스크러빙은 공용 함수 하나로
COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..