Discord에 500 Error Webhook으로 알림 보내기

2025. 11. 26. 00:52·Programming

Webhook이란?

웹 훅(은 특정 이벤트가 발생했을 때, 지정된 URL로 HTTP 요청을 자동으로 보내는 방식을 의미합니다.

쉽게 말해, "이런 일이 일어나면 이 주소로 알려줘!"라고 미리 연결해 두는 것입니다.

이번에 해볼건 서버에서 500에러가 발생했을 때, 이 사실을 프론트에게 혹은 팀원들에게 알리고 싶어서 하나의 팀 스페이스 디스코드나 슬랙을 통해 이 사실을 알려주도록 하는 것입니다!

1. 디스코드 웹훅(Webhook) 설정

먼저 디스코드에서 웹훅을 생성해 웹훅 URL을 준비해야 합니다.

테스트 환경을 위해 저는 팀원들이 사용한다고 가정한 미녁 서버를 기준으로 설명하겠습니다.

 

 

웹훅을 생성하려면 먼저 왼쪽 상단의 서버 이름을 클릭해 서버 설정 메뉴로 들어갑니다.

 

 

이제 왼쪽 메뉴에서 ‘연동’을 선택한 뒤, ‘새 웹후크’ 버튼을 눌러 웹훅 봇을 만들 수 있습니다.

저는 웹훅 봇의 프로필 이미지와 이름, 그리고 메시지를 보낼 채널을 설정해,
해당 채널에서 에러 메시지를 받을 수 있도록 구성했습니다.

 

가장 중요한 단계는 웹훅 URL을 복사하는 것입니다.

이 URL이 있어야 나중에 서버에서 500 에러가 발생했을 때, 해당 URL로 요청을 보내고

웹훅 봇이 전달된 에러 내용을 디스코드 채팅에 출력해줄 수 있습니다.

2. 핸들러를 통해 500 에러 감지

여기까지는 디스코드에서의 설정이었고, 이제는 스프링 서버에서 무엇을 해야 하는지 살펴보겠습니다.

보통 우리의 서버는 클라이언트로부터 요청을 받는 역할을 해왔지만, 디스코드 웹훅으로 메시지를 보내려면 서버가 바깥으로 HTTP 요청을 전송해야 합니다. 이를 구현하는 방법은 여러 가지가 있는데, 저는 WebClient를 사용했습니다.

WebClientConfig

먼저 스프링에서 외부 API로 요청을 보내기 위해 WebClient를 Bean으로 등록해줍니다. 이렇게 해두면 애플리케이션 전역에서 의존성 주입을 통해 WebClient를 손쉽게 사용할 수 있습니다.

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        return WebClient.builder().build();
    }
}

이 설정을 통해 매번 new WebClient() 를 만들 필요 없이, 스프링 컨테이너가 생성한싱글톤 WebClient 인스턴스를 공유하게 됩니다. 특히 웹훅처럼 외부 서비스에 요청을 보내야 할 때, 이렇게 등록된 WebClient를 주입받아 바로 사용할 수 있습니다.


ErrorEmbed

다음으로, 디스코드에 전달할 에러 정보를 한 번에 담기 위해 ErrorEmbed라는 **레코드(Record)**를 만들었습니다. 이 구조체는 서버에서 발생한 예외와 관련된 다양한 정보를 저장해, 웹훅으로 전송할 때 JSON 형태로 쉽게 직렬화할 수 있도록 도와줍니다.

public record ErrorEmbed(
        Throwable throwable,
        String when,
        String method,
        String path,
        String message,
        String traceId,
        String environment,
        String appName
) {}

 

각 필드의 의미는 다음과 같습니다

 

throwable 실제로 발생한 예외 객체
when 에러가 발생한 시각 또는 상황 설명
method 요청 HTTP 메서드(GET, POST 등)
path 요청 URL 경로
message 에러 메시지 요약
traceId 에러 트래킹용 Trace ID(로그 추적에 사용)
environment 현재 실행 환경(local/dev/prod 등)
appName 애플리케이션 이름

 

이 ErrorEmbed는 에러 발생 시 디스코드에 보낼 구조화된 데이터 모델이며,

이후 WebClient에서 JSON으로 변환해 전송하게 됩니다.


DiscordAlertProbs

