Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1fb817b
Add: S2 의존성 추가
Dockerel Aug 8, 2025
75a8cfc
Add: KNU S2 geojson 파일 추가
Dockerel Aug 8, 2025
4587d1d
Feat: 애플리케이션 실행 시 KNU geojson 데이터 삭제 및 삽입
Dockerel Aug 8, 2025
facad89
feat: 현재 유저 위치 저장, 이웃 셀에 위치한 유저 조회
Dockerel Aug 11, 2025
61fe60e
feat: 초기 화면에서 인접한 요청 가져오기 위한 인접 cellId 구하는 메서드
Dockerel Aug 12, 2025
7558d35
refactor: 위치 갱신 API를 RESTful하게 리소스 기반(PUT /api/locations)으로 정리
Dockerel Aug 12, 2025
fe5534b
refactor: 레디스 파이프라인으로 조회 성능 개선
Dockerel Aug 14, 2025
3cc6931
Refactor: 현재 유저 위치 저장 로직 atomic하게 리펙터링
Dockerel Aug 14, 2025
4417548
refactor: 빌더 대신 일반 생성자 사용
Dockerel Aug 15, 2025
ac12e10
refactor: 스케줄러 응용계층과 인프라로 분리
Dockerel Aug 15, 2025
8519899
fix: isNew 판정 처리 수정
Dockerel Aug 15, 2025
6a83e0c
fix: 불필요한 Persistable 제거
Dockerel Aug 15, 2025
e3d9f2e
refactor: AuthUser 사용 및 요청 파라미터 수정
Dockerel Aug 15, 2025
89e97eb
refactor: 조회전용, 쓰기전용 모델로 분리
Dockerel Aug 16, 2025
7ffadb4
refactor: key 생성 기능 클래스로 분리
Dockerel Aug 16, 2025
fe01f30
test: 엣지 케이스들과 동시성 테스트 추가
Dockerel Aug 16, 2025
841a81a
add: @RequireAuth 어노테이션 추가
Dockerel Aug 18, 2025
baf96a8
refactor: shedlock과 redis 분산락으로 다중 인스턴스 환경 대응
Dockerel Aug 18, 2025
1c92526
refactor: OneTimeRunner 위치 변경
Dockerel Aug 18, 2025
7ee71d3
test: 활성 프로필 분리로 Redisson prod 빈 로딩 차단
Dockerel Aug 18, 2025
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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ configurations {

repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}

