Webhook 을 통해 알림을 구현해보자!

위승현·2024년 12월 27일
0

Spring

목록 보기
10/12

디스코드나 슬랙의 웹훅을 통해서
서버에서 특정 동작을 수행하면 알림을 보내는 기능을 추가해보고자 한다.

간단하게 예시를 만들면서 이해해보자.

@Configuration
public class WebConfig {

    @Bean
    public RestTemplate restTemplate() {
        // RestTemplate 은 동기적으로 HTTP 요청을 처리하는 객체
        // WebConfig 에서 RestTemplate 을 Bean 으로 등록하여 애플리케이션 전역에서 사용할 수 있도록 함
        return new RestTemplate();
    }
}

RestTemplate 을 사용하여 구현해보고자 한다.

RestTemplate이 뭔데?

RestTemplate은 Spring에서 제공하는 동기적인 HTTP 클라이언트 라이브러리이다.
주로 서버 간 통신에서 RESTful API를 호출할 때 사용되며
Spring Boot 프로젝트에서 HTTP 요청과 응답을 처리하기 위한 간단하고 직관적인 방법을 제공한다.

특징

  1. 동기적 처리:

    메서드 호출이 끝날 때까지 블로킹(Blocking)된다.
    요청이 완료되기 전까지 호출한 스레드는 대기 상태가 된다.

  2. RESTful API 통신 지원:

    GET, POST, PUT, DELETE 등 HTTP 메서드를 쉽게 호출할 수 있도록 메서드를 제공한다.

  3. 객체 매핑:

    요청 및 응답 데이터를 자동으로 Java 객체로 직렬화/역직렬화(Jackson 등)를 처리한다.

  4. 예외 처리:

    HTTP 응답 코드가 4xx 또는 5xx일 경우 RestClientException을 발생시킨다.
    필요에 따라 커스텀 ResponseErrorHandler를 설정할 수 있다.

  5. 커스터마이징 가능:

    요청 타임아웃, 헤더 설정, 인터셉터 추가 등을 지원한다.


RestTemplate 주요 메서드

메서드설명
getForObjectGET 요청을 보내고, 응답을 Java 객체로 반환.
getForEntityGET 요청을 보내고, ResponseEntity를 반환.
postForObjectPOST 요청을 보내고, 응답을 Java 객체로 반환.
postForEntityPOST 요청을 보내고, ResponseEntity를 반환.
exchangeHTTP 메서드(GET, POST, PUT, DELETE 등)와 헤더, 본문 등을 커스터마이징하여 호출.
deleteDELETE 요청을 보낸다.

일단 단순한게 최고야! RestTemplate

알림 시스템과 RestTemplate의 역할

  1. HTTP 요청을 통해 Discord Webhook 호출:

    • RestTemplate은 Discord Webhook URL에 POST 요청을 보내 알림 데이터를 전송한다
    • 이때, 요청 본문은 JSON 형식으로 직렬화되어 전송된다.
  2. 동기적 HTTP 요청:

    • RestTemplate은 동기 방식이므로 요청이 완료될 때까지 스레드가 대기한다.
    • 간단한 시스템에서는 동기적 방식이 충분히 유용할 수 있다.
  3. 구현의 단순성:

    • RestTemplate은 메서드 호출만으로 직관적으로 HTTP 요청을 보낼 수 있어, 초기 구현 단계에서 적합하다.

Webflux 를 사용하는 방식도 있겠지만 이는 러닝 커브가 발생하고
이번 프로젝트에서 적용할 시간이 부족하다고 판단하여 RestTemplate 을 사용하기로 결정했다.


동작 방식

동작의 순서대로 설명하는 것이 좋을 것 같아서 순서에 따라 정리를 진행하겠따

1. TestController

@RestController
public class TestController {

    private final EventPublisher eventPublisher;

    public TestController(EventPublisher eventPublisher) {
        // EventPublisher를 주입받아 알림 이벤트를 발행할 수 있도록 설정
        this.eventPublisher = eventPublisher;
    }

