From 17be8cfb67fbcadb09bbff25f5d02510daaf948a Mon Sep 17 00:00:00 2001 From: char-yb Date: Mon, 23 Mar 2026 17:08:56 +0900 Subject: [PATCH 1/5] [Chore] enforce English commit messages in PIDA commit skill --- .codex/skills/pida-commit-push/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.codex/skills/pida-commit-push/SKILL.md b/.codex/skills/pida-commit-push/SKILL.md index e34a1a0..9f5ed3d 100644 --- a/.codex/skills/pida-commit-push/SKILL.md +++ b/.codex/skills/pida-commit-push/SKILL.md @@ -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. @@ -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. From 99eda74c78284276a728a47d29f4181795d4eb29 Mon Sep 17 00:00:00 2001 From: char-yb Date: Mon, 23 Mar 2026 17:09:37 +0900 Subject: [PATCH 2/5] [Refactor] optimize flower-spot list loading and cache filtering --- .../pida/client/aws/image/ImageS3Processor.kt | 56 ++++++--- .../com/pida/flowerspot/FlowerSpotFacade.kt | 14 ++- .../com/pida/flowerspot/FlowerSpotFinder.kt | 38 ++++-- .../com/pida/support/aws/ImageS3Caller.kt | 10 ++ .../pida/flowerspot/FlowerSpotFacadeTest.kt | 119 ++++++++++++++++++ .../pida/flowerspot/FlowerSpotFinderTest.kt | 77 ++++++++++++ 6 files changed, 284 insertions(+), 30 deletions(-) create mode 100644 pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFacadeTest.kt create mode 100644 pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFinderTest.kt diff --git a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt index 9610de0..30affa5 100644 --- a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt +++ b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt @@ -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 @@ -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, @@ -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, @@ -86,7 +102,7 @@ class ImageS3Processor( ) return S3ImageInfo( url = url, - uploadedAt = LocalDateTime.ofInstant(lastModified, ZoneId.of("Asia/Seoul")), + uploadedAt = LocalDateTime.ofInstant(lastModified, SEOUL_ZONE_ID), ) } @@ -94,6 +110,12 @@ class ImageS3Processor( filePath: String, ttl: Duration = Duration.ofSeconds(30), ): List = + listImageObjects(filePath) + .map { it.toImageInfo(filePath, ttl) } + .sortedByDescending { it.uploadedAt } + .toList() + + private fun listImageObjects(filePath: String): Sequence = awsS3Client .getBucketListObjects( bucketName = awsProperties.s3.bucket, @@ -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), + ) + } } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt index 3fc44ec..8d0b864 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt @@ -39,17 +39,21 @@ class FlowerSpotFacade( location: FlowerSpotLocation, ): List { 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, + ), ), ) } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFinder.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFinder.kt index dd57e68..8b4963e 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFinder.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFinder.kt @@ -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 @@ -24,14 +25,7 @@ class FlowerSpotFinder( flowerSpotRepository.findAll() } - suspend fun readAllByRegion(region: Region): List = - cacheAdvice.invoke( - ttl = 180L, - key = ALL_SPOT + ":${region.name}", - typeReference = object : TypeReference>() {}, - ) { - flowerSpotRepository.findAllByRegion(region) - } + suspend fun readAllByRegion(region: Region): List = readAll().filterBy(region = region) suspend fun readBy(spotId: Long): FlowerSpot = flowerSpotRepository.findBy(spotId) @@ -43,12 +37,12 @@ class FlowerSpotFinder( is FindFlowerSpotPolicyCondition.ByRegionAndLocation -> readAllByLocationAndRegion(condition.region, condition.location) } - suspend fun readAllByLocation(location: FlowerSpotLocation): List = flowerSpotRepository.findAllByLocation(location) + suspend fun readAllByLocation(location: FlowerSpotLocation): List = readAll().filterBy(location = location) suspend fun readAllByLocationAndRegion( region: Region, location: FlowerSpotLocation, - ): List = flowerSpotRepository.findAllByLocationAndRegion(region, location) + ): List = readAll().filterBy(region = region, location = location) suspend fun searchByStreetName(streetName: String): List = cacheAdvice.invoke( @@ -58,4 +52,28 @@ class FlowerSpotFinder( ) { flowerSpotRepository.findByStreetNameContaining(streetName) } + + private fun List.filterBy( + region: Region? = null, + location: FlowerSpotLocation? = null, + ): List = + 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!! + } } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt b/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt index d04f4f5..8a782b5 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt @@ -22,4 +22,14 @@ interface ImageS3Caller { prefixId: Long, fileName: String?, ): List + + /** + * @param prefix [String] 도메인 + * @param prefixId [Long] 도메인 ID + * @return 최신 미리보기 이미지 (없으면 null) + */ + suspend fun getPreviewImage( + prefix: String, + prefixId: Long, + ): S3ImageInfo? } diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFacadeTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFacadeTest.kt new file mode 100644 index 0000000..b0cab33 --- /dev/null +++ b/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFacadeTest.kt @@ -0,0 +1,119 @@ +package com.pida.flowerspot + +import com.pida.blooming.Blooming +import com.pida.blooming.BloomingService +import com.pida.blooming.BloomingStatus +import com.pida.support.aws.ImagePrefix +import com.pida.support.aws.ImageS3Caller +import com.pida.support.aws.S3ImageInfo +import com.pida.support.geo.GeoJson +import com.pida.support.geo.Region +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +class FlowerSpotFacadeTest { + @Test + fun `목록 조회는 preview 이미지와 미리 계산한 bloomings를 사용한다`(): Unit = + runBlocking { + val flowerSpotService = mockk() + val bloomingService = mockk() + val imageS3Caller = mockk() + val facade = FlowerSpotFacade(flowerSpotService, bloomingService, imageS3Caller) + val location = FlowerSpotLocation(swLat = null, swLng = null, neLat = null, neLng = null) + val firstSpot = flowerSpot(id = 10L, streetName = "첫 번째 거리") + val secondSpot = flowerSpot(id = 20L, streetName = "두 번째 거리") + + coEvery { flowerSpotService.readAllFlowerSpot(region = null, location = location) } returns listOf(firstSpot, secondSpot) + every { bloomingService.recentlyBloomingBySpotIds(listOf(10L, 20L)) } returns + listOf( + Blooming( + id = 1L, + userId = 1L, + flowerSpotId = 10L, + flowerEventId = null, + status = BloomingStatus.BLOOMED, + createdAt = LocalDateTime.of(2026, 3, 20, 10, 0), + ), + Blooming( + id = 2L, + userId = 2L, + flowerSpotId = 10L, + flowerEventId = null, + status = BloomingStatus.BLOOMED, + createdAt = LocalDateTime.of(2026, 3, 20, 11, 0), + ), + Blooming( + id = 3L, + userId = 3L, + flowerSpotId = 20L, + flowerEventId = null, + status = BloomingStatus.LITTLE, + createdAt = LocalDateTime.of(2026, 3, 20, 9, 0), + ), + ) + coEvery { imageS3Caller.getPreviewImage(ImagePrefix.FLOWERSPOT.value, 10L) } returns + S3ImageInfo( + url = "https://cdn.example.com/flower-spot-10-preview.jpg", + uploadedAt = LocalDateTime.of(2026, 3, 20, 12, 0), + ) + coEvery { imageS3Caller.getPreviewImage(ImagePrefix.FLOWERSPOT.value, 20L) } returns null + + val result = facade.findAllFlowerSpot(region = null, location = location) + + result.map { it.id } shouldContainExactly listOf(10L, 20L) + result.first().recentlyVisitedCount shouldBe 2L + result.first().bloomingStatus shouldBe BloomingStatus.BLOOMED + result.first().images.map { it.url } shouldContainExactly listOf("https://cdn.example.com/flower-spot-10-preview.jpg") + result.last().recentlyVisitedCount shouldBe 1L + result.last().bloomingStatus shouldBe BloomingStatus.LITTLE + result.last().images shouldBe emptyList() + + coVerify(exactly = 1) { imageS3Caller.getPreviewImage(ImagePrefix.FLOWERSPOT.value, 10L) } + coVerify(exactly = 1) { imageS3Caller.getPreviewImage(ImagePrefix.FLOWERSPOT.value, 20L) } + coVerify(exactly = 0) { imageS3Caller.getImageUrl(any(), any(), any()) } + } + + @Test + fun `목록 조회 결과가 비어 있으면 추가 조회를 생략한다`(): Unit = + runBlocking { + val flowerSpotService = mockk() + val bloomingService = mockk() + val imageS3Caller = mockk() + val facade = FlowerSpotFacade(flowerSpotService, bloomingService, imageS3Caller) + val location = FlowerSpotLocation(swLat = null, swLng = null, neLat = null, neLng = null) + + coEvery { flowerSpotService.readAllFlowerSpot(region = null, location = location) } returns emptyList() + + val result = facade.findAllFlowerSpot(region = null, location = location) + + result shouldBe emptyList() + verify(exactly = 0) { bloomingService.recentlyBloomingBySpotIds(any()) } + coVerify(exactly = 0) { imageS3Caller.getPreviewImage(any(), any()) } + coVerify(exactly = 0) { imageS3Caller.getImageUrl(any(), any(), any()) } + } + + private fun flowerSpot( + id: Long, + streetName: String, + ) = FlowerSpot( + id = id, + address = "서울특별시 강남구", + streetName = streetName, + district = "역삼동", + description = "벚꽃길", + geom = GeoJson.LineString(listOf(listOf(127.1, 37.5), listOf(127.2, 37.6))), + pinPoint = GeoJson.Point(listOf(127.15, 37.55)), + region = Region.SEOUL, + kind = FlowerKind.BLOSSOM, + type = FlowerSpotType.WALKING_TRAIL, + deletedAt = null, + ) +} diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFinderTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFinderTest.kt new file mode 100644 index 0000000..540bd90 --- /dev/null +++ b/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFinderTest.kt @@ -0,0 +1,77 @@ +package com.pida.flowerspot + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.pida.support.cache.CacheAdvice +import com.pida.support.cache.CacheRepository +import com.pida.support.geo.GeoJson +import com.pida.support.geo.Region +import io.kotest.matchers.collections.shouldContainExactly +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test + +class FlowerSpotFinderTest { + @Test + fun `위치와 지역 조회는 전체 캐시 데이터에서 필터링한다`(): Unit = + runBlocking { + val flowerSpotRepository = mockk() + val cacheAdvice = CacheAdvice(InMemoryCacheRepository(), jacksonObjectMapper().findAndRegisterModules()) + val finder = FlowerSpotFinder(flowerSpotRepository, cacheAdvice) + val location = FlowerSpotLocation(swLat = 37.4, swLng = 126.9, neLat = 37.6, neLng = 127.2) + val seoulInBounds = flowerSpot(id = 1L, region = Region.SEOUL, longitude = 127.0, latitude = 37.5) + val seoulOutOfBounds = flowerSpot(id = 2L, region = Region.SEOUL, longitude = 127.4, latitude = 37.7) + val busanInBounds = flowerSpot(id = 3L, region = Region.BUSAN, longitude = 127.1, latitude = 37.5) + + coEvery { flowerSpotRepository.findAll() } returns listOf(seoulInBounds, seoulOutOfBounds, busanInBounds) + + val byRegionAndLocation = finder.readAllByLocationAndRegion(Region.SEOUL, location) + val byLocation = finder.readAllByLocation(location) + + byRegionAndLocation shouldContainExactly listOf(seoulInBounds) + byLocation shouldContainExactly listOf(seoulInBounds, busanInBounds) + + coVerify(exactly = 1) { flowerSpotRepository.findAll() } + coVerify(exactly = 0) { flowerSpotRepository.findAllByRegion(any()) } + coVerify(exactly = 0) { flowerSpotRepository.findAllByLocation(any()) } + coVerify(exactly = 0) { flowerSpotRepository.findAllByLocationAndRegion(any(), any()) } + } + + private fun flowerSpot( + id: Long, + region: Region, + longitude: Double, + latitude: Double, + ) = FlowerSpot( + id = id, + address = "주소-$id", + streetName = "거리-$id", + district = "행정동-$id", + description = "설명-$id", + geom = GeoJson.LineString(listOf(listOf(longitude, latitude), listOf(longitude + 0.001, latitude + 0.001))), + pinPoint = GeoJson.Point(listOf(longitude, latitude)), + region = region, + kind = FlowerKind.BLOSSOM, + type = FlowerSpotType.WALKING_TRAIL, + deletedAt = null, + ) +} + +private class InMemoryCacheRepository : CacheRepository { + private val storage = mutableMapOf() + + override fun get(key: String): String? = storage[key] + + override fun put( + key: String, + value: String, + ttl: Long, + ) { + storage[key] = value + } + + override fun delete(key: String) { + storage.remove(key) + } +} From 82c6fb8be51ab3c88cc21d0f5401f87b62150440 Mon Sep 17 00:00:00 2001 From: char-yb Date: Mon, 23 Mar 2026 17:10:08 +0900 Subject: [PATCH 3/5] [Chore] add blooming flower-spot indexes and empty guard --- .../storage/db/core/blooming/BloomingCustomRepository.kt | 2 ++ .../com/pida/storage/db/core/blooming/BloomingEntity.kt | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCustomRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCustomRepository.kt index 79eb743..94c6d57 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCustomRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCustomRepository.kt @@ -56,6 +56,8 @@ class BloomingCustomRepository( } fun recentlyBySpotIds(spotIds: List): List { + if (spotIds.isEmpty()) return emptyList() + val threshold = LocalDateTime.now().minusDays(DATE_THRESHOLD) val query = diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingEntity.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingEntity.kt index 35ca63c..7ce9a40 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingEntity.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingEntity.kt @@ -7,10 +7,17 @@ import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated +import jakarta.persistence.Index import jakarta.persistence.Table @Entity -@Table(name = "t_blooming") +@Table( + name = "t_blooming", + indexes = [ + Index(name = "idx_blooming_flower_spot_id_created_at", columnList = "flower_spot_id,created_at"), + Index(name = "idx_blooming_flower_spot_id_status_created_at", columnList = "flower_spot_id,status,created_at"), + ], +) class BloomingEntity( val userId: Long, val flowerSpotId: Long?, From 41ccdda210dc0e8d464a4e5f150e61314b0d8b9f Mon Sep 17 00:00:00 2001 From: char-yb Date: Mon, 23 Mar 2026 17:10:44 +0900 Subject: [PATCH 4/5] [Test] add flower-spot synthetic latency benchmark --- pida-core/core-domain/build.gradle.kts | 13 + .../FlowerSpotPerformanceBenchmarkRunner.kt | 321 ++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/perf/FlowerSpotPerformanceBenchmarkRunner.kt diff --git a/pida-core/core-domain/build.gradle.kts b/pida-core/core-domain/build.gradle.kts index 0dc82c0..be234f2 100644 --- a/pida-core/core-domain/build.gradle.kts +++ b/pida-core/core-domain/build.gradle.kts @@ -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) @@ -21,3 +24,13 @@ dependencies { // Caffeine implementation(libs.caffeine) } + +val sourceSets = the() + +tasks.register("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") +} diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/perf/FlowerSpotPerformanceBenchmarkRunner.kt b/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/perf/FlowerSpotPerformanceBenchmarkRunner.kt new file mode 100644 index 0000000..fb1316d --- /dev/null +++ b/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/perf/FlowerSpotPerformanceBenchmarkRunner.kt @@ -0,0 +1,321 @@ +package com.pida.flowerspot.perf + +import com.pida.blooming.Blooming +import com.pida.blooming.BloomingStatus +import com.pida.flowerspot.FlowerKind +import com.pida.flowerspot.FlowerSpot +import com.pida.flowerspot.FlowerSpotDetails +import com.pida.flowerspot.FlowerSpotType +import com.pida.support.aws.S3ImageInfo +import com.pida.support.geo.GeoJson +import com.pida.support.geo.Region +import java.nio.file.Files +import java.nio.file.Path +import java.time.LocalDateTime +import kotlin.math.roundToLong +import kotlin.system.measureNanoTime + +object FlowerSpotPerformanceBenchmarkRunner { + @JvmStatic + fun main(args: Array) { + val fixture = BenchmarkFixture.create() + val results = + listOf( + benchmark( + scenario = "Blooming aggregation", + legacy = { legacyBloomingAggregation(fixture.spots, fixture.bloomings) }, + current = { currentBloomingAggregation(fixture.spots, fixture.bloomings) }, + ), + benchmark( + scenario = "Preview image selection", + legacy = { legacyPreviewSelection(fixture.spots, fixture.imageStore) }, + current = { currentPreviewSelection(fixture.spots, fixture.imageStore) }, + ), + benchmark( + scenario = "Flower spot list assembly", + legacy = { legacyListAssembly(fixture.spots, fixture.bloomings, fixture.imageStore) }, + current = { currentListAssembly(fixture.spots, fixture.bloomings, fixture.imageStore) }, + ), + ) + + val report = renderReport(fixture, results) + val reportPath = Path.of("build", "reports", "performance", "flower-spot-latency.md") + Files.createDirectories(reportPath.parent) + Files.writeString(reportPath, report) + + println(report) + println() + println("Report written to ${reportPath.toAbsolutePath()}") + } + + private fun benchmark( + scenario: String, + warmups: Int = 5, + iterations: Int = 12, + legacy: () -> Any, + current: () -> Any, + ): BenchmarkResult { + repeat(warmups) { + blackhole = legacy() + blackhole = current() + } + + val legacySamples = LongArray(iterations) { measureNanoTime { blackhole = legacy() } } + val currentSamples = LongArray(iterations) { measureNanoTime { blackhole = current() } } + + val legacyMedianMs = legacySamples.medianMillis() + val currentMedianMs = currentSamples.medianMillis() + val speedupPercent = ((legacyMedianMs - currentMedianMs) / legacyMedianMs) * 100.0 + + return BenchmarkResult( + scenario = scenario, + legacyMedianMs = legacyMedianMs, + currentMedianMs = currentMedianMs, + speedupPercent = speedupPercent, + ratio = legacyMedianMs / currentMedianMs, + ) + } + + private fun legacyBloomingAggregation( + spots: List, + bloomings: List, + ): List> = + spots.map { flowerSpot -> + val recentBloomings = bloomings.groupBy { it.flowerSpotId }[flowerSpot.id] ?: emptyList() + recentBloomings.size.toLong() to representativeStatus(recentBloomings) + } + + private fun currentBloomingAggregation( + spots: List, + bloomings: List, + ): List> { + val bloomingsBySpotId = bloomings.groupBy { it.flowerSpotId } + + return spots.map { flowerSpot -> + val recentBloomings = bloomingsBySpotId[flowerSpot.id] ?: emptyList() + recentBloomings.size.toLong() to representativeStatus(recentBloomings) + } + } + + private fun legacyPreviewSelection( + spots: List, + imageStore: SyntheticImageStore, + ): List = + spots.map { flowerSpot -> + imageStore + .listImages(flowerSpot.id) + .sortedByDescending { it.uploadedAt } + .firstOrNull() + ?.url + } + + private fun currentPreviewSelection( + spots: List, + imageStore: SyntheticImageStore, + ): List = + spots.map { flowerSpot -> + imageStore.previewImage(flowerSpot.id)?.url + } + + private fun legacyListAssembly( + spots: List, + bloomings: List, + imageStore: SyntheticImageStore, + ): List = + spots.map { flowerSpot -> + FlowerSpotDetails.of( + flowerSpot = flowerSpot, + bloomings = bloomings.groupBy { it.flowerSpotId }[flowerSpot.id] ?: emptyList(), + images = imageStore.listImages(flowerSpot.id), + ) + } + + private fun currentListAssembly( + spots: List, + bloomings: List, + imageStore: SyntheticImageStore, + ): List { + val bloomingsBySpotId = bloomings.groupBy { it.flowerSpotId } + + return spots.map { flowerSpot -> + FlowerSpotDetails.of( + flowerSpot = flowerSpot, + bloomings = bloomingsBySpotId[flowerSpot.id] ?: emptyList(), + images = listOfNotNull(imageStore.previewImage(flowerSpot.id)), + ) + } + } + + private fun representativeStatus(bloomings: List): BloomingStatus = + bloomings + .groupBy { it.status } + .maxByOrNull { it.value.size } + ?.key ?: BloomingStatus.NOT_BLOOMED + + private fun renderReport( + fixture: BenchmarkFixture, + results: List, + ): String { + val table = + results.joinToString(separator = "\n") { result -> + "| ${result.scenario} | ${result.legacyMedianMs.formatMillis()} ms | ${result.currentMedianMs.formatMillis()} ms | ${result.speedupPercent.formatPercent()} | ${result.ratio.formatRatio()}x |" + } + + return buildString { + appendLine("# Flower Spot Latency Comparison") + appendLine() + appendLine("Synthetic benchmark comparing the legacy application-layer flow and the current refactored flow.") + appendLine() + appendLine("## Dataset") + appendLine() + appendLine("- Flower spots: ${fixture.spots.size}") + appendLine("- Bloomings per spot: ${fixture.bloomingsPerSpot}") + appendLine("- Images per spot: ${fixture.imagesPerSpot}") + appendLine("- Warmups: 5") + appendLine("- Measured iterations: 12") + appendLine() + appendLine("## Results") + appendLine() + appendLine("| Scenario | Legacy median | Current median | Improvement | Speedup |") + appendLine("| --- | ---: | ---: | ---: | ---: |") + appendLine(table) + appendLine() + appendLine("## Notes") + appendLine() + appendLine("- `Blooming aggregation` isolates repeated `groupBy` removal.") + appendLine("- `Preview image selection` compares listing all image metadata against selecting one preview image.") + appendLine("- `Flower spot list assembly` combines both changes into the end-to-end list payload composition path.") + appendLine("- This benchmark is synthetic and does not replace PostGIS `EXPLAIN ANALYZE` validation for bbox queries.") + } + } + + private fun LongArray.medianMillis(): Double { + val sorted = sorted() + val middle = size / 2 + + return if (size % 2 == 0) { + ((sorted[middle - 1] + sorted[middle]) / 2.0) / 1_000_000.0 + } else { + sorted[middle] / 1_000_000.0 + } + } + + private fun Double.formatMillis(): String = ((this * 100.0).roundToLong() / 100.0).toString() + + private fun Double.formatPercent(): String = ((this * 100.0).roundToLong() / 100.0).toString() + "%" + + private fun Double.formatRatio(): String = ((this * 100.0).roundToLong() / 100.0).toString() + + private var blackhole: Any? = null +} + +private data class BenchmarkResult( + val scenario: String, + val legacyMedianMs: Double, + val currentMedianMs: Double, + val speedupPercent: Double, + val ratio: Double, +) + +private data class BenchmarkFixture( + val spots: List, + val bloomings: List, + val imageStore: SyntheticImageStore, + val bloomingsPerSpot: Int, + val imagesPerSpot: Int, +) { + companion object { + fun create( + spotCount: Int = 400, + bloomingsPerSpot: Int = 8, + imagesPerSpot: Int = 6, + ): BenchmarkFixture { + val spots = + (1..spotCount).map { id -> + FlowerSpot( + id = id.toLong(), + address = "서울시 강남구 ${id}번지", + streetName = "벚꽃길-$id", + district = "역삼동", + description = "테스트용 벚꽃길-$id", + geom = + GeoJson.LineString( + listOf( + listOf(127.0 + id * 0.0001, 37.5 + id * 0.0001), + listOf(127.0005 + id * 0.0001, 37.5005 + id * 0.0001), + ), + ), + pinPoint = GeoJson.Point(listOf(127.0 + id * 0.0001, 37.5 + id * 0.0001)), + region = if (id % 2 == 0) Region.SEOUL else Region.BUSAN, + kind = FlowerKind.BLOSSOM, + type = FlowerSpotType.WALKING_TRAIL, + deletedAt = null, + ) + } + + val bloomings = + spots.flatMap { spot -> + (0 until bloomingsPerSpot).map { offset -> + Blooming( + id = spot.id * 100 + offset, + userId = offset.toLong(), + flowerSpotId = spot.id, + flowerEventId = null, + status = + when (offset % 3) { + 0 -> BloomingStatus.BLOOMED + 1 -> BloomingStatus.LITTLE + else -> BloomingStatus.NOT_BLOOMED + }, + createdAt = LocalDateTime.of(2026, 3, 23, 12, 0).minusHours(offset.toLong()), + ) + } + } + + val imageSeeds = + spots.associate { spot -> + spot.id to + (0 until imagesPerSpot).map { offset -> + SyntheticImageSeed( + fileName = "spot-${spot.id}-$offset.jpeg", + uploadedAt = LocalDateTime.of(2026, 3, 23, 12, 0).minusMinutes(offset.toLong()), + ) + } + } + + return BenchmarkFixture( + spots = spots, + bloomings = bloomings, + imageStore = SyntheticImageStore(imageSeeds), + bloomingsPerSpot = bloomingsPerSpot, + imagesPerSpot = imagesPerSpot, + ) + } + } +} + +private data class SyntheticImageSeed( + val fileName: String, + val uploadedAt: LocalDateTime, +) + +private class SyntheticImageStore( + private val imageSeeds: Map>, +) { + fun listImages(spotId: Long): List = + imageSeeds[spotId] + .orEmpty() + .map { seed -> seed.toImageInfo(spotId) } + + fun previewImage(spotId: Long): S3ImageInfo? = + imageSeeds[spotId] + .orEmpty() + .maxByOrNull(SyntheticImageSeed::uploadedAt) + ?.toImageInfo(spotId) + + private fun SyntheticImageSeed.toImageInfo(spotId: Long): S3ImageInfo = + S3ImageInfo( + url = "https://cdn.example.com/flowerspot/$spotId/$fileName", + uploadedAt = uploadedAt, + ) +} From 08dba77f7dfc6802d9be8af03d5819c0b64323f1 Mon Sep 17 00:00:00 2001 From: char-yb Date: Mon, 23 Mar 2026 17:10:57 +0900 Subject: [PATCH 5/5] [Docs] document flower-spot performance refactor and index checklist --- docs/flower-spot-performance.md | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/flower-spot-performance.md diff --git a/docs/flower-spot-performance.md b/docs/flower-spot-performance.md new file mode 100644 index 0000000..1e5d9bd --- /dev/null +++ b/docs/flower-spot-performance.md @@ -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 비용이 커질 때