Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
8172c72
chore: WebSocket 의존성 및 CORS 설정 추가
JangInho May 17, 2026
57c0d97
feat: TrackingPoint 도메인 모델 추가
JangInho May 17, 2026
c0509d0
feat: 트래킹 GPS 실시간 수집 및 통계 구현
JangInho May 17, 2026
41ca956
fix: flush 트랜잭션 적용 위해 별도 서비스로 분리
JangInho May 19, 2026
7c7d219
feat: AGENTS.md 추가
pooreumjung May 21, 2026
2f198bd
Fix user withdraw persistence
pooreumjung May 21, 2026
a1cc3c2
탈퇴 후 재가입 시 새 사용자 생성
pooreumjung May 21, 2026
5199719
탈퇴 시 소셜 식별자 익명화
pooreumjung May 21, 2026
7f3e238
탈퇴 유저 로그인 방어 로직 추가
pooreumjung May 21, 2026
8397428
Merge pull request #64 from SEMOSAN/refactor/#62-user-withdraw
pooreumjung May 21, 2026
594b6c0
chore: update image to 839742812054d5de0ba8c02ecff975a9984b3db7 [skip…
github-actions[bot] May 21, 2026
f34a536
fix: 관악산 중복 row 정리 (V3 적용 전 hand-seed row 삭제)
JangInho May 21, 2026
f544939
Merge pull request #67 from SEMOSAN/fix/#65-legacy-gwanaksan
JangInho May 21, 2026
7ab3ca3
chore: update image to f5449397cc88ba378597b4bebd002889cddb31db [skip…
github-actions[bot] May 21, 2026
a189be8
chore: postgres 이미지를 postgis/postgis:16-alpine으로 변경
pooreumjung May 21, 2026
8f6a244
chore: update image to a189be835894efa3fd569a3f46e56ca3df6cd64d [skip…
github-actions[bot] May 21, 2026
349e814
카카오 로그인 액세스 토큰 방식 적용
pooreumjung May 21, 2026
445e75d
fix: postgres image 수정
pooreumjung May 21, 2026
f506a72
chore: update image to 445e75d50c4682fc5a980527201c23632994b26f [skip…
github-actions[bot] May 21, 2026
0b145de
Merge pull request #68 from SEMOSAN/feat/#18-tracking-session
JangInho May 21, 2026
e5beefb
chore: update image to 0b145deb46634b781209a4720d567c2e9fa67824 [skip…
github-actions[bot] May 21, 2026
569514d
fix: postgres 이미지를 ARM64 지원 버전으로 변경 (16-3.5)
pooreumjung May 21, 2026
ace41a6
chore: update image to 569514d987bca9b8539917eec3dd681569c51c46 [skip…
github-actions[bot] May 21, 2026
ff6beb3
fix: ARM64 지원 postgis 이미지로 변경 (imresamu/postgis:16-3.4)
pooreumjung May 21, 2026
324c6ea
chore: update image to ff6beb327877599b496fe3a74f16040138f31863 [skip…
github-actions[bot] May 21, 2026
457fafb
fix: V6 마이그레이션 외래키 제약 위반 수정 (amenities 먼저 삭제)
pooreumjung May 21, 2026
0cb828a
chore: update image to 457fafb40cc1ddc4ad26d766b77b77df42fe92fe [skip…
github-actions[bot] May 21, 2026
c274afe
fix: V6 마이그레이션 외래키 제약 위반 수정 (mountains 참조 테이블 cascade 삭제)
pooreumjung May 21, 2026
cb43205
chore: update image to c274afe2fc9f6457f4e20fa2b5dd50ec4191a4bf [skip…
github-actions[bot] May 21, 2026
5819cdb
fix: V6 마이그레이션 restaurants 외래키 제약 위반 추가 수정
pooreumjung May 21, 2026
2598403
chore: update image to 5819cdbe39cd86400117feef8ebb9cfd6541a088 [skip…
github-actions[bot] May 21, 2026
1fb1de7
fix: HandlerMethodValidationException 핸들러 중복 충돌 수정 (@Override로 변경)
pooreumjung May 21, 2026
cbbda93
chore: update image to 1fb1de7dc7c67566c54a61674c69c1ff7d7685fa [skip…
github-actions[bot] May 21, 2026
d27b7b8
Merge pull request #69 from SEMOSAN/refactor/#66-kakao-login
pooreumjung May 21, 2026
73bc7e0
chore: update image to d27b7b86343afd7846e858d29cd5d0bdc8c41f25 [skip…
github-actions[bot] May 21, 2026
c0d6d99
feat: 기본 이름 생성 리소스 추가
pooreumjung May 21, 2026
8d76665
feat: 신규 유저 기본 이름 자동 생성
pooreumjung May 21, 2026
4645a2f
feat: 유저 프로필 응답에 이름 추가
pooreumjung May 21, 2026
99bf9c9
fix: 기본 표시값을 닉네임에 적용
pooreumjung May 21, 2026
a88e749
refactor: 기본 닉네임 생성 검증 개선
pooreumjung May 21, 2026
2408eb0
Merge pull request #72 from SEMOSAN/feat/#71-generate-user-basic-name
pooreumjung May 21, 2026
9a7bb76
chore: update image to 2408eb0c0f5fc413f174a8745cfd08f32d7a88ce [skip…
github-actions[bot] May 21, 2026
e3897f6
Merge remote-tracking branch 'origin/develop' into feat/#19-tracking-gps
JangInho May 21, 2026
ee96567
Merge remote-tracking branch 'origin/develop' into feat/#19-tracking-gps
JangInho May 21, 2026
7054f1e
refactor: GPS 점 flush 실패 시 유실 방지 및 recordedAt 검증 추가
JangInho May 21, 2026
0b2b4cc
refactor: 트래킹 세션 종료 시 GPS 버퍼 정리 (메모리 누수 방지)
JangInho May 21, 2026
ffa4cfc
refactor: 스케줄러 stale 판정 기준을 Redis 활성 마커로 변경
JangInho May 21, 2026
c227637
chore: tracking_points 테이블 마이그레이션 추가
JangInho May 21, 2026
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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ dependencies {

// PostGIS (Hibernate Spatial + JTS)
implementation 'org.hibernate.orm:hibernate-spatial'

// WebSocket (트래킹 GPS 실시간 수집 — STOMP)
implementation 'org.springframework.boot:spring-boot-starter-websocket'
}

jar { enabled = false }
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/com/semosan/api/common/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ public class SecurityConfig {
"/api/auth/token/reissue"
};

/**
* WebSocket(STOMP) 엔드포인트.
* 핸드셰이크는 HTTP JWT 필터로 인증하지 않고 통과시키고,
* STOMP CONNECT 프레임에서 StompAuthChannelInterceptor 가 JWT 를 검증한다.
*/
public static final String[] WEBSOCKET_URIS = {
"/ws/tracking/**"
};

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
Expand All @@ -63,6 +72,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers(SWAGGER_URIS).permitAll()
.requestMatchers(OAUTH_URIS).permitAll()
.requestMatchers(AUTH_URIS).permitAll()
.requestMatchers(WEBSOCKET_URIS).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
Expand All @@ -78,7 +88,17 @@ public CorsConfigurationSource corsConfigurationSource() {
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);

// WebSocket(STOMP) 핸드셰이크는 다양한 origin(모바일/로컬 테스트 페이지 등)에서 들어옴.
// /ws/** 만 별도 정책으로 풀어준다.
// TODO: production 에서는 모바일 앱 origin 만 명시적으로 허용하도록 좁힐 것.
CorsConfiguration wsConfig = new CorsConfiguration();
wsConfig.setAllowedOriginPatterns(List.of("*"));
wsConfig.setAllowedMethods(List.of("GET"));
wsConfig.setAllowedHeaders(List.of("*"));
wsConfig.setAllowCredentials(true);

Comment on lines +91 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict WebSocket origins now (don’t ship wildcard + credentials).

Line 95 currently allows any origin for /ws/** while Line 98 enables credentials. This leaves the handshake policy overly permissive for a privileged channel. Replace * with an explicit allowlist and keep it environment-driven. Also align WebSocketConfig Line 29 to the same allowlist.

🔒 Suggested change
-        wsConfig.setAllowedOriginPatterns(List.of("*"));
+        wsConfig.setAllowedOrigins(List.of(
+                "http://localhost:3000",
+                "https://lgenius.site"
+        ));

Also applies to: 101-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/semosan/api/common/config/SecurityConfig.java` around lines
91 - 99, The WebSocket CORS config is too permissive: update the
CorsConfiguration instance named wsConfig to use an environment-driven explicit
allowlist instead of setAllowedOriginPatterns(List.of("*")), keep
setAllowCredentials(true) only if the allowlist is not wildcard, and ensure
setAllowedOriginPatterns (or setAllowedOrigins) is populated from a configurable
property (e.g., a comma-separated env/property) so production can restrict to
mobile app origins; also mirror the same change in WebSocketConfig (Line 29) so
both places read the same allowlist property.

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/ws/**", wsConfig);
source.registerCorsConfiguration("/**", config);
return source;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.semosan.api.domain.tracking.config;

import com.semosan.api.common.config.TrackingProperties;
import com.semosan.api.domain.tracking.service.TrackingStreamConsumer;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.stream.Consumer;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.connection.stream.ReadOffset;
import org.springframework.data.redis.connection.stream.StreamOffset;
import org.springframework.data.redis.stream.StreamMessageListenerContainer;

import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.Duration;
import java.util.UUID;

/**
* Redis Stream(tracking:gps) 의 GPS 점을 소비하는 컨테이너를 부트업한다.
* - Consumer group 자체는 RedisStreamConfig 에서 사전 생성됨.
* - 본 클래스는 그룹의 이 인스턴스를 위한 consumer 등록 + listener container 시작.
* - consumer 이름은 호스트명 + UUID prefix 로 인스턴스별 유일성 확보 (다중 인스턴스 대비).
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class TrackingStreamListenerConfig implements InitializingBean {

private final RedisConnectionFactory redisConnectionFactory;
private final TrackingProperties trackingProperties;
private final TrackingStreamConsumer trackingStreamConsumer;

private StreamMessageListenerContainer<String, MapRecord<String, String, String>> container;

@Override
public void afterPropertiesSet() {
var options = StreamMessageListenerContainer
.StreamMessageListenerContainerOptions.builder()
.pollTimeout(Duration.ofSeconds(1))
.build();

container = StreamMessageListenerContainer.create(redisConnectionFactory, options);

container.receiveAutoAck(
Consumer.from(trackingProperties.getConsumerGroup(), buildConsumerName()),
StreamOffset.create(trackingProperties.getStreamKey(), ReadOffset.lastConsumed()),
trackingStreamConsumer
);
Comment on lines +48 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

In Spring Data Redis StreamMessageListenerContainer, does receiveAutoAck acknowledge entries before listener processing succeeds, and what pattern is recommended for at-least-once processing with consumer groups?

💡 Result:

Yes. In StreamMessageListenerContainer, using receiveAutoAck (auto-acknowledge) acknowledges messages when they are received, not after your listener code completes successfully. This is explicitly stated as “Every message is acknowledged when received” for receiveAutoAck, whereas the non-auto-ack mode requires acknowledging via StreamOperations.acknowledge after processing completes successfully [1][2][3]. Recommended pattern for at-least-once with consumer groups: 1) Use external acknowledgement: call receive(...) (autoAck=false) and only acknowledge with StreamOperations.acknowledge after your listener processing succeeds [1][4][3]. This keeps failed/unacknowledged deliveries in the consumer group’s Pending Entries List (PEL) [3][5]. 2) Handle PENDING recovery explicitly: start (or periodically run) a “drain PEL” phase for your consumer group (e.g., read pending/previously delivered entries), and only when the PEL is empty switch to reading new messages (ID “>”). Redis documents that re-delivered pending entries remain pending until explicitly acknowledged via XACK, and the typical recovery approach is to first process the pending backlog (using an initial ID such as 0), then proceed to “>” for new messages [6]. In code terms, the container doesn’t provide a built-in “always re-process PENDING until it succeeds before polling for new” switch; you implement the two-phase (PEL-drain then new) flow by choosing the appropriate StreamRead offsets/requests (PEL history first, then “>”), and by acknowledging only after successful processing [1][3][6].

Citations:


Use manual acknowledgement instead of auto-ack to ensure at-least-once delivery for GPS points.

receiveAutoAck() acknowledges entries when received, before your listener processes them. If downstream processing fails, acknowledged entries are permanently lost and cannot be retried from the consumer group's pending backlog.

Switch to receive() (non-auto-ack) and call StreamOperations.acknowledge() only after processing succeeds. This keeps failed entries in the Pending Entries List for recovery and retries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/semosan/api/domain/tracking/config/TrackingStreamListenerConfig.java`
around lines 48 - 52, The code currently uses container.receiveAutoAck(...)
which acknowledges entries immediately; change to container.receive(...)
(non-auto-ack) using the same
Consumer.from(trackingProperties.getConsumerGroup(), buildConsumerName()),
StreamOffset.create(trackingProperties.getStreamKey(),
ReadOffset.lastConsumed()), and trackingStreamConsumer so records are delivered
but not auto-acked. Then update your trackingStreamConsumer processing logic to
call the Redis stream acknowledge API (e.g., StreamOperations.acknowledge(...)
or the container/RedisTemplate opsForStream().acknowledge) after successful
processing using trackingProperties.getConsumerGroup() and the record id; do not
acknowledge on failure so entries remain in the Pending Entries List for
retries.

container.start();
log.info("Started Redis Stream listener: stream={} group={}",
trackingProperties.getStreamKey(),
trackingProperties.getConsumerGroup());
}

@PreDestroy
public void stop() {
if (container != null) {
container.stop();
}
}

private static String buildConsumerName() {
String host;
try {
host = InetAddress.getLocalHost().getHostName();
} catch (UnknownHostException e) {
host = "unknown";
}
return host + "-" + UUID.randomUUID().toString().substring(0, 8);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.semosan.api.domain.tracking.controller;

import com.semosan.api.domain.tracking.dto.message.GpsPointMessage;
import com.semosan.api.domain.tracking.service.TrackingGpsPublisher;
import com.semosan.api.domain.tracking.websocket.UserIdPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Controller;

import java.security.Principal;

/**
* 클라이언트 STOMP SEND: /app/tracking/{sessionId}/gps
* 인증된 사용자만 도달 (CONNECT 시 JWT 검증 완료). userId 는 Principal 에서 추출.
*/
@Slf4j
@Controller
@RequiredArgsConstructor
public class TrackingGpsWebSocketController {

private final TrackingGpsPublisher trackingGpsPublisher;

@MessageMapping("/tracking/{sessionId}/gps")
public void receiveGps(
@DestinationVariable Long sessionId,
@Valid @Payload GpsPointMessage message,
Principal principal
) {
Long userId = resolveUserId(principal);
trackingGpsPublisher.publish(userId, sessionId, message);
}

private Long resolveUserId(Principal principal) {
if (principal instanceof UserIdPrincipal userPrincipal) {
return userPrincipal.getUserId();
}
throw new IllegalStateException("Unauthenticated WebSocket message");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use domain auth exception instead of IllegalStateException.

Line 41 currently throws a generic runtime exception, which can surface as a 500-style internal failure path. Please throw your standard auth/business exception type so STOMP error semantics stay consistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/semosan/api/domain/tracking/controller/TrackingGpsWebSocketController.java`
at line 41, Replace the generic IllegalStateException thrown in
TrackingGpsWebSocketController (the "throw new
IllegalStateException(\"Unauthenticated WebSocket message\");" statement) with
your domain authentication/business exception type so STOMP error semantics are
preserved; locate the throw in TrackingGpsWebSocketController and swap it to the
standard auth exception used across the project (e.g., AuthException,
UnauthenticatedException, or your project's DomainAuthException) and include the
same message when constructing that exception.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.semosan.api.domain.tracking.dto.message;

import jakarta.validation.constraints.NotNull;

import java.time.LocalDateTime;

/**
* 프론트 → 서버 (STOMP SEND /app/tracking/{sessionId}/gps).
* 좌표만 받는다 — 리버스 지오코딩 결과는 받지 않음.
*/
public record GpsPointMessage(
@NotNull(message = "위도(lat)는 필수입니다.")
Double lat,

@NotNull(message = "경도(lng)는 필수입니다.")
Double lng,
Comment on lines +12 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add latitude/longitude range validation.

@NotNull alone is not enough here. Invalid values can enter the stream and skew session stats. Please enforce lat [-90, 90] and lng [-180, 180] at DTO boundary.

✅ Suggested validation annotations
 import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
@@
-        `@NotNull`(message = "위도(lat)는 필수입니다.")
+        `@NotNull`(message = "위도(lat)는 필수입니다.")
+        `@DecimalMin`(value = "-90.0", message = "위도(lat)는 -90 이상이어야 합니다.")
+        `@DecimalMax`(value = "90.0", message = "위도(lat)는 90 이하여야 합니다.")
         Double lat,
@@
-        `@NotNull`(message = "경도(lng)는 필수입니다.")
+        `@NotNull`(message = "경도(lng)는 필수입니다.")
+        `@DecimalMin`(value = "-180.0", message = "경도(lng)는 -180 이상이어야 합니다.")
+        `@DecimalMax`(value = "180.0", message = "경도(lng)는 180 이하여야 합니다.")
         Double lng,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/semosan/api/domain/tracking/dto/message/GpsPointMessage.java`
around lines 12 - 16, Add range validation to the GpsPointMessage DTO: keep the
existing `@NotNull` on the lat and lng fields and add `@DecimalMin` and `@DecimalMax`
constraints (or `@Min/`@Max if using integral types) to enforce lat between -90.0
and 90.0 and lng between -180.0 and 180.0; update the constraint messages to
reflect the range (e.g., "위도(lat)는 -90에서 90 사이여야 합니다.") and import the
corresponding javax.validation.constraints annotations so validation fails at
the DTO boundary for invalid coordinates.


/** 디바이스가 보고한 고도(m). 일부 디바이스에서 누락 가능. */
Double altitude,

@NotNull(message = "측정 시각(recordedAt)은 필수입니다.")
LocalDateTime recordedAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.semosan.api.domain.tracking.entity;

import com.semosan.api.common.base.BaseEntity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.locationtech.jts.geom.Point;

import java.time.LocalDateTime;

@Table(
name = "tracking_points",
indexes = {
@Index(name = "idx_tracking_points_session_recorded", columnList = "tracking_session_id, recorded_at")
}
)
@Getter
@Entity
@Builder(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TrackingPoint extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tracking_session_id", nullable = false)
private TrackingSession trackingSession;

/**
* 좌표 (PostGIS geography(Point, 4326)).
* Haversine 직접 계산보다 ST_Distance(geography) 가 정확 (구 좌표계 기반).
*/
@Column(name = "location", columnDefinition = "geography(Point, 4326)", nullable = false)
private Point location;

/** 디바이스가 보고한 고도(m). null 가능 — 일부 디바이스/실내에선 누락. */
@Column(name = "altitude")
private Double altitude;

@Column(name = "recorded_at", nullable = false)
private LocalDateTime recordedAt;

public static TrackingPoint create(TrackingSession session, Point location, Double altitude, LocalDateTime recordedAt) {
Comment thread
JangInho marked this conversation as resolved.
return TrackingPoint.builder()
.trackingSession(session)
.location(location)
.altitude(altitude)
.recordedAt(recordedAt)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.semosan.api.domain.tracking.repository;

import com.semosan.api.domain.tracking.entity.TrackingPoint;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface TrackingPointRepository extends JpaRepository<TrackingPoint, Long> {

List<TrackingPoint> findByTrackingSession_IdOrderByRecordedAtAsc(Long sessionId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.semosan.api.domain.tracking.service;

import com.semosan.api.common.config.TrackingProperties;
import com.semosan.api.common.exception.GeneralException;
import com.semosan.api.common.status.ErrorStatus;
import com.semosan.api.domain.tracking.dto.message.GpsPointMessage;
import com.semosan.api.domain.tracking.entity.TrackingSession;
import com.semosan.api.domain.tracking.enums.TrackingSessionStatus;
import com.semosan.api.domain.tracking.repository.TrackingSessionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.connection.stream.StringRecord;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Map;

/**
* 트래킹 GPS 점을 Redis Stream 으로 발행하기 전, 세션 소유자/상태를 검증한다.
* - 세션 존재 + 본인 소유 검증은 강제 (위반 시 throw → WebSocket 연결 강제 종료 효과)
* - PAUSED/COMPLETED/ABANDONED 상태에서는 silent drop (예외 X) — 클라이언트 race 흡수
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TrackingGpsPublisher {

private static final String FIELD_SESSION_ID = "sessionId";
private static final String FIELD_USER_ID = "userId";
private static final String FIELD_LAT = "lat";
private static final String FIELD_LNG = "lng";
private static final String FIELD_ALTITUDE = "altitude";
private static final String FIELD_RECORDED_AT = "recordedAt";

private final StringRedisTemplate redisTemplate;
private final TrackingProperties trackingProperties;
private final TrackingSessionRepository trackingSessionRepository;

@Transactional(readOnly = true)
public void publish(Long userId, Long sessionId, GpsPointMessage message) {
TrackingSession session = trackingSessionRepository.findById(sessionId)
Comment thread
JangInho marked this conversation as resolved.
.orElseThrow(() -> new GeneralException(ErrorStatus.TRACKING_SESSION_NOT_FOUND));
if (!session.isOwnedBy(userId)) {
Comment on lines +42 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Remove per-point DB lookup from ingress hot path.

Line 43 does a DB read for every GPS message before publishing to Redis. At scale, this undermines the PR’s low-latency ingestion goal and creates avoidable DB pressure. Consider caching session ownership/state in Redis (or validating once at session start and refreshing asynchronously) so publish remains lightweight.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/semosan/api/domain/tracking/service/TrackingGpsPublisher.java`
around lines 42 - 45, The publish method currently does a per-message DB read
via trackingSessionRepository.findById in TrackingGpsPublisher.publish which
causes DB pressure; change the flow so publish no longer queries the DB on each
GpsPointMessage by using a cached session/ownership lookup (e.g., a Redis-backed
cache or in-memory cache refreshed at session start) and only fall back to the
DB asynchronously or on cache-miss/stale events; specifically, replace the
direct call to trackingSessionRepository.findById and the immediate
TrackingSession.isOwnedBy check with a fast cache lookup (keyed by sessionId)
that stores ownership/state and add a cache-refresh/validation path elsewhere
(session start/stop handlers or a background refresher) so publish stays
lightweight and only touches Redis/in-memory cache.

throw new GeneralException(ErrorStatus.TRACKING_SESSION_FORBIDDEN);
}
if (session.getStatus() != TrackingSessionStatus.IN_PROGRESS) {
log.debug("Dropping GPS point: sessionId={} status={}", sessionId, 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);
}
}
Loading