디스코드나 슬랙의 웹훅을 통해서
서버에서 특정 동작을 수행하면 알림을 보내는 기능을 추가해보고자 한다.
간단하게 예시를 만들면서 이해해보자.
@Configuration
public class WebConfig {
@Bean
public RestTemplate restTemplate() {
// RestTemplate 은 동기적으로 HTTP 요청을 처리하는 객체
// WebConfig 에서 RestTemplate 을 Bean 으로 등록하여 애플리케이션 전역에서 사용할 수 있도록 함
return new RestTemplate();
}
}
RestTemplate 을 사용하여 구현해보고자 한다.
RestTemplate은 Spring에서 제공하는 동기적인 HTTP 클라이언트 라이브러리이다.
주로 서버 간 통신에서 RESTful API를 호출할 때 사용되며
Spring Boot 프로젝트에서 HTTP 요청과 응답을 처리하기 위한 간단하고 직관적인 방법을 제공한다.
동기적 처리:
메서드 호출이 끝날 때까지 블로킹(Blocking)된다.
요청이 완료되기 전까지 호출한 스레드는 대기 상태가 된다.
RESTful API 통신 지원:
GET, POST, PUT, DELETE 등 HTTP 메서드를 쉽게 호출할 수 있도록 메서드를 제공한다.
객체 매핑:
요청 및 응답 데이터를 자동으로 Java 객체로 직렬화/역직렬화(Jackson 등)를 처리한다.
예외 처리:
HTTP 응답 코드가 4xx 또는 5xx일 경우 RestClientException을 발생시킨다.
필요에 따라 커스텀 ResponseErrorHandler를 설정할 수 있다.
커스터마이징 가능:
요청 타임아웃, 헤더 설정, 인터셉터 추가 등을 지원한다.
메서드 | 설명 |
---|---|
getForObject | GET 요청을 보내고, 응답을 Java 객체로 반환. |
getForEntity | GET 요청을 보내고, ResponseEntity 를 반환. |
postForObject | POST 요청을 보내고, 응답을 Java 객체로 반환. |
postForEntity | POST 요청을 보내고, ResponseEntity 를 반환. |
exchange | HTTP 메서드(GET, POST, PUT, DELETE 등)와 헤더, 본문 등을 커스터마이징하여 호출. |
delete | DELETE 요청을 보낸다. |
HTTP 요청을 통해 Discord Webhook 호출:
RestTemplate
은 Discord Webhook URL에 POST 요청을 보내 알림 데이터를 전송한다동기적 HTTP 요청:
RestTemplate
은 동기 방식이므로 요청이 완료될 때까지 스레드가 대기한다.구현의 단순성:
RestTemplate
은 메서드 호출만으로 직관적으로 HTTP 요청을 보낼 수 있어, 초기 구현 단계에서 적합하다.Webflux 를 사용하는 방식도 있겠지만 이는 러닝 커브가 발생하고
이번 프로젝트에서 적용할 시간이 부족하다고 판단하여 RestTemplate 을 사용하기로 결정했다.
동작의 순서대로 설명하는 것이 좋을 것 같아서 순서에 따라 정리를 진행하겠따
@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 메시지를 담는 것으로 보인다.
@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
가 무엇일까? 이것이 무엇이길래 이벤트 시스템에 발행이될까?
Spring에서 제공하는 이벤트 발행(Publish) 기능을 담당하는 인터페이스다.
이를 사용하여 애플리케이션 내에서 이벤트를 생성하고 전달할 수 있다.
Spring의 이벤트 시스템은 Publisher-Subscriber 패턴을 기반으로 설계되어,
특정 이벤트가 발생하면 해당 이벤트를 리스닝(Listen)하는 컴포넌트가 이를 처리할 수 있다.
NotificationEvent 생성
NotificationEvent는 Spring의 이벤트 시스템에서 처리할 수 있는 커스텀 이벤트 객체이다.
Spring에서는 모든 객체를 이벤트로 사용할 수 있지만,
보통 특정 작업을 나타내기 위해 지금처럼 이벤트 클래스를 만들어 사용한다.
이 객체는 위에서 봤듯이 EventPublisher 에서 생성되어 사용된다.
ApplicationEventPublisher.publishEvent() 호출:
publishEvent(NotificationEvent)를 호출하면, Spring이 해당 이벤트를 이벤트 시스템에 등록한다.
등록된 이벤트는 ApplicationEventMulticaster라는
Spring 내부 컴포넌트가 처리하며, 관련된 모든 이벤트 리스너들에게 전달된다.
@EventListener로 이벤트 리스닝:
Spring 컨텍스트에서 @EventListener로 등록된 메서드들이 해당 이벤트를 감지하고, 처리 로직을 실행한다.
이러한 과정을 보면 아~ 이번엔 이벤트 리스너가 등장할 차례겠구나~ 를 얼추 짐작할 수 있다.
@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를 매개변수로 넘겨준다.
@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
를 사용하면 안될까?
이렇게 분리하는 이유는 소프트웨어 설계 원칙과 유지보수성을 고려한 구조적 접근 때문이다.
NotificationEventListener
:
NotificationService
:
WebhookClient
:
이 구조는 각 클래스가 자신의 역할에만 집중할 수 있도록 설계된 것이다.
NotificationService
를 사용하면 알림 처리 로직을 중앙 집중화할 수 있다.
만약 알림 전송 로직이 변경되거나, Discord 외에도 이메일, SMS 등의 추가 채널로 확장해야 한다면, NotificationService
에서만 변경사항을 처리하면 된다/
이메일과 Discord 모두로 알림을 보내야 한다고 가정:
public void sendNotification(String message) {
// Discord Webhook에 알림 전송
webhookClient.sendToDiscord(message);
// 이메일 알림 추가
emailClient.sendEmail(message);
}
이처럼 새로운 알림 채널이 추가되더라도 NotificationEventListener
는 수정이 필요 없습니다. 수정은 NotificationService
내부에서만 이루어진다.
NotificationEventListener
가 WebhookClient
를 직접 사용하면, 알림 처리와 관련된 모든 의존성을 NotificationEventListener
에서 관리해야 한다.
반면, NotificationService
를 통해 접근하면, NotificationEventListener
는 알림 처리 로직의 세부 구현(예: Discord Webhook 전송)을 알 필요 없이 단순히 "알림을 보내라"는 요청만 한다.
이는 의존성 관리가 더 쉬워지고, 코드의 캡슐화(encapsulation)를 강화합니다.
NotificationService
를 통해 간접적으로 WebhookClient
를 호출하면, 테스트가 훨씬 용이하다.
NotificationService
를 Mock 객체로 대체하여 리스너 로직을 테스트할 수 있다.
반대로, WebhookClient
를 Mock 객체로 대체하여 알림 전송 로직을 개별적으로 테스트할 수도 있다.
NotificationEventListener
테스트:NotificationService
를 Mock으로 주입.NotificationService
테스트:WebhookClient
를 Mock으로 주입.이 방식을 더 고도화하는 방법은 다음에 정리하고자한다.
우선은 제일 간단한 이 방식으로 우리 프로젝트에 적용시켜보자.