Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 1 deletion .codex/skills/pida-commit-push/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Use this skill when the user asks to commit, push, or do a combined commit-push
1. Read `./AGENTS.md` for validation defaults and git workflow notes.
2. Inspect `git status` and split unrelated work into the smallest safe commit groups.
3. For each group, choose the smallest Java 21 validation command that proves the change.
4. Propose the ordered commit and push plan first, using `[Topic] 이슈 내용` commit messages.
4. Propose the ordered commit and push plan first, using `[Topic] Issue summary` commit messages in English.
5. Wait for explicit approval before mutating git state. Use the exact approval token `확인` when a single-token confirmation is appropriate.
6. After approval, stage one group at a time, commit, re-stage if `.githooks/pre-commit` reformats Kotlin files, then push once at the end.
7. Never revert unrelated user changes unless the user explicitly asks.
Expand All @@ -28,4 +28,5 @@ Use this skill when the user asks to commit, push, or do a combined commit-push
## Notes

- Prefer `JAVA_HOME=$(/usr/libexec/java_home -v 21)` for Gradle commands.
- Always write PIDA commit messages in English.
- Read `references/commit-push.md` for commit grouping heuristics, validation mapping, and prompt templates.
56 changes: 56 additions & 0 deletions docs/flower-spot-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Flower Spot Performance Notes

## Refactoring Direction

- `spot:all` Redis 캐시를 기준으로 `region`과 `bbox(swLat/swLng/neLat/neLng)` 필터를 애플리케이션에서 적용한다.
- `GET /flower-spot` 목록 응답은 preview 이미지 1장만 조회하고, 상세 응답만 전체 이미지 조회를 유지한다.
- 목록 응답 조합 시 `recentlyBlooming.groupBy { it.flowerSpotId }`는 한 번만 계산한다.
- 빈 spot 목록에서는 bloomings, S3 preview 조회를 생략한다.
- synthetic latency benchmark를 추가해 이전 구현과 현재 구현의 median latency를 비교한다.

## Why This Changed

- bbox 요청은 이전까지 PostGIS 쿼리를 매번 수행했다.
- 목록 응답은 preview URL 하나만 사용하면서도 spot별 전체 이미지 목록을 매번 S3에서 조회했다.
- 동일한 bloomings를 spot 수만큼 다시 `groupBy` 하면서 CPU 비용이 추가됐다.

## Performance Benchmark

### Run

```bash
JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew :pida-core:core-domain:flowerSpotPerformanceBenchmark --no-daemon
```

### Output

- report path: `pida-core/core-domain/build/reports/performance/flower-spot-latency.md`
- benchmark scope:
- repeated `groupBy` 제거 전후 latency
- preview 이미지 1장 조회와 전체 이미지 조회 latency
- `GET /flower-spot` 목록 조합의 end-to-end synthetic latency

### Interpretation

- 이 benchmark는 애플리케이션 레이어의 이전 구현과 현재 구현을 같은 입력으로 비교한다.
- 네트워크, Redis, PostgreSQL, PostGIS 실행 계획은 포함하지 않는다.
- bbox DB 경로 성능은 아래 인덱스 체크리스트와 `EXPLAIN (ANALYZE, BUFFERS)`로 별도 확인한다.

## Bbox Index Checklist

현재 bbox 경로는 캐시 기반 필터를 우선 사용하지만, 데이터 볼륨이 커져 DB 공간 쿼리로 되돌리거나 fallback이 필요해질 수 있다. 그때는 아래 인덱스를 먼저 확인한다.

1. `t_flower_spot.pin_point`에 GIST 인덱스가 있는지 확인한다.
2. soft delete 비중이 높다면 `WHERE deleted_at IS NULL` 조건의 partial GIST 인덱스를 검토한다.
3. `region` 조건과 함께 쓰는 경로가 많다면 `t_flower_spot(region, deleted_at)` B-tree 인덱스를 확인한다.
4. 최근 개화 조회용으로 `t_blooming(flower_spot_id, created_at)` 또는 `t_blooming(flower_spot_id, status, created_at)` 복합 인덱스를 확인한다.
5. 대표 bbox 쿼리와 최근 개화 쿼리에 대해 `EXPLAIN (ANALYZE, BUFFERS)`를 실행해 sequential scan 여부를 확인한다.
6. 실제 실행 계획에서 `pin_point` GIST 인덱스와 `t_blooming` 복합 인덱스가 선택되는지 점검한다.

