Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
706a6e4
feat: 커스텀 예외 처리를 위한 인터페이스 정의
move-hoon Apr 18, 2025
5f826e0
feat: Slack 요청 본문 파싱 실패 처리를 위한 Unchecked 예외 추가
move-hoon Apr 18, 2025
46b3cec
feat: Slack 메시지 생성 및 전송 실패에 대한 Checked 예외 클래스 구현
move-hoon Apr 18, 2025
b9ef824
feat: Slack 관련 예외 메시지 ErrorMessage enum으로 정의
move-hoon Apr 18, 2025
b952eb3
feat: EnvUtil 클래스 생성 및 웹훅 URL 조회 기능 구현
move-hoon Apr 18, 2025
40c40bf
feat: EnvUtil 클래스 생성 및 웹훅 URL 조회 기능 구현
move-hoon Apr 18, 2025
61ce339
feat: ErrorMessage enum에 예외 코드 추가
move-hoon Apr 18, 2025
ca366fb
feat: HttpClientUtil 유틸 클래스 추가
move-hoon Apr 18, 2025
080ed5c
feat: Slack 메시지 생성을 위한 상수 클래스 및 로그 레벨 기반 색상 매핑 기능 추가
move-hoon Apr 18, 2025
d008ab1
feat: Slack 알림 서비스 구현 및 팩토리 클래스 추가
move-hoon Apr 18, 2025
26ccd38
feat: ObjectMapperConfig 클래스 추가
move-hoon Apr 18, 2025
82a5456
feat: SentryEventDetail DTO 추가
move-hoon Apr 18, 2025
44d709d
delete: Main 클래스 삭제
move-hoon Apr 18, 2025
a413566
feat: Slack API 연동을 위한 VO 객체 추가
move-hoon Apr 19, 2025
6406548
refactor: SlackNotificationService Map 기반 구조를 VO 기반으로 변경
move-hoon Apr 19, 2025
c686519
refactor: 사용하지 않는 상수 삭제
move-hoon Apr 19, 2025
2743173
feat: Sentry 웹훅 처리용 Lambda 핸들러 구현
move-hoon Apr 19, 2025
c0bad08
refactor: APIGatewayProxyRequestEvent 파싱을 위한 WebhookRequest DTO 도입 및 …
move-hoon Apr 19, 2025
02b3e93
chore: 에러메시지 세분화
move-hoon Apr 19, 2025
e56513d
refactor: 가독성을 위한 handler 리팩토링 및 상수 추가
move-hoon Apr 19, 2025
d0f926a
chore: 매직 문자열 상수로 변경
move-hoon Apr 19, 2025
a977ac7
refactor: Slack API 응답 본문 검증 누락에 대한 코드리뷰 반영
move-hoon Apr 19, 2025
9918a13
refactor: Z와 같은 UTC 오프셋을 처리할 수 있도록 OffsetDateTime로 변경
move-hoon Apr 19, 2025
c46ddac
refactor: 경로 파라미터에 대한 NPE 방지 코드리뷰 반영
move-hoon Apr 19, 2025
b6b9c00
fix: Slack 공식 스펙에 맞게 구조화 수정
move-hoon Apr 19, 2025
b09bcaf
refactor: 패키지 이동
move-hoon Apr 19, 2025
50f54cb
refactor: 예외 처리 패턴 개선 및 커스텀 예외 추가
move-hoon Apr 19, 2025
75f6d24
docs: README 업데이트
move-hoon Apr 19, 2025
474bf2d
feat: Release Drafter 설정 추가 및 버전 태그 자동화
move-hoon Apr 19, 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
36 changes: 36 additions & 0 deletions .github/release-drafter-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name-template: 'v$RESOLVED_VERSION'
tag-template: 'v$RESOLVED_VERSION'

categories:
- title: '✨ 새로운 기능'
labels: ['feat']
- title: '🐞 버그 수정'
labels: ['fix']
- title: '🧹 코드 개선 및 리팩토링'
labels: ['refactor', 'style', 'chore']
- title: '🧪 테스트'
labels: ['test']
- title: '🛠 문서 및 기타 변경'
labels: ['docs']

change-template: '- $TITLE (#$NUMBER by @$AUTHOR)'
no-changes-template: '이번 릴리스에는 변경 사항이 없습니다.'

template: |
## 🚀 릴리스 노트: v$RESOLVED_VERSION
아래는 이번 릴리스에 포함된 변경 사항입니다.
---

$CHANGES

