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
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.semosan.api.domain.tracking.service;

import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
Expand All @@ -18,7 +20,7 @@
* - TTL: 24h (세션 자동 만료와 정렬)
* - 용도: #46 라이브 액티비티 푸시, 고도 임계 감지, 종료 시 빠른 통계 조회
*
* TODO: 다중 인스턴스/동시성을 고려해야 한다면 Lua 스크립트로 atomic read-modify-write 로 교체.
* Lua 스크립트로 atomic read-modify-write 보장 — 동시성 안전.
*/
@Service
@RequiredArgsConstructor
Expand All @@ -28,70 +30,35 @@ public class TrackingSessionStatsService {
private static final String STATS_SUFFIX = ":stats";
private static final Duration TTL = Duration.ofHours(24);

// 지구 반지름 (m)
private static final double EARTH_RADIUS_METERS = 6_371_000.0;

private static final String F_LAST_LAT = "last_lat";
private static final String F_LAST_LNG = "last_lng";
private static final String F_LAST_ALTITUDE = "last_altitude";
private static final String F_LAST_RECORDED_AT = "last_recorded_at";
private static final String F_DISTANCE_TOTAL = "distance_total";
private static final String F_ASCENT_TOTAL = "ascent_total";
private static final String F_DESCENT_TOTAL = "descent_total";
private static final String F_MAX_ALTITUDE = "max_altitude";
private static final String F_POINT_COUNT = "point_count";

private static final DefaultRedisScript<String> RECORD_POINT_SCRIPT;

static {
RECORD_POINT_SCRIPT = new DefaultRedisScript<>();
RECORD_POINT_SCRIPT.setLocation(new ClassPathResource("redis/tracking-stats-update.lua"));
RECORD_POINT_SCRIPT.setResultType(String.class);
}

private final StringRedisTemplate redisTemplate;

/** 점을 누적 갱신하고, 갱신 후 총 거리(m)를 반환한다 — 호출자에서 마일스톤 트리거에 활용. */
/** 점을 누적 갱신하고, 갱신 후 총 거리(m)를 반환한다 — Lua 스크립트로 atomic 처리. */
public double recordPoint(Long sessionId, double lat, double lng, Double altitude, LocalDateTime recordedAt) {
String key = statsKey(sessionId);
HashOperations<String, String, String> hash = redisTemplate.opsForHash();
Map<String, String> prev = hash.entries(key);

double distanceTotal = parseDouble(prev.get(F_DISTANCE_TOTAL));
double ascentTotal = parseDouble(prev.get(F_ASCENT_TOTAL));
double descentTotal = parseDouble(prev.get(F_DESCENT_TOTAL));
Double maxAltitude = parseNullableDouble(prev.get(F_MAX_ALTITUDE));
long pointCount = parseLong(prev.get(F_POINT_COUNT));

Double prevLat = parseNullableDouble(prev.get(F_LAST_LAT));
Double prevLng = parseNullableDouble(prev.get(F_LAST_LNG));
if (prevLat != null && prevLng != null) {
distanceTotal += haversineMeters(prevLat, prevLng, lat, lng);
}
Double prevAltitude = parseNullableDouble(prev.get(F_LAST_ALTITUDE));
if (altitude != null && prevAltitude != null) {
double delta = altitude - prevAltitude;
if (delta > 0) {
ascentTotal += delta;
} else if (delta < 0) {
descentTotal += -delta;
}
}
if (altitude != null && (maxAltitude == null || altitude > maxAltitude)) {
maxAltitude = altitude;
}
pointCount += 1;

Map<String, String> next = new HashMap<>();
next.put(F_LAST_LAT, String.valueOf(lat));
next.put(F_LAST_LNG, String.valueOf(lng));
if (altitude != null) {
next.put(F_LAST_ALTITUDE, String.valueOf(altitude));
}
next.put(F_LAST_RECORDED_AT, recordedAt.toString());
next.put(F_DISTANCE_TOTAL, String.valueOf(distanceTotal));
next.put(F_ASCENT_TOTAL, String.valueOf(ascentTotal));
next.put(F_DESCENT_TOTAL, String.valueOf(descentTotal));
if (maxAltitude != null) {
next.put(F_MAX_ALTITUDE, String.valueOf(maxAltitude));
}
next.put(F_POINT_COUNT, String.valueOf(pointCount));

hash.putAll(key, next);
redisTemplate.expire(key, TTL);
return distanceTotal;
String result = redisTemplate.execute(
RECORD_POINT_SCRIPT,
List.of(key),
String.valueOf(lat),
String.valueOf(lng),
altitude != null ? String.valueOf(altitude) : "",
recordedAt.toString(),
String.valueOf(TTL.toSeconds())
);
return result != null ? Double.parseDouble(result) : 0.0;
}

public static String statsKey(Long sessionId) {
Expand Down Expand Up @@ -123,15 +90,24 @@ public record Stats(
) {
}

/** Haversine 거리(m). */
static double haversineMeters(double lat1, double lng1, double lat2, double lng2) {
double dLat = Math.toRadians(lat2 - lat1);
double dLng = Math.toRadians(lng2 - lng1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLng / 2) * Math.sin(dLng / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS_METERS * c;
public record LastPosition(Double lat, Double lng, Double altitude) {
public boolean isEmpty() {
return lat == null || lng == null;
}
}

public LastPosition getLastPosition(Long sessionId) {
HashOperations<String, String, String> hash = redisTemplate.opsForHash();
String key = statsKey(sessionId);
List<String> values = hash.multiGet(key, List.of("last_lat", "last_lng", "last_altitude"));
if (values == null || values.size() < 3) {
return new LastPosition(null, null, null);
}
return new LastPosition(
parseNullableDouble(values.get(0)),
parseNullableDouble(values.get(1)),
parseNullableDouble(values.get(2))
);
}
Comment on lines +99 to 111

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] getLastPosition에서 multiGet 결과에 대한 방어적 null 체크 추가

redisTemplate.opsForHash().multiGet은 Redis 연결 장애, 파이프라인/트랜잭션 환경 등 다양한 원인으로 인해 null을 반환할 가능성이 있습니다. 또한, 반환된 리스트의 크기가 예상보다 작을 경우 IndexOutOfBoundsException이 발생할 위험이 있습니다.

안전한 실행을 위해 valuesnull이거나 크기가 부족한 경우를 대비한 방어적 코드(Null-safe guard)를 추가하는 것을 권장합니다.

    public LastPosition getLastPosition(Long sessionId) {
        HashOperations<String, String, String> hash = redisTemplate.opsForHash();
        String key = statsKey(sessionId);
        List<String> values = hash.multiGet(key, List.of("last_lat", "last_lng", "last_altitude"));
        if (values == null || values.size() < 3) {
            return new LastPosition(null, null, null);
        }
        return new LastPosition(
                parseNullableDouble(values.get(0)),
                parseNullableDouble(values.get(1)),
                parseNullableDouble(values.get(2))
        );
    }


private static double parseDouble(String value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
public class TrackingStreamConsumer implements StreamListener<String, MapRecord<String, String, String>> {

private static final int FLUSH_THRESHOLD = 100;
private static final double MIN_HORIZONTAL_DISTANCE_METERS = 10.0;
private static final double MIN_ALTITUDE_CHANGE_METERS = 3.0;

private static final String F_SESSION_ID = "sessionId";
private static final String F_USER_ID = "userId";
Expand All @@ -47,6 +49,7 @@ public class TrackingStreamConsumer implements StreamListener<String, MapRecord<
private static final String F_RECORDED_AT = "recordedAt";

private final Map<Long, Queue<TrackingPointFlushService.PendingPoint>> buffers = new ConcurrentHashMap<>();
private final Map<Long, TrackingSessionStatsService.LastPosition> lastPositions = new ConcurrentHashMap<>();

private final TrackingSessionStatsService statsService;
private final TrackingPointFlushService flushService;
Expand All @@ -67,8 +70,15 @@ public void onMessage(MapRecord<String, String, String> message) {
Double altitude = parseNullableDouble(body.get(F_ALTITUDE));
LocalDateTime recordedAt = LocalDateTime.parse(body.get(F_RECORDED_AT));

double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
activityService.markActive(sessionId);

if (!shouldAcceptPoint(sessionId, lat, lng, altitude)) {
log.debug("[GPS] 노이즈 필터링 | sessionId={}", sessionId);
return;
}

double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
lastPositions.put(sessionId, new TrackingSessionStatsService.LastPosition(lat, lng, altitude));
milestoneTriggerService.evaluate(sessionId, userId, distanceTotal);

Queue<TrackingPointFlushService.PendingPoint> queue =
Expand Down Expand Up @@ -131,6 +141,7 @@ private void flushSession(Long sessionId) {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onSessionTerminated(TrackingSessionTerminatedEvent event) {
Long sessionId = event.sessionId();
lastPositions.remove(sessionId);
Queue<TrackingPointFlushService.PendingPoint> queue = buffers.remove(sessionId);
if (queue == null || queue.isEmpty()) {
return;
Expand All @@ -149,6 +160,41 @@ public void onSessionTerminated(TrackingSessionTerminatedEvent event) {
log.info("[FLUSH] 세션 종료 버퍼 정리 | {}건 | sessionId={}", remaining.size(), sessionId);
}

private boolean shouldAcceptPoint(Long sessionId, double lat, double lng, Double altitude) {
TrackingSessionStatsService.LastPosition last = lastPositions.get(sessionId);
if (last == null) {
last = statsService.getLastPosition(sessionId);
if (last.isEmpty()) {
return true;
}
lastPositions.put(sessionId, last);
}

double horizontalDistance = haversineMeters(last.lat(), last.lng(), lat, lng);
if (horizontalDistance >= MIN_HORIZONTAL_DISTANCE_METERS) {
return true;
}

if (altitude != null && last.altitude() != null) {
if (Math.abs(altitude - last.altitude()) >= MIN_ALTITUDE_CHANGE_METERS) {
return true;
}
}

return false;
}
Comment thread
howooyeon marked this conversation as resolved.

private static double haversineMeters(double lat1, double lng1, double lat2, double lng2) {
double rad = Math.PI / 180;
double dLat = (lat2 - lat1) * rad;
double dLng = (lng2 - lng1) * rad;
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(lat1 * rad) * Math.cos(lat2 * rad)
* Math.sin(dLng / 2) * Math.sin(dLng / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return 6_371_000.0 * c;
}

private static Double parseNullableDouble(String value) {
return (value == null || value.isEmpty()) ? null : Double.parseDouble(value);
}
Expand Down