## Revisit Signals

아래 신호가 보이면 캐시 기반 필터 대신 DB 공간 쿼리를 다시 검토한다.

- Redis에서 `spot:all` 역직렬화 비용이 응답 시간의 대부분을 차지할 때
- flower spot 개수가 커져 애플리케이션 전수 스캔 비용이 커질 때
- bbox 요청이 매우 다양해서 캐시 hit 이점보다 CPU 비용이 커질 때
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.pida.support.aws.PresignedUrlRateLimiter
import com.pida.support.aws.S3ImageInfo
import com.pida.support.aws.S3ImageUrl
import org.springframework.stereotype.Component
import software.amazon.awssdk.services.s3.model.S3Object
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneId
Expand All @@ -18,6 +19,10 @@ class ImageS3Processor(
private val imageFileConstructor: ImageFileConstructor,
private val rateLimiter: PresignedUrlRateLimiter,
) : ImageS3Caller {
companion object {
private val SEOUL_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
}

override fun createUploadUrl(
userId: Long,
prefix: String,
Expand Down Expand Up @@ -55,6 +60,17 @@ class ImageS3Processor(
} ?: listPresignedGets(imageFilePath) // 아니면 해당 경로 아래 모든 이미지 탐색
}

override suspend fun getPreviewImage(
prefix: String,
prefixId: Long,
): S3ImageInfo? {
val imageFilePath = imageFileConstructor.imageFilePath(prefix, prefixId)

return listImageObjects(imageFilePath)
.maxByOrNull(S3Object::lastModified)
?.toImageInfo(imageFilePath, Duration.ofSeconds(30))
}

private fun generateGetUrl(
filePath: String,
fileName: String,
Expand Down Expand Up @@ -86,14 +102,20 @@ class ImageS3Processor(
)
return S3ImageInfo(
url = url,
uploadedAt = LocalDateTime.ofInstant(lastModified, ZoneId.of("Asia/Seoul")),
uploadedAt = LocalDateTime.ofInstant(lastModified, SEOUL_ZONE_ID),
)
}

private fun listPresignedGets(
filePath: String,
ttl: Duration = Duration.ofSeconds(30),
): List<S3ImageInfo> =
listImageObjects(filePath)
.map { it.toImageInfo(filePath, ttl) }
.sortedByDescending { it.uploadedAt }
.toList()

private fun listImageObjects(filePath: String): Sequence<S3Object> =
awsS3Client
.getBucketListObjects(
bucketName = awsProperties.s3.bucket,
Expand All @@ -102,18 +124,22 @@ class ImageS3Processor(
.orEmpty()
.asSequence()
.filterNot { it.key().endsWith("/") }
.map { s3Object ->
val fileName = s3Object.key().substringAfterLast("/")
S3ImageInfo(
url =
awsS3Client.generateUrl(
bucketName = awsProperties.s3.bucket,
filePath = filePath,
fileName = fileName,
ttl = ttl,
),
uploadedAt = LocalDateTime.ofInstant(s3Object.lastModified(), ZoneId.of("Asia/Seoul")),
)
}.sortedByDescending { it.uploadedAt }
.toList()

private fun S3Object.toImageInfo(
filePath: String,
ttl: Duration,
): S3ImageInfo {
val fileName = key().substringAfterLast("/")

return S3ImageInfo(
url =
awsS3Client.generateUrl(
bucketName = awsProperties.s3.bucket,
filePath = filePath,
fileName = fileName,
ttl = ttl,
),
uploadedAt = LocalDateTime.ofInstant(lastModified(), SEOUL_ZONE_ID),
)
}
}
13 changes: 13 additions & 0 deletions pida-core/core-domain/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import org.gradle.api.tasks.JavaExec
import org.gradle.api.tasks.SourceSetContainer

dependencies {
compileOnly(libs.spring.context)
implementation(libs.spring.tx)
Expand All @@ -21,3 +24,13 @@ dependencies {
// Caffeine
implementation(libs.caffeine)
}

val sourceSets = the<SourceSetContainer>()

tasks.register<JavaExec>("flowerSpotPerformanceBenchmark") {
group = "verification"
description = "Run flower-spot synthetic latency benchmark against the legacy implementation"
dependsOn(tasks.named("testClasses"))
classpath = sourceSets["test"].runtimeClasspath
mainClass.set("com.pida.flowerspot.perf.FlowerSpotPerformanceBenchmarkRunner")
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,21 @@ class FlowerSpotFacade(
location: FlowerSpotLocation,
): List<FlowerSpotDetails> {
val flowerSpots = flowerSpotService.readAllFlowerSpot(region, location)
if (flowerSpots.isEmpty()) return emptyList()

val recentlyBlooming = bloomingService.recentlyBloomingBySpotIds(flowerSpots.map { it.id })
val bloomingBySpotId = recentlyBlooming.groupBy { it.flowerSpotId }

return flowerSpots.map { flowerSpot ->
FlowerSpotDetails.of(
flowerSpot = flowerSpot,
bloomings = recentlyBlooming.groupBy { it.flowerSpotId }[flowerSpot.id] ?: emptyList(),
bloomings = bloomingBySpotId[flowerSpot.id] ?: emptyList(),
images =
imageS3Caller.getImageUrl(
prefix = ImagePrefix.FLOWERSPOT.value,
prefixId = flowerSpot.id,
fileName = null,
listOfNotNull(
imageS3Caller.getPreviewImage(
prefix = ImagePrefix.FLOWERSPOT.value,
prefixId = flowerSpot.id,
),
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.pida.flowerspot

import com.fasterxml.jackson.core.type.TypeReference
import com.pida.support.cache.CacheAdvice
import com.pida.support.geo.GeoJson
import com.pida.support.geo.Region
import org.springframework.stereotype.Component

Expand All @@ -24,14 +25,7 @@ class FlowerSpotFinder(
flowerSpotRepository.findAll()
}

suspend fun readAllByRegion(region: Region): List<FlowerSpot> =
cacheAdvice.invoke(
ttl = 180L,
key = ALL_SPOT + ":${region.name}",
typeReference = object : TypeReference<List<FlowerSpot>>() {},
) {
flowerSpotRepository.findAllByRegion(region)
}
suspend fun readAllByRegion(region: Region): List<FlowerSpot> = readAll().filterBy(region = region)

suspend fun readBy(spotId: Long): FlowerSpot = flowerSpotRepository.findBy(spotId)

Expand All @@ -43,12 +37,12 @@ class FlowerSpotFinder(
is FindFlowerSpotPolicyCondition.ByRegionAndLocation -> readAllByLocationAndRegion(condition.region, condition.location)
}

suspend fun readAllByLocation(location: FlowerSpotLocation): List<FlowerSpot> = flowerSpotRepository.findAllByLocation(location)
suspend fun readAllByLocation(location: FlowerSpotLocation): List<FlowerSpot> = readAll().filterBy(location = location)

suspend fun readAllByLocationAndRegion(
region: Region,
location: FlowerSpotLocation,
): List<FlowerSpot> = flowerSpotRepository.findAllByLocationAndRegion(region, location)
): List<FlowerSpot> = readAll().filterBy(region = region, location = location)

suspend fun searchByStreetName(streetName: String): List<FlowerSpot> =
cacheAdvice.invoke(
Expand All @@ -58,4 +52,28 @@ class FlowerSpotFinder(
) {
flowerSpotRepository.findByStreetNameContaining(streetName)
}

private fun List<FlowerSpot>.filterBy(
region: Region? = null,
location: FlowerSpotLocation? = null,
): List<FlowerSpot> =
asSequence()
.filter { region == null || it.region == region }
.filter { location == null || it.isWithin(location) }
.toList()

private fun FlowerSpot.isWithin(location: FlowerSpotLocation): Boolean {
if (!location.hasBounds()) return true

val point = pinPoint as? GeoJson.Point ?: return false
if (point.coordinates.size < 2) return false

val longitude = point.coordinates[0]
val latitude = point.coordinates[1]

return longitude > location.swLng!! &&
longitude < location.neLng!! &&
latitude > location.swLat!! &&
latitude < location.neLat!!
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,14 @@ interface ImageS3Caller {
prefixId: Long,
fileName: String?,
): List<S3ImageInfo>

/**
* @param prefix [String] 도메인
* @param prefixId [Long] 도메인 ID
* @return 최신 미리보기 이미지 (없으면 null)
*/
suspend fun getPreviewImage(
prefix: String,
prefixId: Long,
): S3ImageInfo?
}
Loading
Loading