본격적으로 웹훅 기능을 구현하기 전에, 먼저 디스코드 웹훅 관련 설정 값을 관리하기 위한 준비를 해줍니다. 이를 위해 DiscordAlertProps라는 설정용 레코드 클래스를 생성했습니다.

 

이 클래스는 application.yml에 정의된 설정 값을 바인딩하는 역할을 하며,

환경마다 다른 값을 손쉽게 적용할 수 있도록 도와줍니다.

@ConfigurationProperties(prefix = "alert.discord")
public record DiscordAlertProps(
        boolean enabled,
        String webhookUrl,
        String username,
        int timeoutSeconds
) {
}

“그냥 코드 안에서 직접 값만 넣어두면 되는 거 아닌가?” 라고 생각할 수도 있습니다.

 

하지만 이렇게 별도의 설정 클래스로 분리해두면 환경별로 다른 설정을 관리하기 쉬워지고, 코드에서 웹훅 URL이나 설정 값을 하드코딩하지 않기 때문에 보안성과 유지보수성이 훨씬 좋아질거 같아요!


DiscordServer Sender

사실 직접적으로 구현할 때는 GPT의 도움을 받았지만, 그 안에서 얻어갈 중요 내용들에 대해 정리해보고자 합니다!

SendErrorEmbed

public void sendErrorEmbed(ErrorEmbed ctx, int topN) {
        if (!props.enabled() || isBlank(props.webhookUrl())) {
            return;
        }

        Map<String, Object> payload = buildPayload(ctx, topN);

        webClient.post()
                .uri(props.webhookUrl())
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(payload)
                .retrieve()
                .toBodilessEntity()
                .timeout(Duration.ofSeconds(props.timeoutSeconds()))
                .onErrorResume(e -> {
                    log.warn("Discord webhook send failed: {}", e.toString());
                    return Mono.empty();
                }).subscribe();
    }

 

안에 들어갈 내용 만들기

Map<String, Object> payload = buildPayload(ctx, topN);

 

Discord Webhook을 사용하려면, Discord에서 미리 정해둔 요청 형식과 규칙을 따라야 합니다!

이 함수는 그 형식에 맞춰 데이터를 구성해 전달하는 역할을 하죠.

 

특히 Webhook 공식 문서의 Execute Webhook API 섹션을 보면, 우리는 JSON 방식으로 요청을 보내기 때문에 Discord가 정의한 JSON/Form Params 규칙을 참고하면 됩니다.

 

 
 

 

https://discord.com/developers/docs/resources/webhook#execute-webhook

 

Discord for Developers

Build games, experiences, and integrations for millions of users on Discord.

discord.com

 

Webhook 메시지는 여러 필드를 넣을 수 있지만, 지금은 단순히 에러를 알리는 목적이기 때문에

username과 embeds만 사용해 payload를 구성했습니다.

 

username은 Webhook에 표시될 이름이고,핵심은 embeds 구조에 있어요!

 

 

https://discord.com/developers/docs/resources/message#embed-object

 

Discord for Developers

Build games, experiences, and integrations for millions of users on Discord.

discord.com

 

Embed 객체 안에 title, color, timestamp, description 같은 요소를 담을 수 있고, 에러 로그를 보기 좋게 보여주기 위해 description에 오류 내용을 넣었습니다!

 

description 필드 안에는 오류 상황을 파악하기 위한 주요 정보들을 문자열로 담았어요

구체적으로는 요청 시각(when), HTTP 메서드(method), 요청 경로(path), 상태(status), 예외 타입(exception), 메시지(message), Trace-Id, 그리고 상위 N개의 stacktrace 내용을 포함합니다.

 

여기서 Trace-Id는 아직 완전히 구현된 기능은 아니지만, 향후 요청마다 필터를 통해 고유 ID를 부여해 500 에러가 발생한 요청을 추적할 수 있도록 미리 필드를 만들어둔 것입니다.

 

마지막으로, Stacktrace는 전체 내용을 넣으면 너무 길어지기 때문에, 상위 몇 줄만 추려서 넣어 어떤 오류가 발생했는지 빠르게 파악할 수 있게 했습니다!

 

WebClient를 통해 요청보내기

webClient.post()
                .uri(props.webhookUrl())
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(payload)
                .retrieve()
                .toBodilessEntity()
                .timeout(Duration.ofSeconds(props.timeoutSeconds()))
                .onErrorResume(e -> {
                    log.warn("Discord webhook send failed: {}", e.toString());
                    return Mono.empty();
                }).subscribe();

 

