Skip to content

Commit e386975

Browse files
committed
[#59] feat: 5xx 에러에 대한 슬랙 알림 추가
1 parent bf8ec52 commit e386975

File tree

11 files changed

+109
-0
lines changed

11 files changed

+109
-0
lines changed

.github/workflows/cd.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ jobs:
2929
sed -i 's/${DB_USERNAME:[^}]*}/${{ secrets.DB_USERNAME }}/g' src/main/resources/application.yml
3030
sed -i 's/${DB_PASSWORD:[^}]*}/${{ secrets.DB_PASSWORD }}/g' src/main/resources/application.yml
3131
sed -i 's/${GEMINI_API_KEY:[^}]*}/${{ secrets.GEMINI_API_KEY }}/g' src/main/resources/application.yml
32+
sed -i 's/${SLACK_WEBHOOK_URL:[^}]*}/${{ secrets.SLACK_WEBHOOK_URL }}/g' src/main/resources/application.yml
3233
3334
- name: Setup Gradle cache
3435
uses: actions/cache@v4

build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ dependencies {
9797
implementation("org.springframework.boot:spring-boot-starter-actuator")
9898
implementation("io.micrometer:micrometer-registry-prometheus")
9999

100+
// slack
101+
implementation("com.slack.api:slack-api-client:1.45.3")
102+
100103
// restdocs-api-spec
101104
testImplementation("com.epages:restdocs-api-spec-mockmvc:0.19.4")
102105
testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.nexters.teamace.common.application;
2+
3+
import com.nexters.teamace.common.exception.ErrorType;
4+
5+
public interface AlertService {
6+
void error(ErrorType errorType, String message);
7+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.nexters.teamace.common.config;
2+
3+
import java.util.concurrent.Executor;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.core.task.VirtualThreadTaskExecutor;
6+
import org.springframework.scheduling.annotation.AsyncConfigurer;
7+
import org.springframework.scheduling.annotation.EnableAsync;
8+
9+
@Configuration
10+
@EnableAsync
11+
public class AsyncConfig implements AsyncConfigurer {
12+
13+
@Override
14+
public Executor getAsyncExecutor() {
15+
return new VirtualThreadTaskExecutor("async-");
16+
}
17+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.nexters.teamace.common.infrastructure;
2+
3+
import com.nexters.teamace.common.application.AlertService;
4+
import com.nexters.teamace.common.exception.ErrorType;
5+
import com.slack.api.Slack;
6+
import com.slack.api.webhook.Payload;
7+
import com.slack.api.webhook.WebhookResponse;
8+
import io.jsonwebtoken.lang.Strings;
9+
import java.io.IOException;
10+
import java.util.Objects;
11+
import lombok.extern.slf4j.Slf4j;
12+
import org.springframework.beans.factory.annotation.Value;
13+
import org.springframework.scheduling.annotation.Async;
14+
import org.springframework.stereotype.Service;
15+
16+
@Slf4j
17+
@Service
18+
public class SlackAlertService implements AlertService {
19+
20+
@Value("${slack.enabled:false}")
21+
private boolean enabled;
22+
23+
@Value("${slack.webhook-url:}")
24+
private String webhookUrl;
25+
26+
private final Slack slack = Slack.getInstance();
27+
28+
@Async
29+
@Override
30+
public void error(ErrorType errorType, String message) {
31+
send("Error 발생: " + errorType.name(), message);
32+
}
33+
34+
private void send(String title, String message) {
35+
send("*%s*\n%s".formatted(title, message));
36+
}
37+
38+
private void send(String message) {
39+
if (!enabled || !Strings.hasText(webhookUrl)) {
40+
log.debug("Slack alert deactivate or webhook url is empty");
41+
}
42+
try {
43+
Payload payload = Payload.builder().text(message).build();
44+
WebhookResponse response = slack.send(webhookUrl, payload);
45+
if (response == null || response.getCode() != 200) {
46+
log.error(
47+
"Slack webhook 비정상 응답: code={}, body={}",
48+
Objects.isNull(response) ? null : response.getCode(),
49+
Objects.isNull(response) ? null : response.getBody());
50+
}
51+
} catch (IOException e) {
52+
log.error("Slack message sending failed: {}", e.getMessage(), e);
53+
} catch (Exception e) {
54+
log.error("Slack message sending failed with unexpected error: {}", e.getMessage(), e);
55+
}
56+
}
57+
}

src/main/java/com/nexters/teamace/common/presentation/GlobalExceptionHandler.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.nexters.teamace.common.presentation;
22

3+
import com.nexters.teamace.common.application.AlertService;
34
import com.nexters.teamace.common.exception.CustomException;
45
import com.nexters.teamace.common.exception.ErrorType;
56
import jakarta.persistence.EntityNotFoundException;
67
import jakarta.validation.ConstraintViolationException;
78
import java.util.List;
89
import java.util.Map;
10+
import lombok.RequiredArgsConstructor;
911
import lombok.extern.slf4j.Slf4j;
1012
import org.springframework.http.ResponseEntity;
1113
import org.springframework.security.access.AccessDeniedException;
@@ -20,8 +22,11 @@
2022

2123
@Slf4j
2224
@RestControllerAdvice
25+
@RequiredArgsConstructor
2326
public class GlobalExceptionHandler {
2427

28+
private final AlertService alertService;
29+
2530
@ExceptionHandler(MethodArgumentNotValidException.class)
2631
public ResponseEntity<ApiResponse<Void>> handleMethodArgumentNotValid(
2732
MethodArgumentNotValidException e) {
@@ -149,6 +154,7 @@ public ResponseEntity<ApiResponse<Void>> handleNoSuchElement(CustomException e)
149154
@ExceptionHandler(Exception.class)
150155
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
151156
log.error("Unexpected error occurred: ", e);
157+
alertService.error(ErrorType.INTERNAL_SERVER_ERROR, e.getMessage());
152158
return new ResponseEntity<>(
153159
ApiResponse.error(ErrorType.INTERNAL_SERVER_ERROR),
154160
ErrorType.INTERNAL_SERVER_ERROR.getStatus());

src/main/resources/application.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ spring:
2525
chat: none # 기본 모델 설정 비활성화
2626
openai:
2727
api-key: ${OPENAI_API_KEY:fake-key} # 기본 모델을 비활성화해도 필수값으로 받음.
28+
threads:
29+
virtual:
30+
enabled: true
2831

2932
management:
3033
endpoints:
@@ -49,3 +52,7 @@ jwt:
4952
gemini:
5053
api-key: ${GEMINI_API_KEY:test-api-key}
5154
model: gemini-2.0-flash-exp
55+
56+
slack:
57+
enabled: true
58+
webhook-url: ${SLACK_WEBHOOK_URL:}

src/test/java/com/nexters/teamace/common/utils/ControllerTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.nexters.teamace.auth.infrastructure.security.SecurityErrorHandler;
1111
import com.nexters.teamace.auth.presentation.AuthUserArgumentResolver;
1212
import com.nexters.teamace.chat.application.ChatRoomService;
13+
import com.nexters.teamace.common.infrastructure.SlackAlertService;
1314
import com.nexters.teamace.common.presentation.GlobalExceptionHandler;
1415
import com.nexters.teamace.conversation.application.ConversationClient;
1516
import com.nexters.teamace.conversation.application.ConversationService;
@@ -45,6 +46,7 @@ public abstract class ControllerTest {
4546
@MockitoBean protected FairyService fairyService;
4647
@MockitoBean protected AuthUserArgumentResolver authUserArgumentResolver;
4748
@MockitoBean protected LetterService letterService;
49+
@MockitoBean protected SlackAlertService slackAlertService;
4850

4951
protected Object asParsedJson(Object obj) throws JsonProcessingException {
5052
String json = objectMapper.writeValueAsString(obj);

src/test/java/com/nexters/teamace/common/utils/E2ETest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
import com.fasterxml.jackson.databind.ObjectMapper;
44
import com.navercorp.fixturemonkey.FixtureMonkey;
5+
import com.nexters.teamace.common.infrastructure.SlackAlertService;
56
import io.restassured.RestAssured;
67
import org.junit.jupiter.api.BeforeEach;
78
import org.springframework.beans.factory.annotation.Autowired;
89
import org.springframework.boot.test.context.SpringBootTest;
910
import org.springframework.boot.test.web.server.LocalServerPort;
11+
import org.springframework.test.context.bean.override.mockito.MockitoBean;
1012

1113
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
1214
public abstract class E2ETest {
@@ -15,6 +17,8 @@ public abstract class E2ETest {
1517

1618
@Autowired protected ObjectMapper objectMapper;
1719

20+
@MockitoBean protected SlackAlertService slackAlertService;
21+
1822
protected final FixtureMonkey fixtureMonkey = FixtureMonkey.create();
1923

2024
@BeforeEach

src/test/java/com/nexters/teamace/common/utils/UseCaseIntegrationTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.nexters.teamace.chat.domain.ChatMessageGenerator;
55
import com.nexters.teamace.common.application.DateTimeHolder;
66
import com.nexters.teamace.common.application.SystemHolder;
7+
import com.nexters.teamace.common.infrastructure.SlackAlertService;
78
import org.springframework.boot.test.context.SpringBootTest;
89
import org.springframework.test.context.bean.override.mockito.MockitoBean;
910

@@ -13,6 +14,7 @@ public abstract class UseCaseIntegrationTest {
1314
@MockitoBean protected SystemHolder systemHolder;
1415
@MockitoBean protected DateTimeHolder dateTimeHolder;
1516
@MockitoBean protected ChatMessageGenerator chatMessageGenerator;
17+
@MockitoBean protected SlackAlertService slackAlertService;
1618

1719
protected final FixtureMonkey fixtureMonkey = FixtureMonkey.create();
1820
}

0 commit comments

Comments
 (0)