-
Notifications
You must be signed in to change notification settings - Fork 1
Feat: S2 기반 위치 관리 기능 구현 #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
1fb817b
75a8cfc
4587d1d
facad89
61fe60e
7558d35
fe5534b
3cc6931
4417548
ac12e10
8519899
6a83e0c
e3d9f2e
89e97eb
7ffadb4
fe01f30
841a81a
baf96a8
1c92526
7ee71d3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| } | ||
| } | ||
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. RedisTemplate, RedisConnection등과 같은 외부 시스템 의존성이 있기 때문에
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아하 넵 말씀하신대로 외부 시스템 의존하는 부분이 있어 인프라쪽에 더 잘 어울릴 것 같더라구요. 그래서 스케줄 실행하는 자체는 응용 계층에 두고 인프라단에 외부 의존성 관련 코드들을 옮겨 실행하도록 리펙터링하였습니다!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
||
| 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) | ||
|
||
| .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; | ||
| } | ||
| } | ||
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저도 요즘 개선해나가고 있는 부분인데, 레코드타입에는 보통 빌더를 쓰는게 권장되지 않는다고 하더라구요.
팩토리 생성자는 괜찮지만 빌더는 오히려 레코드의 간편성을 해치거나 불필요한 보일러플레이트 코드가 발생할 수 있다고 하네요..!
물론 제 코드에도 아직 남아있어서 참고용으로 생각해주시면 좋을 것 같아요 ㅎㅎ,,
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아하 넵 동의합니다!