WebClient를 사용해 Discord Webhook URL로 POST 요청을 보내는 부분입니다. 앞서 구성한 payload를 JSON 형태로 전송하며, Discord가 정상적으로 응답하면 별도의 추가 작업 없이 종료됩니다.

 

이 호출은 비동기 방식으로 이루어지며 실제 요청 실행과 응답 처리는 subscribe() 시점에 수행됩니다!

 

만약 네트워크 오류나 Discord 서버 오류로 인해 전송에 실패하더라도 onErrorResume()에서 로그만 남기고 흐름은 계속 진행되도록 처리하였다. 즉, 이 로직은 메인 서비스 흐름과 완전히 분리된 fire-and-forget 방식으로 동작하여, 알림 전송 실패가 본 요청 처리에 영향을 주지 않도록 설계된 것입니다!

 


GeneralExceptionAdvice

기존 RestControllerAdvice를 약간 리팩터링하여, 500 에러가 발생했을 때 앞에서 작성한 sendErrorEmbed 함수를 호출하도록 구성하면 된다. 이렇게 하면 서버 내부 오류가 발생할 때마다 자동으로 Discord에 에러 알림이 전송됩니다.

또한 description 내부에 traceId 값을 포함시키는데, 원래는 필터를 통해 모든 요청에 대해 traceId를 남기는 것이 이상적입니다...

 

다만 지금 단계에서는 단순화를 위해, 500 에러가 발생한 경우에만 @ExceptionHandler 내부에서 임의의 traceId를 생성하여 사용하도록 구현했어요!. 추후 구조가 더 정돈되면 필터 기반의 traceId 관리 방식으로 확장할 수 있습니다!

 

@ExceptionHandler(GeneralException.class)

@ExceptionHandler(GeneralException.class)
    public ResponseEntity<ApiResponse<Void>> handleGeneral(GeneralException ex, HttpServletRequest req) {
        int status = ex.getCode().getStatus().value();
        if (status >= 500) {
            String traceId = UUID.randomUUID().toString().substring(0, 8);
            discord.sendErrorEmbed(
                    new ErrorEmbed(
                            ex,
                            OffsetDateTime.now().toString(),
                            req.getMethod(),
                            req.getRequestURI(),
                            ex.getMessage(),
                            traceId,
                            env,
                            appName
                    ),
                    6
            );
        }
        return ResponseEntity.status(ex.getCode().getStatus())
                .body(ApiResponse.onFailure(ex.getCode(), null));
    }

 

@ExceptionHandler(Exception.class)

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<String>> handleAny(Exception ex, HttpServletRequest req) {
        BaseErrorCode code = GeneralErrorCode.INTERNAL_SERVER_ERROR;

        String traceId = UUID.randomUUID().toString().substring(0, 8);
        discord.sendErrorEmbed(
                new ErrorEmbed(
                        ex,
                        OffsetDateTime.now().toString(),
                        req.getMethod(),
                        req.getRequestURI(),
                        ex.getMessage(),
                        traceId,
                        env,
                        appName
                ),
                6
        );

        return ResponseEntity.status(code.getStatus())
                .body(ApiResponse.onFailure(code, ex.getMessage()));
    }

GeneralException과 Exception 두 핸들러를 모두 둔 이유는, 비즈니스 로직에서 의도적으로 발생시키는 예외와 시스템에서 예상하지 못한 예외를 구분하기 위해서 입니다.

 

GeneralException의 경우 미리 정의된 에러 코드에 따라 적절한 응답을 내려주고, 그 중 서버 오류(500 이상)일 때만 알림을 보내도록 해 불필요한 노이즈를 줄일 수 있어요.

 

반면 일반 Exception은 예기치 않은 오류이기 때문에 무조건 Discord 알림을 보내 장애를 즉시 인지할 수 있도록 합니다. 이렇게 나누면 사용자에게는 안정적인 공통 에러 응답을 제공하면서도, 운영 측면에서는 의도된 예외와 진짜 장애를 명확히 구분해 대응할 수 있겠죠?


3. 에러 발생 시 알림 테스트

테스트를 위해 존재하지 않는 엔드포인트(/test/boom)로 요청을 보내봤습니다.

 

 

 # 서버_에러 채널로 알림이 정상 수신되었고, 메시지에는 No static resource test/boom이라고 표시되어 해당 URL이 존재하지 않음을 명확히 알려줍니다.
