Skip to content

Commit 2be333d

Browse files
authored
✨[feat] AOP 기반 실행 로그 및 에러 알림 기능 추가 (#164)
* ✨ [feat] 예외 발생 시 알림 전송 기능 추가 * 🔊 [feat] ObjectMapper를 활용한 컨트롤러 및 서비스 AOP 로깅 기능 추가
1 parent b5b1840 commit 2be333d

File tree

7 files changed

+282
-4
lines changed

7 files changed

+282
-4
lines changed

Backend_Config

build.gradle

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,6 @@ dependencies {
3333
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
3434
//webflux
3535
implementation 'org.springframework.boot:spring-boot-starter-webflux'
36-
//jpa
37-
// implementation 'mysql:mysql-connector-java:8.0.33'
38-
// implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
3936
//neo4j
4037
implementation 'org.springframework.boot:spring-boot-starter-data-neo4j'
4138
// openAi
@@ -48,6 +45,8 @@ dependencies {
4845
implementation 'sh.platform:config:2.2.2'
4946
implementation 'org.springframework.boot:spring-boot-starter-validation'
5047
implementation 'com.fasterxml.jackson.core:jackson-databind'
48+
//AOP
49+
implementation 'org.springframework.boot:spring-boot-starter-aop'
5150
}
5251

5352
tasks.named('test') {

src/main/java/com/going/server/ServerApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.scheduling.annotation.EnableAsync;
56

7+
@EnableAsync
68
@SpringBootApplication
79
public class ServerApplication {
810

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package com.going.server.global.common;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.aspectj.lang.JoinPoint;
6+
import org.aspectj.lang.annotation.AfterThrowing;
7+
import org.aspectj.lang.annotation.Aspect;
8+
import org.springframework.boot.web.client.RestTemplateBuilder;
9+
import org.springframework.context.annotation.Profile;
10+
import org.springframework.core.env.Environment;
11+
import org.springframework.core.io.ByteArrayResource;
12+
import org.springframework.http.HttpEntity;
13+
import org.springframework.http.HttpHeaders;
14+
import org.springframework.http.MediaType;
15+
import org.springframework.http.ResponseEntity;
16+
import org.springframework.scheduling.annotation.Async;
17+
import org.springframework.stereotype.Component;
18+
import org.springframework.util.LinkedMultiValueMap;
19+
import org.springframework.util.MultiValueMap;
20+
import org.springframework.web.client.RestTemplate;
21+
22+
import java.nio.charset.StandardCharsets;
23+
import java.time.LocalDateTime;
24+
import java.time.format.DateTimeFormatter;
25+
import java.util.ArrayList;
26+
import java.util.List;
27+
import java.util.Map;
28+
29+
30+
@Slf4j
31+
@Aspect
32+
@Component
33+
@Profile("!dev & !test")
34+
public class ExceptionDiscordLoggingAspect {
35+
private String webhookUrl;
36+
37+
private final RestTemplate restTemplate;
38+
private final ObjectMapper objectMapper;
39+
private final Environment environment;
40+
41+
public ExceptionDiscordLoggingAspect(
42+
RestTemplateBuilder restTemplateBuilder, ObjectMapper objectMapper, Environment environment) {
43+
this.restTemplate = restTemplateBuilder.build();
44+
this.objectMapper = objectMapper;
45+
this.environment = environment;
46+
}
47+
48+
private boolean isNotificationDisabled() {
49+
return environment.acceptsProfiles("dev", "test");
50+
}
51+
52+
private boolean shouldSkipNotification(Throwable ex) {
53+
SkipNotification skipNotification = ex.getClass().getAnnotation(SkipNotification.class);
54+
if (skipNotification == null) {
55+
return false;
56+
}
57+
return skipNotification.value();
58+
}
59+
60+
@AfterThrowing(pointcut = "within(com..*)", throwing = "ex")
61+
public void handleException(JoinPoint joinPoint, Throwable ex) {
62+
if (isNotificationDisabled()) {
63+
return;
64+
}
65+
66+
try {
67+
// log.info("Webhook URL: {}", webhookUrl);
68+
if (shouldSkipNotification(ex)) {
69+
log.info("알림을 보내지 않음 : {}", ex.getClass().getName());
70+
return;
71+
}
72+
List<Map<String, Object>> fields = buildFields(joinPoint, ex);
73+
Map<String, Object> embed = Map.of("title", "서버 예외 발생", "color", 16711680, "fields", fields);
74+
Map<String, Object> payload = Map.of("embeds", List.of(embed));
75+
String fullStackTrace = buildStackTrace(ex);
76+
sendToDiscord(objectMapper.writeValueAsString(payload), fullStackTrace);
77+
} catch (Exception e) {
78+
log.warn("디스코드 예외 전송 실패: {}", e.getMessage());
79+
}
80+
}
81+
82+
private List<Map<String, Object>> buildFields(JoinPoint joinPoint, Throwable ex) {
83+
List<Map<String, Object>> fields = new ArrayList<>();
84+
85+
fields.add(Map.of("name", "Exception", "value", ex.getClass().getName(), "inline", false));
86+
fields.add(
87+
Map.of(
88+
"name",
89+
"Message",
90+
"value",
91+
ex.getMessage() != null ? ex.getMessage() : "No message",
92+
"inline",
93+
false));
94+
fields.add(
95+
Map.of(
96+
"name",
97+
"Location",
98+
"value",
99+
joinPoint.getSignature().toShortString(),
100+
"inline",
101+
false));
102+
fields.add(
103+
Map.of(
104+
"name",
105+
"Time",
106+
"value",
107+
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
108+
"inline",
109+
false));
110+
111+
return fields;
112+
}
113+
114+
private String buildStackTrace(Throwable ex) {
115+
StringBuilder sb = new StringBuilder();
116+
for (StackTraceElement element : ex.getStackTrace()) {
117+
sb.append(element.toString()).append("\n");
118+
}
119+
return sb.toString();
120+
}
121+
122+
@Async
123+
protected void sendToDiscord(String payloadJson, String fullStackTrace) {
124+
try {
125+
if (isNotificationDisabled()) {
126+
return;
127+
}
128+
byte[] traceBytes = fullStackTrace.getBytes(StandardCharsets.UTF_8);
129+
ByteArrayResource traceFile = new TraceFileResource(traceBytes, "stacktrace.txt");
130+
131+
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
132+
body.add("payload_json", payloadJson);
133+
body.add("file", traceFile);
134+
135+
HttpHeaders headers = new HttpHeaders();
136+
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
137+
138+
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
139+
140+
ResponseEntity<String> response =
141+
restTemplate.postForEntity(webhookUrl, entity, String.class);
142+
log.info("디스코드 응답: {}", response.getStatusCode());
143+
144+
} catch (Exception e) {
145+
log.error("디스코드 전송 실패: {}", e.getMessage(), e);
146+
}
147+
}
148+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.going.server.global.common;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import jakarta.servlet.http.HttpServletRequest;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.aspectj.lang.ProceedingJoinPoint;
7+
import org.aspectj.lang.annotation.Around;
8+
import org.aspectj.lang.annotation.Aspect;
9+
import org.aspectj.lang.annotation.Pointcut;
10+
import org.springframework.stereotype.Component;
11+
import org.springframework.web.context.request.RequestContextHolder;
12+
import org.springframework.web.context.request.ServletRequestAttributes;
13+
import org.springframework.web.servlet.HandlerMapping;
14+
15+
import java.net.URLDecoder;
16+
import java.nio.charset.StandardCharsets;
17+
import java.util.Enumeration;
18+
import java.util.HashMap;
19+
import java.util.Map;
20+
21+
@Aspect
22+
@Slf4j
23+
@Component
24+
public class LogAspect {
25+
26+
private final ObjectMapper objectMapper;
27+
28+
public LogAspect(ObjectMapper objectMapper) {
29+
this.objectMapper = objectMapper;
30+
}
31+
32+
@Pointcut("execution(* com.going.server.domain..controller..*(..))")
33+
public void controllerMethods() {}
34+
35+
@Pointcut("execution(* com.going.server.domain..service..*(..))")
36+
public void serviceMethods() {}
37+
38+
@Around("controllerMethods()")
39+
public Object logController(ProceedingJoinPoint joinPoint) throws Throwable {
40+
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
41+
if (attributes == null || attributes.getRequest() == null) {
42+
return joinPoint.proceed();
43+
}
44+
45+
HttpServletRequest request = attributes.getRequest();
46+
String uri = URLDecoder.decode(request.getRequestURI(), StandardCharsets.UTF_8);
47+
String httpMethod = request.getMethod();
48+
String controllerClass = joinPoint.getSignature().getDeclaringTypeName();
49+
String methodName = joinPoint.getSignature().getName();
50+
Map<String, Object> paramMap = getParams(request);
51+
52+
try {
53+
log.info("HTTP {} {}", httpMethod, uri);
54+
log.info("Controller: {}.{}", controllerClass, methodName);
55+
log.info("Parameters: {}", objectMapper.writeValueAsString(paramMap));
56+
} catch (Exception e) {
57+
log.error("Failed to log controller {}.{}: {}", controllerClass, methodName, e.getMessage());
58+
}
59+
60+
return joinPoint.proceed();
61+
}
62+
63+
@Around("serviceMethods()")
64+
public Object logService(ProceedingJoinPoint joinPoint) throws Throwable {
65+
long startTime = System.currentTimeMillis();
66+
String className = joinPoint.getSignature().getDeclaringTypeName();
67+
String methodName = joinPoint.getSignature().getName();
68+
69+
try {
70+
Object result = joinPoint.proceed();
71+
long elapsedTime = System.currentTimeMillis() - startTime;
72+
log.info("Service executed: {}.{} ({} ms)", className, methodName, elapsedTime);
73+
return result;
74+
} catch (Throwable e) {
75+
long elapsedTime = System.currentTimeMillis() - startTime;
76+
log.error("Exception in service: {}.{} ({} ms): {}", className, methodName, elapsedTime, e.getMessage());
77+
throw e;
78+
}
79+
}
80+
81+
private static Map<String, Object> getParams(HttpServletRequest request) {
82+
Map<String, Object> paramMap = new HashMap<>();
83+
84+
Enumeration<String> paramNames = request.getParameterNames();
85+
while (paramNames.hasMoreElements()) {
86+
String param = paramNames.nextElement();
87+
paramMap.put(param.replaceAll("\\.", "-"), request.getParameter(param));
88+
}
89+
90+
@SuppressWarnings("unchecked")
91+
Map<String, String> pathVariables = (Map<String, String>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
92+
if (pathVariables != null) {
93+
for (Map.Entry<String, String> entry : pathVariables.entrySet()) {
94+
paramMap.put(entry.getKey().replaceAll("\\.", "-"), entry.getValue());
95+
}
96+
}
97+
98+
return paramMap;
99+
}
100+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.going.server.global.common;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
8+
@Target(ElementType.TYPE)
9+
@Retention(RetentionPolicy.RUNTIME)
10+
public @interface SkipNotification {
11+
boolean value() default true;
12+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.going.server.global.common;
2+
3+
import org.springframework.core.io.ByteArrayResource;
4+
5+
public class TraceFileResource extends ByteArrayResource {
6+
private final String fileName;
7+
8+
public TraceFileResource(byte[] byteArray, String fileName) {
9+
super(byteArray);
10+
this.fileName = fileName;
11+
}
12+
13+
@Override
14+
public String getFilename() {
15+
return fileName;
16+
}
17+
}

0 commit comments

Comments
 (0)