From 7a04f36c284f983e8a9843eb16424307753c108f Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Wed, 21 May 2025 16:39:17 +0900 Subject: [PATCH 01/27] =?UTF-8?q?refactor:=20=EC=A0=84=EB=9E=B5=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=EA=B3=BC=20=EC=8B=AC=ED=94=8C=20=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - serviceType에 따라 적절한 NotificationService 구현체를 심플 팩토리에서 주입 - NotificationContext에서 전략 실행 로직 위임 --- .../makers/handler/SentryWebhookHandler.java | 15 ++++------- .../makers/service/NotificationContext.java | 16 +++++++++++ .../service/NotificationServiceFactory.java | 27 +++++++++---------- 3 files changed, 33 insertions(+), 25 deletions(-) create mode 100644 src/main/java/org/sopt/makers/service/NotificationContext.java diff --git a/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java b/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java index 018a84a..059d60b 100644 --- a/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java +++ b/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java @@ -14,7 +14,7 @@ import org.sopt.makers.global.exception.unchecked.InvalidSlackPayloadException; import org.sopt.makers.global.exception.unchecked.SentryUncheckedException; import org.sopt.makers.global.util.EnvUtil; -import org.sopt.makers.service.NotificationService; +import org.sopt.makers.service.NotificationContext; import org.sopt.makers.service.NotificationServiceFactory; import com.amazonaws.services.lambda.runtime.Context; @@ -38,17 +38,13 @@ public SentryWebhookHandler() { @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent apiGatewayEvent, Context context) { try { - // 웹훅 요청 처리 WebhookRequest webhookRequest = WebhookRequest.from(apiGatewayEvent); logWebhookReceived(webhookRequest); - // Sentry 이벤트 추출 SentryEventDetail sentryEvent = extractSentryEvent(apiGatewayEvent.getBody()); - // 알림 전송 - sendNotification(webhookRequest, sentryEvent); + processNotification(webhookRequest, sentryEvent); - // 성공 응답 반환 return createApiGatewayResponse( HttpURLConnection.HTTP_OK, createSuccessResponseBody() @@ -99,16 +95,15 @@ private JsonNode parseRequestBody(String requestBody) { } } - private void sendNotification(WebhookRequest request, SentryEventDetail event) throws SentryCheckedException { + private void processNotification(WebhookRequest request, SentryEventDetail event) throws SentryCheckedException { String serviceType = request.serviceType(); String team = request.team(); String stage = request.stage(); String type = request.type(); - - NotificationService notificationService = NotificationServiceFactory.createNotificationService(serviceType); String webhookUrl = EnvUtil.getWebhookUrl(serviceType, team, stage, type); - notificationService.sendNotification(team, type, stage, event, webhookUrl); + NotificationContext notificationContext = new NotificationContext(NotificationServiceFactory.createService(serviceType)); + notificationContext.executeNotification(team, type, stage, event, webhookUrl); } private APIGatewayProxyResponseEvent handleSentryCheckedException(SentryCheckedException e) { diff --git a/src/main/java/org/sopt/makers/service/NotificationContext.java b/src/main/java/org/sopt/makers/service/NotificationContext.java new file mode 100644 index 0000000..cbea328 --- /dev/null +++ b/src/main/java/org/sopt/makers/service/NotificationContext.java @@ -0,0 +1,16 @@ +package org.sopt.makers.service; + +import org.sopt.makers.dto.SentryEventDetail; +import org.sopt.makers.global.exception.checked.SentryCheckedException; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class NotificationContext { + private final NotificationService strategy; + + public void executeNotification(String team, String type, String stage, SentryEventDetail event, String webhookUrl) + throws SentryCheckedException { + strategy.sendNotification(team, type, stage, event, webhookUrl); + } +} diff --git a/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java b/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java index 82529dc..9736a7e 100644 --- a/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java +++ b/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java @@ -2,6 +2,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.function.Supplier; import org.sopt.makers.global.exception.message.ErrorMessage; import org.sopt.makers.global.exception.unchecked.UnsupportedServiceTypeException; @@ -13,26 +14,22 @@ @Slf4j @NoArgsConstructor(access = AccessLevel.PRIVATE) public class NotificationServiceFactory { - - private static final Map serviceMap = new HashMap<>(); + private static final Map> services = new HashMap<>(); static { - serviceMap.put("slack", new SlackNotificationService()); - // serviceMap.put("discord", new DiscordNotificationService()); + registerService("slack", SlackNotificationService::new); + registerService("discord", DiscordNotificationService::new); } - /** - * 서비스 유형에 맞는 알림 서비스 구현체 반환 - * - * @param serviceType 서비스 유형 (slack, discord) - * @return 알림 서비스 구현체 - * @throws UnsupportedServiceTypeException 지원하지 않는 서비스 유형인 경우 - */ - public static NotificationService createNotificationService(String serviceType) { - NotificationService notificationService = serviceMap.get(serviceType.toLowerCase()); - if (notificationService == null) { + public static NotificationService createService(String serviceType) { + Supplier supplier = services.get(serviceType.toLowerCase()); + if (supplier == null) { throw new UnsupportedServiceTypeException(ErrorMessage.UNSUPPORTED_SERVICE_TYPE); } - return notificationService; + return supplier.get(); + } + + private static void registerService(String type, Supplier supplier) { + services.put(type.toLowerCase(), supplier); } } From 8a77ed0f40fcbb19f3e0e3b01854b568861d3877 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Wed, 21 May 2025 16:40:52 +0900 Subject: [PATCH 02/27] =?UTF-8?q?refactor:=20=EB=B2=94=EC=9A=A9=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=EC=9C=BC=EB=A1=9C=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/checked/MessageBuildException.java | 13 +++++++++++++ .../global/exception/checked/SendException.java | 13 +++++++++++++ .../checked/SlackMessageBuildException.java | 13 ------------- .../exception/checked/SlackSendException.java | 13 ------------- .../sopt/makers/service/NotificationService.java | 8 ++++---- .../makers/service/SlackNotificationService.java | 14 +++++++------- 6 files changed, 37 insertions(+), 37 deletions(-) create mode 100644 src/main/java/org/sopt/makers/global/exception/checked/MessageBuildException.java create mode 100644 src/main/java/org/sopt/makers/global/exception/checked/SendException.java delete mode 100644 src/main/java/org/sopt/makers/global/exception/checked/SlackMessageBuildException.java delete mode 100644 src/main/java/org/sopt/makers/global/exception/checked/SlackSendException.java diff --git a/src/main/java/org/sopt/makers/global/exception/checked/MessageBuildException.java b/src/main/java/org/sopt/makers/global/exception/checked/MessageBuildException.java new file mode 100644 index 0000000..ebd5c6b --- /dev/null +++ b/src/main/java/org/sopt/makers/global/exception/checked/MessageBuildException.java @@ -0,0 +1,13 @@ +package org.sopt.makers.global.exception.checked; + +import org.sopt.makers.global.exception.base.BaseErrorCode; + +public class MessageBuildException extends SentryCheckedException { + public MessageBuildException(BaseErrorCode errorCode) { + super(errorCode); + } + + public static MessageBuildException from(BaseErrorCode errorCode) { + return new MessageBuildException(errorCode); + } +} diff --git a/src/main/java/org/sopt/makers/global/exception/checked/SendException.java b/src/main/java/org/sopt/makers/global/exception/checked/SendException.java new file mode 100644 index 0000000..f02e934 --- /dev/null +++ b/src/main/java/org/sopt/makers/global/exception/checked/SendException.java @@ -0,0 +1,13 @@ +package org.sopt.makers.global.exception.checked; + +import org.sopt.makers.global.exception.base.BaseErrorCode; + +public class SendException extends SentryCheckedException { + public SendException(BaseErrorCode errorCode) { + super(errorCode); + } + + public static SendException from(BaseErrorCode errorCode) { + return new SendException(errorCode); + } +} diff --git a/src/main/java/org/sopt/makers/global/exception/checked/SlackMessageBuildException.java b/src/main/java/org/sopt/makers/global/exception/checked/SlackMessageBuildException.java deleted file mode 100644 index 7cbbd18..0000000 --- a/src/main/java/org/sopt/makers/global/exception/checked/SlackMessageBuildException.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.sopt.makers.global.exception.checked; - -import org.sopt.makers.global.exception.base.BaseErrorCode; - -public class SlackMessageBuildException extends SentryCheckedException { - public SlackMessageBuildException(BaseErrorCode errorCode) { - super(errorCode); - } - - public static SlackMessageBuildException from(BaseErrorCode errorCode) { - return new SlackMessageBuildException(errorCode); - } -} diff --git a/src/main/java/org/sopt/makers/global/exception/checked/SlackSendException.java b/src/main/java/org/sopt/makers/global/exception/checked/SlackSendException.java deleted file mode 100644 index 9298c0f..0000000 --- a/src/main/java/org/sopt/makers/global/exception/checked/SlackSendException.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.sopt.makers.global.exception.checked; - -import org.sopt.makers.global.exception.base.BaseErrorCode; - -public class SlackSendException extends SentryCheckedException { - public SlackSendException(BaseErrorCode errorCode) { - super(errorCode); - } - - public static SlackSendException from(BaseErrorCode errorCode) { - return new SlackSendException(errorCode); - } -} diff --git a/src/main/java/org/sopt/makers/service/NotificationService.java b/src/main/java/org/sopt/makers/service/NotificationService.java index 9d54f73..8c578de 100644 --- a/src/main/java/org/sopt/makers/service/NotificationService.java +++ b/src/main/java/org/sopt/makers/service/NotificationService.java @@ -2,8 +2,8 @@ import org.sopt.makers.dto.SentryEventDetail; import org.sopt.makers.global.exception.checked.SentryCheckedException; -import org.sopt.makers.global.exception.checked.SlackMessageBuildException; -import org.sopt.makers.global.exception.checked.SlackSendException; +import org.sopt.makers.global.exception.checked.MessageBuildException; +import org.sopt.makers.global.exception.checked.SendException; public interface NotificationService { /** @@ -14,8 +14,8 @@ public interface NotificationService { * @param stage 환경 * @param eventDetail 이벤트 상세 정보 * @param webhookUrl 웹훅 URL - * @throws SlackMessageBuildException 메시지 생성 실패 시 - * @throws SlackSendException 전송 실패 시 + * @throws MessageBuildException 메시지 생성 실패 시 + * @throws SendException 전송 실패 시 */ void sendNotification(String team, String type, String stage, SentryEventDetail eventDetail, String webhookUrl) throws SentryCheckedException; diff --git a/src/main/java/org/sopt/makers/service/SlackNotificationService.java b/src/main/java/org/sopt/makers/service/SlackNotificationService.java index 6f83c1b..daa01e3 100644 --- a/src/main/java/org/sopt/makers/service/SlackNotificationService.java +++ b/src/main/java/org/sopt/makers/service/SlackNotificationService.java @@ -16,8 +16,8 @@ import org.sopt.makers.global.config.ObjectMapperConfig; import org.sopt.makers.global.constant.Color; import org.sopt.makers.global.exception.checked.SentryCheckedException; -import org.sopt.makers.global.exception.checked.SlackMessageBuildException; -import org.sopt.makers.global.exception.checked.SlackSendException; +import org.sopt.makers.global.exception.checked.MessageBuildException; +import org.sopt.makers.global.exception.checked.SendException; import org.sopt.makers.global.exception.message.ErrorMessage; import org.sopt.makers.global.exception.unchecked.HttpRequestException; import org.sopt.makers.global.util.HttpClientUtil; @@ -57,7 +57,7 @@ private SlackMessage createSlackMessage(String team, String type, String stage, } catch (DateTimeException e) { log.error("Slack 메시지 생성 실패: team={}, type={}, stage={}, id={}, error={}", team, type, stage, sentryEventDetail.issueId(), e.getMessage(), e); - throw SlackMessageBuildException.from(ErrorMessage.SLACK_MESSAGE_BUILD_FAILED); + throw MessageBuildException.from(ErrorMessage.SLACK_MESSAGE_BUILD_FAILED); } } @@ -69,19 +69,19 @@ private void sendSlackMessage(SlackMessage slackMessage, String webhookUrl, Stri HttpResponse response = HttpClientUtil.sendPost(webhookUrl, CONTENT_TYPE_JSON, jsonPayload); handleSlackResponse(response, team, type, stage, sentryEventDetail); } catch (HttpRequestException e) { - throw SlackSendException.from(e.getBaseErrorCode()); + throw SendException.from(e.getBaseErrorCode()); } catch (IOException e) { - throw SlackSendException.from(ErrorMessage.SLACK_SERIALIZATION_FAILED); + throw SendException.from(ErrorMessage.SLACK_SERIALIZATION_FAILED); } } private void handleSlackResponse(HttpResponse response, String team, String type, String stage, - SentryEventDetail sentryEventDetail) throws SlackSendException { + SentryEventDetail sentryEventDetail) throws SendException { if (response.statusCode() != 200 || !"ok".equalsIgnoreCase(response.body())) { String errorMsg = String.format("Slack API 응답 오류, status: %d, body: %s", response.statusCode(), response.body()); log.error("{}", errorMsg); - throw SlackSendException.from(ErrorMessage.SLACK_SEND_FAILED); + throw SendException.from(ErrorMessage.SLACK_SEND_FAILED); } log.info("[Slack 전송 완료] team={}, type={}, stage={}, id={}, statusCode={}", team, type, stage, From 71661812e1193691f45df7b54700ed7e1339a489 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Wed, 21 May 2025 17:10:39 +0900 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20Discord=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../makers/global/exception/message/ErrorMessage.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java b/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java index c7f1043..76f2cd3 100644 --- a/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java +++ b/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java @@ -14,14 +14,22 @@ public enum ErrorMessage implements BaseErrorCode { // ===== Slack 및 일반 서비스 오류 ===== INVALID_SLACK_PAYLOAD(400, "Slack 페이로드 형식이 잘못되었습니다.", "S4001"), - UNSUPPORTED_SERVICE_TYPE(400, "지원하지 않는 서비스 유형입니다.", "S4002"), SLACK_MESSAGE_BUILD_FAILED(500, "Slack 메시지 생성에 실패했습니다.", "S5001"), SLACK_SEND_INTERRUPTED(500, "Slack 알림 전송이 중단되었습니다.", "S5002"), SLACK_SERIALIZATION_FAILED(500, "Slack 메시지를 JSON으로 변환하는 중 오류가 발생했습니다.", "S5003"), SLACK_SEND_FAILED(502, "Slack 전송 요청에 실패했습니다.", "S5021"), SLACK_NETWORK_ERROR(503, "Slack 서버 연결에 실패했습니다.", "S5031"), + // ===== Discord 및 일반 서비스 오류 ===== + INVALID_DISCORD_PAYLOAD(400, "Discord 페이로드 형식이 잘못되었습니다.", "D4001"), + DISCORD_MESSAGE_BUILD_FAILED(500, "Discord 메시지 생성에 실패했습니다.", "D5001"), + DISCORD_SEND_INTERRUPTED(500, "Discord 알림 전송이 중단되었습니다.", "D5002"), + DISCORD_SERIALIZATION_FAILED(500, "Discord 메시지를 JSON으로 변환하는 중 오류가 발생했습니다.", "D5003"), + DISCORD_SEND_FAILED(502, "Discord 전송 요청에 실패했습니다.", "D5021"), + DISCORD_NETWORK_ERROR(503, "Discord 서버 연결에 실패했습니다.", "D5031"), + // ===== 공통 오류 ===== + UNSUPPORTED_SERVICE_TYPE(400, "지원하지 않는 서비스 유형입니다.", "C4001"), UNEXPECTED_SERVER_ERROR(500, "내부 서버 오류가 발생했습니다.", "C5001"); private final int status; From 6bcc847a4e93b9d241d1603bfc9533b0cd407d8b Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Wed, 21 May 2025 17:11:14 +0900 Subject: [PATCH 04/27] =?UTF-8?q?feat:=20Discord=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EA=B4=80=EB=A0=A8=20VO=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../makers/vo/discord/embed/DiscordEmbed.java | 26 +++++++++++++++++++ .../vo/discord/message/DiscordMessage.java | 18 +++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/main/java/org/sopt/makers/vo/discord/embed/DiscordEmbed.java create mode 100644 src/main/java/org/sopt/makers/vo/discord/message/DiscordMessage.java diff --git a/src/main/java/org/sopt/makers/vo/discord/embed/DiscordEmbed.java b/src/main/java/org/sopt/makers/vo/discord/embed/DiscordEmbed.java new file mode 100644 index 0000000..410e4b3 --- /dev/null +++ b/src/main/java/org/sopt/makers/vo/discord/embed/DiscordEmbed.java @@ -0,0 +1,26 @@ +package org.sopt.makers.vo.discord.embed; + +import java.util.List; + +public record DiscordEmbed( + String title, + String description, + String url, + Integer color, + List fields +) { + public static DiscordEmbed newInstance(String title, String description, String url, Integer color, + List fields) { + return new DiscordEmbed(title, description, url, color, fields); + } + + public record DiscordEmbedField( + String name, + String value, + boolean inline + ) { + public static DiscordEmbedField newInstance(String name, String value, boolean inline) { + return new DiscordEmbedField(name, value, inline); + } + } +} diff --git a/src/main/java/org/sopt/makers/vo/discord/message/DiscordMessage.java b/src/main/java/org/sopt/makers/vo/discord/message/DiscordMessage.java new file mode 100644 index 0000000..a01d3ff --- /dev/null +++ b/src/main/java/org/sopt/makers/vo/discord/message/DiscordMessage.java @@ -0,0 +1,18 @@ +package org.sopt.makers.vo.discord.message; + +import java.util.List; + +import org.sopt.makers.vo.discord.embed.DiscordEmbed; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record DiscordMessage( + String username, + @JsonProperty("avatar_url") + String avatarUrl, + List embeds +) { + public static DiscordMessage newInstance(String username, String avatarUrl, List embeds) { + return new DiscordMessage(username, avatarUrl, embeds); + } +} From 26acfb7b9ac24d3e3feaef12b4bd569da5ebaf6d Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Wed, 21 May 2025 17:12:48 +0900 Subject: [PATCH 05/27] =?UTF-8?q?feat:=20DiscordNotificationService=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=83=81=EC=88=98=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SlackConstant와 중복되는 에러 메시지가 포함되어 있으나, 향후 서비스별 커스터마이징 가능성을 고려해 중복을 허용하고 별도로 정의함 --- .../global/constant/DiscordConstant.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/main/java/org/sopt/makers/global/constant/DiscordConstant.java diff --git a/src/main/java/org/sopt/makers/global/constant/DiscordConstant.java b/src/main/java/org/sopt/makers/global/constant/DiscordConstant.java new file mode 100644 index 0000000..aa51ff5 --- /dev/null +++ b/src/main/java/org/sopt/makers/global/constant/DiscordConstant.java @@ -0,0 +1,25 @@ +package org.sopt.makers.global.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class DiscordConstant { + // 컨텐츠 타입 + public static final String CONTENT_TYPE_JSON = "application/json"; + + // 날짜 형식 + public static final String DATE_FORMAT_PATTERN = "yyyy-MM-dd HH:mm:ss"; + public static final String TIMEZONE_SEOUL = "Asia/Seoul"; + + // 메시지 형식 + public static final String EMOJI_PREFIX = "🚨"; + public static final String TRUNCATION_SUFFIX = "..."; + + // Discord 리소스 + public static final String SENTRY_ICON_URL = "https://sentry.io/favicon.ico"; + + // Discord 임베드 제한 + public static final int MAX_TITLE_LENGTH = 256; + public static final int MAX_DESCRIPTION_LENGTH = 4096; +} From a15072c753dd83b6e6f3515f1df679356f6a94bf Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Wed, 21 May 2025 17:13:17 +0900 Subject: [PATCH 06/27] =?UTF-8?q?feat:=20Discord=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sentry 이벤트 정보를 기반으로 Discord 메시지 생성 및 전송 - 메시지 생성 실패, 직렬화 실패, 전송 실패에 대한 예외 처리 로직 포함 - 알림 내용에는 에러 수준, 발생 시각, 팀, 서버 유형 등의 정보 포함 - Slack 구조를 참고하되 Discord 형식에 맞게 임베드로 구성 --- .../service/DiscordNotificationService.java | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/main/java/org/sopt/makers/service/DiscordNotificationService.java diff --git a/src/main/java/org/sopt/makers/service/DiscordNotificationService.java b/src/main/java/org/sopt/makers/service/DiscordNotificationService.java new file mode 100644 index 0000000..b54e60b --- /dev/null +++ b/src/main/java/org/sopt/makers/service/DiscordNotificationService.java @@ -0,0 +1,191 @@ +package org.sopt.makers.service; + +import static org.sopt.makers.global.constant.DiscordConstant.*; + +import java.io.IOException; +import java.net.http.HttpResponse; +import java.time.DateTimeException; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +import org.sopt.makers.dto.SentryEventDetail; +import org.sopt.makers.global.config.ObjectMapperConfig; +import org.sopt.makers.global.constant.Color; +import org.sopt.makers.global.exception.checked.MessageBuildException; +import org.sopt.makers.global.exception.checked.SendException; +import org.sopt.makers.global.exception.checked.SentryCheckedException; +import org.sopt.makers.global.exception.message.ErrorMessage; +import org.sopt.makers.global.exception.unchecked.HttpRequestException; +import org.sopt.makers.global.util.HttpClientUtil; +import org.sopt.makers.vo.discord.embed.DiscordEmbed; +import org.sopt.makers.vo.discord.embed.DiscordEmbed.DiscordEmbedField; +import org.sopt.makers.vo.discord.message.DiscordMessage; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class DiscordNotificationService implements NotificationService { + private final ObjectMapper objectMapper; + + public DiscordNotificationService() { + this.objectMapper = ObjectMapperConfig.getInstance(); + } + + @Override + public void sendNotification(String team, String type, String stage, SentryEventDetail sentryEventDetail, + String webhookUrl) throws SentryCheckedException { + DiscordMessage discordMessage = createDiscordMessage(team, type, stage, sentryEventDetail); + sendDiscordMessage(discordMessage, webhookUrl, team, type, stage, sentryEventDetail); + } + + /** + * Discord 메시지 생성 + */ + private DiscordMessage createDiscordMessage(String team, String type, String stage, + SentryEventDetail sentryEventDetail) throws SentryCheckedException { + try { + return buildDiscordMessage(team, type, stage, sentryEventDetail); + } catch (DateTimeException e) { + log.error("Discord 메시지 생성 실패: team={}, type={}, stage={}, id={}, error={}", team, type, stage, + sentryEventDetail.issueId(), e.getMessage(), e); + throw MessageBuildException.from(ErrorMessage.DISCORD_MESSAGE_BUILD_FAILED); + } + } + + /** + * Discord 메시지를 실제로 전송 + */ + private void sendDiscordMessage(DiscordMessage discordMessage, String webhookUrl, String team, String type, + String stage, SentryEventDetail sentryEventDetail) throws SentryCheckedException { + try { + String jsonPayload = objectMapper.writeValueAsString(discordMessage); + log.info("Discord 메시지: {}", jsonPayload); + HttpResponse response = HttpClientUtil.sendPost(webhookUrl, CONTENT_TYPE_JSON, jsonPayload); + handleDiscordResponse(response, team, type, stage, sentryEventDetail); + } catch (HttpRequestException e) { + throw SendException.from(e.getBaseErrorCode()); + } catch (IOException e) { + throw SendException.from(ErrorMessage.DISCORD_SERIALIZATION_FAILED); + } + } + + /** + * Discord API 응답 처리 + */ + private void handleDiscordResponse(HttpResponse response, String team, String type, String stage, + SentryEventDetail sentryEventDetail) throws SendException { + // Discord는 204 No Content 응답 코드를 반환 + if (response.statusCode() != 204) { + String errorMsg = String.format("Discord API 응답 오류, status: %d, body: %s", response.statusCode(), + response.body()); + log.error("{}", errorMsg); + throw SendException.from(ErrorMessage.DISCORD_SEND_FAILED); + } + + log.info("[Discord 전송 완료] team={}, type={}, stage={}, id={}, statusCode={}", team, type, stage, + sentryEventDetail.issueId(), response.statusCode()); + } + + /** + * Discord 메시지 구성 - Slack과 동일한 메시지 내용을 Discord 메시지 형식으로 변환 + */ + private DiscordMessage buildDiscordMessage(String team, String type, String stage, + SentryEventDetail sentryEventDetail) { + String formattedDate = formatDateTime(sentryEventDetail.datetime()); + String colorHex = Color.getColorByLevel(sentryEventDetail.level()); + int colorInt = convertHexColorToInt(colorHex); + + String title = buildTitle(sentryEventDetail.message()); + List fields = buildFields(team, type, stage, formattedDate, sentryEventDetail.issueId(), + sentryEventDetail.level()); + String description = buildDescription(sentryEventDetail.title()); + + // Discord 임베드 생성 + DiscordEmbed embed = DiscordEmbed.newInstance( + title, + description, + sentryEventDetail.webUrl(), + colorInt, + fields); + + // Discord 메시지 생성 + return DiscordMessage.newInstance( + "Sentry Monitor", + SENTRY_ICON_URL, + List.of(embed)); + } + + /** + * 메시지 제목 구성 + */ + private String buildTitle(String message) { + String title = "%s %s".formatted(EMOJI_PREFIX, message); + + if (title.length() > MAX_TITLE_LENGTH) { + int maxContentLength = MAX_TITLE_LENGTH - TRUNCATION_SUFFIX.length(); + title = "%s %s%s".formatted( + EMOJI_PREFIX, + message.substring(0, maxContentLength - EMOJI_PREFIX.length() - 1), + TRUNCATION_SUFFIX + ); + } + + return title; + } + + /** + * 메시지 상세 필드 구성 + */ + private List buildFields(String team, String type, String stage, String date, String issueId, + String level) { + List fields = new ArrayList<>(); + fields.add(DiscordEmbedField.newInstance("Environment", stage, true)); + fields.add(DiscordEmbedField.newInstance("Team", team, true)); + fields.add(DiscordEmbedField.newInstance("Server Type", type, true)); + fields.add(DiscordEmbedField.newInstance("Issue Id", issueId, true)); + fields.add(DiscordEmbedField.newInstance("Happen", date, true)); + fields.add(DiscordEmbedField.newInstance("Level", level, true)); + return fields; + } + + /** + * 메시지 오류 세부 설명 구성 + */ + private String buildDescription(String title) { + String description = """ + **Error Details:** + ``` + %s + ```""".formatted(title.trim()); + + if (description.length() > MAX_DESCRIPTION_LENGTH) { + description = description.substring(0, MAX_DESCRIPTION_LENGTH - TRUNCATION_SUFFIX.length()) + + TRUNCATION_SUFFIX; + } + + return description; + } + + /** + * HEX 색상 코드를 Discord에서 사용하는 정수로 변환 + */ + private int convertHexColorToInt(String hexColor) { + String cleanHex = hexColor.startsWith("#") ? hexColor.substring(1) : hexColor; + return Integer.parseInt(cleanHex, 16); + } + + /** + * ISO 날짜 포맷팅 + */ + private String formatDateTime(String isoDatetime) { + OffsetDateTime utcTime = OffsetDateTime.parse(isoDatetime, DateTimeFormatter.ISO_DATE_TIME); + LocalDateTime koreaTime = utcTime.atZoneSameInstant(ZoneId.of(TIMEZONE_SEOUL)).toLocalDateTime(); + return koreaTime.format(DateTimeFormatter.ofPattern(DATE_FORMAT_PATTERN)); + } +} From 7f6e1df26ac2c331c31dcf736a1b945935ed09ab Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Wed, 21 May 2025 17:23:29 +0900 Subject: [PATCH 07/27] =?UTF-8?q?chore:=20=EC=9D=98=EB=8F=84=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=EB=A7=A4=EA=B0=9C=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sopt/makers/global/util/EnvUtil.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/sopt/makers/global/util/EnvUtil.java b/src/main/java/org/sopt/makers/global/util/EnvUtil.java index 170a9a3..7aaec78 100644 --- a/src/main/java/org/sopt/makers/global/util/EnvUtil.java +++ b/src/main/java/org/sopt/makers/global/util/EnvUtil.java @@ -20,7 +20,7 @@ public final class EnvUtil { /** * 서비스 유형에 맞는 웹훅 URL 반환 * - * @param service 서비스 유형 (slack, discord 등) + * @param serviceType 서비스 유형 (slack, discord 등) * @param team 팀 이름 (crew, app 등) * @param stage 환경 (dev, prod) * @param type 서버 유형 (be, fe) @@ -28,13 +28,13 @@ public final class EnvUtil { * @throws UnsupportedServiceTypeException 지원하지 않는 서비스 유형인 경우 * @throws WebhookUrlNotFoundException 환경 변수를 찾을 수 없는 경우 */ - public static String getWebhookUrl(String service, String team, String stage, String type) { - if (service == null || team == null || stage == null || type == null) { - log.error("환경 변수 입력값이 null입니다: service={}, team={}, stage={}, type={}", service, team, stage, type); + public static String getWebhookUrl(String serviceType, String team, String stage, String type) { + if (serviceType == null || team == null || stage == null || type == null) { + log.error("환경 변수 입력값이 null입니다: serviceType={}, team={}, stage={}, type={}", serviceType, team, stage, type); throw InvalidEnvParameterException.from(ErrorMessage.INVALID_ENV_PARAMETER); } - String prefix = resolvePrefix(service.toLowerCase()); + String prefix = resolvePrefix(serviceType.toLowerCase()); String envKey = String.format("%s%s_%s_%s", prefix, team.toUpperCase(), @@ -51,8 +51,8 @@ public static String getWebhookUrl(String service, String team, String stage, St return webhookUrl; } - private static String resolvePrefix(String service) { - return switch (service) { + private static String resolvePrefix(String serviceTypeLowerCase) { + return switch (serviceTypeLowerCase) { case "slack" -> SLACK_WEBHOOK_PREFIX; case "discord" -> DISCORD_WEBHOOK_PREFIX; default -> throw new UnsupportedServiceTypeException(ErrorMessage.UNSUPPORTED_SERVICE_TYPE); From 551a82c9865dbe3673d57fe4a386347aead08a2f Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Wed, 21 May 2025 22:10:21 +0900 Subject: [PATCH 08/27] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index ab727e3..1e91c55 100644 --- a/build.gradle +++ b/build.gradle @@ -37,7 +37,9 @@ dependencies { // Test testImplementation platform('org.junit:junit-bom:5.10.2') testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.junit.platform:junit-platform-launcher' testImplementation 'org.mockito:mockito-core:5.10.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.10.0' // Env implementation 'io.github.cdimascio:java-dotenv:5.2.2' From 1ef783f10add7bf942a01331b83a43a687cc4848 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Wed, 21 May 2025 23:16:27 +0900 Subject: [PATCH 09/27] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EC=A0=84=EC=9A=A9=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=EB=A5=BC=20=EA=B4=80=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../makers/handler/FakeSentryPayload.java | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 src/test/java/org/sopt/makers/handler/FakeSentryPayload.java diff --git a/src/test/java/org/sopt/makers/handler/FakeSentryPayload.java b/src/test/java/org/sopt/makers/handler/FakeSentryPayload.java new file mode 100644 index 0000000..7186704 --- /dev/null +++ b/src/test/java/org/sopt/makers/handler/FakeSentryPayload.java @@ -0,0 +1,424 @@ +package org.sopt.makers.handler; + +public final class FakeSentryPayload { + public static final String REAL_SENTRY_WEBHOOK_PAYLOAD = """ + { + "action": "triggered", + "installation": { + "uuid": "14570066-3c21-4c4d-8e9d-78e4995df1f3" + }, + "data": { + "event": { + "event_id": "93153fe001674c70b39af22c49db350b", + "project": 9999999999999999, + "release": "x.y.z", + "dist": null, + "platform": "other", + "message": "Authentication failed, token expired!", + "datetime": "2025-05-21T08:52:45.182000Z", + "tags": [ + [ + "browser", + "Chrome 60.0.3112" + ], + [ + "browser.name", + "Chrome" + ], + [ + "client_os", + "Mac OS X 10.12.6" + ], + [ + "client_os.name", + "Mac OS X" + ], + [ + "device", + "Mac" + ], + [ + "device.family", + "Mac" + ], + [ + "environment", + "production" + ], + [ + "level", + "error" + ], + [ + "os", + "Mac OS X 10.12.6" + ], + [ + "sample_event", + "yes" + ], + [ + "release", + "x.y.z" + ], + [ + "user", + "username:anonymous" + ], + [ + "server_name", + "server-example.com" + ], + [ + "url", + "http://localhost:8080/" + ] + ], + "_meta": { + "platform": { + "": { + "val": "java-logback" + } + } + }, + "_metrics": { + "bytes.stored.event": 33577 + }, + "_ref": 9999999999999999, + "_ref_version": 2, + "breadcrumbs": { + "values": [ + { + "level": "debug", + "message": "Querying for user.", + "timestamp": 1501799139, + "type": "default" + }, + { + "level": "debug", + "message": "User found: anonymous@example.com", + "timestamp": 1501799139, + "type": "default" + }, + { + "level": "info", + "message": "Loaded homepage content from memcached.", + "timestamp": 1501799139, + "type": "default" + }, + { + "level": "warning", + "message": "Sidebar content not in cache, hitting API server.", + "timestamp": 1501799139, + "type": "default" + } + ] + }, + "contexts": { + "browser": { + "browser": "Chrome 60.0.3112", + "name": "Chrome", + "type": "browser", + "version": "60.0.3112" + }, + "client_os": { + "name": "Mac OS X", + "os": "Mac OS X 10.12.6", + "type": "os", + "version": "10.12.6" + }, + "device": { + "brand": "Apple", + "family": "Mac", + "model": "Mac", + "type": "device" + } + }, + "culprit": "io.example.ApiRequest in perform", + "environment": "production", + "exception": { + "values": [ + { + "module": "io.example", + "stacktrace": { + "frames": [ + { + "abs_path": "Thread.java", + "addr_mode": null, + "colno": null, + "context_line": null, + "data": { + "category": "std", + "client_in_app": false + }, + "errors": null, + "filename": "Thread.java", + "function": "run", + "image_addr": null, + "in_app": false, + "instruction_addr": null, + "lineno": 748, + "lock": null, + "module": "java.lang.Thread", + "package": null, + "platform": null, + "post_context": null, + "pre_context": null, + "raw_function": null, + "source_link": null, + "symbol": null, + "symbol_addr": null, + "trust": null, + "vars": null + }, + { + "abs_path": "Application.java", + "addr_mode": null, + "colno": null, + "context_line": null, + "data": { + "category": "framework", + "client_in_app": true, + "orig_in_app": 1 + }, + "errors": null, + "filename": "Application.java", + "function": "home", + "image_addr": null, + "in_app": false, + "instruction_addr": null, + "lineno": 102, + "lock": null, + "module": "io.example.Application", + "package": null, + "platform": null, + "post_context": null, + "pre_context": null, + "raw_function": null, + "source_link": null, + "symbol": null, + "symbol_addr": null, + "trust": null, + "vars": null + }, + { + "abs_path": "Sidebar.java", + "addr_mode": null, + "colno": null, + "context_line": null, + "data": { + "category": "framework", + "client_in_app": true, + "orig_in_app": 1 + }, + "errors": null, + "filename": "Sidebar.java", + "function": "fetch", + "image_addr": null, + "in_app": false, + "instruction_addr": null, + "lineno": 5, + "lock": null, + "module": "io.example.Sidebar", + "package": null, + "platform": null, + "post_context": null, + "pre_context": null, + "raw_function": null, + "source_link": null, + "symbol": null, + "symbol_addr": null, + "trust": null, + "vars": null + }, + { + "abs_path": "ApiRequest.java", + "addr_mode": null, + "colno": null, + "context_line": null, + "data": { + "category": "framework", + "client_in_app": true, + "orig_in_app": 1 + }, + "errors": null, + "filename": "ApiRequest.java", + "function": "perform", + "image_addr": null, + "in_app": false, + "instruction_addr": null, + "lineno": 8, + "lock": null, + "module": "io.example.ApiRequest", + "package": null, + "platform": null, + "post_context": null, + "pre_context": null, + "raw_function": null, + "source_link": null, + "symbol": null, + "symbol_addr": null, + "trust": null, + "vars": null + } + ] + }, + "type": "ApiException", + "value": "Authentication failed, token expired!" + } + ] + }, + "extra": { + "emptyList": [], + "emptyMap": {}, + "length": 10837790, + "results": [ + 1, + 2, + 3, + 4, + 5 + ], + "session": { + "foo": "bar" + }, + "unauthorized": false, + "url": "http://example.org/foo/bar/" + }, + "fingerprint": [ + "{{ default }}" + ], + "grouping_config": { + "enhancements": "KLUv_SAYwQAAkwKRs25ld3N0eWxlOjIwMjMtMDEtMTGQ", + "id": "newstyle:2023-01-11" + }, + "hashes": [ + "438f4d33ad9cb76a7531995b59d34c96" + ], + "level": "error", + "location": "ApiRequest.java", + "logentry": { + "formatted": "Authentication failed, token expired!", + "message": null, + "params": null + }, + "logger": "", + "metadata": { + "filename": "ApiRequest.java", + "function": "perform", + "in_app_frame_mix": "system-only", + "type": "ApiException", + "value": "Authentication failed, token expired!" + }, + "modules": { + "my.package": "1.0.0" + }, + "nodestore_insert": 1747817625.45355, + "received": 1747817625.183474, + "request": { + "api_target": null, + "cookies": null, + "data": { + "logged_in": [ + "1" + ] + }, + "env": { + "AUTH_TYPE": null, + "LOCAL_ADDR": "0:0:0:0:0:0:0:1", + "LOCAL_NAME": "localhost", + "LOCAL_PORT": 8080, + "REMOTE_ADDR": "0:0:0:0:0:0:0:1", + "REMOTE_USER": null, + "REQUEST_ASYNC": false, + "REQUEST_SECURE": false, + "SERVER_NAME": "localhost", + "SERVER_PORT": 8080, + "SERVER_PROTOCOL": "HTTP/1.1" + }, + "fragment": null, + "headers": [ + [ + "Accept-Language", + "en-US,en;q=0.8" + ], + [ + "Host", + "localhost:8080" + ], + [ + "Upgrade-Insecure-Requests", + "1" + ], + [ + "Connection", + "keep-alive" + ], + [ + "Dnt", + "1" + ], + [ + "Cache-Control", + "max-age=0" + ], + [ + "Accept-Encoding", + "gzip, deflate, br" + ], + [ + "User-Agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.78 Safari/537.36" + ], + [ + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" + ] + ], + "inferred_content_type": "application/json", + "method": "GET", + "query_string": [ + [ + "logged_in", + "1" + ] + ], + "url": "http://localhost:8080/" + }, + "sdk": { + "integrations": null, + "name": "sentry-java", + "packages": null, + "version": "1.4.0-3ded0" + }, + "timestamp": 1747817565.182, + "title": "ApiException: Authentication failed, token expired!", + "type": "error", + "user": { + "data": { + "account_level": "premium" + }, + "email": "anonymous@example.com", + "ip_address": "0:0:0:0:0:0:0:1", + "sentry_user": "username:anonymous", + "username": "anonymous" + }, + "version": "5", + "url": "https://sentry.io/api/0/projects/example-org/example-project/events/93153fe001674c70b39af22c49db350b/", + "web_url": "https://sentry.io/organizations/example-org/issues/0000000000/events/93153fe001674c70b39af22c49db350b/", + "issue_url": "https://sentry.io/api/0/organizations/example-org/issues/0000000000/", + "issue_id": "0000000000" + }, + "triggered_rule": "example-alert-dev" + }, + "actor": { + "type": "application", + "id": "sentry", + "name": "Sentry" + } + } + """; + + private FakeSentryPayload() { + } +} From 7ad288966380359f3bd946f4960dde305b45af6e Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Wed, 21 May 2025 23:59:36 +0900 Subject: [PATCH 10/27] =?UTF-8?q?feat:=20eventNode=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EC=B6=9C?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SentryEventExtractorService.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/main/java/org/sopt/makers/service/SentryEventExtractorService.java diff --git a/src/main/java/org/sopt/makers/service/SentryEventExtractorService.java b/src/main/java/org/sopt/makers/service/SentryEventExtractorService.java new file mode 100644 index 0000000..ca6336d --- /dev/null +++ b/src/main/java/org/sopt/makers/service/SentryEventExtractorService.java @@ -0,0 +1,44 @@ +package org.sopt.makers.service; + +import org.sopt.makers.dto.SentryEventDetail; +import org.sopt.makers.global.exception.message.ErrorMessage; +import org.sopt.makers.global.exception.unchecked.InvalidSlackPayloadException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class SentryEventExtractorService { + private final ObjectMapper objectMapper; + + public SentryEventDetail extractEvent(String requestBody) { + JsonNode rootNode = parseRequestBody(requestBody); + JsonNode eventNode = rootNode.path("data").path("event"); + + log.info("rootNode 정보: {}", rootNode); + log.info("eventNode 정보: {}", eventNode); + + if (eventNode.isMissingNode() || eventNode.isEmpty()) { + log.error("[이벤트 데이터 누락] 요청 본문에 필수 이벤트 정보가 없습니다"); + throw InvalidSlackPayloadException.from(ErrorMessage.INVALID_SLACK_PAYLOAD); + } + + SentryEventDetail sentryEvent = SentryEventDetail.from(eventNode); + log.info("[이벤트 추출] issueId={}, level={}", sentryEvent.issueId(), sentryEvent.level()); + + return sentryEvent; + } + + private JsonNode parseRequestBody(String requestBody) { + try { + return objectMapper.readTree(requestBody); + } catch (Exception e) { + log.error("[요청 본문 파싱 실패] error={}", e.getMessage(), e); + throw InvalidSlackPayloadException.from(ErrorMessage.INVALID_SLACK_PAYLOAD); + } + } +} From c59e644b57a06a8b961195b5cb0746cc1015608a Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 00:00:49 +0900 Subject: [PATCH 11/27] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EB=8B=B4=EB=8B=B9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationProcessorService.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/org/sopt/makers/service/NotificationProcessorService.java diff --git a/src/main/java/org/sopt/makers/service/NotificationProcessorService.java b/src/main/java/org/sopt/makers/service/NotificationProcessorService.java new file mode 100644 index 0000000..3845cc8 --- /dev/null +++ b/src/main/java/org/sopt/makers/service/NotificationProcessorService.java @@ -0,0 +1,27 @@ +package org.sopt.makers.service; + +import org.sopt.makers.dto.SentryEventDetail; +import org.sopt.makers.dto.WebhookRequest; +import org.sopt.makers.global.exception.checked.SentryCheckedException; +import org.sopt.makers.global.util.EnvUtil; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class NotificationProcessorService { + private final ObjectMapper objectMapper; + + public void processNotification(WebhookRequest request, SentryEventDetail event) throws SentryCheckedException { + String serviceType = request.serviceType(); + String team = request.team(); + String stage = request.stage(); + String type = request.type(); + + String webhookUrl = EnvUtil.getWebhookUrl(serviceType, team, stage, type); + NotificationContext notificationContext = new NotificationContext( + NotificationServiceFactory.createService(serviceType, objectMapper)); + notificationContext.executeNotification(team, type, stage, event, webhookUrl); + } +} From bd62d6663b643866b8f70959cc0090a6c4c8de80 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 00:01:40 +0900 Subject: [PATCH 12/27] =?UTF-8?q?refactor:=20=EC=99=B8=EB=B6=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=83=9D=EC=84=B1=EC=9E=90=EB=A1=9C=20objectMapper?= =?UTF-8?q?=EB=A5=BC=20=EC=A3=BC=EC=9E=85=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/makers/service/DiscordNotificationService.java | 6 ++---- .../org/sopt/makers/service/SlackNotificationService.java | 7 ++----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/sopt/makers/service/DiscordNotificationService.java b/src/main/java/org/sopt/makers/service/DiscordNotificationService.java index b54e60b..d88d87b 100644 --- a/src/main/java/org/sopt/makers/service/DiscordNotificationService.java +++ b/src/main/java/org/sopt/makers/service/DiscordNotificationService.java @@ -27,16 +27,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j +@RequiredArgsConstructor public class DiscordNotificationService implements NotificationService { private final ObjectMapper objectMapper; - public DiscordNotificationService() { - this.objectMapper = ObjectMapperConfig.getInstance(); - } - @Override public void sendNotification(String team, String type, String stage, SentryEventDetail sentryEventDetail, String webhookUrl) throws SentryCheckedException { diff --git a/src/main/java/org/sopt/makers/service/SlackNotificationService.java b/src/main/java/org/sopt/makers/service/SlackNotificationService.java index daa01e3..dac72ce 100644 --- a/src/main/java/org/sopt/makers/service/SlackNotificationService.java +++ b/src/main/java/org/sopt/makers/service/SlackNotificationService.java @@ -32,17 +32,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j +@RequiredArgsConstructor public class SlackNotificationService implements NotificationService { - private final ObjectMapper objectMapper; - public SlackNotificationService() { - this.objectMapper = ObjectMapperConfig.getInstance(); - } - @Override public void sendNotification(String team, String type, String stage, SentryEventDetail sentryEventDetail, String webhookUrl) throws SentryCheckedException { From 5d7da33b8d0477e4b765546c27fcce5396476595 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 00:03:00 +0900 Subject: [PATCH 13/27] =?UTF-8?q?refactor:=20SentryWebhookHandler=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=8C=EB=A6=BC=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../makers/handler/SentryWebhookHandler.java | 58 +++---------------- 1 file changed, 7 insertions(+), 51 deletions(-) diff --git a/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java b/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java index 059d60b..0e7cf50 100644 --- a/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java +++ b/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java @@ -11,29 +11,23 @@ import org.sopt.makers.global.config.ObjectMapperConfig; import org.sopt.makers.global.exception.checked.SentryCheckedException; import org.sopt.makers.global.exception.message.ErrorMessage; -import org.sopt.makers.global.exception.unchecked.InvalidSlackPayloadException; import org.sopt.makers.global.exception.unchecked.SentryUncheckedException; -import org.sopt.makers.global.util.EnvUtil; -import org.sopt.makers.service.NotificationContext; -import org.sopt.makers.service.NotificationServiceFactory; +import org.sopt.makers.service.NotificationProcessorService; +import org.sopt.makers.service.SentryEventExtractorService; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @Slf4j public class SentryWebhookHandler implements RequestHandler { - - private final ObjectMapper objectMapper; - - public SentryWebhookHandler() { - this.objectMapper = ObjectMapperConfig.getInstance(); - } + private final ObjectMapper objectMapper = ObjectMapperConfig.getInstance(); + private final SentryEventExtractorService eventExtractorService = new SentryEventExtractorService(objectMapper); + private final NotificationProcessorService notificationProcessorService = new NotificationProcessorService(objectMapper); @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent apiGatewayEvent, Context context) { @@ -41,9 +35,9 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent ap WebhookRequest webhookRequest = WebhookRequest.from(apiGatewayEvent); logWebhookReceived(webhookRequest); - SentryEventDetail sentryEvent = extractSentryEvent(apiGatewayEvent.getBody()); + SentryEventDetail sentryEvent = eventExtractorService.extractEvent(apiGatewayEvent.getBody()); - processNotification(webhookRequest, sentryEvent); + notificationProcessorService.processNotification(webhookRequest, sentryEvent); return createApiGatewayResponse( HttpURLConnection.HTTP_OK, @@ -68,44 +62,6 @@ private void logWebhookReceived(WebhookRequest webhookRequest) { ); } - private SentryEventDetail extractSentryEvent(String requestBody) { - JsonNode rootNode = parseRequestBody(requestBody); - JsonNode eventNode = rootNode.path("data").path("event"); - - log.info("rootNode 정보: {}", rootNode); - log.info("eventNode 정보: {}", eventNode); - - if (eventNode.isMissingNode() || eventNode.isEmpty()) { - log.error("[이벤트 데이터 누락] 요청 본문에 필수 이벤트 정보가 없습니다"); - throw InvalidSlackPayloadException.from(ErrorMessage.INVALID_SLACK_PAYLOAD); - } - - SentryEventDetail sentryEvent = SentryEventDetail.from(eventNode); - log.info("[이벤트 추출] issueId={}, level={}", sentryEvent.issueId(), sentryEvent.level()); - - return sentryEvent; - } - - private JsonNode parseRequestBody(String requestBody) { - try { - return objectMapper.readTree(requestBody); - } catch (Exception e) { - log.error("[요청 본문 파싱 실패] error={}", e.getMessage(), e); - throw InvalidSlackPayloadException.from(ErrorMessage.INVALID_SLACK_PAYLOAD); - } - } - - private void processNotification(WebhookRequest request, SentryEventDetail event) throws SentryCheckedException { - String serviceType = request.serviceType(); - String team = request.team(); - String stage = request.stage(); - String type = request.type(); - String webhookUrl = EnvUtil.getWebhookUrl(serviceType, team, stage, type); - - NotificationContext notificationContext = new NotificationContext(NotificationServiceFactory.createService(serviceType)); - notificationContext.executeNotification(team, type, stage, event, webhookUrl); - } - private APIGatewayProxyResponseEvent handleSentryCheckedException(SentryCheckedException e) { log.error("[처리 실패] code={}, message={}", e.getBaseErrorCode().getCode(), e.getMessage(), e); return createApiGatewayErrorResponse( From 73a62204ff3c4348c7435f19aa95131a5d16d8f6 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 00:03:41 +0900 Subject: [PATCH 14/27] =?UTF-8?q?refactor:=20ObjectMapper=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=9E=90=EB=A1=9C=20=EB=B0=9B=EC=95=84=20Notification?= =?UTF-8?q?Service=EB=A5=BC=20=EC=83=9D=EC=84=B1=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../makers/service/NotificationServiceFactory.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java b/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java index 9736a7e..3bfcb3e 100644 --- a/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java +++ b/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java @@ -2,11 +2,13 @@ import java.util.HashMap; import java.util.Map; -import java.util.function.Supplier; +import java.util.function.Function; import org.sopt.makers.global.exception.message.ErrorMessage; import org.sopt.makers.global.exception.unchecked.UnsupportedServiceTypeException; +import com.fasterxml.jackson.databind.ObjectMapper; + import lombok.AccessLevel; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -14,22 +16,22 @@ @Slf4j @NoArgsConstructor(access = AccessLevel.PRIVATE) public class NotificationServiceFactory { - private static final Map> services = new HashMap<>(); + private static final Map> services = new HashMap<>(); static { registerService("slack", SlackNotificationService::new); registerService("discord", DiscordNotificationService::new); } - public static NotificationService createService(String serviceType) { - Supplier supplier = services.get(serviceType.toLowerCase()); + public static NotificationService createService(String serviceType, ObjectMapper objectMapper) { + Function supplier = services.get(serviceType.toLowerCase()); if (supplier == null) { throw new UnsupportedServiceTypeException(ErrorMessage.UNSUPPORTED_SERVICE_TYPE); } - return supplier.get(); + return supplier.apply(objectMapper); } - private static void registerService(String type, Supplier supplier) { + private static void registerService(String type, Function supplier) { services.put(type.toLowerCase(), supplier); } } From c4b0d4b5b60ffdc381e6b090d38f8d5d4683da80 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 01:21:50 +0900 Subject: [PATCH 15/27] =?UTF-8?q?delete:=20FakeEnvUtil=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sopt/makers/global/util/FakeEnvUtil.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/{test/java/org/sopt/makers/global/util/TestEnvUtil.java => main/java/org/sopt/makers/global/util/FakeEnvUtil.java} (98%) diff --git a/src/test/java/org/sopt/makers/global/util/TestEnvUtil.java b/src/main/java/org/sopt/makers/global/util/FakeEnvUtil.java similarity index 98% rename from src/test/java/org/sopt/makers/global/util/TestEnvUtil.java rename to src/main/java/org/sopt/makers/global/util/FakeEnvUtil.java index 2c8302b..cad5e75 100644 --- a/src/test/java/org/sopt/makers/global/util/TestEnvUtil.java +++ b/src/main/java/org/sopt/makers/global/util/FakeEnvUtil.java @@ -7,7 +7,7 @@ import io.github.cdimascio.dotenv.Dotenv; -class TestEnvUtil { +public class FakeEnvUtil { private static final String SLACK_WEBHOOK_PREFIX = "SLACK_WEBHOOK_"; private static final String DISCORD_WEBHOOK_PREFIX = "DISCORD_WEBHOOK_"; private static final Dotenv dotenv = Dotenv.configure() From 00417498ad5f05fffdc8357723bfccb573cbb036 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 01:24:26 +0900 Subject: [PATCH 16/27] =?UTF-8?q?refactor:=20env=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EB=A5=BC=20setter=EB=A1=9C=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 환경별로 구현체를 분리해 주입하는 방법도 고려했지만, 해당 방식은 인스턴스 생성을 전제로 하므로 유틸성 클래스의 성격과는 맞지 않다고 판단하여, setter를 통해 주입하는 방식으로 개선하였습니다. --- .../org/sopt/makers/global/util/EnvUtil.java | 17 ++++++++++-- .../sopt/makers/global/util/EnvUtilTest.java | 26 ++++++++++++------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/sopt/makers/global/util/EnvUtil.java b/src/main/java/org/sopt/makers/global/util/EnvUtil.java index 7aaec78..6956ccd 100644 --- a/src/main/java/org/sopt/makers/global/util/EnvUtil.java +++ b/src/main/java/org/sopt/makers/global/util/EnvUtil.java @@ -15,7 +15,7 @@ public final class EnvUtil { private static final String SLACK_WEBHOOK_PREFIX = "SLACK_WEBHOOK_"; private static final String DISCORD_WEBHOOK_PREFIX = "DISCORD_WEBHOOK_"; - private static final Dotenv dotenv = Dotenv.configure().load(); + private static Dotenv dotenv; /** * 서비스 유형에 맞는 웹훅 URL 반환 @@ -41,7 +41,7 @@ public static String getWebhookUrl(String serviceType, String team, String stage stage.toUpperCase(), type.toUpperCase()); - String webhookUrl = dotenv.get(envKey); + String webhookUrl = getDotenv().get(envKey); if (webhookUrl == null || webhookUrl.isBlank()) { log.error("Webhook URL을 찾을 수 없습니다: {}", envKey); @@ -51,6 +51,19 @@ public static String getWebhookUrl(String serviceType, String team, String stage return webhookUrl; } + + public static void setDotenv(Dotenv customDotenv) { + dotenv = customDotenv; + } + + + private static Dotenv getDotenv() { + if (dotenv == null) { + dotenv = Dotenv.configure().load(); + } + return dotenv; + } + private static String resolvePrefix(String serviceTypeLowerCase) { return switch (serviceTypeLowerCase) { case "slack" -> SLACK_WEBHOOK_PREFIX; diff --git a/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java b/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java index 0a00eb6..ba27f0b 100644 --- a/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java +++ b/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java @@ -19,19 +19,25 @@ import org.sopt.makers.global.exception.unchecked.UnsupportedServiceTypeException; import org.sopt.makers.global.exception.unchecked.WebhookUrlNotFoundException; +import io.github.cdimascio.dotenv.Dotenv; + @DisplayName("EnvUtil 테스트") class EnvUtilTest { - private static final String ENV_FILE_PATH = "src/test/resources/.env"; @BeforeAll static void setUp() throws IOException { String content = """ - SLACK_WEBHOOK_CREW_DEV_BE=https://hooks.slack.com/services/crew/dev/be - SLACK_WEBHOOK_APP_PROD_FE=https://hooks.slack.com/services/app/prod/fe - """; + SLACK_WEBHOOK_CREW_DEV_BE=https://hooks.slack.com/services/crew/dev/be + SLACK_WEBHOOK_APP_PROD_FE=https://hooks.slack.com/services/app/prod/fe + """; Files.createDirectories(Paths.get("src/test/resources")); Files.write(Paths.get(ENV_FILE_PATH), content.getBytes()); + + Dotenv testDotenv = Dotenv.configure() + .directory("src/test/resources") + .load(); + EnvUtil.setDotenv(testDotenv); } @AfterAll @@ -43,7 +49,7 @@ static void tearDown() throws IOException { @Test void testGetWebhookUrl_valid() { String expectedUrl = "https://hooks.slack.com/services/crew/dev/be"; - String actualUrl = TestEnvUtil.getWebhookUrl("slack", "crew", "dev", "be"); + String actualUrl = EnvUtil.getWebhookUrl("slack", "crew", "dev", "be"); assertEquals(expectedUrl, actualUrl); } @@ -52,7 +58,7 @@ void testGetWebhookUrl_valid() { @Test void testGetWebhookUrl_notFound() { WebhookUrlNotFoundException exception = assertThrows(WebhookUrlNotFoundException.class, () -> - TestEnvUtil.getWebhookUrl("slack", "crew", "prod", "be") + EnvUtil.getWebhookUrl("slack", "crew", "prod", "be") ); assertTrue(exception.getMessage().contains("Webhook URL을 찾을 수 없습니다.")); @@ -62,7 +68,7 @@ void testGetWebhookUrl_notFound() { @Test void testGetWebhookUrl_unsupportedServiceType() { assertThrows(UnsupportedServiceTypeException.class, () -> - TestEnvUtil.getWebhookUrl("telegram", "crew", "dev", "be") + EnvUtil.getWebhookUrl("telegram", "crew", "dev", "be") ); } @@ -71,7 +77,7 @@ void testGetWebhookUrl_unsupportedServiceType() { @MethodSource("provideNullParameters") void testGetWebhookUrl_withNullParameters_shouldThrow(String service, String team, String stage, String type) { assertThrows(InvalidEnvParameterException.class, () -> - TestEnvUtil.getWebhookUrl(service, team, stage, type) + EnvUtil.getWebhookUrl(service, team, stage, type) ); } @@ -87,12 +93,12 @@ void testGetWebhookUrl_withNullParameters_shouldThrow(String service, String tea }) void testGetWebhookUrl_caseInsensitive(String service, String team, String stage, String type) { if (team.equalsIgnoreCase("app")) { - String actualUrl = TestEnvUtil.getWebhookUrl(service, team, stage, type); + String actualUrl = EnvUtil.getWebhookUrl(service, team, stage, type); assertEquals("https://hooks.slack.com/services/app/prod/fe", actualUrl); return; } - String actualUrl = TestEnvUtil.getWebhookUrl(service, team, stage, type); + String actualUrl = EnvUtil.getWebhookUrl(service, team, stage, type); assertEquals("https://hooks.slack.com/services/crew/dev/be", actualUrl); } private static Stream provideNullParameters() { From bf9be0ae561bc3e8d685eebec8ba5fb40885f8fd Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 01:43:34 +0900 Subject: [PATCH 17/27] =?UTF-8?q?test:=20SentryEventExtractorService?= =?UTF-8?q?=EC=97=90=20=EC=8B=A4=EC=A0=9C=20Sentry=20=EC=9B=B9=ED=9B=85=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B8=B0=EB=B0=98=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실제 Sentry 웹훅 페이로드를 사용하여 이벤트 정보가 정확히 추출되는지 검증 --- .../SentryEventExtractorServiceTest.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java diff --git a/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java b/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java new file mode 100644 index 0000000..a632370 --- /dev/null +++ b/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java @@ -0,0 +1,40 @@ +package org.sopt.makers.service; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.sopt.makers.dto.SentryEventDetail; +import org.sopt.makers.global.config.ObjectMapperConfig; +import org.sopt.makers.handler.FakeSentryPayload; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@DisplayName("SentryEventExtractorService 단위 테스트") +class SentryEventExtractorServiceTest { + private final ObjectMapper objectMapper = ObjectMapperConfig.getInstance(); + private final SentryEventExtractorService extractorService = new SentryEventExtractorService(objectMapper); + + @Test + @DisplayName("실제 Sentry 웹훅 데이터에서 이벤트 정보를 정확히 추출하는지 테스트") + void extractSentryEventFromRealPayload() { + // Given + String payload = FakeSentryPayload.REAL_SENTRY_WEBHOOK_PAYLOAD; + + // When + SentryEventDetail result = extractorService.extractEvent(payload); + + // Then + assertNotNull(result); + assertEquals("0000000000", result.issueId()); + assertEquals( + "https://sentry.io/organizations/example-org/issues/0000000000/events/93153fe001674c70b39af22c49db350b/", + result.webUrl()); + assertEquals("Authentication failed, token expired!", result.message()); + assertEquals("2025-05-21T08:52:45.182000Z", result.datetime()); + assertEquals("error", result.level()); + assertEquals("ApiException: Authentication failed, token expired!", result.title()); + } +} + From e908b001d9925789a7dda6b4bfc8fe16e4f75f60 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 01:49:02 +0900 Subject: [PATCH 18/27] =?UTF-8?q?delete:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20dotenv=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트 환경에서 dotenv 경로를 setter로 주입하도록 변경하며 관련 유틸 클래스를 제거합니다 --- .../sopt/makers/global/util/FakeEnvUtil.java | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 src/main/java/org/sopt/makers/global/util/FakeEnvUtil.java diff --git a/src/main/java/org/sopt/makers/global/util/FakeEnvUtil.java b/src/main/java/org/sopt/makers/global/util/FakeEnvUtil.java deleted file mode 100644 index cad5e75..0000000 --- a/src/main/java/org/sopt/makers/global/util/FakeEnvUtil.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.sopt.makers.global.util; - -import org.sopt.makers.global.exception.message.ErrorMessage; -import org.sopt.makers.global.exception.unchecked.InvalidEnvParameterException; -import org.sopt.makers.global.exception.unchecked.UnsupportedServiceTypeException; -import org.sopt.makers.global.exception.unchecked.WebhookUrlNotFoundException; - -import io.github.cdimascio.dotenv.Dotenv; - -public class FakeEnvUtil { - private static final String SLACK_WEBHOOK_PREFIX = "SLACK_WEBHOOK_"; - private static final String DISCORD_WEBHOOK_PREFIX = "DISCORD_WEBHOOK_"; - private static final Dotenv dotenv = Dotenv.configure() - .directory("src/test/resources") - .load(); - - /** - * 서비스 유형에 맞는 웹훅 URL 반환 - * - * @param service 서비스 유형 (slack, discord 등) - * @param team 팀 이름 (crew, app 등) - * @param stage 환경 (dev, prod) - * @param type 서버 유형 (be, fe) - * @return 웹훅 URL - * @throws UnsupportedServiceTypeException 지원하지 않는 서비스 유형인 경우 - * @throws WebhookUrlNotFoundException 환경 변수를 찾을 수 없는 경우 - */ - public static String getWebhookUrl(String service, String team, String stage, String type) { - if (service == null || team == null || stage == null || type == null) { - throw InvalidEnvParameterException.from(ErrorMessage.INVALID_ENV_PARAMETER); - } - - String prefix = resolvePrefix(service.toLowerCase()); - String envKey = String.format("%s%s_%s_%s", - prefix, - team.toUpperCase(), - stage.toUpperCase(), - type.toUpperCase()); - - String webhookUrl = dotenv.get(envKey); - - if (webhookUrl == null || webhookUrl.isBlank()) { - throw new WebhookUrlNotFoundException(ErrorMessage.WEBHOOK_URL_NOT_FOUND); - } - - return webhookUrl; - } - - private static String resolvePrefix(String service) { - return switch (service) { - case "slack" -> SLACK_WEBHOOK_PREFIX; - case "discord" -> DISCORD_WEBHOOK_PREFIX; - default -> throw new UnsupportedServiceTypeException(ErrorMessage.UNSUPPORTED_SERVICE_TYPE); - }; - } -} From 4d80d3b9832d66e8213844fbfb1c46de322bc6d1 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 16:52:33 +0900 Subject: [PATCH 19/27] =?UTF-8?q?chore:=20assertJ=EC=99=80=20wireMock=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index 1e91c55..4ff8b38 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,8 @@ dependencies { testImplementation 'org.junit.platform:junit-platform-launcher' testImplementation 'org.mockito:mockito-core:5.10.0' testImplementation 'org.mockito:mockito-junit-jupiter:5.10.0' + testImplementation 'org.assertj:assertj-core:3.25.3' + testImplementation "org.wiremock:wiremock:3.13.0" // Env implementation 'io.github.cdimascio:java-dotenv:5.2.2' From 4cf8745d4dbc64428f33aa0f6073f3565f8a0753 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 16:53:45 +0900 Subject: [PATCH 20/27] =?UTF-8?q?chore:=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EB=8A=94=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20URL=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sopt/makers/global/constant/DiscordConstant.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/sopt/makers/global/constant/DiscordConstant.java b/src/main/java/org/sopt/makers/global/constant/DiscordConstant.java index aa51ff5..3959fad 100644 --- a/src/main/java/org/sopt/makers/global/constant/DiscordConstant.java +++ b/src/main/java/org/sopt/makers/global/constant/DiscordConstant.java @@ -17,7 +17,7 @@ public final class DiscordConstant { public static final String TRUNCATION_SUFFIX = "..."; // Discord 리소스 - public static final String SENTRY_ICON_URL = "https://sentry.io/favicon.ico"; + public static final String SENTRY_ICON_URL = "https://raw.githubusercontent.com/getsentry/sentry/master/src/sentry/static/sentry/images/sentry-glyph-black.png"; // Discord 임베드 제한 public static final int MAX_TITLE_LENGTH = 256; From c486183467ade9b377437bd282d7d94892df40f0 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 16:55:15 +0900 Subject: [PATCH 21/27] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20import=EB=AC=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20discord=20username=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/makers/service/DiscordNotificationService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/sopt/makers/service/DiscordNotificationService.java b/src/main/java/org/sopt/makers/service/DiscordNotificationService.java index d88d87b..378ec49 100644 --- a/src/main/java/org/sopt/makers/service/DiscordNotificationService.java +++ b/src/main/java/org/sopt/makers/service/DiscordNotificationService.java @@ -13,7 +13,6 @@ import java.util.List; import org.sopt.makers.dto.SentryEventDetail; -import org.sopt.makers.global.config.ObjectMapperConfig; import org.sopt.makers.global.constant.Color; import org.sopt.makers.global.exception.checked.MessageBuildException; import org.sopt.makers.global.exception.checked.SendException; @@ -114,7 +113,7 @@ private DiscordMessage buildDiscordMessage(String team, String type, String stag // Discord 메시지 생성 return DiscordMessage.newInstance( - "Sentry Monitor", + "Sentry", SENTRY_ICON_URL, List.of(embed)); } From 63d87c96ff935c740036acba0347cb4a0fb2816e Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 16:55:40 +0900 Subject: [PATCH 22/27] =?UTF-8?q?fix:=20NotFoundException=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=BD=94=EB=93=9C=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/makers/global/exception/message/ErrorMessage.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java b/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java index 76f2cd3..fc52b6b 100644 --- a/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java +++ b/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java @@ -10,7 +10,7 @@ public enum ErrorMessage implements BaseErrorCode { // ===== Webhook 관련 오류 ===== INVALID_ENV_PARAMETER(400, "환경 변수 입력값이 올바르지 않습니다.", "W4001"), - WEBHOOK_URL_NOT_FOUND(500, "Webhook URL을 찾을 수 없습니다.", "W5001"), + WEBHOOK_URL_NOT_FOUND(404, "Webhook URL을 찾을 수 없습니다.", "W4041"), // ===== Slack 및 일반 서비스 오류 ===== INVALID_SLACK_PAYLOAD(400, "Slack 페이로드 형식이 잘못되었습니다.", "S4001"), From 5456922d8d7daaee1fbed414c537abe44a6980f3 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 16:56:01 +0900 Subject: [PATCH 23/27] =?UTF-8?q?chore:=20DisplayName=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/makers/service/SentryEventExtractorServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java b/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java index a632370..e41519e 100644 --- a/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java +++ b/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java @@ -11,7 +11,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; -@DisplayName("SentryEventExtractorService 단위 테스트") +@DisplayName("SentryEventExtractorService 테스트") class SentryEventExtractorServiceTest { private final ObjectMapper objectMapper = ObjectMapperConfig.getInstance(); private final SentryEventExtractorService extractorService = new SentryEventExtractorService(objectMapper); From 246dd0bc5848ba43486fadbe0d97e3b1d2b334e6 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 16:59:46 +0900 Subject: [PATCH 24/27] =?UTF-8?q?test:=20SentryWebhookHandler=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Discord 웹훅 알림 전송 테스트 추가 (embeds 필드 검증) - Slack 웹훅 알림 전송 테스트 추가 (attachments 필드 검증) - 다양한 Sentry 레벨별 이벤트 처리 테스트 추가 (fatal, warning, info) - API 에러 응답 및 Rate Limiting 처리 테스트 추가 - 네트워크 타임아웃 및 잘못된 파라미터 처리 테스트 추가 --- .../handler/SentryWebhookHandlerTest.java | 485 ++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 src/test/java/org/sopt/makers/handler/SentryWebhookHandlerTest.java diff --git a/src/test/java/org/sopt/makers/handler/SentryWebhookHandlerTest.java b/src/test/java/org/sopt/makers/handler/SentryWebhookHandlerTest.java new file mode 100644 index 0000000..ebfe69b --- /dev/null +++ b/src/test/java/org/sopt/makers/handler/SentryWebhookHandlerTest.java @@ -0,0 +1,485 @@ +package org.sopt.makers.handler; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.*; +import static org.assertj.core.api.Assertions.*; +import static org.sopt.makers.handler.FakeSentryPayload.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.makers.global.util.EnvUtil; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.common.ConsoleNotifier; + +import io.github.cdimascio.dotenv.Dotenv; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SentryWebhookHandler 테스트") +class SentryWebhookHandlerTest { + private static final String ENV_FILE_PATH = "src/test/resources/.env"; + + @Mock + private Context lambdaContext; + + private WireMockServer wireMockServer; + private SentryWebhookHandler handler; + + @BeforeAll + static void setUpEnvironment() throws IOException { + String content = """ + SLACK_WEBHOOK_CREW_DEV_BE=http://localhost:8089/services/crew/dev/be + SLACK_WEBHOOK_APP_PROD_FE=http://localhost:8089/services/app/prod/fe + DISCORD_WEBHOOK_CREW_PROD_BE=http://localhost:8089/api/webhooks/123456789/crew-prod-be-token + DISCORD_WEBHOOK_APP_DEV_FE=http://localhost:8089/api/webhooks/987654321/app-dev-fe-token + """; + Files.createDirectories(Paths.get("src/test/resources")); + Files.write(Paths.get(ENV_FILE_PATH), content.getBytes()); + + Dotenv testDotenv = Dotenv.configure().directory("src/test/resources").load(); + EnvUtil.setDotenv(testDotenv); + } + + @AfterAll + static void tearDownEnvironment() throws IOException { + Files.deleteIfExists(Paths.get(ENV_FILE_PATH)); + } + + @BeforeEach + void setUp() { + wireMockServer = new WireMockServer(wireMockConfig().port(8089).notifier(new ConsoleNotifier(false))); + wireMockServer.start(); + + handler = new SentryWebhookHandler(); + setupDefaultApiResponses(); + } + + @AfterEach + void tearDown() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + @Nested + @DisplayName("Discord 알림 테스트") + class DiscordNotificationTests { + + @Test + @DisplayName("CREW PROD BE 환경으로 Discord 알림 전송 성공") + void shouldSendDiscordNotificationToCrewProdBe() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/api/webhooks/123456789/crew-prod-be-token")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(containing("embeds"))); + + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/services/.*"))); + } + + @Test + @DisplayName("APP DEV FE 환경으로 Discord 알림 전송 성공") + void shouldSendDiscordNotificationToAppDevFe() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("dev", "app", "fe", "discord"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/api/webhooks/987654321/app-dev-fe-token")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(containing("embeds"))); + + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/services/.*"))); + } + + @Test + @DisplayName("Discord API 404 에러 응답 처리") + void shouldHandleDiscordApiNotFoundError() { + // Given + wireMockServer.resetMappings(); + setupDefaultApiResponses(); + wireMockServer.stubFor( + post(urlPathMatching("/api/webhooks/.*")) + .willReturn(aResponse().withStatus(404) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"Unknown Webhook\", \"code\": 10015}")) + ); + + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(502); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("Discord API Rate Limiting 처리") + void shouldHandleDiscordRateLimiting() { + // Given + wireMockServer.resetMappings(); + setupDefaultApiResponses(); + wireMockServer.stubFor( + post(urlPathMatching("/api/webhooks/.*")) + .willReturn(aResponse().withStatus(429) + .withHeader("Retry-After", "5") + .withBody("{\"message\": \"You are being rate limited.\", \"retry_after\": 5000}")) + ); + + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(502); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("Critical 레벨 Sentry 이벤트 처리") + void shouldHandleCriticalSentryEventWithDiscord() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "app", "fe", "discord", "fatal"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/api/webhooks/.*")) + .withRequestBody(containing("embeds"))); + } + + @Test + @DisplayName("Warning 레벨 Sentry 이벤트 처리") + void shouldHandleWarningSentryEventWithDiscord() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "app", "fe", "discord", "warning"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("Info 레벨 Sentry 이벤트 처리") + void shouldHandleInfoSentryEventWithDiscord() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "app", "fe", "discord", "info"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + } + + @Nested + @DisplayName("Slack 알림 테스트") + class SlackNotificationTests { + + @Test + @DisplayName("CREW DEV BE 환경으로 Slack 알림 전송 성공") + void shouldSendSlackNotificationToCrewDevBe() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("dev", "crew", "be", "slack"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/services/crew/dev/be")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(containing("text")) // Slack은 "text" 필드 사용 + ); + + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("APP PROD FE 환경으로 Slack 알림 전송 성공") + void shouldSendSlackNotificationToAppProdFe() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "app", "fe", "slack"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/services/app/prod/fe")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(containing("text"))); + + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("Slack API 에러 응답 처리") + void shouldHandleSlackApiError() { + // Given - Slack API 에러 응답 설정 + wireMockServer.resetMappings(); + setupDefaultApiResponses(); + wireMockServer.stubFor( + post(urlPathMatching("/services/.*")) + .willReturn(aResponse().withStatus(400) + .withHeader("Content-Type", "text/plain") + .withBody("invalid_payload")) + ); + + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("dev", "crew", "be", "slack"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(502); + + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/services/.*"))); + } + + @Test + @DisplayName("네트워크 타임아웃 시나리오") + void shouldHandleNetworkTimeout() { + // Given + wireMockServer.resetMappings(); + setupDefaultApiResponses(); + wireMockServer.stubFor( + post(urlPathMatching("/api/webhooks/.*")) + .willReturn(aResponse().withStatus(200).withFixedDelay(10000)) // 10초 지연 + ); + + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + + // When & Then + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + assertThat(response.getStatusCode()).isEqualTo(503); + } + + @Test + @DisplayName("Slack API Rate Limiting 처리") + void shouldHandleSlackRateLimiting() { + // Given - Slack Rate Limiting 응답 설정 + wireMockServer.resetMappings(); + setupDefaultApiResponses(); + wireMockServer.stubFor( + post(urlPathMatching("/services/.*")) + .willReturn(aResponse().withStatus(429) + .withHeader("Retry-After", "1") + .withBody("rate_limited")) + ); + + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("dev", "crew", "be", "slack"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(502); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/services/.*"))); + } + + @Test + @DisplayName("Critical 레벨 Sentry 이벤트 처리") + void shouldHandleCriticalSentryEventWithSlack() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "crew", "be", "slack", "fatal"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/services/.*")) + .withRequestBody(containing("attachments"))); + + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + + @Test + @DisplayName("Warning 레벨 Sentry 이벤트 처리") + void shouldHandleWarningSentryEventWithSlack() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "crew", "be", "slack", "warning"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/services/.*"))); + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("Info 레벨 Sentry 이벤트 처리") + void shouldHandleInfoSentryEventWithSlack() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "crew", "be", "slack", "info"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/services/.*"))); + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + } + + @Nested + @DisplayName("에러 처리 테스트") + class ErrorHandlingTests { + + @Test + @DisplayName("잘못된 환경 파라미터 처리") + void shouldHandleInvalidEnvironmentParameters() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("invalid", "invalid", "invalid", "discord"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(404); + } + + @Test + @DisplayName("PathParameters가 없는 경우 처리") + void shouldHandleMissingPathParameters() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + apiEvent.setPathParameters(null); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(400); + } + + @Test + @DisplayName("잘못된 JSON 페이로드 처리") + void shouldHandleInvalidJsonPayload() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + apiEvent.setBody("{invalid json}"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(400); + } + + @Test + @DisplayName("빈 페이로드 처리") + void shouldHandleEmptyPayload() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + apiEvent.setBody(null); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(400); + } + } + + private APIGatewayProxyRequestEvent createSentryWebhookEvent(String env, String team, String type, String service) { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent(); + event.setPath("/webhook"); + event.setHttpMethod("POST"); + + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + event.setHeaders(headers); + + Map pathParams = new HashMap<>(); + pathParams.put("team", team); + pathParams.put("type", type); + pathParams.put("service", service); + event.setPathParameters(pathParams); + + event.setBody(REAL_SENTRY_WEBHOOK_PAYLOAD); + + APIGatewayProxyRequestEvent.ProxyRequestContext context = new APIGatewayProxyRequestEvent.ProxyRequestContext(); + context.setStage(env); + event.setRequestContext(context); + + return event; + } + + private APIGatewayProxyRequestEvent createSentryWebhookEventWithLevel(String env, String team, String type, String service, String level) { + APIGatewayProxyRequestEvent event = createSentryWebhookEvent(env, team, type, service); + + String payload = event.getBody().replace("\"level\": \"error\"", "\"level\": \"" + level + "\""); + event.setBody(payload); + + return event; + } + + private void setupDefaultApiResponses() { + wireMockServer.stubFor( + post(urlPathMatching("/api/webhooks/[0-9]+/.*")) + .willReturn(aResponse().withStatus(204) + .withHeader("Content-Type", "application/json") + .withBody("{\"id\": \"123456789\", \"type\": 1}")) + ); + + wireMockServer.stubFor( + post(urlPathMatching("/services/.*")) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "text/plain") + .withBody("ok")) + ); + } +} From 81c3e34f55cdee7ce4ed4ca2ac6f79553aa0e555 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 17:32:26 +0900 Subject: [PATCH 25/27] =?UTF-8?q?test:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/makers/service/SentryEventExtractorServiceTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java b/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java index e41519e..a0f1a39 100644 --- a/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java +++ b/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java @@ -3,7 +3,6 @@ import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.sopt.makers.dto.SentryEventDetail; import org.sopt.makers.global.config.ObjectMapperConfig; From f917b39968dbdaf66bcbedf8f5c0c330b46a9f5f Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 18:30:36 +0900 Subject: [PATCH 26/27] =?UTF-8?q?refactor:=20=EC=8A=AC=EB=9E=99/=EB=94=94?= =?UTF-8?q?=EC=8A=A4=EC=BD=94=EB=93=9C=20=EA=B3=B5=ED=86=B5=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/exception/message/ErrorMessage.java | 2 +- .../unchecked/InvalidPayloadException.java | 13 +++++++++++++ .../unchecked/InvalidSlackPayloadException.java | 13 ------------- .../makers/service/SentryEventExtractorService.java | 6 +++--- 4 files changed, 17 insertions(+), 17 deletions(-) create mode 100644 src/main/java/org/sopt/makers/global/exception/unchecked/InvalidPayloadException.java delete mode 100644 src/main/java/org/sopt/makers/global/exception/unchecked/InvalidSlackPayloadException.java diff --git a/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java b/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java index fc52b6b..4ef2916 100644 --- a/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java +++ b/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java @@ -13,7 +13,6 @@ public enum ErrorMessage implements BaseErrorCode { WEBHOOK_URL_NOT_FOUND(404, "Webhook URL을 찾을 수 없습니다.", "W4041"), // ===== Slack 및 일반 서비스 오류 ===== - INVALID_SLACK_PAYLOAD(400, "Slack 페이로드 형식이 잘못되었습니다.", "S4001"), SLACK_MESSAGE_BUILD_FAILED(500, "Slack 메시지 생성에 실패했습니다.", "S5001"), SLACK_SEND_INTERRUPTED(500, "Slack 알림 전송이 중단되었습니다.", "S5002"), SLACK_SERIALIZATION_FAILED(500, "Slack 메시지를 JSON으로 변환하는 중 오류가 발생했습니다.", "S5003"), @@ -30,6 +29,7 @@ public enum ErrorMessage implements BaseErrorCode { // ===== 공통 오류 ===== UNSUPPORTED_SERVICE_TYPE(400, "지원하지 않는 서비스 유형입니다.", "C4001"), + INVALID_PAYLOAD(400, "페이로드 형식이 잘못되었습니다.", "C4002"), UNEXPECTED_SERVER_ERROR(500, "내부 서버 오류가 발생했습니다.", "C5001"); private final int status; diff --git a/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidPayloadException.java b/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidPayloadException.java new file mode 100644 index 0000000..a260b60 --- /dev/null +++ b/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidPayloadException.java @@ -0,0 +1,13 @@ +package org.sopt.makers.global.exception.unchecked; + +import org.sopt.makers.global.exception.base.BaseErrorCode; + +public class InvalidPayloadException extends SentryUncheckedException { + public InvalidPayloadException(BaseErrorCode errorCode) { + super(errorCode); + } + + public static InvalidPayloadException from(BaseErrorCode errorCode) { + return new InvalidPayloadException(errorCode); + } +} diff --git a/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidSlackPayloadException.java b/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidSlackPayloadException.java deleted file mode 100644 index d1e856f..0000000 --- a/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidSlackPayloadException.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.sopt.makers.global.exception.unchecked; - -import org.sopt.makers.global.exception.base.BaseErrorCode; - -public class InvalidSlackPayloadException extends SentryUncheckedException { - public InvalidSlackPayloadException(BaseErrorCode errorCode) { - super(errorCode); - } - - public static InvalidSlackPayloadException from(BaseErrorCode errorCode) { - return new InvalidSlackPayloadException(errorCode); - } -} diff --git a/src/main/java/org/sopt/makers/service/SentryEventExtractorService.java b/src/main/java/org/sopt/makers/service/SentryEventExtractorService.java index ca6336d..4849224 100644 --- a/src/main/java/org/sopt/makers/service/SentryEventExtractorService.java +++ b/src/main/java/org/sopt/makers/service/SentryEventExtractorService.java @@ -2,7 +2,7 @@ import org.sopt.makers.dto.SentryEventDetail; import org.sopt.makers.global.exception.message.ErrorMessage; -import org.sopt.makers.global.exception.unchecked.InvalidSlackPayloadException; +import org.sopt.makers.global.exception.unchecked.InvalidPayloadException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -24,7 +24,7 @@ public SentryEventDetail extractEvent(String requestBody) { if (eventNode.isMissingNode() || eventNode.isEmpty()) { log.error("[이벤트 데이터 누락] 요청 본문에 필수 이벤트 정보가 없습니다"); - throw InvalidSlackPayloadException.from(ErrorMessage.INVALID_SLACK_PAYLOAD); + throw InvalidPayloadException.from(ErrorMessage.INVALID_PAYLOAD); } SentryEventDetail sentryEvent = SentryEventDetail.from(eventNode); @@ -38,7 +38,7 @@ private JsonNode parseRequestBody(String requestBody) { return objectMapper.readTree(requestBody); } catch (Exception e) { log.error("[요청 본문 파싱 실패] error={}", e.getMessage(), e); - throw InvalidSlackPayloadException.from(ErrorMessage.INVALID_SLACK_PAYLOAD); + throw InvalidPayloadException.from(ErrorMessage.INVALID_PAYLOAD); } } } From 4b536e2a3c06002660ed4817a8776c7267c327ee Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Thu, 22 May 2025 18:35:18 +0900 Subject: [PATCH 27/27] =?UTF-8?q?fix:=20=ED=95=9C=20=EB=B2=88=EC=97=90=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EC=9D=98=20=EC=8A=A4=EB=A0=88=EB=93=9C?= =?UTF-8?q?=EB=A7=8C=20=EC=A0=91=EA=B7=BC=20=EA=B0=80=EB=8A=A5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=ED=95=98=EC=97=AC=20=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/sopt/makers/global/util/EnvUtil.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/sopt/makers/global/util/EnvUtil.java b/src/main/java/org/sopt/makers/global/util/EnvUtil.java index 6956ccd..f61f18d 100644 --- a/src/main/java/org/sopt/makers/global/util/EnvUtil.java +++ b/src/main/java/org/sopt/makers/global/util/EnvUtil.java @@ -51,13 +51,12 @@ public static String getWebhookUrl(String serviceType, String team, String stage return webhookUrl; } - public static void setDotenv(Dotenv customDotenv) { dotenv = customDotenv; } - private static Dotenv getDotenv() { + private static synchronized Dotenv getDotenv() { if (dotenv == null) { dotenv = Dotenv.configure().load(); }