    @GetMapping("/test-notification")
    public String testNotification() {
        // "/test-notification" 엔드포인트를 호출하면 알림 이벤트를 발행
        // 테스트 메시지를 포함한 NotificationEvent를 발행하고, 클라이언트에 성공 메시지를 반환
        eventPublisher.publishNotification("테스트 알림: 카드가 생성되었습니다.");
        return "Notification sent!";
    }
}

가장 먼저 호출될 test controller 를 살펴보자. 무엇인지는 모르겠지만
EventPublusher 라는 것을 사용하여 해당 엔드포인트로 요청이 들어올 때
publishNotification 메서드를 호출하고 매개변수로 String 메시지를 담는 것으로 보인다.

2. EventPublisher

@Component
public class EventPublisher {

    private final ApplicationEventPublisher publisher;

    public EventPublisher(ApplicationEventPublisher publisher) {
        // ApplicationEventPublisher를 주입받아 이벤트를 발행할 수 있도록 설정
        this.publisher = publisher;
    }

    public void publishNotification(String message) {
        // NotificationEvent라는 이벤트 객체를 생성하여 발행(publish)
        // 이를 통해 Spring의 이벤트 리스너(EventListener)에서 이 이벤트를 처리
        publisher.publishEvent(new NotificationEvent(message));
    }
}

@Getter
public class NotificationEvent {
    private final String message;

    public NotificationEvent(String message) {
        // NotificationEvent는 이벤트 객체로, 발행된 메시지를 저장
        // 이를 통해 리스너에서 이벤트 내용을 참조
        this.message = message;
    }
}

EventPublisher는 특정 이벤트 메시지를 담은 NotificationEvent
Spring의 이벤트 시스템에 발행하는 역할을 수행한다.

ApplicationEventPublisher 가 무엇일까? 이것이 무엇이길래 이벤트 시스템에 발행이될까?


ApplicationEventPublisher란 무엇인가?

Spring에서 제공하는 이벤트 발행(Publish) 기능을 담당하는 인터페이스다.
이를 사용하여 애플리케이션 내에서 이벤트를 생성하고 전달할 수 있다.

Spring의 이벤트 시스템은 Publisher-Subscriber 패턴을 기반으로 설계되어,
특정 이벤트가 발생하면 해당 이벤트를 리스닝(Listen)하는 컴포넌트가 이를 처리할 수 있다.


ApplicationEventPublisher의 역할

  1. 이벤트 발행:
    애플리케이션에서 발생한 특정 작업(예: 알림, 데이터 변경 등)을 이벤트 객체로 감싸서
    다른 컴포넌트들에게 알림.
  1. 리스너 호출:
    이벤트가 발행되면, 해당 이벤트를 처리하도록 등록된 이벤트 리스너(@EventListener)
    메서드들이 자동으로 호출됨.

NotificationEvent 객체가 이벤트 시스템에 등록되는 과정

  1. NotificationEvent 생성

    NotificationEvent는 Spring의 이벤트 시스템에서 처리할 수 있는 커스텀 이벤트 객체이다.
    Spring에서는 모든 객체를 이벤트로 사용할 수 있지만,
    보통 특정 작업을 나타내기 위해 지금처럼 이벤트 클래스를 만들어 사용한다.

    이 객체는 위에서 봤듯이 EventPublisher 에서 생성되어 사용된다.

  2. ApplicationEventPublisher.publishEvent() 호출:

    publishEvent(NotificationEvent)를 호출하면, Spring이 해당 이벤트를 이벤트 시스템에 등록한다.
    등록된 이벤트는 ApplicationEventMulticaster라는
    Spring 내부 컴포넌트가 처리하며, 관련된 모든 이벤트 리스너들에게 전달된다.

  3. @EventListener로 이벤트 리스닝:

    Spring 컨텍스트에서 @EventListener로 등록된 메서드들이 해당 이벤트를 감지하고, 처리 로직을 실행한다.

