프로덕션 에러 추적 세팅
배포 후 '잘 되는데요?' 했는데 사용자만 에러를 겪고 있었다. Sentry 도입 후 에러를 실시간으로 잡게 된 이야기.
배포 후 '잘 되는데요?' 했는데 사용자만 에러를 겪고 있었다. Sentry 도입 후 에러를 실시간으로 잡게 된 이야기.
서버를 끄지 않고 배포하는 법. 롤링, 카나리, 블루-그린의 차이점과 실제 구축 전략. DB 마이그레이션의 난제(팽창-수축 패턴)와 AWS CodeDeploy 활용법까지 심층 분석합니다.

새벽엔 낭비하고 점심엔 터지는 서버 문제 해결기. '택시 배차'와 '피자 배달' 비유로 알아보는 오토 스케일링과 서버리스의 차이, 그리고 Spot Instance를 활용한 비용 절감 꿀팁.

내 서버가 해킹당하지 않는 이유. 포트와 IP를 검사하는 '패킷 필터링'부터 AWS Security Group까지, 방화벽의 진화 과정.

왜 넷플릭스는 멀쩡한 서버를 랜덤하게 꺼버릴까요? 시스템의 약점을 찾기 위해 고의로 장애를 주입하는 카오스 엔지니어링의 철학과 실천 방법(GameDay)을 소개합니다.

