Skip to content
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/com/semosan/api/common/jwt/JwtFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,17 +69,18 @@ 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<>());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
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");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, String> 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<String, String> 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");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 호출 시 이 키를 읽어 마일스톤 도달을 판정하므로,
Expand All @@ -91,13 +92,15 @@ 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);
}

@Transactional
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);
}

Expand Down Expand Up @@ -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());
}
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +59,9 @@ public void onMessage(MapRecord<String, String, String> 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));
Expand All @@ -75,7 +79,10 @@ public void onMessage(MapRecord<String, String, String> 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");
}
}

Expand Down Expand Up @@ -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 를 큐로 되돌려 다음 주기에 재시도.
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
29 changes: 29 additions & 0 deletions src/main/resources/logback-spring.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{sessionId}] - %msg%n</pattern>
</encoder>
</appender>

<springProfile name="!prod">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>

<springProfile name="prod">
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>sessionId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
<timeZone>Asia/Seoul</timeZone>
</encoder>
Comment on lines +18 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[P3] MDC 키 필터링 방식 검토 (향후 분산 추적 연동 고려)

현재 LogstashEncoder 설정에서 <includeMdcKeyName>을 사용하여 sessionIduserId만 MDC에서 포함하도록 필터링하고 있습니다.
이 방식은 로그를 깔끔하게 유지하는 데 도움이 되지만, 향후 Spring Cloud SleuthMicrometer Tracing 등을 도입하여 분산 추적(traceId, spanId 등)을 로그에 포함하고자 할 때, 해당 MDC 키들이 누락되는 문제가 발생할 수 있습니다.

따라서 특정 MDC 키만 명시적으로 포함하기보다는, 기본적으로 모든 MDC 키를 포함하도록 하거나 제외할 키만 <excludeMdcKeyNames>로 관리하는 방식을 검토해 주세요.

References
  1. 유지보수성 및 모니터링 시스템과의 연동성을 고려하여 설정을 검토합니다. (link)

</appender>
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
</springProfile>

</configuration>