이러한 과정을 보면 아~ 이번엔 이벤트 리스너가 등장할 차례겠구나~ 를 얼추 짐작할 수 있다.


3. NotificationEventListener

@Component
public class NotificationEventListener {

    private final NotificationService notificationService;

    public NotificationEventListener(NotificationService notificationService) {
        // NotificationService를 주입받아 이벤트가 발생했을 때 알림을 처리할 수 있도록 설정
        this.notificationService = notificationService;
    }

    @EventListener
    public void handleNotificationEvent(NotificationEvent event) {
        // NotificationEvent가 발생하면 이 메서드가 호출
        // 이벤트의 메시지를 NotificationService를 통해 처리
        notificationService.sendNotification(event.getMessage());
    }
}

보면 handleNotificationEvent 메서드 위에 @EventListener 가 등록된 모습을 볼 수 있다.

아까도 말했듯이
ApplicationEventPublisher.publishEvent(newNotificationEvent(message));

가 호출될 때 이 메서드가 호출된다.

이는 notificationService의 sendNotification 메서드를 호출한다.
이때 event 에 담겼던 String message를 매개변수로 넘겨준다.


4. NotificationService, WebhookClient

@Service
public class NotificationService {

    private final WebhookClient webhookClient;

    public NotificationService(WebhookClient webhookClient) {
        // WebhookClient를 주입받아 알림을 Discord Webhook에 전송할 수 있도록 설정
        this.webhookClient = webhookClient;
    }

    public void sendNotification(String message) {
        // 메시지를 Discord Webhook으로 전송
        webhookClient.sendToDiscord(message);
    }
}

@Service
public class WebhookClient {

    private final RestTemplate restTemplate;

    @Value("${discord.webhook.url}")
    private String discordWebhookUrl;

    public WebhookClient(RestTemplate restTemplate) {
        // RestTemplate을 주입받아 HTTP 요청을 처리할 수 있도록 설정.
        this.restTemplate = restTemplate;
    }

    public void sendToDiscord(String message) {
        // Discord Webhook으로 메시지를 전송하는 메서드

        HttpHeaders headers = new HttpHeaders();
        headers.set("Content-Type", "application/json"); // 요청 본문의 Content-Type을 JSON으로 설정

        // Discord Webhook이 요구하는 JSON 형식으로 메시지를 생성
        String payload = "{ \"content\": \"" + message + "\" }";
        HttpEntity<String> request = new HttpEntity<>(payload, headers);

        // RestTemplate의 exchange 메서드를 사용하여 POST 요청을 전송
        ResponseEntity<String> response = restTemplate.exchange(
            discordWebhookUrl,  // Webhook URL
            HttpMethod.POST,    // HTTP 메서드 (POST)
            request,            // 요청 본문과 헤더
            String.class        // 응답 타입
        );

        // 요청 결과를 콘솔에 출력합니다.
        System.out.println("Discord response: " + response.getBody());
    }
}

그 후 NotificationService 에서 WebhookClient 의 sendToDiscord 메서드를 호출하고
discord 웹훅 url 을 사용하여 해당 url에 json 형태의 데이터를 POST로 보내주면 알림이 정상적으로 서버에 가게된다.

실행해보자

해당 엔드포인트로 get 요청을 보내면 정상적으로 알림이 수행되는 모습을 볼 수 있다.


왜 이렇게 분리할까?

구조를 보다보면 과한가? 싶을 정도로 많이 분리된 모습을 볼 수 있다.
굳이 왜 이렇게 event listner 라느니, service, webhook client 를 나누는 걸까?
NotificationEventListener에서 바로 WebhookClient를 사용하면 안될까?

이렇게 분리하는 이유는 소프트웨어 설계 원칙유지보수성을 고려한 구조적 접근 때문이다.


