-
Notifications
You must be signed in to change notification settings - Fork 2
feat: dev, prod 환경별 xml 로그 파일 관리 및 dev 환경 http응답/요청 디스코드 웹훅 연동 - #153 #154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4646681
6c7588c
eeeb9a4
2295b3a
479bf6e
4348fa7
f4f3a43
c9b064c
0ca700f
f19475f
0fc4c0f
21e23cf
ab6fd08
1c9479a
96026e1
c1c370a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,11 +15,11 @@ public record TicketRoundWithTypeCreateRequest( | |
| String ticketRoundName, | ||
|
|
||
| @NotNull(message = "티켓라운드 판매 시작일은 필수입니다.") | ||
| @JsonFormat(pattern = "yyyy.MM.dd HH:mm") | ||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm") | ||
| LocalDateTime ticketRoundSalesStartDate, | ||
|
|
||
| @NotNull(message = "티켓라운드 판매 종료일은 필수입니다.") | ||
| @JsonFormat(pattern = "yyyy.MM.dd HH:mm") | ||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm") | ||
| LocalDateTime ticketRoundSalesEndDate, | ||
|
Comment on lines
+18
to
23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainAPI 호환성 깨짐 가능성을 확인하세요. 날짜 형식을 "yyyy.MM.dd HH:mm"에서 "yyyy-MM-dd HH:mm"로 변경했습니다. 이는 기존 API 클라이언트에 영향을 주는 breaking change입니다. 다음을 확인하세요:
다음 스크립트로 코드베이스 전체에서 날짜 형식 일관성을 확인하세요: 🏁 Script executed: #!/bin/bash
# 설명: 다른 DTO에서 여전히 구 형식을 사용하는지 확인
# 구 형식 패턴 검색
echo "=== 구 형식 (yyyy.MM.dd) 사용 중인 파일 ==="
rg -n '@JsonFormat\(pattern\s*=\s*"yyyy\.MM\.dd' --type=java
# 새 형식 패턴 검색
echo -e "\n=== 새 형식 (yyyy-MM-dd) 사용 중인 파일 ==="
rg -n '@JsonFormat\(pattern\s*=\s*"yyyy-MM-dd' --type=javaLength of output: 2388 날짜 형식 일관화 필요 TimetableResponse.java, TimetableDetailResponse.java 등에서 여전히 🤖 Prompt for AI Agents |
||
|
|
||
| @NotNull(message = "티켓타입 리스트는 필수입니다.") | ||
|
|
@@ -41,11 +41,11 @@ public record TicketTypeRequest( | |
| Integer totalCount, | ||
|
|
||
| @NotNull(message = "티켓타입 시작일은 필수입니다.") | ||
| @JsonFormat(pattern = "yyyy.MM.dd HH:mm") | ||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm") | ||
| LocalDateTime startDate, | ||
|
|
||
| @NotNull(message = "티켓타입 종료일은 필수입니다.") | ||
| @JsonFormat(pattern = "yyyy.MM.dd HH:mm") | ||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm") | ||
| LocalDateTime endDate | ||
| ) { } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| package com.permitseoul.permitserver.global.config; | ||
|
|
||
| import org.slf4j.MDC; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.scheduling.annotation.EnableAsync; | ||
| import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; | ||
|
|
||
| import java.util.Map; | ||
| import java.util.concurrent.Executor; | ||
|
|
||
| @Configuration | ||
| @EnableAsync | ||
| public class AsyncConfig { | ||
|
|
||
| @Bean(name = "alertExecutor") | ||
| public Executor alertExecutor() { | ||
| final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); | ||
| executor.setCorePoolSize(2); | ||
| executor.setMaxPoolSize(4); | ||
| executor.setQueueCapacity(20); | ||
| executor.setKeepAliveSeconds(60); | ||
| executor.setThreadNamePrefix("DiscordAlert-"); | ||
| executor.setAllowCoreThreadTimeOut(true); | ||
|
|
||
| //AsyncAppender를 사용하는데, MDC를 비동기 스레드 전파하기 위함 | ||
| executor.setTaskDecorator(runnable -> { | ||
| Map<String, String> contextMap = MDC.getCopyOfContextMap(); | ||
| return () -> { | ||
| if (contextMap != null) { | ||
| MDC.setContextMap(contextMap); | ||
| } | ||
| try { | ||
| runnable.run(); | ||
| } finally { | ||
| MDC.clear(); | ||
| } | ||
| }; | ||
| }); | ||
| executor.initialize(); | ||
|
|
||
| return executor; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,19 @@ | ||
| package com.permitseoul.permitserver.global.config; | ||
|
|
||
|
|
||
| import org.springframework.boot.autoconfigure.ImportAutoConfiguration; | ||
| import feign.Retryer; | ||
| import org.springframework.cloud.openfeign.EnableFeignClients; | ||
| import org.springframework.cloud.openfeign.FeignAutoConfiguration; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
|
|
||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| @Configuration | ||
| @EnableFeignClients("com.permitseoul") | ||
| public class FeignConfig { | ||
|
|
||
| @Bean | ||
| public Retryer retryer() { | ||
| // 3회 재시도 (최대 1초 간격) | ||
| return new Retryer.Default(100, TimeUnit.SECONDS.toMillis(1), 3); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package com.permitseoul.permitserver.global.external.discord; | ||
|
|
||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.permitseoul.permitserver.global.external.discord.dto.DiscordMessage; | ||
| import com.permitseoul.permitserver.global.external.discord.util.DiscordMessageFormatterUtil; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class DiscordDevHttpLogSender implements DiscordSender { | ||
|
|
||
| private final DiscordFeignClient discordFeignClient; | ||
| private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); | ||
|
|
||
| @Async("alertExecutor") | ||
| public void send(final String jsonMessage) { | ||
| try { | ||
| final JsonNode root = OBJECT_MAPPER.readTree(jsonMessage); | ||
| final String content = DiscordMessageFormatterUtil.formatDevHttpLogToMarkdown(root); | ||
| discordFeignClient.sendMessage(DiscordMessage.of(content)); | ||
|
|
||
| } catch (Exception e) { | ||
| log.error("🚨 [DiscordDevHttpLogSender] Discord 전송 실패", e); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package com.permitseoul.permitserver.global.external.discord; | ||
|
|
||
| import com.permitseoul.permitserver.global.config.FeignConfig; | ||
| import com.permitseoul.permitserver.global.external.discord.dto.DiscordMessage; | ||
| import org.springframework.cloud.openfeign.FeignClient; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
|
|
||
| @FeignClient( | ||
| name = "discordClient", | ||
| url = "${discord.webhook-url}", | ||
| configuration = FeignConfig.class | ||
| ) | ||
|
Comment on lines
+10
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainFeignConfig에서 재시도 및 타임아웃 설정 확인 필요. Feign 클라이언트가 외부 Discord webhook을 호출하는데, 네트워크 장애나 일시적인 오류 발생 시 재시도 로직과 적절한 타임아웃 설정이 중요합니다. FeignConfig에 Retryer와 타임아웃이 적절히 구성되어 있는지 확인하세요. 다음 스크립트로 FeignConfig의 구성을 확인하세요: 🏁 Script executed: #!/bin/bash
# FeignConfig에서 Retryer 및 타임아웃 설정 확인
rg -n -A 10 "class FeignConfig" --type=javaLength of output: 717 FeignConfig에 Timeout 설정 추가 필요 FeignConfig에 Retryer는 정의되어 있으나, connect 및 read timeout이 설정되어 있지 않습니다. Request.Options 빈을 추가해 적절한 타임아웃을 설정하세요. 예시: @Bean
public Request.Options options() {
return new Request.Options(
5000, TimeUnit.MILLISECONDS, // connect timeout
10000, TimeUnit.MILLISECONDS, // read timeout
true
);
}🤖 Prompt for AI Agents |
||
| public interface DiscordFeignClient { | ||
|
|
||
| @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) | ||
| void sendMessage(@RequestBody DiscordMessage discordMessage); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.permitseoul.permitserver.global.external.discord; | ||
|
|
||
| public interface DiscordSender { | ||
| void send(String message); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.permitseoul.permitserver.global.external.discord.dto; | ||
|
|
||
|
|
||
| public record DiscordMessage(String content) { | ||
|
|
||
| public static DiscordMessage of(final String message) { | ||
| return new DiscordMessage(message); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| package com.permitseoul.permitserver.global.external.discord.util; | ||
|
|
||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import lombok.experimental.UtilityClass; | ||
|
|
||
| @UtilityClass | ||
| public final class DiscordMessageFormatterUtil { | ||
|
|
||
| private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); | ||
|
|
||
| private static final String FIELD_TIME = "time"; | ||
| private static final String FIELD_LOG_TYPE = "log_type"; | ||
| private static final String FIELD_METHOD = "method"; | ||
| private static final String FIELD_URL = "url"; | ||
| private static final String FIELD_STATUS = "status"; | ||
| private static final String FIELD_TRACE_ID = "trace_id"; | ||
| private static final String FIELD_DURATION = "duration_ms"; | ||
| private static final String FIELD_REQUEST_BODY = "request_body"; | ||
| private static final String FIELD_RESPONSE_BODY = "response_body"; | ||
| private static final String DEFAULT_NO_TIME = "(no time)"; | ||
| private static final String DEFAULT_NO_LOG_TYPE = "N/A"; | ||
| private static final String DEFAULT_NO_METHOD = "UNKNOWN"; | ||
| private static final String DEFAULT_NO_URL = "N/A"; | ||
| private static final String DEFAULT_NO_TRACE_ID = "(no-trace)"; | ||
| private static final long DEFAULT_DURATION = 0L; | ||
| private static final String EMPTY_BODY = "(Empty Body)"; | ||
| private static final int DEFAULT_NO_STATUS = 0; | ||
|
|
||
| // [dev 환경] HTTP 요청/응답 로그 포맷팅 | ||
| public static String formatDevHttpLogToMarkdown(final JsonNode root) { | ||
| final String time = root.path(FIELD_TIME).asText(DEFAULT_NO_TIME); | ||
| final String logType = root.path(FIELD_LOG_TYPE).asText(DEFAULT_NO_LOG_TYPE); | ||
| final String method = root.path(FIELD_METHOD).asText(DEFAULT_NO_METHOD); | ||
| final String url = root.path(FIELD_URL).asText(DEFAULT_NO_URL); | ||
| final int status = root.path(FIELD_STATUS).asInt(DEFAULT_NO_STATUS); | ||
| final String traceId = root.path(FIELD_TRACE_ID).asText(DEFAULT_NO_TRACE_ID); | ||
| final long duration = root.path(FIELD_DURATION).asLong(DEFAULT_DURATION); | ||
|
|
||
| final String requestBody = prettyJson(root.path(FIELD_REQUEST_BODY).asText()); | ||
| final String responseBody = prettyJson(root.path(FIELD_RESPONSE_BODY).asText()); | ||
|
|
||
| return """ | ||
| **[HTTP 요청/응답 로그]** | ||
|
|
||
| 🕓 **time:** %s | ||
| 🧩 **log_type:** %s | ||
| 🧭 **method:** %s | ||
| 🌐 **url:** %s | ||
| 📦 **status:** %d | ||
| **trace_id:** `%s` | ||
| ⏱️ **duration:** %dms | ||
|
|
||
| **Request Body:** | ||
| ```json | ||
| %s | ||
| ``` | ||
| **Response Body:** | ||
| ```json | ||
| %s | ||
| ``` | ||
| """.formatted( | ||
| time, logType, method, url, status, traceId, duration, | ||
| requestBody, responseBody | ||
| ); | ||
| } | ||
|
|
||
| public static String prettyJson(final String body) { | ||
| if (body == null || body.isBlank() || body.equals(EMPTY_BODY)) { | ||
| return EMPTY_BODY; | ||
| } | ||
| try { | ||
| final JsonNode node = OBJECT_MAPPER.readTree(body); | ||
| return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(node); | ||
| } catch (Exception e) { | ||
| return body; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,73 +1,42 @@ | ||
| package com.permitseoul.permitserver.global.filter; | ||
|
|
||
| import com.permitseoul.permitserver.global.external.discord.DiscordSender; | ||
| import com.permitseoul.permitserver.global.util.HttpReqResLogJsonBuilder; | ||
| import jakarta.servlet.*; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.context.annotation.Profile; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.util.ContentCachingRequestWrapper; | ||
| import org.springframework.web.util.ContentCachingResponseWrapper; | ||
|
|
||
| import java.io.IOException; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.time.LocalDateTime; | ||
|
|
||
| @Slf4j | ||
| @Component // 필터 가장 처음 | ||
| @Profile("dev") // dev에서만 적용(테스트 및 qa용) | ||
| @Component | ||
| @Profile("dev") | ||
| @RequiredArgsConstructor | ||
| public class DevRequestResponseLoggingFilter implements Filter { | ||
|
|
||
| private final DiscordSender discordSender; | ||
|
|
||
| @Override | ||
| public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) | ||
| throws IOException, ServletException { | ||
| public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { | ||
|
|
||
| // 요청 응답을 여러 번 읽을 수 있도록 래핑 | ||
| final ContentCachingRequestWrapper reqWrapper = new ContentCachingRequestWrapper((HttpServletRequest) request); | ||
| final ContentCachingResponseWrapper resWrapper = new ContentCachingResponseWrapper((HttpServletResponse) response); | ||
| // 요청,응답을 여러 번 읽을 수 있도록 래핑 | ||
| final ContentCachingRequestWrapper req = new ContentCachingRequestWrapper((HttpServletRequest) request); | ||
| final ContentCachingResponseWrapper res = new ContentCachingResponseWrapper((HttpServletResponse) response); | ||
|
|
||
| final long start = System.currentTimeMillis(); | ||
| chain.doFilter(reqWrapper, resWrapper); | ||
| final long duration = System.currentTimeMillis() - start; | ||
|
|
||
| logRequestResponse(reqWrapper, resWrapper, duration); | ||
| resWrapper.copyBodyToResponse(); | ||
| } | ||
|
|
||
| private void logRequestResponse(final ContentCachingRequestWrapper request, | ||
| final ContentCachingResponseWrapper response, | ||
| final long duration) { | ||
| try { | ||
| final String method = request.getMethod(); | ||
| final String uri = request.getRequestURI(); | ||
| final String query = request.getQueryString(); | ||
| final String fullUrl = uri + (query != null ? "?" + query : ""); | ||
|
|
||
| final String reqBody = new String(request.getContentAsByteArray(), StandardCharsets.UTF_8); | ||
| final String resBody = new String(response.getContentAsByteArray(), StandardCharsets.UTF_8); | ||
|
|
||
| log.info(""" | ||
| 🧩 [HTTP LOG] | ||
| ▶️ Time: {} | ||
| ▶️ Method: {} | ||
| ▶️ URL: {} | ||
| ▶️ Status: {} | ||
| ▶️ Duration: {} ms | ||
| ▶️ Request Body: {} | ||
| ▶️ Response Body: {} | ||
| """, | ||
| LocalDateTime.now(), method, fullUrl, | ||
| response.getStatus(), duration, sanitize(reqBody), sanitize(resBody) | ||
| ); | ||
|
|
||
| } catch (Exception e) { | ||
| log.error("Error logging request/response", e); | ||
| chain.doFilter(req, res); | ||
| } finally { | ||
| final long duration = System.currentTimeMillis() - start; | ||
| final String jsonLog = HttpReqResLogJsonBuilder.buildJsonLog(req, res, duration); | ||
| discordSender.send(jsonLog); | ||
| res.copyBodyToResponse(); | ||
| } | ||
| } | ||
|
|
||
| private String sanitize(String input) { | ||
| if (input == null || input.isBlank()) return "(empty Body)"; | ||
| // 필요 시 개인정보 마스킹 처리 | ||
| return input.length() > 2000 ? input.substring(0, 2000) + "...(truncated)" : input; //2000자까지만 보임 | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
날짜 형식 변경으로 인한 API 호환성 영향을 확인하세요.
날짜 형식을 "yyyy.MM.dd HH:mm"에서 "yyyy-MM-dd HH:mm"로 변경한 것은 ISO 8601 표준에 더 가깝게 정렬하는 좋은 개선입니다. 하지만 이는 기존 API 클라이언트에 영향을 줄 수 있는 breaking change입니다.
다음 사항들을 확인해주세요:
코드베이스 전체의 일관성: 다른 DTO 파일들에서도 동일한 날짜 형식 패턴이 사용되는지 확인이 필요합니다.
클라이언트 영향도: 기존 API 클라이언트들이 새로운 형식으로 요청을 보낼 수 있도록 업데이트되어야 합니다.
API 문서: Swagger/OpenAPI 문서나 기타 API 문서에 날짜 형식 변경 사항이 반영되어야 합니다.
아래 스크립트를 실행하여 코드베이스 전체에서 다른 날짜 형식 패턴이 남아있는지 확인하세요:
Also applies to: 22-22, 44-44, 48-48
🏁 Script executed:
Length of output: 148
다음 스크립트를 실행하여 리포지토리 내 모든 Java 파일에서
@JsonFormat어노테이션에 남아있는 이전 날짜 형식(yyyy.MM.dd) 패턴을 검색해 주세요:🏁 Script executed:
Length of output: 1159
일관성: 모든
@JsonFormat어노테이션 패턴을"yyyy-MM-dd HH:mm"으로 통일하세요아래 파일들에 아직
"yyyy.MM.dd HH:mm"패턴이 남아있습니다. 모두"yyyy-MM-dd HH:mm"으로 수정해야 합니다.• src/main/java/com/permitseoul/permitserver/domain/eventtimetable/timetable/api/dto/TimetableResponse.java (11,13,42,44)
• src/main/java/com/permitseoul/permitserver/domain/eventtimetable/timetable/api/dto/TimetableDetailResponse.java (17,19)
• src/main/java/com/permitseoul/permitserver/domain/admin/coupon/api/dto/response/CouponResponse.java (10)
Swagger/OpenAPI 문서 및 기타 API 문서에도 날짜 형식 변경 내용을 반영하세요.
기존 클라이언트가 새 포맷을 사용하도록 공지・업데이트 계획을 검토하세요.
🤖 Prompt for AI Agents