Skip to content

Commit 3dddd37

Browse files
committed
refactor: 캐싱 전략 적용을 위해 지도 API 서비스 로직 수정
- Look-Aside + Write-Around 캐싱 전략 적용
1 parent c0f26b2 commit 3dddd37

File tree

10 files changed

+158
-78
lines changed

10 files changed

+158
-78
lines changed

src/main/java/com/example/log4u/domain/diary/service/DiaryService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,4 +278,8 @@ public void checkDiaryExists(Long diaryId) {
278278
public List<Diary> getTop10Diaries() {
279279
return diaryRepository.findTop10ByVisibilityOrderByLikeCountDesc(VisibilityType.PUBLIC);
280280
}
281+
282+
public List<Diary> getDiaries(List<Long> ids) {
283+
return diaryRepository.findAllById(ids);
284+
}
281285
}

src/main/java/com/example/log4u/domain/map/dto/response/DiaryMarkerResponseDto.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import java.time.LocalDateTime;
44

5+
import com.example.log4u.domain.diary.entity.Diary;
6+
57
public record DiaryMarkerResponseDto(
68
Long diaryId,
79
String title,
@@ -10,6 +12,16 @@ public record DiaryMarkerResponseDto(
1012
Double lat,
1113
Double lon,
1214
LocalDateTime createdAt
13-
1415
) {
16+
public static DiaryMarkerResponseDto of(Diary diary) {
17+
return new DiaryMarkerResponseDto(
18+
diary.getDiaryId(),
19+
diary.getTitle(),
20+
diary.getThumbnailUrl(),
21+
diary.getLikeCount(),
22+
diary.getLocation().getLatitude(),
23+
diary.getLocation().getLongitude(),
24+
diary.getCreatedAt()
25+
);
26+
}
1527
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.example.log4u.domain.map.exception;
2+
3+
public class InvalidGeohashException extends MapException {
4+
public InvalidGeohashException() {
5+
super(MapErrorCode.INVALID_GEOHASH);
6+
}
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.example.log4u.domain.map.exception;
2+
3+
public class InvalidMapLevelException extends MapException {
4+
public InvalidMapLevelException() {
5+
super(MapErrorCode.INVALID_MAP_LEVEL);
6+
}
7+
}

src/main/java/com/example/log4u/domain/map/exception/MapErrorCode.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
public enum MapErrorCode implements ErrorCode {
1313

1414
NOT_FOUND_REGION(HttpStatus.NOT_FOUND, "해당 지역(시/군/구)을 찾을 수 없습니다."),
15-
UNAUTHORIZED_MAP_ACCESS(HttpStatus.FORBIDDEN, "지도 리소스에 대한 권한이 없습니다.");
15+
UNAUTHORIZED_MAP_ACCESS(HttpStatus.FORBIDDEN, "지도 리소스에 대한 권한이 없습니다."),
16+
INVALID_MAP_LEVEL(HttpStatus.BAD_REQUEST, "유효하지 않은 지도 level 값입니다."),
17+
INVALID_GEOHASH(HttpStatus.BAD_REQUEST,"geohash 길이가 유효하지 않습니다");
1618

1719
private final HttpStatus httpStatus;
1820
private final String message;

src/main/java/com/example/log4u/domain/map/repository/sido/query/SidoAreasRepositoryCustom.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
import com.example.log4u.domain.map.dto.response.DiaryClusterResponseDto;
66

77
public interface SidoAreasRepositoryCustom {
8-
List<DiaryClusterResponseDto> findSidoAreaClusters(double south, double north, double west, double east);
9-
10-
List<DiaryClusterResponseDto> findAllWithDiaryCount();
118

9+
List<DiaryClusterResponseDto> findByGeohashPrefix(String geohashPrefix);
1210
}

src/main/java/com/example/log4u/domain/map/repository/sido/query/SidoAreasRepositoryImpl.java

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,29 +21,7 @@ public SidoAreasRepositoryImpl(@Qualifier("postgresJPAQueryFactory") JPAQueryFac
2121
}
2222

2323
@Override
24-
public List<DiaryClusterResponseDto> findSidoAreaClusters(double south, double north, double west, double east) {
25-
QSidoAreas s = QSidoAreas.sidoAreas;
26-
QSidoAreasDiaryCount c = QSidoAreasDiaryCount.sidoAreasDiaryCount;
27-
28-
return queryFactory
29-
.select(new QDiaryClusterResponseDto(
30-
s.name,
31-
s.id,
32-
s.lat,
33-
s.lon,
34-
c.diaryCount.coalesce(0L)
35-
))
36-
.from(s)
37-
.leftJoin(c).on(s.id.eq(c.id))
38-
.where(
39-
s.lat.between(south, north),
40-
s.lon.between(west, east)
41-
)
42-
.fetch();
43-
}
44-
45-
@Override
46-
public List<DiaryClusterResponseDto> findAllWithDiaryCount() {
24+
public List<DiaryClusterResponseDto> findByGeohashPrefix(String geohashPrefix) {
4725
QSidoAreas s = QSidoAreas.sidoAreas;
4826
QSidoAreasDiaryCount c = QSidoAreasDiaryCount.sidoAreasDiaryCount;
4927

@@ -57,6 +35,7 @@ public List<DiaryClusterResponseDto> findAllWithDiaryCount() {
5735
))
5836
.from(s)
5937
.leftJoin(c).on(s.id.eq(c.id))
38+
.where(s.geohash.startsWith(geohashPrefix))
6039
.fetch();
6140
}
6241
}

src/main/java/com/example/log4u/domain/map/repository/sigg/query/SiggAreasRepositoryCustom.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
import com.example.log4u.domain.map.dto.response.DiaryClusterResponseDto;
66

77
public interface SiggAreasRepositoryCustom {
8-
List<DiaryClusterResponseDto> findSiggAreaClusters(double south, double north, double west, double east);
9-
10-
List<DiaryClusterResponseDto> findAllWithDiaryCount();
118

9+
List<DiaryClusterResponseDto> findByGeohashPrefix(String geohashPrefix);
1210
}

src/main/java/com/example/log4u/domain/map/repository/sigg/query/SiggAreasRepositoryImpl.java

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,30 +21,7 @@ public SiggAreasRepositoryImpl(@Qualifier("postgresJPAQueryFactory") JPAQueryFac
2121
}
2222

2323
@Override
24-
public List<DiaryClusterResponseDto> findSiggAreaClusters(double south, double north, double west, double east) {
25-
QSiggAreas s = QSiggAreas.siggAreas;
26-
QSiggAreasDiaryCount c = QSiggAreasDiaryCount.siggAreasDiaryCount;
27-
28-
return queryFactory
29-
.select(new QDiaryClusterResponseDto(
30-
s.sggName,
31-
s.gid,
32-
s.lat,
33-
s.lon,
34-
c.diaryCount.coalesce(0L)
35-
))
36-
.from(s)
37-
.leftJoin(c).on(s.gid.eq(c.id))
38-
.where(
39-
s.lat.between(south, north),
40-
s.lon.between(west, east)
41-
)
42-
.fetch();
43-
}
44-
45-
46-
@Override
47-
public List<DiaryClusterResponseDto> findAllWithDiaryCount() {
24+
public List<DiaryClusterResponseDto> findByGeohashPrefix(String geohashPrefix) {
4825
QSiggAreas s = QSiggAreas.siggAreas;
4926
QSiggAreasDiaryCount c = QSiggAreasDiaryCount.siggAreasDiaryCount;
5027

@@ -58,6 +35,7 @@ public List<DiaryClusterResponseDto> findAllWithDiaryCount() {
5835
))
5936
.from(s)
6037
.leftJoin(c).on(s.gid.eq(c.id))
38+
.where(s.geohash.startsWith(geohashPrefix))
6139
.fetch();
6240
}
6341
}

src/main/java/com/example/log4u/domain/map/service/MapService.java

Lines changed: 118 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
package com.example.log4u.domain.map.service;
22

3-
import java.time.Duration;
3+
import java.util.ArrayList;
4+
import java.util.Collections;
5+
import java.util.HashSet;
46
import java.util.List;
7+
import java.util.Set;
8+
import java.util.stream.Collectors;
9+
import java.util.stream.Stream;
510

611
import org.springframework.stereotype.Service;
712
import org.springframework.transaction.annotation.Transactional;
813

9-
import com.example.log4u.common.redis.RedisDao;
10-
import com.example.log4u.domain.diary.repository.DiaryRepository;
14+
import com.example.log4u.domain.diary.entity.Diary;
15+
import com.example.log4u.domain.diary.service.DiaryService;
16+
import com.example.log4u.domain.diary.service.DiaryGeohashService;
1117
import com.example.log4u.domain.map.dto.response.DiaryClusterResponseDto;
1218
import com.example.log4u.domain.map.dto.response.DiaryMarkerResponseDto;
19+
import com.example.log4u.domain.map.cache.dao.ClusterCacheDao;
20+
import com.example.log4u.domain.map.cache.dao.DiaryCacheDao;
21+
import com.example.log4u.domain.map.cache.RedisTTLPolicy;
22+
import com.example.log4u.domain.map.exception.InvalidGeohashException;
23+
import com.example.log4u.domain.map.exception.InvalidMapLevelException;
1324
import com.example.log4u.domain.map.repository.sido.SidoAreasDiaryCountRepository;
1425
import com.example.log4u.domain.map.repository.sido.SidoAreasRepository;
1526
import com.example.log4u.domain.map.repository.sigg.SiggAreasDiaryCountRepository;
@@ -27,28 +38,118 @@ public class MapService {
2738
private final SidoAreasDiaryCountRepository sidoAreasDiaryCountRepository;
2839
private final SiggAreasRepository siggAreasRepository;
2940
private final SiggAreasDiaryCountRepository siggAreasDiaryCountRepository;
30-
private final DiaryRepository diaryRepository;
31-
private final RedisDao redisDao;
32-
33-
public List<DiaryClusterResponseDto> getDiaryClusters(
34-
double south, double north, double west, double east, int zoom) {
35-
if (zoom <= 10) {
36-
return getSidoAreasClusters(south, north, west, east);
37-
} else {
38-
return getSiggAreasClusters(south, north, west, east);
41+
private final DiaryCacheDao diaryCacheDao;
42+
private final ClusterCacheDao clusterCacheDao;
43+
private final DiaryService diaryService;
44+
private final DiaryGeohashService diaryGeohashService;
45+
46+
/**
47+
* 캐싱 전략: Look-Aside + Write-Around
48+
* - Redis에서 클러스터 데이터를 먼저 조회 (캐시 HIT 시 바로 응답)
49+
* - 캐시 MISS 시 DB에서 조회 후 Redis에 저장하고 응답
50+
*
51+
* level 기준:
52+
* - level 1: 시/도 단위 클러스터 (sido)
53+
* - level 2: 시/군/구 단위 클러스터 (sigg)
54+
*/
55+
@Transactional(readOnly = true)
56+
public List<DiaryClusterResponseDto> getDiaryClusters(String geohash, int level) {
57+
validateGeohashLength(geohash, 3);
58+
return clusterCacheDao.getDiaryCluster(geohash, level)
59+
.orElseGet(() -> {
60+
List<DiaryClusterResponseDto> dbResult = loadClustersFromDb(geohash, level);
61+
clusterCacheDao.setDiaryCluster(geohash, level, dbResult, RedisTTLPolicy.CLUSTER_TTL);
62+
return dbResult;
63+
});
64+
}
65+
66+
private List<DiaryClusterResponseDto> loadClustersFromDb(String geohash, int level) {
67+
return switch (level) {
68+
case 1 -> sidoAreasRepository.findByGeohashPrefix(geohash);
69+
case 2 -> siggAreasRepository.findByGeohashPrefix(geohash);
70+
default -> throw new InvalidMapLevelException();
71+
};
72+
}
73+
74+
/**
75+
* 캐싱 전략: Look-Aside + Write-Around
76+
*
77+
* [1단계] geohash → diaryIds 조회
78+
* - Redis(Set)에서 diaryIds 조회 (캐시 HIT 시 바로 사용)
79+
* - 캐시 MISS 시 DB 조회 후 Redis에 저장 (Write-Around)
80+
*
81+
* [2단계] diaryId → DiaryMarkerDto 조회
82+
* - Redis(String)에서 Diary DTO 조회 (캐시 HIT 시 바로 사용)
83+
* - 캐시 MISS 시 DB에서 조회 후 Redis에 저장 (Write-Around)
84+
*/
85+
@Transactional(readOnly = true)
86+
public List<DiaryMarkerResponseDto> getDiariesByGeohash(String geohash) {
87+
validateGeohashLength(geohash, 5);
88+
Set<Long> diaryIds = loadDiaryIdsFromGeoCache(geohash);
89+
return loadDiaryDtosFromCache(diaryIds);
90+
}
91+
92+
private Set<Long> loadDiaryIdsFromGeoCache(String geohash) {
93+
Set<Long> cachedIds = diaryCacheDao.getDiaryIdSetFromCache(geohash);
94+
if (!cachedIds.isEmpty())
95+
return cachedIds;
96+
97+
List<Long> diaryIds = diaryGeohashService.getDiaryIdsByGeohash(geohash);
98+
diaryCacheDao.cacheDiaryIdSetByGeohash(geohash, diaryIds);
99+
return new HashSet<>(diaryIds);
100+
}
101+
102+
private List<DiaryMarkerResponseDto> loadDiaryDtosFromCache(Set<Long> diaryIds) {
103+
List<DiaryMarkerResponseDto> cached = getCachedDiaryDtos(diaryIds);
104+
List<Long> missedIds = getCacheMissedIds(diaryIds, cached);
105+
List<DiaryMarkerResponseDto> loaded = loadDiariesAndCache(missedIds);
106+
107+
return Stream.concat(cached.stream(), loaded.stream())
108+
.toList();
109+
}
110+
111+
private List<DiaryMarkerResponseDto> getCachedDiaryDtos(Set<Long> ids) {
112+
List<DiaryMarkerResponseDto> cached = new ArrayList<>();
113+
for (Long id : ids) {
114+
DiaryMarkerResponseDto dto = diaryCacheDao.getDiaryFromCache(id);
115+
if (dto != null) cached.add(dto);
39116
}
117+
return cached;
40118
}
41119

42-
private List<DiaryClusterResponseDto> getSidoAreasClusters(double south, double north, double west, double east) {
43-
return sidoAreasRepository.findSidoAreaClusters(south, north, west, east);
120+
private List<Long> getCacheMissedIds(Set<Long> allIds, List<DiaryMarkerResponseDto> cachedDtos) {
121+
Set<Long> cachedIds = cachedDtos.stream()
122+
.map(DiaryMarkerResponseDto::diaryId)
123+
.collect(Collectors.toSet());
124+
125+
return allIds.stream()
126+
.filter(id -> !cachedIds.contains(id))
127+
.collect(Collectors.toList());
44128
}
45129

46-
private List<DiaryClusterResponseDto> getSiggAreasClusters(double south, double north, double west, double east) {
47-
return siggAreasRepository.findSiggAreaClusters(south, north, west, east);
130+
private List<DiaryMarkerResponseDto> loadDiariesAndCache(List<Long> missedIds) {
131+
if (missedIds.isEmpty())
132+
return Collections.emptyList();
133+
134+
List<Diary> diaries = diaryService.getDiaries(missedIds);
135+
List<DiaryMarkerResponseDto> result = new ArrayList<>();
136+
137+
for (Diary diary : diaries) {
138+
DiaryMarkerResponseDto dto = DiaryMarkerResponseDto.of(diary);
139+
diaryCacheDao.cacheDiary(diary.getDiaryId(), dto);
140+
result.add(dto);
141+
}
142+
143+
return result;
48144
}
49145

146+
private void validateGeohashLength(String geohash, int expectedLength) {
147+
if (geohash == null || geohash.length() != expectedLength) {
148+
throw new InvalidGeohashException();
149+
}
150+
}
50151

51-
@Transactional
152+
@Transactional
52153
public void increaseRegionDiaryCount(Double lat, Double lon) {
53154
sidoAreasRepository.findRegionByLatLon(lat, lon)
54155
.flatMap(sido -> sidoAreasDiaryCountRepository.findById(sido.getId()))
@@ -81,10 +182,4 @@ public void decreaseRegionDiaryCount(Double lat, Double lon) {
81182
siggAreasDiaryCountRepository.save(count);
82183
});
83184
}
84-
85-
@Transactional(readOnly = true)
86-
public List<DiaryMarkerResponseDto> getDiariesInBounds(double south, double north, double west, double east) {
87-
return diaryRepository.findDiariesInBounds(south, north, west, east);
88-
}
89-
90185
}

0 commit comments

Comments
 (0)