Skip to main content

Command Palette

Search for a command to run...

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

Published
8 min read

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


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

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

어느 날 서버가 죽어있었는데 아무도 몰랐다. 그날 이후, 모니터링 시스템 구축을 결심했다. (Issue #102)


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

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

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

우리의 선택

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

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


3. Sentry 도입하기

왜 Sentry인가?

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

의존성 하나 추가:

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

설정 추가:

# 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 에러 모니터링 대시보드

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

환경별 샘플링 차이

환경샘플링 비율이유
Dev100%모든 에러 다 봐야 디버깅 가능
Prod30%비용 절감. 패턴만 파악하면 충분

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

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

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

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

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

이 설정 하나로 해결되었지만, 찾는 데 한참 걸렸다... (Issue #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 복사

자주 헷갈리는 것들 정리:

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

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

Webhook URL 만드는 법

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

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

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


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

전체 구조

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 호출!

동작 흐름

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

핵심 코드 1: GlobalExceptionHandler

@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번 울릴까? 아니다!

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: 민감정보 마스킹

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

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 메시지가 훨씬 보기 좋다:

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
    ) {}
}

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

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

환경별 설정

# 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에서 여러 방안을 검토했다:

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

직접 구현 시 고려한 것들:

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

8. 최종 모니터링 구조

[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에서 로그 추적 → 재현 & 디버깅

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


📎 참고 이슈

More from this blog

[Elasticsearch] Elasticsearch 기본용어와 CRUD 명령어

-elastic ▶ 들어가며 이번 글에서는 Elasticsearch를 공부하면서 가장 먼저 익혀야 하는 기본 용어를 정리하고,직접 코드를 쳐가며 CRUD(Create / Read / Update / Delete) 명령어에 익숙해지는 시간을 가져보려고 한다. Elasticsearch는 처음 보면 생소한 용어가 많아서 막막할 수 있는데,사실 구조적으로는 우리가 익숙한 MySQL과 닮은 부분이 굉장히 많다. 둘 다 데이터베이스라는 큰 틀 안에서 데...

Feb 17, 20264 min read

Jpa N+1 문제, 우리는 이렇게 잡았다 — 1:1 문의 Api 실전 최적화기

코드 리뷰 한 줄에서 시작된 쿼리 최적화 여정 1. 시작 — "일단 돌아가게 만들자" Finders 프로젝트에서 1:1 문의(Inquiry) API를 맡았다. 현상소에 문의를 남기고, 답변을 받고, 목록을 조회하는 — 평범한 CRUD다. "JPA 쓰면 쿼리 안 짜도 되는 거 아니야?" 솔직히 처음엔 그렇게 생각했다. JpaRepository에 findAll, findById 쓰면 끝이니까. // 첫 번째 버전의 목록 조회 (QueryDS...

Feb 11, 20268 min read

[CI/CD] 수동 태그에서 자동 릴리즈까지 — Git Flow와 Auto Release

🚀 우리 auto-release.yml 바로 보러가기 → 이 글에서 설명하는 워크플로우의 전체 코드를 바로 확인할 수 있다! main에 머지만 하면 버전 태그부터 릴리즈 노트까지 알아서 생긴다 1. 우리의 Git 전략: Git Flow (경량 버전) Finders 프로젝트는 Git Flow 전략을 사용하고 있다. 다만 hotfix나 release 브랜치 없이, 조금 가볍게 운영한다. main ← 운영 서버 (prod) 배포 브...

Feb 11, 20265 min read

[ci/cd] 대학생 팀의 배포 파이프라인 진화기

PR 하나면 끝나는 무중단 배포까지, 삽질의 기록 1. 시작은 단순했다 "서버에 올려야 하는데... 어떻게 하지?" Finders 프로젝트를 시작했을 때, 배포라는 걸 해본 적이 없었다. 그래서 처음에는 이렇게 했다. 1. SSH로 서버 접속 2. git pull 3. ./gradlew build 4. java -jar app.jar 당연히 문제가 생겼다. 빌드하는 동안 서버가 꺼져있음 (프론트: "API 왜 안 돼요?" 🔥) 빌...

Feb 11, 20267 min read
F

Finders Tech Blog

16 posts

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