dependencies {
Expand Down Expand Up @@ -60,6 +61,9 @@ dependencies {
implementation 'dev.langchain4j:langchain4j:1.0.0-beta1'
implementation 'dev.langchain4j:langchain4j-google-ai-gemini:1.0.0-beta1'

// S2
implementation 'com.github.google:s2-geometry-library-java:2.0.0'

// etc
implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.7.0'
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.1.0'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.knu.ddip.common.exception;

import com.knu.ddip.auth.exception.*;
import com.knu.ddip.location.exception.LocationNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
Expand Down Expand Up @@ -67,4 +68,12 @@ public ResponseEntity<ProblemDetail> handleTokenStolenException(TokenStolenExcep
return ResponseEntity.status(HttpStatusCode.valueOf(462)).body(problemDetail);
}

@ExceptionHandler(LocationNotFoundException.class)
public ResponseEntity<ProblemDetail> handleLocationNotFoundException(LocationNotFoundException e) {

ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
problemDetail.setTitle("Location Not Found");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problemDetail);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.knu.ddip.location.application.dto;

import lombok.Builder;

import java.util.UUID;

@Builder
public record GetNeighborsRequest(
UUID userId, // 테스트용 파라미터
double lat,
double lng
) {
public static GetNeighborsRequest of(UUID userId, double lat, double lng) {
return GetNeighborsRequest.builder()
.userId(userId)
.lat(lat)
.lng(lng)
.build();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

저도 요즘 개선해나가고 있는 부분인데, 레코드타입에는 보통 빌더를 쓰는게 권장되지 않는다고 하더라구요.
팩토리 생성자는 괜찮지만 빌더는 오히려 레코드의 간편성을 해치거나 불필요한 보일러플레이트 코드가 발생할 수 있다고 하네요..!
물론 제 코드에도 아직 남아있어서 참고용으로 생각해주시면 좋을 것 같아요 ㅎㅎ,,

Copy link
Contributor Author

Choose a reason for hiding this comment

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

아하 넵 동의합니다!

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.knu.ddip.location.application.dto;

import lombok.Builder;

import java.util.UUID;

@Builder
public record UpdateMyLocationRequest(
UUID userId, // 테스트용 파라미터
double lat,
double lng
) {
public static UpdateMyLocationRequest of(UUID userId, double lat, double lng) {
return UpdateMyLocationRequest.builder()
.userId(userId)
.lat(lat)
.lng(lng)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.knu.ddip.location.application.scheduler;
Copy link
Contributor

Choose a reason for hiding this comment

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

RedisTemplate, RedisConnection등과 같은 외부 시스템 의존성이 있기 때문에
스케쥴러의 특성상 운영계층보다는 인프라스트럭처 하위가 헥사고날 관점에서 좀 더 적절한 위치가 아닐까 싶네요
스케쥴러 자체 구현체를 변경할 필요까지는 없을 것 같아 포트와 어댑터를 구분할 필요까지는 없고 현재 코드대로 위치만 변경하면 괜찮을 것 같습니다

Copy link
Contributor Author

@Dockerel Dockerel Aug 15, 2025

Choose a reason for hiding this comment

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

아하 넵 말씀하신대로 외부 시스템 의존하는 부분이 있어 인프라쪽에 더 잘 어울릴 것 같더라구요. 그래서 스케줄 실행하는 자체는 응용 계층에 두고 인프라단에 외부 의존성 관련 코드들을 옮겨 실행하도록 리펙터링하였습니다!

Copy link
Contributor

Choose a reason for hiding this comment

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

의존성 코드만 옮기는 방식도 좋네요 👍


import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.util.Set;

@Component
@RequiredArgsConstructor
public class LocationScheduler {

private final RedisTemplate redisTemplate;

@Scheduled(cron = "0 0 * * * *") // 매 정시
public void cleanupExpiredUserLocations() {
long now = System.currentTimeMillis();
ScanOptions scan = ScanOptions.scanOptions()
.match("cell:*:expiry")
.build();

RedisConnectionFactory connectionFactory = redisTemplate.getConnectionFactory();

if (connectionFactory == null) return;

try (
RedisConnection conn = connectionFactory.getConnection();
Cursor<byte[]> cursor = conn.keyCommands().scan(scan)
) {
while (cursor.hasNext()) {
// 1. key 생성
byte[] expiryKey = cursor.next();
String expiryKeyStr = new String(expiryKey, StandardCharsets.UTF_8);
String usersKeyStr = expiryKeyStr.replace(":expiry", ":users");
byte[] usersKey = usersKeyStr.getBytes(StandardCharsets.UTF_8);

// 2. 만료된 멤버 수집 (-inf ~ now)
Set<byte[]> expired = conn.zSetCommands()
.zRangeByScore(expiryKey, Double.NEGATIVE_INFINITY, (double) now);
if (expired == null || expired.isEmpty()) continue;

// 3. 파이프라인으로 SREM + ZREM
conn.openPipeline();
for (byte[] member : expired) {
conn.sRem(usersKey, member); // SET에서 제거
conn.zSetCommands().zRem(expiryKey, member); // ZSET에서도 제거
}
conn.closePipeline();
}
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.knu.ddip.location.application.service;

import java.util.List;
import java.util.Optional;

public interface LocationRepository {
void deleteAll();

void saveAll(List<String> cellIds);

Optional<String> findCellIdByUserId(String encodedUserId);

void deleteUserIdByCellId(String encodedUserId, String cellIdByUserId);

void saveUserIdByCellId(String encodedUserId, String cellId);

void saveCellIdByUserId(String encodedUserId, String cellId);

void validateLocationByCellId(String cellId);

List<String> findAllLocationsByCellIdIn(List<String> cellIds);

List<String> findUserIdsByCellId(String targetCellId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package com.knu.ddip.location.application.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.geometry.S2CellId;
import com.knu.ddip.location.application.dto.UpdateMyLocationRequest;
import com.knu.ddip.location.application.util.S2Converter;
import com.knu.ddip.location.application.util.UuidBase64Utils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class LocationService {

private final LocationRepository locationRepository;

private final ObjectMapper objectMapper;

public static final int LEVEL = 17;
public static final String KNU_GEOJSON_FEATURE_FILENAME = "geojson/cells.geojson";

// KNU GeoJSON 파일을 읽어서 각 Feature를 DB에 저장
@Transactional
public void loadAndSaveGeoJsonFeatures() {
try {
// resources 폴더에서 파일 읽기
ClassPathResource resource = new ClassPathResource(KNU_GEOJSON_FEATURE_FILENAME);
String geoJsonContent = new String(Files.readAllBytes(resource.getFile().toPath()));

// 기존 데이터 삭제
locationRepository.deleteAll();

// JSON 파싱
JsonNode rootNode = objectMapper.readTree(geoJsonContent);
JsonNode featuresNode = rootNode.get("features");
List<String> cellIds = StreamSupport.stream(featuresNode.spliterator(), false)
.map(featureNode -> featureNode.get("properties").get("id").asText())
.collect(Collectors.toList());

locationRepository.saveAll(cellIds);

log.info("총 {}개의 S2Cell Feature가 저장되었습니다.", cellIds.size());
} catch (IOException e) {
e.printStackTrace();
log.error(e.getMessage());
}
}

public void saveUserLocation(UUID userId, UpdateMyLocationRequest request) {
Copy link
Member

Choose a reason for hiding this comment

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

한 가지 엣지 케이스가 궁금해 질문드립니다. 동시성 문제, 경쟁 상태에 관한 질문인데요,

만약 네트워크가 불안정한 사용자가 아주 짧은 간격으로 위치 업데이트 요청을 두 번 보내서, 두 요청이 거의 동시에 서버에서 처리되는 상황을 가정해 보았습니다.

이때 두 요청을 처리하는 스레드가 모두 사용자의 이전 위치를 거의 동시에 읽어 간다면, 결과적으로 사용자가 두 개의 다른 Cell에 동시에 존재하는 데이터 불일치 상태가 발생할 가능성이 있지 않을까 싶습니다.

이렇게 되면 사용자가 이미 떠난 위치의 '띱' 알림을 받는 경험을 할 수도 있을 것 같아 의견 여쭙니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

아하 넵 이해했습니다. 찰나의 순간에 두 요청이 섞이면 사용자가 두 위치에 모두 존재할 수도 , 두 위치에 모두 존재하지 않을 수도 있다는 말씀이시죠?

그럼 처음 위치 한번만 validate 후 위치의 삭제와 저장을 원자적으로 처리하도록 수정하겠습니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

lua script 방식 도입하여 유저 위치 데이터의 삭제 및 삽입 프로세스가 원자적으로 이루어지게 수정하였습니다!

S2CellId cellIdObj = S2Converter.toCellId(request.lat(), request.lng(), LEVEL);
String cellId = cellIdObj.toToken();

String encodedUserId = UuidBase64Utils.uuidToBase64String(userId);

// 이전 유저 위치 정보 삭제 후 저장
// 예전 위치 있으면 -> 현재 위치와 다르면 삭제 후 저장
// 예전 위치 있으면 -> 현재 위치와 같으면 바로 리턴
// 예전 위치 없으면 -> 저장

// 예전 위치 있으면
Optional<String> cellIdByUserIdOptional = locationRepository.findCellIdByUserId(encodedUserId);
if (cellIdByUserIdOptional.isPresent()) {
String cellIdByUserId = cellIdByUserIdOptional.get();
// 현재 위치와 같으면 바로 리턴
if (cellId.equals(cellIdByUserId)) return;
// 이전 위치 삭제
locationRepository.deleteUserIdByCellId(encodedUserId, cellIdByUserId);
}

// 경북대 내부에 위치하는지 확인
locationRepository.validateLocationByCellId(cellId);

// 현재 위치 저장
// 유저의 현재 cellId 저장
locationRepository.saveCellIdByUserId(encodedUserId, cellId);
// 현재 cellId에 포함된 유저 저장
locationRepository.saveUserIdByCellId(encodedUserId, cellId);
}

// 요청 전송 시 이웃 userIds 조회
public List<UUID> getNeighborRecipientUserIds(UUID myUserId, double lat, double lng) {
S2CellId cellIdObj = S2Converter.toCellId(lat, lng, LEVEL);
String cellId = cellIdObj.toToken();

// 경북대 내부에 위치하는지 확인
locationRepository.validateLocationByCellId(cellId);

// 이웃 cellIds 가져오기
List<S2CellId> neighbors = new ArrayList<>();
cellIdObj.getAllNeighbors(LEVEL, neighbors);
List<String> neighborCellIds = neighbors.stream()
.map(S2CellId::toToken)
.collect(Collectors.toList());

// 경북대 내부에 위치하는 이웃 cellIds만 가져오기
List<String> targetCellIds = locationRepository.findAllLocationsByCellIdIn(neighborCellIds);
targetCellIds.add(cellId);

// targetCellId의 userIds만 가져오기
List<UUID> userIds = targetCellIds.stream()
.map(locationRepository::findUserIdsByCellId)
Copy link
Member

Choose a reason for hiding this comment

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

targetCellIds 리스트에는 현재 위치 Cell과 주변 이웃 Cell을 합쳐 최대 9개의 Cell ID가 들어있는데요,
해당 지점에서 findUserIdsByCellId 메소드를 각 각 한번씩, 총 9번 호출하게 되어서 Redis 서버와 최대 9번의 개별적인 네트워크 통신을 순차적으로 수행하게 될지 모른다는 우려가 있을 수 있는 것 같습니다.

한 번 확인해 봐주실 수 있을까용?? 가능하다면, Redis 파이프라이닝으로 개선이 가능할 지 여부도 확인해주세용

Copy link
Contributor Author

Choose a reason for hiding this comment

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

넵 말씀하신대로 레디스 파이프라인으로 구현하였더니

cellCount=9, usersPerCell=10000

데이터에 대해

Single Calls: 564 ms
Pipeline: 197 ms

위와 같은 성능개선이 이루어졌습니다!

.flatMap(List::stream)
.map(UuidBase64Utils::base64StringToUuid)
.filter(userId -> !userId.equals(myUserId))
.collect(Collectors.toList());

return userIds;
}

// 초기 화면에서 인접한 요청 가져오기 (현재는 인접한 cellId 가져오는 것만 구현)
public List<String> getNeighborCellIds(double lat, double lng) {
S2CellId cellIdObj = S2Converter.toCellId(lat, lng, LEVEL);
String cellId = cellIdObj.toToken();

// 경북대 내부에 위치하는지 확인
locationRepository.validateLocationByCellId(cellId);

// 이웃 cellIds 가져오기
List<S2CellId> neighbors = new ArrayList<>();
cellIdObj.getAllNeighbors(LEVEL, neighbors);
List<String> neighborCellIds = neighbors.stream()
.map(S2CellId::toToken)
.collect(Collectors.toList());

// 경북대 내부에 위치하는 이웃 cellIds만 가져오기
List<String> targetCellIds = locationRepository.findAllLocationsByCellIdIn(neighborCellIds);
targetCellIds.add(cellId);

return targetCellIds;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.knu.ddip.location.application.util;

import com.google.common.geometry.S2CellId;
import com.google.common.geometry.S2LatLng;

public abstract class S2Converter {

public static S2CellId toCellId(double lat, double lng, int level) {
S2LatLng latLng = S2LatLng.fromDegrees(lat, lng);
return S2CellId.fromLatLng(latLng).parent(level);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.knu.ddip.location.application.util;

import java.nio.ByteBuffer;
import java.util.Base64;
import java.util.UUID;

import static java.util.Base64.getUrlDecoder;
import static java.util.Base64.getUrlEncoder;

public abstract class UuidBase64Utils {

private static final Base64.Encoder B64_URL = getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64_DEC = getUrlDecoder();

public static String uuidToBase64String(UUID uuid) {
ByteBuffer bb = ByteBuffer.allocate(16)
.putLong(uuid.getMostSignificantBits())
.putLong(uuid.getLeastSignificantBits());
return B64_URL.encodeToString(bb.array());
}

public static UUID base64StringToUuid(String string) {
byte[] bytes = B64_DEC.decode(string);
ByteBuffer bb = ByteBuffer.wrap(bytes);
return new UUID(bb.getLong(), bb.getLong());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.knu.ddip.location.exception;

public class LocationNotFoundException extends RuntimeException {
public LocationNotFoundException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.knu.ddip.location.infrastructure.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.*;
import org.springframework.data.domain.Persistable;

@Entity
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "LOCATIONS")
public class LocationEntity implements Persistable<String> {

@Id
private String cellId;

public static LocationEntity create(String cellId) {
return LocationEntity.builder()
.cellId(cellId)
.build();
}

@Override
public String getId() {
return cellId;
}

@Override
public boolean isNew() {
return cellId == null;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

cellId에 대한 자동생성 로직을 두지 않았기 때문에 하이버네이트단의 select 쿼리발생 방지를 위해 isNew를 두신게 맞을까용

Copy link
Contributor Author

@Dockerel Dockerel Aug 14, 2025

Choose a reason for hiding this comment

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

네네 정확합니다! 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

다시 확인해보니 벌크 삽입이 jdbc를 통해 이루어지게 구현되어 있는데 이때는 isNew 결과에 상관없이 들어간다고 하네요. 실제로 찍히는 쿼리 로그를 통해 확인해봤는데 실제로도 그렇더라구요!

jdbc 쿼리 로그 확인해보고 싶으시면

spring.datasource.url 뒤에

&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=2147483647

를 추가해주시고

logging.level.com.mysql.cj: trace

를 추가해주시면 됩니다!

Loading
Loading