diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 8905b26..d17cb82 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -68,12 +68,14 @@ jobs: DB_USERNAME: ${{ secrets.DB_USERNAME }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} FCM_JSON: ${{ secrets.FCM_JSON }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USERNAME }} key: ${{ secrets.EC2_PRIVATE_KEY }} script_stop: true - envs: DOCKER_USERNAME,IMAGE_NAME,DB_URL,DB_USERNAME,DB_PASSWORD,FCM_JSON + envs: DOCKER_USERNAME,IMAGE_NAME,DB_URL,DB_USERNAME,DB_PASSWORD,FCM_JSON, SENTRY_DSN, SENTRY_AUTH_TOKEN script: | set -e @@ -85,6 +87,8 @@ jobs: DB_USERNAME=${DB_USERNAME} DB_PASSWORD=${DB_PASSWORD} FCM_JSON=${FCM_JSON} + SENTRY_DSN=${SENTRY_DSN} + SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} EOF sudo docker-compose down diff --git a/build.gradle b/build.gradle index af89980..5f1f23c 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.springframework.boot' version '3.5.6' id 'io.spring.dependency-management' version '1.1.7' id 'org.asciidoctor.jvm.convert' version '4.0.5' + id "io.sentry.jvm.gradle" version "6.0.0" } group = 'com.todaysound' @@ -30,11 +31,24 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-actuator' - // Micrometer + + // 1. Sentry + implementation platform('io.sentry:sentry-bom:8.31.0') + implementation 'io.sentry:sentry-spring-boot-starter-jakarta' + implementation 'io.sentry:sentry-logback' + + // P6Spy Spring Boot Starter + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1' + + // Micrometer Metrics implementation 'io.micrometer:micrometer-registry-prometheus' // Prometheus + // Micrometer Tracing - traceId/spanId 자동 생성 + implementation 'io.micrometer:micrometer-tracing-bridge-brave' + // 구조화된 JSON 로깅 implementation 'net.logstash.logback:logstash-logback-encoder:8.0' @@ -123,4 +137,13 @@ bootJar { // 생성된 HTML 파일을 static/docs 폴더에 복사 } } +sentry { + // 소스 코드를 센트리에 업로드하여 에러 발생 시 코드 문맥을 보여줍니다. + includeSourceContext = true + + org = "todaysound" + projectName = "java-spring-boot" + + authToken = System.getenv("SENTRY_AUTH_TOKEN") +} diff --git a/src/main/java/com/todaysound/todaysound_server/domain/alarm/controller/InternalAlertController.java b/src/main/java/com/todaysound/todaysound_server/domain/alarm/controller/InternalAlertController.java index 051ca98..a4de172 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/alarm/controller/InternalAlertController.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/alarm/controller/InternalAlertController.java @@ -6,10 +6,14 @@ import com.todaysound.todaysound_server.domain.summary.entity.Summary; import com.todaysound.todaysound_server.domain.summary.repository.SummaryRepository; import com.todaysound.todaysound_server.domain.user.entity.User; +import static com.todaysound.todaysound_server.global.utils.LogMarkers.BUSINESS; +import static net.logstash.logback.argument.StructuredArguments.kv; + import com.todaysound.todaysound_server.global.application.FCMService; import com.todaysound.todaysound_server.global.exception.BaseException; import com.todaysound.todaysound_server.global.exception.CommonErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,6 +25,7 @@ * POST /internal/alerts { "user_id": 10, "subscription_id": 1, "site_post_id": "12345", "title": "게시글 제목", "url": * "https://...", "content_raw": "...원문...", "content_summary": "...요약...", "keyword_matched": true } */ +@Slf4j @RestController @RequestMapping("/internal") @RequiredArgsConstructor @@ -32,6 +37,11 @@ public class InternalAlertController implements InternalAlertApi { @PostMapping("/alerts") public void createAlert(@RequestBody InternalAlertRequest request) { + log.info(BUSINESS, "크롤러 알림 수신 {} {} {}", + kv("userId", request.userId()), + kv("subscriptionId", request.subscriptionId()), + kv("sitePostId", request.sitePostId())); + Subscription subscription = subscriptionRepository.findById(request.subscriptionId()) .orElseThrow(() -> BaseException.type(CommonErrorCode.ENTITY_NOT_FOUND)); @@ -64,6 +74,11 @@ public void createAlert(@RequestBody InternalAlertRequest request) { ); summaryRepository.save(summary); + + log.info(BUSINESS, "크롤러 알림 처리 완료 {} {} {}", + kv("subscriptionId", request.subscriptionId()), + kv("sitePostId", request.sitePostId()), + kv("alarmSent", subscription.isAlarmEnabled())); } public record InternalAlertRequest( diff --git a/src/main/java/com/todaysound/todaysound_server/domain/subscription/service/SubscriptionService.java b/src/main/java/com/todaysound/todaysound_server/domain/subscription/service/SubscriptionService.java index 46bc2af..54e92e7 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/subscription/service/SubscriptionService.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/subscription/service/SubscriptionService.java @@ -12,12 +12,17 @@ import com.todaysound.todaysound_server.domain.subscription.repository.SubscriptionRepository; import com.todaysound.todaysound_server.domain.user.entity.User; import com.todaysound.todaysound_server.domain.user.validator.HeaderAuthValidator; +import static com.todaysound.todaysound_server.global.utils.LogMarkers.BUSINESS; +import static net.logstash.logback.argument.StructuredArguments.kv; + import com.todaysound.todaysound_server.global.exception.BaseException; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -41,6 +46,10 @@ public void deleteSubscription(final Long subscriptionId, final String userUuid, } subscriptionRepository.deleteById(subscriptionId); + + log.info(BUSINESS, "구독 삭제 완료 {} {}", + kv("subscriptionId", subscriptionId), + kv("userId", user.getId())); } public SubscriptionCreationResponse createSubscription(final String headerUserUuid, @@ -58,6 +67,12 @@ public SubscriptionCreationResponse createSubscription(final String headerUserUu requestDto.isAlarmEnabled() ); Subscription savedSubscription = subscriptionRepository.save(subscription); + + log.info(BUSINESS, "구독 생성 완료 {} {} {}", + kv("subscriptionId", savedSubscription.getId()), + kv("userId", user.getId()), + kv("urlId", requestDto.urlId())); + return SubscriptionCreationResponse.from(savedSubscription); } @@ -87,5 +102,8 @@ public void updateSubscription(Long subscriptionId, String userUuid, String devi subscription.updateAlias(request.alias()); subscription.updateIsAlarmEnabled(request.isAlarmEnabled()); + log.info(BUSINESS, "구독 수정 완료 {} {}", + kv("subscriptionId", subscriptionId), + kv("userId", user.getId())); } } diff --git a/src/main/java/com/todaysound/todaysound_server/domain/summary/infra/scheduler/SummaryCleanupScheduler.java b/src/main/java/com/todaysound/todaysound_server/domain/summary/infra/scheduler/SummaryCleanupScheduler.java index 9dd2d89..f830b4d 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/summary/infra/scheduler/SummaryCleanupScheduler.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/summary/infra/scheduler/SummaryCleanupScheduler.java @@ -1,12 +1,17 @@ package com.todaysound.todaysound_server.domain.summary.infra.scheduler; +import static com.todaysound.todaysound_server.global.utils.LogMarkers.SCHEDULER; +import static net.logstash.logback.argument.StructuredArguments.kv; + import com.todaysound.todaysound_server.domain.summary.repository.SummaryRepository; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Component @RequiredArgsConstructor public class SummaryCleanupScheduler { @@ -16,9 +21,15 @@ public class SummaryCleanupScheduler { @Transactional @Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시에 실행 public void deleteOldSummaries() { + log.info(SCHEDULER, "Summary 정리 스케줄러 시작"); + long startTime = System.currentTimeMillis(); LocalDateTime threshold = LocalDateTime.now().minusDays(7); summaryRepository.deleteByCreatedAtBefore(threshold); + long elapsed = System.currentTimeMillis() - startTime; + log.info(SCHEDULER, "Summary 정리 스케줄러 완료 {} {}", + kv("threshold", threshold), + kv("elapsedMs", elapsed)); } } diff --git a/src/main/java/com/todaysound/todaysound_server/domain/user/service/UserService.java b/src/main/java/com/todaysound/todaysound_server/domain/user/service/UserService.java index a051156..5d970bd 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/user/service/UserService.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/user/service/UserService.java @@ -7,6 +7,9 @@ import com.todaysound.todaysound_server.domain.user.factory.UserFactory; import com.todaysound.todaysound_server.domain.user.repository.UserRepository; import com.todaysound.todaysound_server.domain.user.validator.HeaderAuthValidator; +import static com.todaysound.todaysound_server.global.utils.LogMarkers.AUTH; +import static net.logstash.logback.argument.StructuredArguments.kv; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -26,14 +29,13 @@ public class UserService { public UserIdResponse anonymous(UserSecretRequest userSecretRequest) { - log.info("Anonymous user command received"); + log.info(AUTH, "익명 사용자 인증 요청 수신"); boolean secretExists = userQueryService.existsBySecretFingerprint(userSecretRequest.deviceSecret()); User user; if (!secretExists) { - log.info("User secret does not exist, creating new user"); - log.info("fcmToken: {}", userSecretRequest.fcmToken()); + log.info(AUTH, "신규 사용자 생성 시작"); User newUser = userFactory.createAnonymousUser(userSecretRequest); @@ -43,9 +45,11 @@ public UserIdResponse anonymous(UserSecretRequest userSecretRequest) { user = userRepository.save(newUser); + log.info(AUTH, "신규 사용자 생성 완료 {}", kv("userId", user.getId())); + user.clearPlainSecret(); } else { - log.info("User secret exists, returning existing user"); + log.info(AUTH, "기존 사용자 인증 완료"); user = userQueryService.findBySecretFingerprint(userSecretRequest.deviceSecret()); } @@ -58,5 +62,7 @@ public void withdraw(String userUuid, String deviceSecret) { User user = headerAuthValidator.validateAndGetUser(userUuid, deviceSecret); userRepository.delete(user); + + log.info(AUTH, "회원 탈퇴 완료 {}", kv("userId", user.getId())); } } diff --git a/src/main/java/com/todaysound/todaysound_server/global/application/FCMService.java b/src/main/java/com/todaysound/todaysound_server/global/application/FCMService.java index 74a8907..51f524b 100644 --- a/src/main/java/com/todaysound/todaysound_server/global/application/FCMService.java +++ b/src/main/java/com/todaysound/todaysound_server/global/application/FCMService.java @@ -9,6 +9,9 @@ import com.google.firebase.messaging.MulticastMessage; import com.google.firebase.messaging.Notification; import com.google.firebase.messaging.SendResponse; +import static com.todaysound.todaysound_server.global.utils.LogMarkers.EXTERNAL_API; +import static net.logstash.logback.argument.StructuredArguments.kv; + import com.todaysound.todaysound_server.domain.user.entity.FCM_Token; import com.todaysound.todaysound_server.domain.user.entity.User; import com.todaysound.todaysound_server.domain.user.repository.FCMRepository; @@ -42,7 +45,7 @@ public void sendNotificationToUser(User user, String title, String body) { List devices = fcmRepository.findByUser(user); if (devices.isEmpty()) { - log.warn("알림을 보낼 기기(토큰)가 없습니다. (User ID: {})", user.getId()); + log.warn(EXTERNAL_API, "알림을 보낼 기기 토큰 없음 {}", kv("userId", user.getId())); return; } @@ -64,15 +67,19 @@ public void sendNotificationToUser(User user, String title, String body) { try { response = FirebaseMessaging.getInstance().sendEachForMulticast(message); - log.info("총 {}건의 알림 발송 요청 성공. (성공: {}건, 실패: {}건)", response.getSuccessCount() + response.getFailureCount(), - response.getSuccessCount(), response.getFailureCount()); + log.info(EXTERNAL_API, "FCM 알림 발송 완료 {} {} {}", + kv("total", response.getSuccessCount() + response.getFailureCount()), + kv("success", response.getSuccessCount()), + kv("failure", response.getFailureCount())); if (response.getFailureCount() > 0) { handleFailedTokens(response, tokens); } } catch (FirebaseMessagingException e) { - log.error("FCM Multicast 발송 실패", e); + log.error(EXTERNAL_API, "FCM Multicast 발송 실패 {} {}", + kv("userId", user.getId()), + kv("errorMessage", e.getMessage()), e); } } @@ -96,22 +103,19 @@ private void handleFailedTokens(BatchResponse response, List originalTok MessagingErrorCode errorCode = exception.getMessagingErrorCode(); // Enum 값 String errorMessage = exception.getMessage(); // 실제 에러 내용 - log.error("--------------------------------------------------"); - log.error("[FCM 발송 실패 상세 로그]"); - log.error("대상 토큰: {}", failedToken); - log.error("에러 코드: {}", errorCode); - log.error("에러 메시지: {}", errorMessage); + String maskedToken = maskToken(failedToken); + int httpStatus = exception.getHttpResponse() != null + ? exception.getHttpResponse().getStatusCode() : 0; - if (exception.getHttpResponse() != null) { - log.error("HTTP 상태 코드: {}", exception.getHttpResponse().getStatusCode()); - log.error("HTTP 응답 본문: {}", exception.getHttpResponse().getContent()); - } + log.error(EXTERNAL_API, "FCM 발송 실패 {} {} {} {}", + kv("token", maskedToken), + kv("errorCode", errorCode), + kv("errorMessage", errorMessage), + kv("httpStatus", httpStatus)); if (errorCode == MessagingErrorCode.UNREGISTERED) { - log.warn("FCM 토큰 {}이(가) 만료(UNREGISTERED)되었습니다. DB 삭제 목록에 추가합니다.", failedToken); + log.warn(EXTERNAL_API, "만료된 FCM 토큰 삭제 대상 추가 {}", kv("token", maskedToken)); tokensToDelete.add(failedToken); - } else { - log.warn("FCM 토큰 {} 발송 실패. (에러 코드: {})", failedToken, errorCode); } } } @@ -119,8 +123,15 @@ private void handleFailedTokens(BatchResponse response, List originalTok // 삭제할 토큰이 있다면 DB에서 일괄 삭제 if (!tokensToDelete.isEmpty()) { fcmRepository.deleteAllByFcmTokenIn(tokensToDelete); - log.info("만료된 FCM 토큰 {}건을 DB에서 삭제했습니다.", tokensToDelete.size()); + log.info(EXTERNAL_API, "만료된 FCM 토큰 DB 삭제 완료 {}", kv("deletedCount", tokensToDelete.size())); + } + } + + private static String maskToken(String token) { + if (token == null || token.length() <= 8) { + return "****"; } + return token.substring(0, 8) + "****"; } @Transactional diff --git a/src/main/java/com/todaysound/todaysound_server/global/config/ApiLoggingAspect.java b/src/main/java/com/todaysound/todaysound_server/global/config/ApiLoggingAspect.java new file mode 100644 index 0000000..09b9fd1 --- /dev/null +++ b/src/main/java/com/todaysound/todaysound_server/global/config/ApiLoggingAspect.java @@ -0,0 +1,54 @@ +package com.todaysound.todaysound_server.global.config; + +import static net.logstash.logback.argument.StructuredArguments.kv; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.stereotype.Component; + +@Slf4j +@Aspect +@Component +public class ApiLoggingAspect { + + @Pointcut("execution(* com.todaysound.todaysound_server..controller..*(..)) || " + + "execution(* com.todaysound.todaysound_server..presentation..*(..))") + private void controllerMethods() { + } + + @Around("controllerMethods()") + public Object logApiCall(ProceedingJoinPoint joinPoint) throws Throwable { + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + + log.info("API 요청 시작 {} {}", + kv("class", className), + kv("method", methodName)); + + long startTime = System.currentTimeMillis(); + try { + Object result = joinPoint.proceed(); + long elapsed = System.currentTimeMillis() - startTime; + + log.info("API 요청 완료 {} {} {}", + kv("class", className), + kv("method", methodName), + kv("elapsedMs", elapsed)); + + return result; + } catch (Throwable ex) { + long elapsed = System.currentTimeMillis() - startTime; + + log.warn("API 요청 실패 {} {} {} {}", + kv("class", className), + kv("method", methodName), + kv("elapsedMs", elapsed), + kv("exception", ex.getClass().getSimpleName())); + + throw ex; + } + } +} diff --git a/src/main/java/com/todaysound/todaysound_server/global/config/FCMConfig.java b/src/main/java/com/todaysound/todaysound_server/global/config/FCMConfig.java index 7800068..2bb3504 100644 --- a/src/main/java/com/todaysound/todaysound_server/global/config/FCMConfig.java +++ b/src/main/java/com/todaysound/todaysound_server/global/config/FCMConfig.java @@ -9,6 +9,10 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; +import static com.todaysound.todaysound_server.global.utils.LogMarkers.CRITICAL; +import static com.todaysound.todaysound_server.global.utils.LogMarkers.EXTERNAL_API; +import static net.logstash.logback.argument.StructuredArguments.kv; + import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; @@ -27,23 +31,22 @@ public void initialize() { try { InputStream serviceAccount; - log.info("🔍 FCM Secret String 상태: fcm={},null={}, blank={}, length={}", - fcmSecretString, fcmSecretString == null, - fcmSecretString != null && fcmSecretString.isBlank(), - fcmSecretString != null ? fcmSecretString.length() : 0); + log.info("FCM Secret String 상태 확인 {} {} {}", + kv("isNull", fcmSecretString == null), + kv("isBlank", fcmSecretString != null && fcmSecretString.isBlank()), + kv("length", fcmSecretString != null ? fcmSecretString.length() : 0)); // 환경 변수(GitHub Secrets)가 존재하면 우선 사용 (Prod 환경) if (fcmSecretString != null && !fcmSecretString.isBlank()) { - log.info("🔑 Firebase 키를 [환경 변수]에서 로드합니다."); + log.info("Firebase 키를 환경 변수에서 로드합니다 {}", kv("source", "env")); byte[] decoded = Base64.getDecoder().decode(fcmSecretString); String jsonString = new String(decoded, StandardCharsets.UTF_8); if (!jsonString.trim().startsWith("{")) { - log.error("❌ 디코딩된 데이터가 올바른 JSON 형식이 아닙니다!"); + log.error(CRITICAL, "디코딩된 데이터가 올바른 JSON 형식이 아닙니다"); } - log.info("🔑 디코딩된 Firebase 키 JSON : {}", jsonString); serviceAccount = new ByteArrayInputStream(jsonString.getBytes(StandardCharsets.UTF_8)); @@ -57,12 +60,12 @@ public void initialize() { if (FirebaseApp.getApps().isEmpty()) { FirebaseApp.initializeApp(options); - log.info("✅ Firebase Admin SDK가 성공적으로 초기화되었습니다. (환경 변수에서 로드)"); + log.info(EXTERNAL_API, "Firebase Admin SDK 초기화 완료 {}", kv("source", "env")); } else { - log.info("ℹ️ Firebase Admin SDK가 이미 초기화되어 있습니다."); + log.info(EXTERNAL_API, "Firebase Admin SDK 이미 초기화됨 {}", kv("source", "env")); } } else { - log.info("🔑 Firebase 키를 [로컬 파일]에서 로드합니다."); + log.info("Firebase 키를 로컬 파일에서 로드합니다 {}", kv("source", "local")); ClassPathResource resource = new ClassPathResource( "todaysound-68df8-firebase-adminsdk-fbsvc-6b2b6e6a71.json"); serviceAccount = resource.getInputStream(); @@ -72,14 +75,14 @@ public void initialize() { if (FirebaseApp.getApps().isEmpty()) { FirebaseApp.initializeApp(options); - log.info("✅ Firebase Admin SDK가 성공적으로 초기화되었습니다. (로컬 파일에서 로드)"); + log.info(EXTERNAL_API, "Firebase Admin SDK 초기화 완료 {}", kv("source", "local")); } else { - log.info("ℹ️ Firebase Admin SDK가 이미 초기화되어 있습니다."); + log.info(EXTERNAL_API, "Firebase Admin SDK 이미 초기화됨 {}", kv("source", "local")); } } } catch (Exception e) { - log.error("❌ Firebase Admin SDK 초기화 실패", e); + log.error(CRITICAL, "Firebase Admin SDK 초기화 실패 {}", kv("exception", e.getMessage()), e); } } diff --git a/src/main/java/com/todaysound/todaysound_server/global/config/P6SpyConfig.java b/src/main/java/com/todaysound/todaysound_server/global/config/P6SpyConfig.java new file mode 100644 index 0000000..f9b277f --- /dev/null +++ b/src/main/java/com/todaysound/todaysound_server/global/config/P6SpyConfig.java @@ -0,0 +1,49 @@ +package com.todaysound.todaysound_server.global.config; + + +import com.p6spy.engine.spy.P6SpyOptions; +import com.p6spy.engine.spy.appender.MessageFormattingStrategy; +import jakarta.annotation.PostConstruct; +import java.util.Locale; +import org.hibernate.engine.jdbc.internal.FormatStyle; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class P6SpyConfig { + + @PostConstruct + public void setLogMessageFormat() { + P6SpyOptions.getActiveInstance().setLogMessageFormat(P6SpyFormatter.class.getName()); + } + + // 내부 클래스로 포맷터 정의 + public static class P6SpyFormatter implements MessageFormattingStrategy { + + @Override + public String formatMessage(int connectionId, String now, long elapsed, String category, + String prepared, String sql, String url) { + sql = formatSql(category, sql); + // [실행시간] | SQL 문법 + return String.format("[%s] | %d ms | %s", category, elapsed, sql); + } + + private String formatSql(String category, String sql) { + if (sql == null || sql.trim().isEmpty()) { + return sql; + } + + // Only format Statement, PreparedStatement + if ("statement".equals(category)) { + String trimmedSQL = sql.trim().toLowerCase(Locale.ROOT); + if (trimmedSQL.startsWith("create") || trimmedSQL.startsWith("alter") + || trimmedSQL.startsWith("comment")) { + sql = FormatStyle.DDL.getFormatter().format(sql); + } else { + sql = FormatStyle.BASIC.getFormatter().format(sql); + } + return sql; + } + return sql; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/todaysound/todaysound_server/global/exception/GlobalExceptionHandler.java b/src/main/java/com/todaysound/todaysound_server/global/exception/GlobalExceptionHandler.java index ea89364..d3712d7 100644 --- a/src/main/java/com/todaysound/todaysound_server/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/todaysound/todaysound_server/global/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; import org.springframework.http.ResponseEntity; import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException; @@ -11,20 +12,26 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; +import static net.logstash.logback.argument.StructuredArguments.kv; + @Slf4j @RestControllerAdvice @RequiredArgsConstructor public class GlobalExceptionHandler { - /* - 커스텀 예외 처리 - 비즈니스 로직에서 발생하는 예외처리 - ErrorCode 기반 일관성 응답 제공 - */ @ExceptionHandler(BaseException.class) - public ResponseEntity handleBaseException(BaseException e) { + public ResponseEntity handleBaseException(BaseException e, HttpServletRequest request) { ErrorCode errorCode = e.getErrorCode(); - logError("BaseException", errorCode, e); + + // 비즈니스 예외는 WARN (예상된 에러) + log.warn("Business exception occurred", + kv("errorType", "BUSINESS"), + kv("errorCode", errorCode.getErrorCode()), + kv("errorMessage", errorCode.getMessage()), + kv("exceptionClass", e.getClass().getSimpleName()), + kv("requestUri", request.getRequestURI()), + kv("requestMethod", request.getMethod()), + kv("queryString", request.getQueryString())); return convert(errorCode); } @@ -35,13 +42,13 @@ public ResponseEntity handleBaseException(BaseException e) 파라미터 타입 변환 실패 */ @ExceptionHandler({NoHandlerFoundException.class, MethodArgumentTypeMismatchException.class}) - public ResponseEntity handleNotFoundOrTypeMismatch(Exception e) { - if (e instanceof MethodArgumentTypeMismatchException typeMismatch) { - log.warn("Type conversion failed: {} -> {}", - typeMismatch.getValue(), typeMismatch.getRequiredType().getSimpleName()); - } else { - log.warn("Handler not found: {}", e.getMessage()); - } + public ResponseEntity handleNotFoundOrTypeMismatch(Exception e, HttpServletRequest request) { + // 클라이언트 잘못 (4xx)은 INFO로 낮춤 - 노이즈 감소 + log.info("Client error - invalid request", + kv("errorType", "CLIENT"), + kv("exceptionClass", e.getClass().getSimpleName()), + kv("requestUri", request.getRequestURI()), + kv("errorMessage", e.getMessage())); return convert(CommonErrorCode.NOT_SUPPORTED_URI_ERROR); } @@ -50,8 +57,13 @@ public ResponseEntity handleNotFoundOrTypeMismatch(Exceptio * 지원하지 않는 HTTP 메서드 처리 (405) */ @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - public ResponseEntity handleMethodNotSupported(HttpRequestMethodNotSupportedException e) { - log.warn("Method not supported: {} for {}", e.getMethod(), e.getMessage()); + public ResponseEntity handleMethodNotSupported( + HttpRequestMethodNotSupportedException e, HttpServletRequest request) { + log.info("Client error - method not supported", + kv("errorType", "CLIENT"), + kv("requestMethod", e.getMethod()), + kv("requestUri", request.getRequestURI())); + return convert(CommonErrorCode.NOT_SUPPORTED_METHOD_ERROR); } @@ -59,8 +71,13 @@ public ResponseEntity handleMethodNotSupported(HttpRequestM * 지원하지 않는 미디어 타입 처리 (415) */ @ExceptionHandler(HttpMediaTypeNotSupportedException.class) - public ResponseEntity handleMediaTypeNotSupported(HttpMediaTypeNotSupportedException e) { - log.warn("Media type not supported: {}", e.getContentType()); + public ResponseEntity handleMediaTypeNotSupported( + HttpMediaTypeNotSupportedException e, HttpServletRequest request) { + log.info("Client error - media type not supported", + kv("errorType", "CLIENT"), + kv("contentType", String.valueOf(e.getContentType())), + kv("requestUri", request.getRequestURI())); + return convert(CommonErrorCode.NOT_SUPPORTED_MEDIA_TYPE_ERROR); } @@ -69,29 +86,42 @@ public ResponseEntity handleMediaTypeNotSupported(HttpMedia 위의 핸들러들로 처리되지 않은 모든 RuntimeException */ @ExceptionHandler(RuntimeException.class) - public ResponseEntity handleUnexpectedException(RuntimeException e, - HttpServletRequest request) { - log.error("Unexpected error occurred", e); - log.error("Request info: {} {}", request.getMethod(), request.getRequestURI()); + public ResponseEntity handleUnexpectedException( + RuntimeException e, HttpServletRequest request) { + + log.error("Unexpected server error", + kv("errorType", "UNEXPECTED"), + kv("exceptionClass", e.getClass().getName()), + kv("exceptionMessage", e.getMessage()), + kv("requestUri", request.getRequestURI()), + kv("requestMethod", request.getMethod()), + kv("queryString", request.getQueryString()), + kv("userAgent", request.getHeader("User-Agent")), + kv("traceId", MDC.get("traceId")), + e); // 스택트레이스 포함 return convert(CommonErrorCode.INTERNAL_SERVER_ERROR); } + @ExceptionHandler(Exception.class) + public ResponseEntity handleAllException( + Exception e, HttpServletRequest request) { + + log.error("Unhandled exception caught", + kv("errorType", "UNHANDLED"), + kv("exceptionClass", e.getClass().getName()), + kv("exceptionMessage", e.getMessage()), + kv("requestUri", request.getRequestURI()), + kv("requestMethod", request.getMethod()), + kv("traceId", MDC.get("traceId")), + e); + + return convert(CommonErrorCode.INTERNAL_SERVER_ERROR); + } private ResponseEntity convert(ErrorCode errorCode) { return ResponseEntity .status(errorCode.getStatus()) .body(CustomErrorResponse.from(errorCode)); } - - - private void logError(String exceptionType, ErrorCode code, Exception e) { - log.warn("[{}] {} | {} | {} | Message: {}", - exceptionType, - code.getStatus().value(), - code.getErrorCode(), - code.getMessage(), - e.getMessage()); - } - } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 206b0b3..b417072 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -11,14 +11,18 @@ spring: jpa: hibernate: ddl-auto: validate - show-sql: true + show-sql: false properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect - format_sql: true - show_sql: true generate_statistics: true +decorator: + datasource: + p6spy: + enable-logging: true + logging: slf4j + # FCM 설정 fcm: secret-string: ${FCM_JSON:} @@ -47,3 +51,7 @@ management: endpoint: health: show-details: always + tracing: + enabled: true # Tracing 기능 활성화 (기본 true) + sampling: + probability: 1.0 # 로컬 테스트를 위해 모든 요청을 추적하도록 1.0(100%) 설정 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 8685d3f..953ef0f 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -65,3 +65,14 @@ management: distribution: percentiles-histogram: http.server.requests: true + tracing: + sampling: + probability: 1.0 + +sentry: + dsn: ${SENTRY_DSN} + environment: ${spring.profiles.active} + exception-resolver-order: -2147483647 + max-request-body-size: none + send-default-pii: false + traces-sample-rate: ${SENTRY_TRACES_SAMPLE_RATE:1.0} \ No newline at end of file