1. 역할과 책임 분리 (Single Responsibility Principle, SRP)

  • NotificationEventListener:

    • 이벤트를 리스닝하고 이벤트가 발생했을 때 필요한 처리를 트리거하는 역할만 담당
    • 실제 알림 전송 로직에 대해 알 필요가 없다
  • NotificationService:

    • 알림 처리의 비즈니스 로직을 담당
    • 알림 메시지를 가공하거나, 로깅, 재시도 정책 등 추가 로직을 포함할 수 있다
  • WebhookClient:

    • HTTP 요청을 보내는 저수준의 기술적 구현에만 집중한다,
    • 외부 시스템과의 통신 세부사항을 관리한다.

이 구조는 각 클래스가 자신의 역할에만 집중할 수 있도록 설계된 것이다.


2. 유지보수성과 확장성

  • NotificationService를 사용하면 알림 처리 로직을 중앙 집중화할 수 있다.

  • 만약 알림 전송 로직이 변경되거나, Discord 외에도 이메일, SMS 등의 추가 채널로 확장해야 한다면, NotificationService에서만 변경사항을 처리하면 된다/

예:

  • 이메일과 Discord 모두로 알림을 보내야 한다고 가정:

    public void sendNotification(String message) {
        // Discord Webhook에 알림 전송
        webhookClient.sendToDiscord(message);
        
        // 이메일 알림 추가
        emailClient.sendEmail(message);
    }
  • 이처럼 새로운 알림 채널이 추가되더라도 NotificationEventListener는 수정이 필요 없습니다. 수정은 NotificationService 내부에서만 이루어진다.


3. 의존성 주입 계층 간소화

  • NotificationEventListenerWebhookClient를 직접 사용하면, 알림 처리와 관련된 모든 의존성을 NotificationEventListener에서 관리해야 한다.

  • 반면, NotificationService를 통해 접근하면, NotificationEventListener는 알림 처리 로직의 세부 구현(예: Discord Webhook 전송)을 알 필요 없이 단순히 "알림을 보내라"는 요청만 한다.

  • 이는 의존성 관리가 더 쉬워지고, 코드의 캡슐화(encapsulation)를 강화합니다.


4. 테스트 용이성

  • NotificationService를 통해 간접적으로 WebhookClient를 호출하면, 테스트가 훨씬 용이하다.

    • NotificationService를 Mock 객체로 대체하여 리스너 로직을 테스트할 수 있다.

    • 반대로, WebhookClient를 Mock 객체로 대체하여 알림 전송 로직을 개별적으로 테스트할 수도 있다.

테스트 구조:

  1. NotificationEventListener 테스트:
    • NotificationService를 Mock으로 주입.
    • 이벤트 발생 시 서비스 호출 여부만 검증.
  1. NotificationService 테스트:
    • WebhookClient를 Mock으로 주입.
    • 알림 메시지 생성 및 전송 로직 검증.

5. 가독성 및 코드 이해도 향상

  • 서비스 계층을 통해 로직을 한 곳에 집중시키면 이벤트 처리 로직알림 처리 로직이 명확히 분리됩니다.
  • 새로운 개발자가 코드를 읽을 때도 각각의 역할이 명확히 드러나므로 이해하기 쉽습니다.

결론

  1. 역할과 책임 분리: 이벤트 리스너는 알림 처리의 시작점일 뿐, 세부 구현을 알 필요 없다
  2. 유지보수성: 알림 로직을 확장하거나 변경할 때, 변경 범위를 최소화할 수 있다.
  3. 테스트 용이성: 각각의 계층을 독립적으로 테스트할 수 있다.
  4. 코드 가독성: 서비스 계층을 통해 로직이 더 구조화되고 명확해진다.
  5. 확장성: 알림 처리 로직을 다른 채널(이메일, SMS 등)로 쉽게 확장할 수 있다.

이 방식을 더 고도화하는 방법은 다음에 정리하고자한다.

우선은 제일 간단한 이 방식으로 우리 프로젝트에 적용시켜보자.

profile
개발일기

0개의 댓글

관련 채용 정보