version-resolver:
major:
labels:
- '1️⃣ major'
minor:
labels:
- '2️⃣ minor'
patch:
labels:
- '3️⃣ patch'
default: patch
14 changes: 14 additions & 0 deletions .github/workflows/release-drafter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: Release Drafter
on:
push:
branches:
- main
jobs:
update_release_draft:
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v5
with:
config-name: release-drafter-config.yml
env:
GITHUB_TOKEN: ${{ secrets.ACTION_TOKEN }}
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
이를 **팀명과 서버 유형(FE/BE 등)에 따라 분기 처리**하여
슬랙 등의 외부 알림 채널로 전송해주는 Lambda 기반 서비스입니다.

본 프로젝트는 Sentry 이벤트를 Slack으로 전송하는 AWS Lambda 함수를 구현하기 위해 Spring 없이 Java만으로 구현했습니다.
이러한 기술적 결정에는 다음과 같은 이유가 있습니다:

- AWS Lambda 콜드 스타트 최소화: SpringBoot와 같은 무거운 프레임워크는 초기화 시간이 길어 Lambda의 콜드 스타트 지연 문제를 악화시킬 수 있습니다. 그래서 순수 Java로만 구현하여 시작 시간을 단축했습니다.
- 리소스 효율성: 경량화된 애플리케이션으로 Lambda의 메모리 사용량을 최소화하고, 이는 비용 효율성으로 이어집니다.
- 조직 친화적 기술 스택: Kotlin + Ktor와 같은 대안도 고려했지만, BE 챕터원들이 메이커스 프로젝트 개발에 Java를 주로 사용하고 있다는 점을 고려했습니다. 새로운 언어 도입 시 팀 내 지식 공유와 유지보수에 추가적인 부담이 발생할 수 있어 기존 기술 스택인 Java를 선택했습니다.
- 최소 의존성: 필요한 최소한의 라이브러리만 사용하여 배포 패키지 크기를 줄이고 시작 시간을 개선했습니다.

## 🏗️ Tech Stack

- **Java 21**
Expand Down
7 changes: 0 additions & 7 deletions src/main/java/org/sopt/makers/Main.java

This file was deleted.

25 changes: 25 additions & 0 deletions src/main/java/org/sopt/makers/dto/SentryEventDetail.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.sopt.makers.dto;

import com.fasterxml.jackson.databind.JsonNode;

import lombok.AccessLevel;
import lombok.Builder;

@Builder(access = AccessLevel.PRIVATE)
public record SentryEventDetail(
String issueId,
String webUrl,
String message,
String datetime,
String level
) {
public static SentryEventDetail from(JsonNode eventNode) {
return SentryEventDetail.builder()
.issueId(eventNode.path("issue_id").asText())
.webUrl(eventNode.path("web_url").asText())
.message(eventNode.path("message").asText())
.datetime(eventNode.path("datetime").asText())
.level(eventNode.path("level").asText())
.build();
}
}
35 changes: 35 additions & 0 deletions src/main/java/org/sopt/makers/dto/WebhookRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.sopt.makers.dto;

import java.util.Map;

import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;

import lombok.AccessLevel;
import lombok.Builder;

