Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ out/
### Mac ###
.DS_Store

src/main/resources/application.yml
src/main/resources/application.yml
src/main/resources/application-dev.yml
src/main/resources/application-local.yml
src/main/resources/application-prod.yml
6 changes: 4 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@ dependencies {
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'

//s3
implementation("software.amazon.awssdk:bom:2.21.0")
implementation("software.amazon.awssdk:s3:2.21.0")
implementation "software.amazon.awssdk:s3:2.34.0"

//Loki
implementation 'com.github.loki4j:loki-logback-appender:1.5.2'
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link

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입니다.

다음 사항들을 확인해주세요:

  1. 코드베이스 전체의 일관성: 다른 DTO 파일들에서도 동일한 날짜 형식 패턴이 사용되는지 확인이 필요합니다.

  2. 클라이언트 영향도: 기존 API 클라이언트들이 새로운 형식으로 요청을 보낼 수 있도록 업데이트되어야 합니다.

  3. API 문서: Swagger/OpenAPI 문서나 기타 API 문서에 날짜 형식 변경 사항이 반영되어야 합니다.

아래 스크립트를 실행하여 코드베이스 전체에서 다른 날짜 형식 패턴이 남아있는지 확인하세요:

Also applies to: 22-22, 44-44, 48-48


🏁 Script executed:

#!/bin/bash
# 설명: 이전 날짜 형식 패턴이 남아있는 파일 검색

# yyyy.MM.dd 패턴을 사용하는 @JsonFormat 어노테이션 검색
rg -n '@JsonFormat.*yyyy\.MM\.dd' --type java -g '!**/test/**'

Length of output: 148


다음 스크립트를 실행하여 리포지토리 내 모든 Java 파일에서 @JsonFormat 어노테이션에 남아있는 이전 날짜 형식(yyyy.MM.dd) 패턴을 검색해 주세요:


🏁 Script executed:

#!/bin/bash
# 이전 날짜 형식 'yyyy.MM.dd' 패턴 검색 (.java 파일 전체 대상)

rg -n '@JsonFormat.*yyyy\.MM\.dd' -g '*.java' -g '!**/test/**'

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
In
src/main/java/com/permitseoul/permitserver/domain/admin/ticket/api/dto/req/TicketRoundWithTypeCreateRequest.java
around line 18, ensure the @JsonFormat pattern is the unified "yyyy-MM-dd
HH:mm"; if it already is, verify the other listed files use the same pattern and
update any occurrences of "yyyy.MM.dd HH:mm" to "yyyy-MM-dd HH:mm", then update
Swagger/OpenAPI date format annotations and API documentation accordingly and
coordinate a client-notification/update plan for the new format.

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

API 호환성 깨짐 가능성을 확인하세요.

날짜 형식을 "yyyy.MM.dd HH:mm"에서 "yyyy-MM-dd HH:mm"로 변경했습니다. 이는 기존 API 클라이언트에 영향을 주는 breaking change입니다. 다음을 확인하세요:

  1. 모든 API 소비자(프론트엔드, 모바일 앱 등)가 새 형식을 사용하도록 업데이트되었는지
  2. 관련 테스트 케이스가 새 형식으로 업데이트되었는지
  3. API 문서가 새 형식을 반영하도록 업데이트되었는지

다음 스크립트로 코드베이스 전체에서 날짜 형식 일관성을 확인하세요:


🏁 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=java

Length of output: 2388


날짜 형식 일관화 필요

TimetableResponse.java, TimetableDetailResponse.java 등에서 여전히 "yyyy.MM.dd HH:mm" 패턴을 사용 중입니다. 모든 @JsonFormat(pattern = ...)"yyyy-MM-dd HH:mm"으로 통일하고, 관련 API 문서·테스트·클라이언트도 함께 업데이트하세요.

🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/domain/admin/ticket/api/dto/req/TicketRoundWithTypeCreateRequest.java
around lines 18 to 23, the @JsonFormat pattern is "yyyy-MM-dd HH:mm" but other
DTOs (e.g., TimetableResponse.java, TimetableDetailResponse.java) still use
"yyyy.MM.dd HH:mm"; update those files to use the unified "yyyy-MM-dd HH:mm"
pattern, then search the codebase for any remaining @JsonFormat usages and
replace the old pattern, and finally update API documentation, tests, and any
client code or test fixtures to reflect the new date format so
serialization/deserialization remains consistent across services.


@NotNull(message = "티켓타입 리스트는 필수입니다.")
Expand All @@ -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
Expand Up @@ -17,11 +17,11 @@ public record TicketRoundWithTypeUpdateRequest(
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,

@NotNull(message = "티켓타입 리스트는 필수입니다.")
Expand All @@ -44,11 +44,11 @@ public record TicketTypeUpdateRequest(
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

FeignConfig에서 재시도 및 타임아웃 설정 확인 필요.

Feign 클라이언트가 외부 Discord webhook을 호출하는데, 네트워크 장애나 일시적인 오류 발생 시 재시도 로직과 적절한 타임아웃 설정이 중요합니다. FeignConfig에 Retryer와 타임아웃이 적절히 구성되어 있는지 확인하세요.

다음 스크립트로 FeignConfig의 구성을 확인하세요:


🏁 Script executed:

#!/bin/bash
# FeignConfig에서 Retryer 및 타임아웃 설정 확인
rg -n -A 10 "class FeignConfig" --type=java

Length 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
In
src/main/java/com/permitseoul/permitserver/global/external/discord/DiscordFeignClient.java
around lines 10 to 14, the FeignConfig currently defines a Retryer but lacks
connect/read timeouts; add a Request.Options bean in the FeignConfig to set
sensible connect and read timeouts (for example 5000ms connect and 10000ms read)
and register it as a @Bean so Feign uses those timeouts; ensure you import/use
java.util.concurrent.TimeUnit and return a new Request.Options with the connect
timeout, read timeout and followRedirects flag set appropriately.

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자까지만 보임
}
}
}
Loading