# [모니터링] Sentry 도입부터 Discord 에러 알림까지 — 서버 감시 시스템 구축기

> "서버 죽었는데 아무도 몰랐다"에서 "에러나면 1분 안에 안다"까지

---

## 1\. 모니터링을 시작한 계기

> 프론트엔드: "API 안 되는데요?"  
> 백엔드: "엥? 언제부터요?"  
> 프론트엔드: "...2시간 전부터요?"  
> 백엔드: 😱

어느 날 서버가 죽어있었는데 **아무도 몰랐다**. 그날 이후, 모니터링 시스템 구축을 결심했다. (Issue [#102](https://github.com/Finders-Official/BE/issues/102))

---

## 2\. 모니터링 도구 비교: 뭘 쓸까?

처음에는 여러 도구를 비교했다.

| 도구 | 무료 티어 | 장점 | 단점 |
| --- | --- | --- | --- |
| **Sentry** | 5,000 에러/월, 1유저 | 에러 추적 특화, Spring Boot 연동 쉬움 | Discord 연동은 Team 플랜($26/월) 필요 |
| **Datadog** | 14일 체험 | 올인원 (메트릭+로그+APM) | 무료 없음. 비쌈 ($15~/호스트/월) |
| **New Relic** | 100GB/월 무료 | 무료 티어 넉넉 | 설정 복잡, 학습 곡선 높음 |
| **ELK Stack** | 셀프호스팅 무료 | 로그 검색 강력 | **직접 운영**해야 함. 리소스 많이 먹음 |
| **GCP Cloud Monitoring** | GCP 자원 무료 | GCP 인프라 지표 자동 수집 | **앱 레벨 에러 추적은 못 함** |

### 우리의 선택

```plaintext
앱 에러 추적    → Sentry (무료 Developer 플랜)
인프라 지표     → GCP Cloud Monitoring (무료)
실시간 알림     → Discord Webhook (직접 구현, 무료)
로그 중앙화     → GCP Cloud Logging (Docker gcplogs 드라이버)
```

**핵심 전략: "무료로 최대한 커버하고, 부족한 건 직접 만들자"**

---

## 3\. Sentry 도입하기

### 왜 Sentry인가?

Spring Boot에서 에러 모니터링을 시작하기 가장 쉬운 도구였다.

**의존성 하나 추가:**

```plaintext
// build.gradle
implementation 'io.sentry:sentry-spring-boot-starter-jakarta:8.28.0'
```

**설정 추가:**

```yaml
# application.yml
sentry:
  dsn: ${SENTRY_DSN}              # Sentry 프로젝트 DSN
  environment: ${SPRING_PROFILES_ACTIVE:local}
  traces-sample-rate: 1.0          # 성능 추적 비율
  send-default-pii: false          # 개인정보 전송 안 함
  in-app-includes: com.finders     # 우리 코드만 하이라이트
  logging:
    minimum-event-level: warn      # WARN 이상만 Sentry로
    minimum-breadcrumb-level: info # Breadcrumb은 INFO부터
```

이것만으로 서버에서 발생하는 **모든 Exception이 자동으로 Sentry에 기록**된다.

![Sentry 에러 모니터링 대시보드](https://sentry.io/static/javascript-fd40b2ef06c89b843f16055734aa2eed.jpg align="left")

*Sentry 대시보드에서 에러 목록과 발생 빈도를 한눈에 볼 수 있다*

### 환경별 샘플링 차이

| 환경 | 샘플링 비율 | 이유 |
| --- | --- | --- |
| Dev | **100%** | 모든 에러 다 봐야 디버깅 가능 |
| Prod | **30%** | 비용 절감. 패턴만 파악하면 충분 |

> 무료 플랜은 월 5,000 에러까지라서, prod에서 100% 캡처하면 한도를 금방 넘긴다.

### Sentry 초기 삽질: @ExceptionHandler와의 충돌

처음에 Sentry를 붙이고 나서, **에러가 캡처가 안 되는** 문제가 있었다.

원인: Spring의 `@ExceptionHandler`가 에러를 처리해버리면, Sentry 입장에서는 "에러가 아닌 정상 응답"으로 보이는 것이었다!

```yaml
# 해결: exception-resolver-order를 최우선으로 설정
sentry:
  exception-resolver-order: -2147483648  # HIGHEST_PRECEDENCE
  # → @ExceptionHandler보다 먼저 예외를 캡처!
```

이 설정 하나로 해결되었지만, 찾는 데 한참 걸렸다... (Issue [#177](https://github.com/Finders-Official/BE/issues/177))

---

## 4\. Sentry Discord 알림 — 왜 포기했나

Sentry에서 에러가 기록되는 건 좋은데, **대시보드를 열어봐야 알 수 있다**는 게 문제였다.

> "에러 났을 때 바로 알림 받고 싶은데..."

### Sentry의 알림 옵션

| 방법 | 비용 | 비고 |
| --- | --- | --- |
| **Email 알림** | **무료** ✅ | Developer 플랜에서 사용 가능 |
| **Slack 연동** | $26/월 (Team 플랜) | Third-party integration 필요 |
| **Discord 연동** | $26/월 (Team 플랜) | Third-party integration 필요 |

**Discord 연동을 하려면 Team 플랜($26/월)이 필요**했다! 대학생 팀에게 매달 $26은... 🥲

그래서 결론:

* Sentry는 **Email 알림만** 사용 (무료)
    
* Discord 알림은 **직접 구현**하기로!
    

---

## 5\. Discord Webhook 직접 만들기

### Discord 설정: 이게 제일 헷갈렸다

Discord에서 Webhook URL을 만드는 과정이 **의외로 복잡**했다.

> 💡 **Discord 웹후크 설정 경로**: 서버 설정(⚙️) → 앱 → 연동 → 웹후크 → "새 웹후크" → URL 복사

**자주 헷갈리는 것들 정리:**

| 이름 | 뭔가 | 어디서 찾나 |
| --- | --- | --- |
| **서버 ID** | Discord 서버의 고유 번호 | 서버 아이콘 우클릭 → "서버 ID 복사" |
| **채널 ID** | 텍스트 채널의 고유 번호 | 채널 이름 우클릭 → "채널 ID 복사" |
| **Webhook URL** | 메시지를 보낼 수 있는 URL | 서버 설정 → 연동 → 웹후크 → URL 복사 |

> **주의!** "채널 ID"와 "Webhook URL"은 **완전히 다른 것**이다.  
> Sentry 같은 서비스는 채널 ID를 요구하고, 직접 구현할 때는 Webhook URL을 쓴다.

### Webhook URL 만드는 법

```plaintext
1. Discord 서버 설정 (⚙️) 클릭
2. 앱 > 연동 > 웹후크 클릭
3. "새 웹후크" 클릭
4. 이름 설정 (예: "Finders 에러 알림")
5. 채널 선택 (에러 알림을 받을 채널)
6. "웹후크 URL 복사" 클릭
```

> **⚠️ 개발자 모드가 필요할 수 있다!** Discord 사용자 설정 → 고급 → **개발자 모드 켜기** 이게 꺼져있으면 "ID 복사" 메뉴가 안 보인다. 찾기 어려워서 한참 헤맸다...

> 💡 **개발자 모드 경로**: Discord 사용자 설정(⚙️) → 앱 설정 → 고급 → 개발자 모드 토글 ON

---

## 6\. 코드 구조: 어떻게 만들었나

### 전체 구조

```plaintext
src/main/java/com/finders/api/
├── infra/discord/
│   ├── DiscordProperties.java       # 설정 (webhook-url, enabled)
│   ├── DiscordWebhookService.java   # 알림 전송 로직
│   └── dto/
│       └── DiscordMessage.java      # Discord Embed 메시지 포맷
└── global/exception/
    └── GlobalExceptionHandler.java  # 여기서 Discord 호출!
```

### 동작 흐름

```mermaid
graph TD
    A["🚨 서버 500 에러"] --> B["GlobalExceptionHandler"]
    B --> C["DiscordWebhookService"]
    C --> D["💬 Discord 채널 알림"]
    B --> E["📊 Sentry 자동 기록"]
    B --> F["👤 사용자에게 에러 응답"]
```

```plaintext
1. 사용자 요청 → 서버에서 에러 발생 (500)
2. GlobalExceptionHandler가 catch
3. DiscordWebhookService.sendErrorNotification() 호출
4. Discord 채널에 Embed 메시지 전송 🔔
5. (동시에) Sentry에도 자동 기록
6. (동시에) 사용자에게는 깔끔한 에러 응답 반환
```

### 핵심 코드 1: GlobalExceptionHandler

```java
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(
        Exception e, HttpServletRequest request) {
    
    log.error("[UnhandledException] {} - {}", 
              request.getRequestURI(), e.getMessage(), e);
    
    try {
        // 💡 에러 발생 즉시 Discord로 알림!
        discordWebhookService.sendErrorNotification(
            e, request.getMethod(), request.getRequestURI()
        );
    } catch (Exception ignored) {
        // 알림 실패가 사용자 응답에 영향 주면 안 됨
    }
    
    return ResponseEntity.status(500)
        .body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR));
}
```

**핵심 설계:**

* Discord 알림이 실패해도 **사용자 응답에 영향 없음** (try-catch로 감쌈)
    
* `500 에러만` 알림 (400대 에러는 클라이언트 실수이니 알림 불필요)
    

### 핵심 코드 2: 중복 알림 방지

같은 에러가 1초에 100번 나면 Discord도 100번 울릴까? **아니다!**

```java
private static final long DEDUPE_WINDOW_MS = 60_000; // 60초

private boolean isDuplicate(String errorKey) {
    long now = System.currentTimeMillis();
    Long lastSeen = recentErrors.putIfAbsent(errorKey, now);
    
    if (lastSeen != null && now - lastSeen < DEDUPE_WINDOW_MS) {
        return true;  // 60초 내 같은 에러 → 스킵!
    }
    return false;
}
```

* **60초 내 같은 에러** (같은 Exception + 같은 URL)는 **한 번만** 알림
    
* Discord Webhook Rate Limit(30요청/분)도 넘기지 않음
    

### 핵심 코드 3: 민감정보 마스킹

스택트레이스에 비밀번호나 토큰이 포함될 수 있다. 자동으로 걸러낸다:

```java
private String filterSensitiveInfo(String stackTrace) {
    return stackTrace
        .replaceAll("(?i)password[=:]\\s*\\S+", "password=***")
        .replaceAll("(?i)token[=:]\\s*\\S+", "token=***")
        .replaceAll("(?i)secret[=:]\\s*\\S+", "secret=***")
        .replaceAll("(?i)api[_-]?key[=:]\\s*\\S+", "api_key=***");
}
```

### 핵심 코드 4: Discord Embed 메시지

Discord에는 단순 텍스트보다 **Embed 메시지**가 훨씬 보기 좋다:

```java
public record DiscordMessage(List<Embed> embeds) {
    public record Embed(
        String title,       // "Server Error"
        String description,
        int color,          // 0xE74C3C (빨간색)
        List<Field> fields, // Exception, Method, URL, Message, Stack Trace
        Instant timestamp
    ) {}
}
```

실제로 받는 알림은 이런 느낌이다:

```plaintext
🚨 Server Error
━━━━━━━━━━━━━━━━━━━
Exception    NullPointerException
Method       POST
URL          /api/v1/reservations
Message      Cannot invoke method on null
Stack Trace  (첫 5줄만)
━━━━━━━━━━━━━━━━━━━
```

### 환경별 설정

```yaml
# application.yml (공통 - 기본 비활성화)
discord:
  webhook-url: ${DISCORD_WEBHOOK_URL:}
  enabled: false

# application-prod.yml (운영에서만 활성화)
discord:
  enabled: true
  webhook-url: ${DISCORD_WEBHOOK_URL}
```

local이나 dev에서 에러 날 때마다 디코에 알림이 오면 **스팸**이 되니까, **prod에서만** 활성화했다.

---

## 7\. 기술 선택 이유: 왜 직접 만들었나?

Issue [#211](https://github.com/Finders-Official/BE/issues/211)에서 여러 방안을 검토했다:

| 방안 | 비용 | 판단 |
| --- | --- | --- |
| Sentry Team + Discord 연동 | **$26/월** | 대학생팀엔 부담 ❌ |
| discord-webhooks 라이브러리 | 무료 | OkHttp 기반, 기존 WebClient 패턴과 불일치 ❌ |
| **WebClient로 직접 구현** | **무료** | 기존 프로젝트 패턴 유지, 의존성 최소화 ✅ |

**직접 구현 시 고려한 것들:**

| 고려사항 | 해결책 |
| --- | --- |
| 알림 실패 시 서비스 영향? | 비동기 전송 + try-catch |
| 스택트레이스에 비밀번호? | 정규식으로 자동 마스킹 |
| 같은 에러 반복 시 스팸? | 60초 중복 제거 (deduplication) |
| local에서도 알림? | enabled: false로 환경별 분리 |

---

## 8\. 최종 모니터링 구조

```plaintext
[Spring Boot 서버]
│
├── 에러 발생 시
│   ├── Sentry ──→ 대시보드에서 상세 분석 📊
│   │              (스택트레이스, 빈도, 환경 정보)
│   │              이메일 알림 (무료)
│   │
│   └── Discord Webhook ──→ 실시간 알림 🔔
│                            (에러 요약 Embed 메시지)
│
├── 인프라 지표
│   └── GCP Cloud Monitoring ──→ 대시보드 📈
│       (CPU, 메모리, 디스크, 네트워크, 에러 로그 수)
│       Terraform으로 대시보드 코드 관리
│
└── 로그
    └── Docker gcplogs ──→ GCP Cloud Logging 📝
        (컨테이너 로그 자동 수집, 라벨로 필터링)
```

---

## 9\. 마치며

### 비용 정리

| 도구 | 비용 | 역할 |
| --- | --- | --- |
| Sentry Developer | **무료** | 에러 추적 + 이메일 알림 |
| Discord Webhook | **무료** (직접 구현) | 실시간 에러 알림 |
| GCP Cloud Monitoring | **무료** | 인프라 지표 대시보드 |
| GCP Cloud Logging | **무료** | 로그 중앙화 |
| **총 비용** | **$0/월** |  |

$26/월짜리 Sentry Team 플랜 대신, 직접 구현해서 **동일한 효과를 무료로** 얻었다.

### 배운 것들

1. **Discord Webhook URL 만들기가 의외로 어렵다** — 개발자 모드, 채널 ID vs 서버 ID vs Webhook URL 구분이 처음엔 많이 헷갈린다
    
2. **Sentry의 무료 플랜은 생각보다 쓸 만하다** — 에러 추적 자체는 무료로 충분. 알림 연동만 유료
    
3. **직접 만들면 우리 상황에 딱 맞출 수 있다** — 중복 제거, 민감정보 필터링, 환경별 분리 등 세밀한 제어 가능
    
4. **모니터링은 빨리 도입할수록 좋다** — "나중에 하자"가 아니라, 서버가 죽은 걸 모르는 것보다 빨리 하는 게 낫다
    

에러가 나면:

1. **Discord로 즉시 알림** → 1분 내 인지
    
2. **Sentry에서 상세 분석** → 원인 파악
    
3. **GCP Cloud Logging에서 로그 추적** → 재현 & 디버깅
    

"서버 죽었는데 몰랐다"는 이제 **옛날 얘기**가 되었다. 😄

---

### 📎 참고 이슈

* [#102 Sentry 모니터링 시스템 구축](https://github.com/Finders-Official/BE/issues/102)
    
* [#177 Sentry가 @ExceptionHandler 예외를 캡처하도록 설정](https://github.com/Finders-Official/BE/issues/177)
    
* [#211 Discord Webhook을 통한 서버 에러 알림 구현](https://github.com/Finders-Official/BE/issues/211)
    
* [Sentry 공식 가격표](https://sentry.io/pricing/)
    
* [Discord Webhook 가이드](https://discord.com/developers/docs/resources/webhook)