@Builder(access = AccessLevel.PRIVATE)
public record WebhookRequest(
String stage,
String team,
String type,
String serviceType
) {
public static WebhookRequest from(APIGatewayProxyRequestEvent input) {
String stage = input.getRequestContext().getStage();
Map<String, String> pathParameters = input.getPathParameters();
if (pathParameters == null) {
pathParameters = Map.of();
}

String team = pathParameters.get("team");
String type = pathParameters.get("type");
String serviceType = pathParameters.getOrDefault("service", "slack");

return WebhookRequest.builder()
.stage(stage)
.team(team)
.type(type)
.serviceType(serviceType)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.sopt.makers.global.config;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ObjectMapperConfig {

public static ObjectMapper getInstance() {
return ObjectMapperHolder.INSTANCE;
}

private static class ObjectMapperHolder {
private static final ObjectMapper INSTANCE = createObjectMapper();

private static ObjectMapper createObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
return objectMapper;
}
}
}
34 changes: 34 additions & 0 deletions src/main/java/org/sopt/makers/global/constant/Color.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.sopt.makers.global.constant;

import java.util.Arrays;
import java.util.Set;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Color {
RED("#FF0000", Set.of("fatal", "critical")),
ORANGE("#E36209", Set.of("error")),
YELLOW("#FFCC00", Set.of("warning")),
GREEN("#36A64F", Set.of("info")),
BLUE("#87CEFA", Set.of("debug")),
GRAY("#AAAAAA", Set.of());

private final String value;
private final Set<String> levels;

public static String getColorByLevel(String level) {
if (level == null || level.trim().isEmpty()) {
return GRAY.value;
}

String normalized = level.trim().toLowerCase();
return Arrays.stream(Color.values())
.filter(color -> color.levels.contains(normalized))
.map(Color::getValue)
.findFirst()
.orElse(GRAY.value);
}
}
49 changes: 49 additions & 0 deletions src/main/java/org/sopt/makers/global/constant/SlackConstant.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package org.sopt.makers.global.constant;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class SlackConstant {
// 날짜 포맷 형식
public static final String DATE_FORMAT_PATTERN = "yyyy-MM-dd HH:mm:ss";

// 버튼 텍스트
public static final String SENTRY_BUTTON_TEXT = "상세 보기";

// HTTP 관련 상수
public static final String HEADER_CONTENT_TYPE = "Content-Type";
public static final String CONTENT_TYPE_JSON = "application/json";

// Slack 블록 타입 상수
public static final String BLOCK_TYPE_HEADER = "header";
public static final String BLOCK_TYPE_SECTION = "section";
public static final String BLOCK_TYPE_ACTIONS = "actions";

// 텍스트 타입 상수
public static final String TEXT_TYPE_PLAIN = "plain_text";
public static final String TEXT_TYPE_MARKDOWN = "mrkdwn";

// 요소 타입 상수 (버튼)
public static final String ELEMENT_TYPE_BUTTON = "button";

// 스타일 상수
public static final String STYLE_PRIMARY = "primary";

// 시간대 관련 상수
public static final String TIMEZONE_SEOUL = "Asia/Seoul";

// 줄바꿈 상수
public static final String NEW_LINE = "\n";

// JSON 키 상수
public static final String MESSAGE = "message";
public static final String ERROR = "error";
public static final String CODE = "code";

// 성공 메시지
public static final String SUCCESS_MESSAGE = "알림이 성공적으로 전송되었습니다";

// 폴백 메시지 (기본 오류 메시지)
public static final String FALLBACK_MESSAGE = "알림이 전송되었습니다";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.sopt.makers.global.exception.base;

public interface BaseErrorCode {
int getStatus();
String getCode();
String getMessage();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.sopt.makers.global.exception.base;

public interface SentryException {
BaseErrorCode getBaseErrorCode();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.sopt.makers.global.exception.checked;

import org.sopt.makers.global.exception.base.BaseErrorCode;
import org.sopt.makers.global.exception.base.SentryException;

import lombok.Getter;

@Getter
public class SentryCheckedException extends Exception implements SentryException {
private final BaseErrorCode baseErrorCode;

protected SentryCheckedException(BaseErrorCode baseErrorCode) {
super(baseErrorCode.getMessage());
this.baseErrorCode = baseErrorCode;
}
}
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 SlackMessageBuildException extends SentryCheckedException {
public SlackMessageBuildException(BaseErrorCode errorCode) {
super(errorCode);
}

public static SlackMessageBuildException from(BaseErrorCode errorCode) {
return new SlackMessageBuildException(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 SlackSendException extends SentryCheckedException {
public SlackSendException(BaseErrorCode errorCode) {
super(errorCode);
}

public static SlackSendException from(BaseErrorCode errorCode) {
return new SlackSendException(errorCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.sopt.makers.global.exception.message;

import org.sopt.makers.global.exception.base.BaseErrorCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum ErrorMessage implements BaseErrorCode {
// ===== Webhook 관련 오류 =====
WEBHOOK_URL_NOT_FOUND(500, "Webhook URL을 찾을 수 없습니다.", "W5001"),

// ===== 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"),

// ===== 공통 오류 =====
UNEXPECTED_SERVER_ERROR(500, "내부 서버 오류가 발생했습니다.", "C5001");

private final int status;
private final String message;
private final String code;
}
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 HttpRequestException extends SentryUncheckedException {
public HttpRequestException(BaseErrorCode errorCode) {
super(errorCode);
}

public static HttpRequestException from(BaseErrorCode errorCode) {
return new HttpRequestException(errorCode);
}
}
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 InvalidSlackPayloadException extends SentryUncheckedException {
public InvalidSlackPayloadException(BaseErrorCode errorCode) {
super(errorCode);
}

public static InvalidSlackPayloadException from(BaseErrorCode errorCode) {
return new InvalidSlackPayloadException(errorCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.sopt.makers.global.exception.unchecked;

import org.sopt.makers.global.exception.base.BaseErrorCode;
import org.sopt.makers.global.exception.base.SentryException;

import lombok.Getter;

@Getter
public class SentryUncheckedException extends RuntimeException implements SentryException {
private final BaseErrorCode baseErrorCode;

protected SentryUncheckedException(BaseErrorCode baseErrorCode) {
super(baseErrorCode.getMessage());
this.baseErrorCode = baseErrorCode;
}
}
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 UnsupportedServiceTypeException extends SentryUncheckedException {
public UnsupportedServiceTypeException(BaseErrorCode errorCode) {
super(errorCode);
}

public static UnsupportedServiceTypeException from(BaseErrorCode errorCode) {
return new UnsupportedServiceTypeException(errorCode);
}
}
Loading