Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7a04f36
refactor: 전략 패턴과 심플 팩토리 패턴 적용
move-hoon May 21, 2025
8a77ed0
refactor: 범용 네이밍으로 예외 클래스 이름 변경
move-hoon May 21, 2025
7166181
feat: Discord 관련 오류메시지 추가
move-hoon May 21, 2025
6bcc847
feat: Discord 메시지 관련 VO 구현
move-hoon May 21, 2025
26acfb7
feat: DiscordNotificationService 전용 상수 클래스 추가
move-hoon May 21, 2025
a15072c
feat: Discord 알림 전송 기능 구현
move-hoon May 21, 2025
7f6e1df
chore: 의도에 맞게 매개변수 이름 변경
move-hoon May 21, 2025
551a82c
chore: 테스트 관련 의존성 추가
move-hoon May 21, 2025
1ef783f
feat: 테스트 환경 전용 환경 변수를 관리하는 클래스 구현
move-hoon May 21, 2025
7ad2889
feat: eventNode에서 이벤트를 추출하는 서비스 클래스 구현
move-hoon May 21, 2025
c59e644
feat: 알림 처리 로직을 담당하는 서비스 클래스 구현
move-hoon May 21, 2025
bd62d66
refactor: 외부에서 생성자로 objectMapper를 주입하도록 변경
move-hoon May 21, 2025
5d7da33
refactor: SentryWebhookHandler에서 이벤트 추출 및 알림 처리 로직 분리
move-hoon May 21, 2025
73a6220
refactor: ObjectMapper를 인자로 받아 NotificationService를 생성하도록 구조 변경
move-hoon May 21, 2025
c4b0d4b
delete: FakeEnvUtil 클래스 삭제
move-hoon May 21, 2025
0041749
refactor: env 파일 경로를 setter로 주입할 수 있도록 변경
move-hoon May 21, 2025
bf9be0a
test: SentryEventExtractorService에 실제 Sentry 웹훅 데이터 기반 단위 테스트 추가
move-hoon May 21, 2025
e908b00
delete: 사용하지 않는 dotenv 유틸 클래스 삭제
move-hoon May 21, 2025
4d80d3b
chore: assertJ와 wireMock 의존성 추가
move-hoon May 22, 2025
4cf8745
chore: 실제로 존재하는 이미지 URL로 변경
move-hoon May 22, 2025
c486183
chore: 사용하지 않는 import문 정리 및 discord username 변경
move-hoon May 22, 2025
63d87c9
fix: NotFoundException 상태코드에 맞게 수정
move-hoon May 22, 2025
5456922
chore: DisplayName 변경
move-hoon May 22, 2025
246dd0b
test: SentryWebhookHandler 테스트 케이스 작성
move-hoon May 22, 2025
81c3e34
test: 불필요한 import문 제거
move-hoon May 22, 2025
f917b39
refactor: 슬랙/디스코드 공통 예외로 변경
move-hoon May 22, 2025
4b536e2
fix: 한 번에 하나의 스레드만 접근 가능하도록 하여 동시 초기화 방지
move-hoon May 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ 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'
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'
Expand Down
25 changes: 25 additions & 0 deletions src/main/java/org/sopt/makers/global/constant/DiscordConstant.java
Original file line number Diff line number Diff line change
@@ -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://raw.githubusercontent.com/getsentry/sentry/master/src/sentry/static/sentry/images/sentry-glyph-black.png";

// Discord 임베드 제한
public static final int MAX_TITLE_LENGTH = 256;
public static final int MAX_DESCRIPTION_LENGTH = 4096;
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,26 @@
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"),
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"),
INVALID_PAYLOAD(400, "페이로드 형식이 잘못되었습니다.", "C4002"),
UNEXPECTED_SERVER_ERROR(500, "내부 서버 오류가 발생했습니다.", "C5001");

private final int status;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}

This file was deleted.

30 changes: 21 additions & 9 deletions src/main/java/org/sopt/makers/global/util/EnvUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,33 @@
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 반환
*
* @param service 서비스 유형 (slack, discord 등)
* @param serviceType 서비스 유형 (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) {
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(),
stage.toUpperCase(),
type.toUpperCase());

String webhookUrl = dotenv.get(envKey);
String webhookUrl = getDotenv().get(envKey);

if (webhookUrl == null || webhookUrl.isBlank()) {
log.error("Webhook URL을 찾을 수 없습니다: {}", envKey);
Expand All @@ -51,8 +51,20 @@ public static String getWebhookUrl(String service, String team, String stage, St
return webhookUrl;
}

private static String resolvePrefix(String service) {
return switch (service) {
public static void setDotenv(Dotenv customDotenv) {
dotenv = customDotenv;
}


private static synchronized 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;
case "discord" -> DISCORD_WEBHOOK_PREFIX;
default -> throw new UnsupportedServiceTypeException(ErrorMessage.UNSUPPORTED_SERVICE_TYPE);
Expand Down
63 changes: 7 additions & 56 deletions src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,44 +11,34 @@
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.NotificationService;
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<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

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) {
try {
// 웹훅 요청 처리
WebhookRequest webhookRequest = WebhookRequest.from(apiGatewayEvent);
logWebhookReceived(webhookRequest);

// Sentry 이벤트 추출
SentryEventDetail sentryEvent = extractSentryEvent(apiGatewayEvent.getBody());
SentryEventDetail sentryEvent = eventExtractorService.extractEvent(apiGatewayEvent.getBody());

// 알림 전송
sendNotification(webhookRequest, sentryEvent);
notificationProcessorService.processNotification(webhookRequest, sentryEvent);

// 성공 응답 반환
return createApiGatewayResponse(
HttpURLConnection.HTTP_OK,
createSuccessResponseBody()
Expand All @@ -72,45 +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 sendNotification(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);
}

private APIGatewayProxyResponseEvent handleSentryCheckedException(SentryCheckedException e) {
log.error("[처리 실패] code={}, message={}", e.getBaseErrorCode().getCode(), e.getMessage(), e);
return createApiGatewayErrorResponse(
Expand Down
Loading
Loading