diff --git a/build.gradle b/build.gradle index dc126273..87942b05 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,13 @@ dependencies { implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-database-postgresql' + // Actuator + Prometheus + implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + + // JSON 로깅 (Loki 연동) + implementation 'net.logstash.logback:logstash-logback-encoder:8.0' + } jar { enabled = false } diff --git a/src/main/java/com/semosan/api/common/jwt/JwtFilter.java b/src/main/java/com/semosan/api/common/jwt/JwtFilter.java index b1d6a62f..ce85cf16 100644 --- a/src/main/java/com/semosan/api/common/jwt/JwtFilter.java +++ b/src/main/java/com/semosan/api/common/jwt/JwtFilter.java @@ -11,6 +11,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -68,6 +69,7 @@ protected void doFilterInternal( throw new GeneralException(ErrorStatus.JWT_BLACKLISTED); } Long userId = jwtService.getUserIdFromJwtToken(accessToken); + MDC.put("userId", String.valueOf(userId)); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, new ArrayList<>()); @@ -75,10 +77,10 @@ protected void doFilterInternal( } filterChain.doFilter(request, response); } catch (GeneralException e) { - // GeneralException을 그대로 ApiResponse 포맷으로 직렬화하여 응답 - // Spring Security가 가로채지 않도록 직접 response에 작성 log.warn("[*] JwtFilter GeneralException : {}", e.getMessage()); sendErrorResponse(response, e); + } finally { + MDC.remove("userId"); } } diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingGpsPublisher.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingGpsPublisher.java index f6e7b2be..115a3e11 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingGpsPublisher.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingGpsPublisher.java @@ -9,6 +9,7 @@ import com.semosan.api.domain.tracking.repository.TrackingSessionRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; import org.springframework.data.redis.connection.stream.RecordId; import org.springframework.data.redis.connection.stream.StringRecord; import org.springframework.data.redis.core.StringRedisTemplate; @@ -40,26 +41,33 @@ public class TrackingGpsPublisher { @Transactional(readOnly = true) public void publish(Long userId, Long sessionId, GpsPointMessage message) { - TrackingSession session = trackingSessionRepository.findById(sessionId) - .orElseThrow(() -> new GeneralException(ErrorStatus.TRACKING_SESSION_NOT_FOUND)); - if (!session.isOwnedBy(userId)) { - throw new GeneralException(ErrorStatus.TRACKING_SESSION_FORBIDDEN); - } - if (session.getStatus() != TrackingSessionStatus.IN_PROGRESS) { - log.debug("Dropping GPS point: sessionId={} status={}", sessionId, session.getStatus()); - return; - } + MDC.put("sessionId", String.valueOf(sessionId)); + MDC.put("userId", String.valueOf(userId)); + try { + TrackingSession session = trackingSessionRepository.findById(sessionId) + .orElseThrow(() -> new GeneralException(ErrorStatus.TRACKING_SESSION_NOT_FOUND)); + if (!session.isOwnedBy(userId)) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_FORBIDDEN); + } + if (session.getStatus() != TrackingSessionStatus.IN_PROGRESS) { + log.info("[GPS] 좌표 드롭 | status={}", session.getStatus()); + return; + } - Map body = Map.of( - FIELD_SESSION_ID, String.valueOf(sessionId), - FIELD_USER_ID, String.valueOf(userId), - FIELD_LAT, String.valueOf(message.lat()), - FIELD_LNG, String.valueOf(message.lng()), - FIELD_ALTITUDE, message.altitude() == null ? "" : String.valueOf(message.altitude()), - FIELD_RECORDED_AT, message.recordedAt().toString() - ); - StringRecord record = StringRecord.of(body).withStreamKey(trackingProperties.getStreamKey()); - RecordId id = redisTemplate.opsForStream().add(record); - log.trace("Published GPS point: sessionId={} streamId={}", sessionId, id); + Map body = Map.of( + FIELD_SESSION_ID, String.valueOf(sessionId), + FIELD_USER_ID, String.valueOf(userId), + FIELD_LAT, String.valueOf(message.lat()), + FIELD_LNG, String.valueOf(message.lng()), + FIELD_ALTITUDE, message.altitude() == null ? "" : String.valueOf(message.altitude()), + FIELD_RECORDED_AT, message.recordedAt().toString() + ); + StringRecord record = StringRecord.of(body).withStreamKey(trackingProperties.getStreamKey()); + RecordId id = redisTemplate.opsForStream().add(record); + log.info("[GPS] 좌표 발행 | streamId={}", id); + } finally { + MDC.remove("sessionId"); + MDC.remove("userId"); + } } } diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java index 39a2bc01..d088ed2e 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java @@ -68,6 +68,7 @@ public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest TrackingSession session = TrackingSession.create(user, mountain, course, request.isFreeRecording()); TrackingSession saved = saveSession(session); + log.info("[SESSION] 세션 생성 | sessionId={} | userId={} | mountainId={}", saved.getId(), userId, request.mountainId()); // 세션 생성과 동시에 사진 마일스톤 거리 리스트(코스 4컷 / 자유 6컷)를 Redis 에 적재한다. // 이후 GPS Consumer 가 매 점마다 evaluate 호출 시 이 키를 읽어 마일스톤 도달을 판정하므로, @@ -91,6 +92,7 @@ public TrackingSessionResponse get(Long userId, Long sessionId) { public TrackingSessionResponse pause(Long userId, Long sessionId) { TrackingSession session = findOwnedSession(userId, sessionId); session.pause(); + log.info("[SESSION] 세션 일시정지 | sessionId={} | userId={}", sessionId, userId); return TrackingSessionResponse.from(session); } @@ -98,6 +100,7 @@ public TrackingSessionResponse pause(Long userId, Long sessionId) { public TrackingSessionResponse resume(Long userId, Long sessionId) { TrackingSession session = findOwnedSession(userId, sessionId); session.resume(); + log.info("[SESSION] 세션 재개 | sessionId={} | userId={}", sessionId, userId); return TrackingSessionResponse.from(session); } @@ -131,6 +134,8 @@ public TrackingSessionResponse complete(Long userId, Long sessionId) { hikingMemberRepository.save(member); eventPublisher.publishEvent(new TrackingSessionTerminatedEvent(sessionId)); + log.info("[SESSION] 세션 완료 | sessionId={} | userId={} | distance={}m | altitude={}m", + sessionId, userId, stats.distanceMeters(), stats.maxAltitudeMeters()); return TrackingSessionResponse.from(session, savedRecord.getId()); } @@ -140,6 +145,7 @@ public TrackingSessionResponse abandon(Long userId, Long sessionId) { TrackingSession session = findOwnedSession(userId, sessionId); session.abandon(); eventPublisher.publishEvent(new TrackingSessionTerminatedEvent(sessionId)); + log.info("[SESSION] 세션 포기 | sessionId={} | userId={}", sessionId, userId); return TrackingSessionResponse.from(session); } diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java index 013cda98..83c46025 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java @@ -3,6 +3,7 @@ import com.semosan.api.domain.tracking.event.TrackingSessionTerminatedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; import org.springframework.data.redis.connection.stream.MapRecord; import org.springframework.data.redis.stream.StreamListener; import org.springframework.scheduling.annotation.Scheduled; @@ -58,6 +59,9 @@ public void onMessage(MapRecord message) { try { Long sessionId = Long.parseLong(body.get(F_SESSION_ID)); Long userId = Long.parseLong(body.get(F_USER_ID)); + MDC.put("sessionId", String.valueOf(sessionId)); + MDC.put("userId", String.valueOf(userId)); + double lat = Double.parseDouble(body.get(F_LAT)); double lng = Double.parseDouble(body.get(F_LNG)); Double altitude = parseNullableDouble(body.get(F_ALTITUDE)); @@ -75,7 +79,10 @@ public void onMessage(MapRecord message) { flushSession(sessionId); } } catch (RuntimeException e) { - log.warn("Failed to process GPS stream message: id={} body={}", message.getId(), body, e); + log.warn("[GPS] 메시지 처리 실패 | streamId={} | sessionId={}", message.getId(), body.get(F_SESSION_ID), e); + } finally { + MDC.remove("sessionId"); + MDC.remove("userId"); } } @@ -106,7 +113,7 @@ private void flushSession(Long sessionId) { try { int saved = flushService.flush(sessionId, batch); if (saved > 0) { - log.debug("Flushed {} GPS points for session {}", saved, sessionId); + log.info("[FLUSH] DB 저장 | {}건 | sessionId={}", saved, sessionId); } } catch (RuntimeException e) { // DB flush 실패 시 batch 를 큐로 되돌려 다음 주기에 재시도. @@ -139,8 +146,7 @@ public void onSessionTerminated(TrackingSessionTerminatedEvent event) { sessionId, chunk.size(), e); } } - log.debug("Cleaned up tracking buffer for terminated session {} ({} points)", - sessionId, remaining.size()); + log.info("[FLUSH] 세션 종료 버퍼 정리 | {}건 | sessionId={}", remaining.size(), sessionId); } private static Double parseNullableDouble(String value) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 86e8659d..378837ce 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -63,6 +63,22 @@ minio: demo: photo-filenames: ${DEMO_PHOTO_FILENAMES} +management: + endpoints: + web: + exposure: + include: health,prometheus + base-path: /actuator + endpoint: + health: + show-details: never + server: + port: 9090 + prometheus: + metrics: + export: + enabled: true + discord: alert: enabled: ${DISCORD_ALERT_ENABLED} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..555daf2b --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,29 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{sessionId}] - %msg%n + + + + + + + + + + + + + sessionId + userId + Asia/Seoul + + + + + + + +