| name | place |
| description | Use when working in the ssolv-api-place module โ Google Places API client, Redis ZSET-based place ranking, Server-Sent Events for like updates, `MeetingPlaceSearchService`, `PlaceLikeService`, and place scoring. Detailed docs live in `.claude/docs/place-*.md`. Trigger on any file under ssolv-api-place/ or when touching place search/like/SSE flows. |
Place Module (ssolv-api-place)
์์ธ ๋ฌธ์: .claude/docs/place-recommendation-logic.md, place-redis-caching-strategy.md, place-like-flow.md
๋ชจ๋ ๊ฐ์
ssolv-api-place๋ ๋
๋ฆฝ bootable JAR. ssolv-api-core์ ์ ๋ ์ํธ ์ฐธ์กฐ ๊ธ์ง.
๊ณต์ ๋ชจ๋(ssolv-api-common, ssolv-domain, ssolv-infrastructure)์ ํตํด์๋ง ํต์ .
์ฅ์ ์ถ์ฒ ํ๋ฆ (ํต์ฌ)
์ค๋ฌธ ์๋ฃ โ Redis Stream(meeting_calculation_stream) ๋ฐํ
โ PlaceSearchConsumer ์๋น
โ ExecutePlaceSearchService.execute(meetingId)
โโ 1. GetSurveyAggregateService โ ์ค๋ฌธ ์นดํ
๊ณ ๋ฆฌ ์ง๊ณ
โโ 2. SelectSurveyKeywordsService โ ๊ฒ์ ํค์๋ ์ต๋ 5๊ฐ ์ ํ
โโ 3. Google Places Text Search API โ ์ญ ๊ธฐ์ค ๋ฐ๊ฒฝ 3km, ๋ณ๋ ฌ ํธ์ถ
โโ 4. ์ต์ข
์ฑ์ + Redis ์ ์ฅ
์ฑ์ ๊ณต์
Score = SurveyScore(0~100)
+ LikeScore(ln(count+1) ร 50)
+ GoogleScore(rating ร ln(userRatingsTotal+1) ร 2)
+ DistanceScore(0~100, 5km ์ด๋ด)
+ ProximityBoost(์ญ 1km ์ด๋ด โ +50)
+ CategoryMatchBoost(๊ตฌ๊ธ types ์ผ์น โ +50)
ํํฐ: ์ญ ๊ธฐ์ค 5km ์ด๊ณผ ์ฅ์ ์์ฒ ๋ฐฐ์
Redis ๋ฐ์ดํฐ ๊ตฌ์กฐ
| ํค ํจํด | ์๋ฃ๊ตฌ์กฐ | TTL | ์ฉ๋ |
|---|
meeting:places:{meetingId} | ZSET | 7์ผ (๋์ ) | ๋ชจ์๋ณ ์ฅ์ ๋ญํน ์ ์ |
place:details:{placeId} | String | 30์ผ | ์ฅ์ ์์ธ JSON (Google API ์ฝ๊ด) |
meeting:{mId}:place:{pId}:likes | Set | 30์ผ | ์ข์์ userId ์งํฉ |
place_search_lock:{meetingId} | String | 10์ด | Thundering Herd ๋ถ์ฐ ๋ฝ |
Google API ์ฝ๊ด: place:details:* 30์ผ TTL๋ก ์๋ ์ค์. ์ง์ TTL ์ ๊ฑฐ ๊ธ์ง.
์ข์์(Like) ํซํจ์ค
- TTL ์ฐ์ ํ์ธ โ Redis
getExpire(meetingKey) > 0 ์ด๋ฉด DB ์กฐํ ์์ด ์ฌ์ฌ์ฉ
- Lua ์คํฌ๋ฆฝํธ ์์์ ์คํ โ SADD/SREM + ZINCRBY + EXPIRE๋ฅผ ๋จ์ผ ํธ๋์ญ์
์ผ๋ก ์ฒ๋ฆฌ
- Redis Pub/Sub ๋ฐํ โ
meeting:updates:{meetingId} ์ฑ๋
- SSE ๋ธ๋ก๋์บ์คํธ โ ํด๋น ๋ชจ์ ๊ตฌ๋
์์๊ฒ๋ง
placeUpdate ์ด๋ฒคํธ ์ ์ก
val ttl = when {
existingTtl > 0 -> existingTtl
else -> meeting.endAt?.let {
Duration.between(LocalDateTime.now(), it).seconds.coerceAtLeast(1)
} ?: Duration.ofDays(7).seconds
}
Cache Stampede ๋ฐฉ์ด (Double-Check Lock)
if (redisExists(meetingKey)) return getCached(meetingKey)
val locked = redisTemplate.opsForValue()
.setIfAbsent("place_search_lock:$meetingId", "1", Duration.ofSeconds(10))
?: false
if (!locked) {
return waitAndGetCached(meetingKey)
}
if (redisExists(meetingKey)) return getCached(meetingKey)
return executeSearch(meetingId)
Watchdog ๋ถํ์: Google API ํ์์์์ด 5์ด๋ก ๋ช
ํํ๊ธฐ ๋๋ฌธ์ ๊ณ ์ 10์ด ๋ฝ์ผ๋ก ์ถฉ๋ถ.
์กฐํ ํจํด (2๋จ๊ณ MGET)
val placeIds = redisTemplate.opsForZSet()
.reverseRange("meeting:places:$meetingId", 0, 9)
val details = redisTemplate.opsForValue()
.multiGet(placeIds.map { "place:details:$it" })
val result = placeIds.zip(details).map { (placeId, detail) ->
val likeCount = redisTemplate.opsForSet()
.size("meeting:$meetingId:place:$placeId:likes") ?: 0L
val isLiked = redisTemplate.opsForSet()
.isMember("meeting:$meetingId:place:$placeId:likes", userId.toString()) ?: false
detail.copy(likeCount = likeCount, isLiked = isLiked)
}
๋ฐ์ดํฐ ์ฌ๊ณ์ฐ ์๋ช
์ฃผ๊ธฐ
์ต์ด ์กฐํ: Redis Miss โ ์ถ์ฒ ๋ก์ง ์คํ โ RDB ์ ์ฅ + Redis ์ ์ฌ(7์ผ TTL)
๋ฐ๋ณต ์กฐํ: Redis Hit โ ZREVRANGE + MGET + ์ข์์ ๋ณํฉ โ ์ฆ์ ๋ฐํ
๋ง๋ฃ(7์ผ ๊ฒฝ๊ณผ): Redis Expired โ ๋ค์ ์์ฒญ ์ ์ต์ด ์กฐํ ๊ณผ์ ๋ฐ๋ณต
RDB(tb_place): ์๋น ๋ง์คํฐ ์ ๋ณด ์๊ตฌ ๋ณด๊ด. Google API ์ฝ๊ด๊ณผ ๋ฌด๊ดํ๊ฒ ์ ์ง.
Redis: ๋ชจ์๋ณ ๋ญํน + ์บ์๋ง. ๋ง๋ฃ๋๋ฉด RDB ๊ธฐ๋ฐ์ผ๋ก ์ฌ๊ณ์ฐ ๊ฐ๋ฅ.
SSE ๊ตฌ๋
ํด์ ์ฒ๋ฆฌ
emitter.onTimeout { emitters[meetingId]?.remove(emitter) }
emitter.onError { emitters[meetingId]?.remove(emitter) }
์ ๊ฑฐํ์ง ์์ผ๋ฉด ์ฃฝ์ Emitter๋ก ๋ธ๋ก๋์บ์คํธ ์ ์๋ฌ ๋์ .
ํ
์คํธ ์ฃผ์์ฌํญ
- Google Places API โ
@MockitoBean GooglePlacesClient๋ก ๋์ฒด ํ์
- Redis Stream Consumer โ
@Profile("!test") Config์ ํฌํจ๋จ (์๋ ๋นํ์ฑํ)
- SSE ํ
์คํธ โ
MockMvc์ async ๋ชจ๋ ์ฌ์ฉ + text/event-stream Content-Type ๊ฒ์ฆ