즉, 디스코드 웹훅 전송 흐름이 제대로 동작하며, 미등록 경로에 대한 오류도 기대한 대로 감지·통지되고 있음을 확인할 수 있었어요!

 


4. 개발 서버, 로컬 서버 알림 분리

 

현재는 로컬 서버에서 발생하는 500 에러도 디스코드로 알림이 가도록 설정해두었지만, 실제 배포 환경에서는 이렇게 두면 혼란을 초래할 수 있습니다. 예를 들어 백엔드 개발 중에 단순히 Postman으로 테스트하다가 잘못된 엔드포인트를 호출했을 뿐인데, 디스코드에는 에러 알림이 뜨게 되고, 이를 본 프론트엔드 팀이 자신들의 문제라고 오해할 수 있겠죠..?

 

따라서 로컬 환경과 실제 서버 환경의 알림을 분리하는 것이 필수입니다!!.

로컬에서는 단순 로그만 찍어도 충분하고, 디스코드 알림은 개발 서버나 운영 서버에서만 활성화되면 끝!

다행히 현재 구조에도 이미 이를 위한 장치가 준비되어 있습니다.

 

DiscordAlertProps를 보면 enabled라는 설정 값이 있는데, application.yaml에서 이 값을 true로 설정하면 알림 기능이 활성화되고 false로 설정하면 비활성화 됩니다. 즉, 로컬 환경과 개발/운영 환경에서 서로 다른 설정 파일을 사용해 enabled 값을 달리 지정해주기만 하면 되는거죠!

 

사실 다른 방법도 있을 듯 하다..!

지금은 application.yaml 설정에서 Discord 알림을 단순히 켜고 끄는 방식으로도 충분히 해결할 수 있습니다. 하지만 앞으로 Swagger 공개 여부까지 함께 고려하면, 로컬 환경과 개발 서버 환경을 명확히 분리하는 것이 더 좋은 방향일 것 같다는 생각이 듭니다. 예를 들어 로컬에서는 알림과 외부 연동 기능을 비활성화하고, 개발 서버에서는 실제 운영과 비슷한 설정으로 알림·Swagger를 활성화하는 식으로 말이죠.. 이렇게 프로필을 구분해두면 환경에 따라 동작이 깔끔하게 분리되고, 배포 과정에서도 설정 실수를 방지할 수 있어 전체적인 운영 안정성과 개발 편의성이 높아질거 같아요!

 

'Programming' 카테고리의 다른 글

우테코 3주차 회고  (0) 2025.11.26
우테코 2주차 회고  (0) 2025.11.26
AOP랑 OOP랑 뭐가 다르지?  (0) 2025.09.26
SOLID 원칙: 객체 지향 설계의 5가지 기본 원칙  (0) 2025.09.18
'Programming' 카테고리의 다른 글
  • 우테코 3주차 회고
  • 우테코 2주차 회고
  • AOP랑 OOP랑 뭐가 다르지?
  • SOLID 원칙: 객체 지향 설계의 5가지 기본 원칙
baeminn
baeminn
새로운 기술을 익히는 데 그치지 않고, 실제 문제 해결에 적용하며 구조와 한계를 함께 고민해왔습니다. 서비스 설계, 공간 데이터 분석, 도구 개발 등 다양한 프로젝트를 통해 문제 해결에 필요한 기술을 직접 선택하고 적용해 왔으며, 하나의 역할에 머무르지 않고 필요한 영역까지 책임지는 개발자로 성장하고자 합니다!
  • baeminn
    BaeLog
    baeminn
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • Programming
      • Java
      • CS
        • DB
        • OS
      • Web
      • Spring
      • GeoInfo
      • Infra N
        • Kubernates
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃허브
  • 공지사항

  • 인기 글

  • 태그

    hash
    database
    poll
    spring
    Datebase
    sharding
    replicaton
    데이터베이스
    OS
    Java
    dbcp
    OOP
    db
    MAP
    n+1
    infra
    spring aop
    멀티플렉싱
    redis
    epoll
    matcher
    우테코
    partitioning
    cs
    I/O
    select
    AOP
    쉬운코드
    io
    set
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
baeminn
Discord에 500 Error Webhook으로 알림 보내기
상단으로

티스토리툴바