배포하고 한숨 돌렸다. 로컬에서 테스트도 다 했고, 스테이징에서도 문제없었다. 그런데 디스코드에 메시지가 왔다.
"회원가입 버튼 누르면 아무것도 안 돼요."
황급히 접속해보니 내 브라우저에서는 잘 된다. 개발자 도구 열어도 에러가 없다. 사용자한테 스크린샷을 부탁했는데, 그냥 흰 화면만 보인다. "다시 해보시겠어요?"라고 말하는 순간, 나는 깨달았다. 나는 내가 만든 앱에서 무슨 일이 벌어지는지 전혀 모르고 있었다는 걸.
프로덕션은 로컬과 다르다. 사용자의 브라우저는 내 맥북이 아니다. Safari에서는 되는데 삼성 인터넷에서는 안 된다. 한국에서는 되는데 베트남에서는 API 타임아웃이 난다. 사용자 A는 문제없는데 사용자 B는 매번 크래시한다.
console.log는 내 터미널에만 찍힌다. 사용자의 에러는 조용히 삼켜진다. 마치 우주에서 혼자 비명을 지르는 것처럼, 아무도 듣지 못한다.
결국 이해했다. 프로덕션에서는 내가 보지 못하는 것을 봐야 한다. 사용자가 신고하기 전에 에러를 알아야 한다. 그래서 Sentry를 도입했다.
Sentry를 세팅하고 배포한 첫날, 슬랙 채널이 울렸다.
[Sentry] New Issue: Cannot read property 'map' of undefined
in ProfilePage.tsx:45
User: user-abc-123
Browser: Safari 15.2
5 occurrences in the last hour
아, 이게 그 버그였구나. 프로필 페이지에서 팔로워 리스트를 렌더링할 때, API 응답이 null인 케이스를 처리하지 않았다. 로컬에서는 내 계정에 팔로워가 있어서 문제가 없었지만, 신규 사용자는 빈 배열도 아닌 null을 받았던 거다.
5분 만에 고쳤다. 사용자가 신고하기 전에.
그 순간 와닿았다. 에러 추적은 단순히 로그를 보는 게 아니라, 사용자 경험을 먼저 지키는 것이었다. 마치 소방서가 화재 신고를 기다리는 게 아니라 연기 감지기로 먼저 아는 것처럼.
가장 간단한 방법은 공식 위저드를 쓰는 거다.
npx @sentry/wizard@latest -i nextjs
근데 나는 뭐가 설치되는지 알고 싶었다. 그래서 수동으로 했다.
// sentry.client.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// 프로덕션에서만 100% 샘플링, 개발에서는 끄기
tracesSampleRate: process.env.NODE_ENV === "production" ? 1.0 : 0,
// 배포 버전 추적 - 이게 중요하다
release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA,
// 환경 구분
environment: process.env.NEXT_PUBLIC_VERCEL_ENV || "development",
// 민감한 데이터 필터링
beforeSend(event, hint) {
// 로컬 개발 환경에서는 보내지 않기
if (window.location.hostname === "localhost") {
return null;
}
// 비밀번호 같은 필드 제거
if (event.request?.data) {
delete event.request.data.password;
delete event.request.data.token;
}
return event;
},
// 무시할 에러들
ignoreErrors: [
"ResizeObserver loop limit exceeded", // 브라우저 버그
"Non-Error promise rejection captured", // 라이브러리 경고
],
});
서버 사이드도 따로 설정해야 한다.
// sentry.server.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
// 서버 에러는 더 많은 컨텍스트 필요
integrations: [
new Sentry.Integrations.Http({ tracing: true }),
],
beforeSend(event) {
// 환경 변수 노출 방지
if (event.contexts?.runtime?.environment) {
delete event.contexts.runtime.environment;
}
return event;
},
});
프로덕션 빌드는 압축된다. 에러 스택이 t.map is not a function at r (chunk-abc.js:1:2345) 이런 식으로 나온다. 이걸 읽으려면 소스맵을 업로드해야 한다.
// next.config.js
const { withSentryConfig } = require("@sentry/nextjs");
module.exports = withSentryConfig(
{
// 기존 Next.js 설정
},
{
// Sentry 웹팩 플러그인 설정
silent: true, // 빌드 로그 조용히
org: "your-org",
project: "your-project",
// 소스맵 업로드
widenClientFileUpload: true,
// 트리 쉐이킹으로 프로덕션 번들 사이즈 줄이기
hideSourceMaps: true,
// 디버깅 정보 자동 삭제
disableLogger: true,
}
);
이제 에러가 나면 원본 TypeScript 파일과 라인 번호가 정확히 나온다.
처음에는 같은 에러가 사용자마다 다른 이슈로 생성됐다. Sentry가 URL이나 사용자 ID로 구분해버렸기 때문이다. 이걸 해결하려면 fingerprinting이 필요했다.
Sentry.init({
beforeSend(event, hint) {
// 에러 타입과 발생 위치만으로 그룹화
if (event.exception?.values?.[0]) {
const error = event.exception.values[0];
event.fingerprint = [
error.type || "Error",
error.value || "Unknown",
error.stacktrace?.frames?.[0]?.filename || "unknown",
];
}
return event;
},
});
이제 100명이 같은 버그를 겪어도 1개의 이슈로 묶인다. 노이즈가 줄었다.
단순히 에러 메시지만으로는 재현이 어렵다. 사용자의 상태를 함께 보내야 한다.
import * as Sentry from "@sentry/nextjs";
// 사용자 정보 설정 (로그인 시)
Sentry.setUser({
id: user.id,
email: user.email,
username: user.username,
});
// 추가 컨텍스트
Sentry.setContext("subscription", {
plan: user.plan,
status: user.subscriptionStatus,
expiresAt: user.subscriptionExpiresAt,
});
// 특정 액션에 breadcrumb 남기기
Sentry.addBreadcrumb({
category: "payment",
message: "User clicked checkout button",
level: "info",
data: {
amount: 99,
currency: "USD",
},
});
// 수동으로 에러 캡처
try {
await processPayment();
} catch (error) {
Sentry.captureException(error, {
tags: {
payment_method: "stripe",
flow: "checkout",
},
extra: {
cartItems: cart.items.length,
totalAmount: cart.total,
},
});
}
이제 에러가 나면 "무료 플랜 사용자가 결제 버튼 눌렀을 때"처럼 컨텍스트가 함께 보인다.
Sentry 대시보드를 계속 볼 수는 없다. 중요한 에러는 슬랙으로 알림을 받도록 설정했다.
Alert Rule 예시:payment, auth)를 가진 에러일 때# .sentry/alerts.yaml (선언적으로 관리)
- name: "Critical Payment Errors"
conditions:
- type: "event.type"
match: "error"
- type: "event.tag"
key: "transaction"
match: "contains"
value: "/api/payment"
actions:
- type: "slack"
workspace: "your-workspace"
channel: "#alerts-critical"
새벽 3시에 알림이 울린 적도 있다. 결제 API가 다운됐었는데, 사용자가 신고하기 전에 알아서 바로 대응했다.
Git SHA를 릴리즈 버전으로 쓰면, 배포와 에러를 연결할 수 있다.
# 배포할 때 릴리즈 생성
sentry-cli releases new $VERCEL_GIT_COMMIT_SHA
sentry-cli releases set-commits $VERCEL_GIT_COMMIT_SHA --auto
sentry-cli releases finalize $VERCEL_GIT_COMMIT_SHA
Vercel에서는 환경 변수로 자동으로 들어간다.
// sentry.client.config.ts
Sentry.init({
release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA,
});
이제 Sentry에서 "이 에러는 커밋 abc1234 이후에만 발생했다"고 알려준다. 어떤 PR이 문제인지 바로 알 수 있다.
Sentry는 에러뿐 아니라 성능도 추적한다.
Sentry.init({
tracesSampleRate: 0.1, // 10%만 샘플링 (비용 절약)
integrations: [
new Sentry.BrowserTracing({
// 특정 URL만 추적
tracePropagationTargets: [
"localhost",
/^https:\/\/yourapp\.com\/api/,
],
}),
],
});
API 호출 시간, 페이지 로드 시간, 느린 컴포넌트를 볼 수 있다. "프로필 페이지가 평균 5초 걸린다"는 걸 알고, 데이터 fetching을 최적화했다.
처음에는 선택지가 많아서 헷갈렸다.
Sentry: 에러 추적 전문. 오픈소스라 셀프 호스팅 가능. 무료 플랜이 넉넉하다 (월 5,000 이벤트). 스타트업 친화적. 내가 선택한 이유는 단순했다 - 빠르게 시작할 수 있고, 나중에 커스터마이징도 가능해서.
LogRocket: 세션 리플레이가 강력하다. 사용자가 클릭하고 타이핑한 걸 영상처럼 볼 수 있다. 근데 비싸다. 스타트업 초기에는 오버킬이었다.
Datadog: 인프라 모니터링까지 다 한다. 로그, 메트릭, APM, 에러 추적을 한곳에서. 근데 엔터프라이즈 가격이다. 우리는 아직 그 단계가 아니었다.
Rollbar: Sentry의 경쟁자. 비슷한데 UI가 조금 덜 직관적이었다. 커뮤니티도 Sentry가 더 크다.
결국 Sentry + Vercel Analytics + PostHog(사용자 분석)으로 정착했다. 각자 전문 분야가 달라서 겹치지 않았다.
Sentry 무료 플랜은 월 5,000 이벤트다. 처음에는 넉넉했는데, 트래픽이 늘자 3일 만에 한도가 찼다.
해결책:
ResizeObserver 같은 브라우저 노이즈는 무시.Sentry.init({
// 에러는 100% 캡처하되, 퍼포먼스는 샘플링
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.05 : 0,
beforeSend(event) {
// rate limit 직접 구현
if (Math.random() > 0.3) { // 30%만 보내기
return null;
}
return event;
},
});
이렇게 했더니 무료 플랜으로 버틸 수 있었다.
프로덕션 에러 추적을 세팅하고 나서 달라진 것들:
마치 자동차에 블랙박스를 다는 것과 같다. 사고가 나면 정확히 무슨 일이 있었는지 알 수 있다.
프로덕션에서 장님으로 사는 건 끔찍하다. 이제는 눈을 뜨고 운전한다. Sentry 덕분에.