diff --git a/.env.example b/.env.example index 2708a73..62aa028 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,8 @@ DB_USERNAME=nook DB_PASSWORD=nook JVM_OPTS=-XX:MaxRAMPercentage=75.0 KAKAO_REST_API_KEY=replace-with-kakao-rest-api-key -NAVER_CLIENT_ID=replace-with-naver-cloud-map-client-id -NAVER_CLIENT_SECRET=replace-with-naver-cloud-map-client-secret +NAVER_API_HUB_CLIENT_ID= +NAVER_API_HUB_CLIENT_SECRET= GOOGLE_PLACE_PHOTO_ENABLED=false GOOGLE_MAPS_API_KEY=replace-with-google-maps-api-key GOOGLE_PLACE_PHOTO_MAX_WIDTH_PX=600 diff --git a/docs/tasks/NOOK-171/README.md b/docs/tasks/NOOK-171/README.md new file mode 100644 index 0000000..e5611cf --- /dev/null +++ b/docs/tasks/NOOK-171/README.md @@ -0,0 +1,29 @@ +# NOOK-171 장소 검색·선택 및 Google 보충정보 파이프라인 개편 + +## 목적 + +게시물에서 추출한 장소 단서를 카카오 우선으로 검색하고, 결과 신뢰도가 낮을 때만 네이버 지역검색으로 보강한다. 확정된 장소는 Google Places의 이름·주소·거리 복합 점수로 다시 식별해 Place ID, 사진, 영업시간을 저장한다. + +## 범위 + +- NAVER API Hub 지역검색 연동 +- 카카오 우선 후보 점수화와 조건부 네이버 폴백 +- Google Places 위치 편향 검색 및 이름·주소·거리 복합 매칭 +- Google Place ID 저장과 재조회 시 재사용 +- 임시 DEBUG 추적 로그 추가 + +## 제외 범위 + +- 기존 공개 API 계약 변경 +- 장소 병합 및 관리자 보정 기능 +- Prometheus 지표 변경 + +## 검증 + +- `./gradlew detekt` +- `./gradlew test` +- `./gradlew check` + +## 배포 전 작업 + +`ddl/up.sql`을 적용하고 애플리케이션을 배포한다. 롤백 시 애플리케이션을 이전 버전으로 되돌린 뒤 `ddl/rollback.sql`을 적용한다. diff --git a/docs/tasks/NOOK-171/ddl/rollback.sql b/docs/tasks/NOOK-171/ddl/rollback.sql new file mode 100644 index 0000000..e8cba1e --- /dev/null +++ b/docs/tasks/NOOK-171/ddl/rollback.sql @@ -0,0 +1,2 @@ +ALTER TABLE places + DROP COLUMN google_place_id; diff --git a/docs/tasks/NOOK-171/ddl/up.sql b/docs/tasks/NOOK-171/ddl/up.sql new file mode 100644 index 0000000..8f9da1a --- /dev/null +++ b/docs/tasks/NOOK-171/ddl/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE places + ADD COLUMN google_place_id VARCHAR(255) NULL COMMENT 'Google Places 장소 식별자' AFTER phone_number; diff --git a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceCandidate.kt b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceCandidate.kt index ead8525..38c5a1a 100644 --- a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceCandidate.kt +++ b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceCandidate.kt @@ -15,4 +15,5 @@ data class PlaceCandidate( val providerUrl: String?, val distanceMeters: Int? = null, val city: String? = KoreanCityNameExtractor.extract(address), + val googlePlaceId: String? = null, ) diff --git a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceSupplement.kt b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceSupplement.kt index 541ade3..c679a6a 100644 --- a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceSupplement.kt +++ b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceSupplement.kt @@ -3,7 +3,11 @@ package org.every.nook.api.application.place import java.time.Instant import java.time.ZoneId -data class PlaceSupplement(val openingHours: PlaceOpeningHours?, val photoUrls: List) { +data class PlaceSupplement( + val openingHours: PlaceOpeningHours?, + val photoUrls: List, + val googlePlaceId: String? = null, +) { init { require(photoUrls.size <= MAX_PHOTO_COUNT) { "Place photos must not exceed $MAX_PHOTO_COUNT" } } @@ -14,9 +18,9 @@ data class PlaceSupplement(val openingHours: PlaceOpeningHours?, val photoUrls: } data class PlaceOpeningHours( - val timeZone: String, - val periods: List, - val weekdayDescriptions: List, + val timeZone: String = "", + val periods: List = emptyList(), + val weekdayDescriptions: List = emptyList(), ) { fun isOpenAt(instant: Instant): Boolean { val local = instant.atZone(ZoneId.of(timeZone)) @@ -45,9 +49,9 @@ data class PlaceOpeningHours( } } -data class PlaceOpeningPeriod(val open: PlaceOpeningPoint, val close: PlaceOpeningPoint?) +data class PlaceOpeningPeriod(val open: PlaceOpeningPoint = PlaceOpeningPoint(), val close: PlaceOpeningPoint? = null) -data class PlaceOpeningPoint(val day: Int, val hour: Int, val minute: Int) { +data class PlaceOpeningPoint(val day: Int = 0, val hour: Int = 0, val minute: Int = 0) { init { require(day in MIN_DAY..MAX_DAY) { "Opening day must be between $MIN_DAY and $MAX_DAY" } require(hour in MIN_HOUR..MAX_HOUR) { "Opening hour must be between $MIN_HOUR and $MAX_HOUR" } diff --git a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/StorePlaceThumbnailUseCase.kt b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/StorePlaceThumbnailUseCase.kt index d369496..b02825d 100644 --- a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/StorePlaceThumbnailUseCase.kt +++ b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/StorePlaceThumbnailUseCase.kt @@ -13,18 +13,27 @@ class StorePlaceThumbnailUseCase( private val clock: Clock = Clock.systemUTC(), ) { operator fun invoke(postId: Long, place: PlaceCandidate) { - updatePort.update(place.provider, place.externalPlaceId, PlaceThumbnailParsingStatus.PROCESSING) - val supplement = runCatching { - metrics.measure(THUMBNAIL_FLOW, FETCH_STAGE, postId, null, clock) { + runCatching { + updatePort.update(place.provider, place.externalPlaceId, PlaceThumbnailParsingStatus.PROCESSING) + val supplement = metrics.measure(THUMBNAIL_FLOW, FETCH_STAGE, postId, null, clock) { thumbnailProvider.fetch(place) } + metrics.measure(THUMBNAIL_FLOW, COMPLETE_STAGE, postId, null, clock) { + updatePort.update( + place.provider, + place.externalPlaceId, + PlaceThumbnailParsingStatus.COMPLETED, + supplement, + ) + } }.getOrElse { exception -> - updatePort.update(place.provider, place.externalPlaceId, PlaceThumbnailParsingStatus.FAILED) + runCatching { + updatePort.update(place.provider, place.externalPlaceId, PlaceThumbnailParsingStatus.FAILED) + }.onFailure { statusException -> + exception.addSuppressed(statusException) + } throw exception } - metrics.measure(THUMBNAIL_FLOW, COMPLETE_STAGE, postId, null, clock) { - updatePort.update(place.provider, place.externalPlaceId, PlaceThumbnailParsingStatus.COMPLETED, supplement) - } } private companion object { diff --git a/nook-api-application/src/test/kotlin/org/every/nook/api/application/place/StorePlaceThumbnailUseCaseTest.kt b/nook-api-application/src/test/kotlin/org/every/nook/api/application/place/StorePlaceThumbnailUseCaseTest.kt index 3d5acbe..8b8b472 100644 --- a/nook-api-application/src/test/kotlin/org/every/nook/api/application/place/StorePlaceThumbnailUseCaseTest.kt +++ b/nook-api-application/src/test/kotlin/org/every/nook/api/application/place/StorePlaceThumbnailUseCaseTest.kt @@ -4,6 +4,7 @@ import org.every.nook.api.domain.place.PlaceThumbnailParsingStatus import java.math.BigDecimal import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class StorePlaceThumbnailUseCaseTest { @Test @@ -32,6 +33,49 @@ class StorePlaceThumbnailUseCaseTest { assertEquals(listOf("KAKAO:123:https://cdn.example.com/place.jpg"), updates) } + @Test + fun `marks thumbnail as failed when completion update fails`() { + val statuses = mutableListOf() + val place = place() + val useCase = StorePlaceThumbnailUseCase( + thumbnailProvider = PlaceThumbnailProvider { PlaceSupplement(null, emptyList()) }, + updatePort = object : PlaceThumbnailUpdatePort { + override fun update( + provider: String, + externalPlaceId: String, + status: PlaceThumbnailParsingStatus, + supplement: PlaceSupplement?, + ) { + statuses += status + if (status == PlaceThumbnailParsingStatus.COMPLETED) error("completion failed") + } + }, + ) + + assertFailsWith { useCase(11, place) } + + assertEquals( + listOf( + PlaceThumbnailParsingStatus.PROCESSING, + PlaceThumbnailParsingStatus.COMPLETED, + PlaceThumbnailParsingStatus.FAILED, + ), + statuses, + ) + } + + private fun place() = PlaceCandidate( + provider = "KAKAO", + externalPlaceId = "123", + name = "Nook Cafe", + address = "Seoul", + latitude = BigDecimal("37.1"), + longitude = BigDecimal("127.1"), + category = null, + phoneNumber = null, + providerUrl = null, + ) + private class FakeThumbnailUpdatePort(private val updates: MutableList) : PlaceThumbnailUpdatePort { var status: PlaceThumbnailParsingStatus = PlaceThumbnailParsingStatus.PENDING diff --git a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/config/PlaceSearchConfig.kt b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/config/PlaceSearchConfig.kt index 025aa6e..d7e11f4 100644 --- a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/config/PlaceSearchConfig.kt +++ b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/config/PlaceSearchConfig.kt @@ -6,13 +6,13 @@ import org.every.nook.api.application.place.SearchPlaceCandidatesUseCase import org.every.nook.api.application.place.UpdatePlaceBookmarkUseCase import org.every.nook.api.application.place.port.PlaceDetailQueryPort import org.every.nook.api.application.place.port.UpdatePlaceBookmarkPort -import org.every.nook.api.infrastructure.place.CompositePlaceSearchProvider import org.every.nook.api.infrastructure.place.KakaoPlaceMapper import org.every.nook.api.infrastructure.place.KakaoPlaceProperties import org.every.nook.api.infrastructure.place.KakaoPlaceSearchProvider import org.every.nook.api.infrastructure.place.NaverPlaceMapper import org.every.nook.api.infrastructure.place.NaverPlaceProperties import org.every.nook.api.infrastructure.place.NaverPlaceSearchProvider +import org.every.nook.api.infrastructure.place.PrioritizedPlaceSearchProvider import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean @@ -21,8 +21,6 @@ import org.springframework.context.annotation.Primary import org.springframework.http.client.SimpleClientHttpRequestFactory import org.springframework.web.client.RestClient import tools.jackson.module.kotlin.jacksonObjectMapper -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors @Configuration @EnableConfigurationProperties(KakaoPlaceProperties::class, NaverPlaceProperties::class) @@ -81,23 +79,12 @@ class PlaceSearchConfig { mapper = mapper, ) - @Bean(destroyMethod = "shutdown") - fun placeSearchExecutor(): java.util.concurrent.ExecutorService = - Executors.newFixedThreadPool(PLACE_SEARCH_THREAD_COUNT) - @Bean @Primary fun placeSearchProvider( @Qualifier("kakaoPlaceSearchProvider") kakaoProvider: PlaceSearchProvider, @Qualifier("naverPlaceSearchProvider") naverProvider: PlaceSearchProvider, - @Qualifier("placeSearchExecutor") placeSearchExecutor: ExecutorService, - ): PlaceSearchProvider = CompositePlaceSearchProvider( - providers = listOf( - CompositePlaceSearchProvider.NamedPlaceSearchProvider("KAKAO", kakaoProvider), - CompositePlaceSearchProvider.NamedPlaceSearchProvider("NAVER", naverProvider), - ), - executor = placeSearchExecutor, - ) + ): PlaceSearchProvider = PrioritizedPlaceSearchProvider(kakaoProvider, naverProvider) @Bean fun searchPlaceCandidatesUseCase( @@ -111,8 +98,4 @@ class PlaceSearchConfig { @Bean fun getPlaceDetailUseCase(placeDetailQueryPort: PlaceDetailQueryPort): GetPlaceDetailUseCase = GetPlaceDetailUseCase(placeDetailQueryPort) - - private companion object { - const val PLACE_SEARCH_THREAD_COUNT = 2 - } } diff --git a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceEntity.kt b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceEntity.kt index 119d25d..d072295 100644 --- a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceEntity.kt +++ b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceEntity.kt @@ -60,6 +60,8 @@ class PlaceEntity( val category: String? = null, @Column(name = "phone_number", nullable = true, length = Place.MAX_PHONE_NUMBER_LENGTH) val phoneNumber: String? = null, + @Column(name = "google_place_id", nullable = true, length = GOOGLE_PLACE_ID_MAX_LENGTH) + var googlePlaceId: String? = null, @Column(name = "thumbnail_url", nullable = true, length = THUMBNAIL_URL_MAX_LENGTH) var thumbnailUrl: String? = null, @Enumerated(EnumType.STRING) @@ -91,6 +93,7 @@ class PlaceEntity( const val COORDINATE_SCALE = 7 const val THUMBNAIL_URL_MAX_LENGTH = 2048 const val THUMBNAIL_PARSING_STATUS_LENGTH = 20 + const val GOOGLE_PLACE_ID_MAX_LENGTH = 255 private const val MAX_REPRESENTATIVE_TAG_COUNT = 4 private val logger = KotlinLogging.logger {} } @@ -118,6 +121,7 @@ class PlaceEntity( } fun updateSupplement(supplement: PlaceSupplement) { + supplement.googlePlaceId?.let { googlePlaceId = it } supplement.openingHours?.let { openingHours = it } if (supplement.photoUrls.isNotEmpty()) { photoUrls = supplement.photoUrls diff --git a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceParsingPersistenceAdapter.kt b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceParsingPersistenceAdapter.kt index e4eeb2a..d6a1d1d 100644 --- a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceParsingPersistenceAdapter.kt +++ b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceParsingPersistenceAdapter.kt @@ -102,11 +102,14 @@ class PlaceParsingPersistenceAdapter( val job = requireNotNull(jobRepository.findByPostId(postId)) check(job.status == PlaceParsingStatus.PROCESSING) val distinctPlaces = places.distinctBy { it.provider to it.externalPlaceId } - val postPlaces = distinctPlaces.mapIndexed { sequence, candidate -> + val resolvedPlaces = distinctPlaces.map { candidate -> val place = placeRepository.findByProviderAndExternalPlaceId( candidate.provider, candidate.externalPlaceId, ) ?: placeRepository.save(candidate.toEntity()) + candidate.copy(googlePlaceId = place.googlePlaceId) to place + } + val postPlaces = resolvedPlaces.mapIndexed { sequence, (_, place) -> PostPlaceEntity( postId = postId, placeId = requireNotNull(place.id), @@ -121,7 +124,7 @@ class PlaceParsingPersistenceAdapter( } job.status = PlaceParsingStatus.COMPLETED job.failureReason = null - distinctPlaces.zip(postPlaces).forEach { (place, postPlace) -> + resolvedPlaces.map { it.first }.zip(postPlaces).forEach { (place, postPlace) -> eventPublisher.publishEvent(PlaceThumbnailRequestedEvent(postId, place, clock.instant())) eventPublisher.publishEvent(PlaceTagsRequestedEvent(postId, postPlace.placeId, place)) } @@ -199,6 +202,7 @@ class PlaceParsingPersistenceAdapter( longitude = longitude, category = category, phoneNumber = phoneNumber, + googlePlaceId = googlePlaceId, ) private companion object { diff --git a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/GooglePlacePhotoProvider.kt b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/GooglePlacePhotoProvider.kt index 6b3ebfa..c5ccf06 100644 --- a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/GooglePlacePhotoProvider.kt +++ b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/GooglePlacePhotoProvider.kt @@ -54,6 +54,7 @@ class GooglePlacePhotoProvider( PlaceSupplement( openingHours = googlePlace.toOpeningHours(), photoUrls = photoUrls, + googlePlaceId = googlePlace.placeId(), ) } }.onFailure { exception -> @@ -66,15 +67,38 @@ class GooglePlacePhotoProvider( } private fun searchPlace(place: PlaceCandidate): GooglePlace? { + place.googlePlaceId?.let { return getPlace(it) } val response = restClient.post() .uri("/v1/places:searchText") .contentType(MediaType.APPLICATION_JSON) .header(API_KEY_HEADER, properties.apiKey) .header(FIELD_MASK_HEADER, SEARCH_FIELD_MASK) - .body(TextSearchRequest(textQuery = "${place.name} ${place.address}".trim(), languageCode = "ko")) + .body( + TextSearchRequest( + textQuery = "${place.name} ${place.address}".trim(), + languageCode = "ko", + regionCode = "KR", + pageSize = SEARCH_PAGE_SIZE, + locationBias = LocationBias( + Circle( + center = GoogleLocation(place.latitude.toDouble(), place.longitude.toDouble()), + radius = LOCATION_BIAS_RADIUS_METERS, + ), + ), + ), + ) .retrieve() .body(TextSearchResponse::class.java) - val matched = response?.places.orEmpty().firstOrNull { it.matches(place) } + val scored = response?.places.orEmpty().map { it to it.matchScore(place) } + val matched = scored.maxByOrNull { it.second } + ?.takeIf { it.second >= MIN_MATCH_SCORE } + ?.first + logger.debug { + "[PostParcingTracker] stage=GOOGLE_PLACE_MATCH status=COMPLETED " + + "provider=${place.provider} externalPlaceId=${place.externalPlaceId} " + + "candidateScores=${scored.map { "${it.first.placeId()}:${it.second}" }} " + + "selectedId=${matched?.placeId()}" + } logger.info { "Google place photo search completed: provider=${place.provider}, " + "externalPlaceId=${place.externalPlaceId}, googlePlaceCount=${response?.places.orEmpty().size}, " + @@ -83,24 +107,51 @@ class GooglePlacePhotoProvider( return matched } - private fun GooglePlace.matches(candidate: PlaceCandidate): Boolean { - val googleName = displayName?.text?.normalize() ?: return false + private fun getPlace(googlePlaceId: String): GooglePlace? = restClient.get() + .uri("/v1/places/{placeId}", googlePlaceId) + .header(API_KEY_HEADER, properties.apiKey) + .header(FIELD_MASK_HEADER, DETAIL_FIELD_MASK) + .retrieve() + .body(GooglePlace::class.java) + + private fun GooglePlace.matchScore(candidate: PlaceCandidate): Int { + val googleName = displayName?.text?.normalize() ?: return 0 val candidateName = candidate.name.normalize() - val nameMatches = googleName.contains(candidateName) || candidateName.contains(googleName) + val nameScore = nameScore(googleName, candidateName, displayName.text.orEmpty(), candidate.name) val addressMatches = formattedAddress?.normalize()?.let { googleAddress -> val candidateAddress = candidate.address.normalize() googleAddress.contains(candidateAddress) || candidateAddress.contains(googleAddress) } ?: false - val googleLocation = location ?: return false - val distance = distanceMeters( - candidate.latitude, - candidate.longitude, - BigDecimal.valueOf(googleLocation.latitude), - BigDecimal.valueOf(googleLocation.longitude), - ) - val locationMatches = distance <= CLOSE_MATCH_DISTANCE_METERS || - (addressMatches && distance <= MAX_MATCH_DISTANCE_METERS) - return nameMatches && locationMatches + val distance = location?.let { googleLocation -> + distanceMeters( + candidate.latitude, + candidate.longitude, + BigDecimal.valueOf(googleLocation.latitude), + BigDecimal.valueOf(googleLocation.longitude), + ) + } + val addressScore = if (addressMatches) 30 else 0 + val distanceScore = when { + distance == null -> 0 + distance <= CLOSE_MATCH_DISTANCE_METERS -> 25 + distance <= MAX_MATCH_DISTANCE_METERS -> 15 + distance <= FAR_MATCH_DISTANCE_METERS -> 5 + else -> 0 + } + return nameScore + addressScore + distanceScore + } + + private fun nameScore(googleName: String, candidateName: String, rawGoogleName: String, rawCandidateName: String) = + when { + googleName == candidateName -> EXACT_NAME_SCORE + googleName.contains(candidateName) || candidateName.contains(googleName) -> CONTAINS_NAME_SCORE + else -> tokenOverlap(rawGoogleName, rawCandidateName) * TOKEN_NAME_SCORE + } + + private fun tokenOverlap(left: String, right: String): Int { + val leftTokens = left.lowercase().split(Regex("[^가-힣a-z0-9]+")).filter { it.length >= 2 }.toSet() + val rightTokens = right.lowercase().split(Regex("[^가-힣a-z0-9]+")).filter { it.length >= 2 }.toSet() + return if (leftTokens.intersect(rightTokens).isEmpty()) 0 else 1 } private fun String.normalize(): String = lowercase().filter(Char::isLetterOrDigit) @@ -156,13 +207,25 @@ class GooglePlacePhotoProvider( @JsonIgnoreProperties(ignoreUnknown = true) private data class GooglePlace( + val id: String? = null, + val name: String? = null, val displayName: LocalizedText? = null, val formattedAddress: String? = null, val location: GoogleLocation? = null, val timeZone: GoogleTimeZone? = null, val regularOpeningHours: GoogleOpeningHours? = null, val photos: List? = null, - ) + ) { + fun placeId(): String? = id + ?: name?.removePrefix(PLACE_RESOURCE_PREFIX)?.takeIf(String::isNotBlank) + ?: photos.orEmpty().asSequence() + .mapNotNull(GooglePhoto::name) + .mapNotNull { photoName -> + photoName.removePrefix(PLACE_RESOURCE_PREFIX).substringBefore(PHOTO_RESOURCE_SEPARATOR) + .takeIf(String::isNotBlank) + } + .firstOrNull() + } @JsonIgnoreProperties(ignoreUnknown = true) private data class LocalizedText(val text: String? = null) @@ -188,7 +251,15 @@ class GooglePlacePhotoProvider( @JsonIgnoreProperties(ignoreUnknown = true) private data class GooglePhoto(val name: String? = null) - private data class TextSearchRequest(val textQuery: String, val languageCode: String) + private data class TextSearchRequest( + val textQuery: String, + val languageCode: String, + val regionCode: String, + val pageSize: Int, + val locationBias: LocationBias, + ) + private data class LocationBias(val circle: Circle) + private data class Circle(val center: GoogleLocation, val radius: Double) @JsonIgnoreProperties(ignoreUnknown = true) private data class PhotoMediaResponse(val photoUri: String? = null) @@ -197,10 +268,21 @@ class GooglePlacePhotoProvider( val logger = KotlinLogging.logger {} const val API_KEY_HEADER = "X-Goog-Api-Key" const val FIELD_MASK_HEADER = "X-Goog-FieldMask" + const val DETAIL_FIELD_MASK = + "id,name,displayName,formattedAddress,location,timeZone,regularOpeningHours,photos.name" const val SEARCH_FIELD_MASK = - "places.displayName,places.formattedAddress,places.location,places.timeZone," + + "places.id,places.name,places.displayName,places.formattedAddress,places.location,places.timeZone," + "places.regularOpeningHours,places.photos.name" + const val PLACE_RESOURCE_PREFIX = "places/" + const val PHOTO_RESOURCE_SEPARATOR = "/photos/" + const val SEARCH_PAGE_SIZE = 5 + const val LOCATION_BIAS_RADIUS_METERS = 1_000.0 + const val MIN_MATCH_SCORE = 45 + const val EXACT_NAME_SCORE = 60 + const val CONTAINS_NAME_SCORE = 45 + const val TOKEN_NAME_SCORE = 30 const val MAX_MATCH_DISTANCE_METERS = 500.0 + const val FAR_MATCH_DISTANCE_METERS = 2_000.0 const val CLOSE_MATCH_DISTANCE_METERS = 100.0 const val EARTH_RADIUS_METERS = 6_371_000.0 diff --git a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapper.kt b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapper.kt index e982f7f..0d43c6d 100644 --- a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapper.kt +++ b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapper.kt @@ -1,35 +1,33 @@ package org.every.nook.api.infrastructure.place import org.every.nook.api.application.place.PlaceCandidate +import org.springframework.web.util.HtmlUtils import java.math.BigDecimal +import java.security.MessageDigest class NaverPlaceMapper { fun map(query: String, response: NaverPlaceResponse): List { - return response.addresses.mapNotNull { address -> - val longitude = address.x.toCoordinateOrNull(MIN_LONGITUDE, MAX_LONGITUDE) + return response.items.mapNotNull { item -> + val longitude = item.mapx.toCoordinateOrNull(MIN_LONGITUDE, MAX_LONGITUDE) ?: return@mapNotNull null - val latitude = address.y.toCoordinateOrNull(MIN_LATITUDE, MAX_LATITUDE) + val latitude = item.mapy.toCoordinateOrNull(MIN_LATITUDE, MAX_LATITUDE) ?: return@mapNotNull null - val displayAddress = address.roadAddress - .orFallback(address.jibunAddress) - .orFallback(address.address) + val displayAddress = item.roadAddress + .orFallback(item.address) .toNullableValue() ?: return@mapNotNull null + val name = item.title.toPlainText() ?: query PlaceCandidate( provider = PROVIDER, - externalPlaceId = listOf( - displayAddress, - longitude.toPlainString(), - latitude.toPlainString(), - ).joinToString("|"), - name = query, + externalPlaceId = stableId(name, displayAddress, longitude, latitude), + name = name, address = displayAddress, latitude = latitude, longitude = longitude, - category = null, - phoneNumber = null, - providerUrl = null, + category = item.category.toPlainText(), + phoneNumber = item.telephone.toNullableValue(), + providerUrl = item.link.toNullableValue(), ) } } @@ -48,9 +46,20 @@ class NaverPlaceMapper { private fun String?.toNullableValue(): String? = this?.trim()?.takeIf(String::isNotEmpty) + private fun String?.toPlainText(): String? = toNullableValue()?.let(HtmlUtils::htmlUnescape) + ?.replace(HTML_TAG, "") + ?.trim() + ?.takeIf(String::isNotEmpty) + + private fun stableId(name: String, address: String, longitude: BigDecimal, latitude: BigDecimal): String = + MessageDigest.getInstance("SHA-256") + .digest("$name|$address|${longitude.toPlainString()}|${latitude.toPlainString()}".toByteArray()) + .joinToString("") { "%02x".format(it) } + private companion object { const val PROVIDER = "NAVER" const val WGS84_SCALE = 7 + val HTML_TAG = Regex("<[^>]+>") val MIN_LONGITUDE = BigDecimal("-180") val MAX_LONGITUDE = BigDecimal("180") val MIN_LATITUDE = BigDecimal("-90") diff --git a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceProperties.kt b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceProperties.kt index 4cab4eb..5a35450 100644 --- a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceProperties.kt +++ b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceProperties.kt @@ -3,9 +3,9 @@ package org.every.nook.api.infrastructure.place import org.springframework.boot.context.properties.ConfigurationProperties import java.time.Duration -@ConfigurationProperties("external.naver-map") +@ConfigurationProperties("external.naver-api-hub") data class NaverPlaceProperties( - val baseUrl: String = "https://maps.apigw.ntruss.com", + val baseUrl: String = "https://naverapihub.apigw.ntruss.com", val clientId: String = "", val clientSecret: String = "", val connectTimeout: Duration = Duration.ofSeconds(DEFAULT_CONNECT_TIMEOUT_SECONDS), diff --git a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceResponse.kt b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceResponse.kt index 4a418b2..483dac0 100644 --- a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceResponse.kt +++ b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceResponse.kt @@ -3,14 +3,17 @@ package org.every.nook.api.infrastructure.place import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) -data class NaverPlaceResponse(val addresses: List
= emptyList()) { +data class NaverPlaceResponse(val items: List = emptyList()) { @JsonIgnoreProperties(ignoreUnknown = true) - data class Address( - val roadAddress: String?, - val jibunAddress: String?, - val englishAddress: String?, + data class Item( + val title: String?, + val link: String?, + val category: String?, + val description: String?, + val telephone: String?, val address: String?, - val x: String?, - val y: String?, + val roadAddress: String?, + val mapx: String?, + val mapy: String?, ) } diff --git a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceSearchProvider.kt b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceSearchProvider.kt index 48ca0cb..a1b7e6f 100644 --- a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceSearchProvider.kt +++ b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceSearchProvider.kt @@ -25,6 +25,8 @@ class NaverPlaceSearchProvider( .uri { builder -> builder.path(SEARCH_PATH) .queryParam(QUERY, request.query) + .queryParam(DISPLAY, request.size.coerceIn(1, MAX_DISPLAY)) + .queryParam(START, 1) .build() } .header(CLIENT_ID, properties.clientId) @@ -45,11 +47,11 @@ class NaverPlaceSearchProvider( mapper.map(request.query, objectMapper.readValue(responseBody, NaverPlaceResponse::class.java)) .also { candidates -> logger.info { - "Naver map geocoding completed: query=${request.query}, candidateCount=${candidates.size}" + "Naver local search completed: query=${request.query}, candidateCount=${candidates.size}" } } }.getOrElse { exception -> - logger.warn(exception) { "Failed to map Naver Map geocoding response" } + logger.warn(exception) { "Failed to map Naver local search response" } providerFailure(exception) } } @@ -70,8 +72,11 @@ class NaverPlaceSearchProvider( private companion object { val logger = KotlinLogging.logger {} - const val SEARCH_PATH = "/map-geocode/v2/geocode" + const val SEARCH_PATH = "/search/v1/local" const val QUERY = "query" + const val DISPLAY = "display" + const val START = "start" + const val MAX_DISPLAY = 5 const val CLIENT_ID = "X-NCP-APIGW-API-KEY-ID" const val CLIENT_SECRET = "X-NCP-APIGW-API-KEY" } diff --git a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/PrioritizedPlaceSearchProvider.kt b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/PrioritizedPlaceSearchProvider.kt new file mode 100644 index 0000000..1c316c7 --- /dev/null +++ b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/PrioritizedPlaceSearchProvider.kt @@ -0,0 +1,83 @@ +package org.every.nook.api.infrastructure.place + +import mu.KotlinLogging +import org.every.nook.api.application.place.PlaceCandidate +import org.every.nook.api.application.place.PlaceSearchProvider + +class PrioritizedPlaceSearchProvider(private val kakao: PlaceSearchProvider, private val naver: PlaceSearchProvider) : + PlaceSearchProvider { + override fun search(request: PlaceSearchProvider.Request): List { + val kakaoCandidates = runCatching { kakao.search(request) }.getOrElse { exception -> + logger.debug(exception) { "[PostParcingTracker] stage=KAKAO_SEARCH status=FAILED query=${request.query}" } + emptyList() + }.sortedByDescending { score(request.query, it) } + val bestKakaoScore = kakaoCandidates.firstOrNull()?.let { score(request.query, it) } ?: 0 + logger.debug { + "[PostParcingTracker] stage=KAKAO_SEARCH status=COMPLETED query=${request.query} " + + "candidateCount=${kakaoCandidates.size} bestScore=$bestKakaoScore" + } + if (bestKakaoScore >= KAKAO_CONFIDENCE_SCORE) { + logger.debug { "[PostParcingTracker] stage=NAVER_SEARCH status=SKIPPED reason=kakao_confident" } + return kakaoCandidates + } + + val naverCandidates = runCatching { naver.search(request) }.getOrElse { exception -> + logger.debug(exception) { "[PostParcingTracker] stage=NAVER_SEARCH status=FAILED query=${request.query}" } + emptyList() + } + logger.debug { + "[PostParcingTracker] stage=NAVER_SEARCH status=COMPLETED query=${request.query} " + + "candidateCount=${naverCandidates.size}" + } + if (kakaoCandidates.isEmpty()) { + return naverCandidates.sortedByDescending { score(request.query, it) } + } + return kakaoCandidates.sortedByDescending { candidate -> + score(request.query, candidate) + naverValidationScore(candidate, naverCandidates) + } + } + + private fun naverValidationScore(candidate: PlaceCandidate, naverCandidates: List): Int = + if (naverCandidates.any { naver -> + val namesMatch = candidate.name.normalize().let { kakaoName -> + val naverName = naver.name.normalize() + kakaoName.contains(naverName) || naverName.contains(kakaoName) + } + val addressesMatch = candidate.address.tokens().intersect(naver.address.tokens().toSet()).size >= + MIN_SHARED_ADDRESS_TOKEN_COUNT + namesMatch && addressesMatch + } + ) { + NAVER_VALIDATION_SCORE + } else { + 0 + } + + private fun score(query: String, candidate: PlaceCandidate): Int { + val normalizedQuery = query.normalize() + val normalizedName = candidate.name.normalize() + val normalizedAddress = candidate.address.normalize() + return when { + normalizedName == normalizedQuery -> EXACT_NAME_SCORE + normalizedName.contains(normalizedQuery) || normalizedQuery.contains(normalizedName) -> CONTAINS_NAME_SCORE + query.tokens().any { it.length >= MIN_TOKEN_LENGTH && normalizedName.contains(it) } -> TOKEN_NAME_SCORE + query.tokens().any { it.length >= MIN_TOKEN_LENGTH && normalizedAddress.contains(it) } -> ADDRESS_SCORE + else -> 0 + } + } + + private fun String.normalize(): String = lowercase().filter(Char::isLetterOrDigit) + private fun String.tokens(): List = lowercase().split(Regex("[^가-힣a-z0-9]+")) + + private companion object { + val logger = KotlinLogging.logger {} + const val KAKAO_CONFIDENCE_SCORE = 80 + const val EXACT_NAME_SCORE = 100 + const val CONTAINS_NAME_SCORE = 80 + const val TOKEN_NAME_SCORE = 55 + const val ADDRESS_SCORE = 30 + const val MIN_TOKEN_LENGTH = 2 + const val MIN_SHARED_ADDRESS_TOKEN_COUNT = 2 + const val NAVER_VALIDATION_SCORE = 30 + } +} diff --git a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/GooglePlacePhotoProviderTest.kt b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/GooglePlacePhotoProviderTest.kt index b3bafe9..930652b 100644 --- a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/GooglePlacePhotoProviderTest.kt +++ b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/GooglePlacePhotoProviderTest.kt @@ -31,6 +31,7 @@ class GooglePlacePhotoProviderTest { """ { "places": [{ + "id": "google-place-id", "displayName": {"text": "원동미나리삼겹살"}, "formattedAddress": "서울 용산구 한강대로77길 4-1", "location": {"latitude": 37.1, "longitude": 127.1}, @@ -74,6 +75,7 @@ class GooglePlacePhotoProviderTest { val result = fixture.provider.fetch(candidate()) assertEquals(6, result?.photoUrls?.size) + assertEquals("google-place-id", result?.googlePlaceId) assertEquals("Asia/Seoul", result?.openingHours?.timeZone) assertEquals(1, result?.openingHours?.periods?.single()?.open?.day) assertEquals((0..5).toList(), fixture.storage.captured.map(PostMedia::sequence)) @@ -89,6 +91,31 @@ class GooglePlacePhotoProviderTest { assertNull(fixture.provider.fetch(candidate())) } + @Test + fun `recovers Google place id from resource name when id field is absent`() { + val fixture = providerFixture() + fixture.server.expect(requestTo(containsString("/v1/places:searchText"))) + .andRespond( + withSuccess( + """ + {"places":[{ + "name":"places/fallback-place-id", + "displayName":{"text":"원동미나리삼겹살"}, + "formattedAddress":"서울 용산구 한강대로77길 4-1", + "location":{"latitude":37.1,"longitude":127.1}, + "photos":[] + }]} + """.trimIndent(), + MediaType.APPLICATION_JSON, + ), + ) + + val result = fixture.provider.fetch(candidate()) + + assertEquals("fallback-place-id", result?.googlePlaceId) + fixture.server.verify() + } + @Test fun `disabled provider does not call Google`() { val fixture = providerFixture(properties = GooglePlacePhotoProperties(enabled = false, apiKey = "google-key")) diff --git a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapperTest.kt b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapperTest.kt index 3812eb9..0c44acf 100644 --- a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapperTest.kt +++ b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapperTest.kt @@ -9,7 +9,7 @@ class NaverPlaceMapperTest { val result = NaverPlaceMapper().map( query = "원동미나리삼겹살", response = NaverPlaceResponse( - addresses = listOf( + items = listOf( address( roadAddress = "서울특별시 용산구 한강대로77길 4-1", x = "126.972332", @@ -32,7 +32,7 @@ class NaverPlaceMapperTest { val result = NaverPlaceMapper().map( query = "Nook Cafe", response = NaverPlaceResponse( - addresses = listOf(address(x = "1269723320", y = "375431230")), + items = listOf(address(x = "1269723320", y = "375431230")), ), ) @@ -46,7 +46,7 @@ class NaverPlaceMapperTest { val result = NaverPlaceMapper().map( query = "Nook Cafe", response = NaverPlaceResponse( - addresses = listOf(address(x = "9999999999", y = "9999999999")), + items = listOf(address(x = "9999999999", y = "9999999999")), ), ) @@ -55,16 +55,18 @@ class NaverPlaceMapperTest { private fun address( roadAddress: String? = "서울특별시 용산구 한강대로77길 4-1", - jibunAddress: String? = null, address: String? = null, x: String? = "126.972332", y: String? = "37.543123", - ): NaverPlaceResponse.Address = NaverPlaceResponse.Address( - roadAddress = roadAddress, - jibunAddress = jibunAddress, - englishAddress = null, + ): NaverPlaceResponse.Item = NaverPlaceResponse.Item( + title = "원동미나리삼겹살", + link = "https://map.naver.com/place/1", + category = "음식점>한식", + description = null, + telephone = null, address = address, - x = x, - y = y, + roadAddress = roadAddress, + mapx = x, + mapy = y, ) } diff --git a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceSearchProviderTest.kt b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceSearchProviderTest.kt index eb6a9ae..d93ec78 100644 --- a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceSearchProviderTest.kt +++ b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceSearchProviderTest.kt @@ -19,10 +19,11 @@ import kotlin.test.assertFailsWith class NaverPlaceSearchProviderTest { @Test - fun `uses Naver Cloud map credentials`() { + fun `uses Naver API Hub local search credentials`() { val fixture = providerFixture() - fixture.server.expect(requestTo(containsString("/map-geocode/v2/geocode"))) + fixture.server.expect(requestTo(containsString("/search/v1/local"))) .andExpect(requestTo(containsString("query=Nook%20Cafe"))) + .andExpect(requestTo(containsString("display=5"))) .andExpect(method(HttpMethod.GET)) .andExpect(header("X-NCP-APIGW-API-KEY-ID", "test-client-id")) .andExpect(header("X-NCP-APIGW-API-KEY", "test-client-secret")) @@ -38,7 +39,7 @@ class NaverPlaceSearchProviderTest { @Test fun `provider error is converted to application exception`() { val fixture = providerFixture() - fixture.server.expect(requestTo(containsString("/map-geocode/v2/geocode"))) + fixture.server.expect(requestTo(containsString("/search/v1/local"))) .andRespond(withServerError()) assertFailsWith { @@ -80,12 +81,14 @@ class NaverPlaceSearchProviderTest { val SUCCESS_RESPONSE = """ { - "status": "OK", - "addresses": [{ + "items": [{ + "title": "Nook Cafe", + "link": "https://map.naver.com/place/1", + "category": "카페", + "address": "서울특별시 용산구 갈월동 99-1", "roadAddress": "서울특별시 용산구 한강대로77길 4-1", - "jibunAddress": "서울특별시 용산구 갈월동 99-1", - "x": "126.972332", - "y": "37.543123" + "mapx": "126.972332", + "mapy": "37.543123" }] } """.trimIndent() diff --git a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/PrioritizedPlaceSearchProviderTest.kt b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/PrioritizedPlaceSearchProviderTest.kt new file mode 100644 index 0000000..773dcc4 --- /dev/null +++ b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/PrioritizedPlaceSearchProviderTest.kt @@ -0,0 +1,55 @@ +package org.every.nook.api.infrastructure.place + +import org.every.nook.api.application.place.PlaceCandidate +import org.every.nook.api.application.place.PlaceSearchProvider +import java.math.BigDecimal +import kotlin.test.Test +import kotlin.test.assertEquals + +class PrioritizedPlaceSearchProviderTest { + @Test + fun `skips Naver when Kakao name match is confident`() { + var naverCalled = false + val provider = PrioritizedPlaceSearchProvider( + kakao = PlaceSearchProvider { listOf(candidate("KAKAO", "누크 카페")) }, + naver = PlaceSearchProvider { + naverCalled = true + emptyList() + }, + ) + + val result = provider.search(PlaceSearchProvider.Request("누크 카페")) + + assertEquals("KAKAO", result.single().provider) + assertEquals(false, naverCalled) + } + + @Test + fun `uses Naver to validate but keeps Kakao as the selected provider`() { + var naverCalled = false + val provider = PrioritizedPlaceSearchProvider( + kakao = PlaceSearchProvider { listOf(candidate("KAKAO", "누크 용산점")) }, + naver = PlaceSearchProvider { + naverCalled = true + listOf(candidate("NAVER", "누크 용산")) + }, + ) + + val result = provider.search(PlaceSearchProvider.Request("누크 카페")) + + assertEquals("KAKAO", result.first().provider) + assertEquals(true, naverCalled) + } + + private fun candidate(provider: String, name: String) = PlaceCandidate( + provider = provider, + externalPlaceId = "$provider-$name", + name = name, + address = "서울 용산구 한강대로 1", + latitude = BigDecimal("37.5"), + longitude = BigDecimal("127.0"), + category = null, + phoneNumber = null, + providerUrl = null, + ) +} diff --git a/nook-api-presentation/src/main/kotlin/org/every/nook/api/config/PlaceParsingAsyncConfig.kt b/nook-api-presentation/src/main/kotlin/org/every/nook/api/config/PlaceParsingAsyncConfig.kt index 5c930a3..6673908 100644 --- a/nook-api-presentation/src/main/kotlin/org/every/nook/api/config/PlaceParsingAsyncConfig.kt +++ b/nook-api-presentation/src/main/kotlin/org/every/nook/api/config/PlaceParsingAsyncConfig.kt @@ -7,6 +7,7 @@ import org.springframework.core.task.SimpleAsyncTaskExecutor import org.springframework.core.task.TaskDecorator import org.springframework.scheduling.annotation.EnableAsync import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler import java.util.concurrent.Executor @@ -31,6 +32,18 @@ class PlaceParsingAsyncConfig { setTaskDecorator(mdcTaskDecorator) } + @Bean("placeSupplementTaskExecutor") + fun placeSupplementTaskExecutor(mdcTaskDecorator: TaskDecorator): Executor = ThreadPoolTaskExecutor().apply { + corePoolSize = PLACE_SUPPLEMENT_POOL_SIZE + maxPoolSize = PLACE_SUPPLEMENT_POOL_SIZE + queueCapacity = PLACE_SUPPLEMENT_QUEUE_CAPACITY + setThreadNamePrefix(PLACE_SUPPLEMENT_THREAD_NAME_PREFIX) + setTaskDecorator(mdcTaskDecorator) + setWaitForTasksToCompleteOnShutdown(true) + setAwaitTerminationSeconds(PLACE_SUPPLEMENT_SHUTDOWN_TIMEOUT_SECONDS) + initialize() + } + @Bean("parsingRetryTaskScheduler") fun parsingRetryTaskScheduler(mdcTaskDecorator: TaskDecorator): ThreadPoolTaskScheduler = ThreadPoolTaskScheduler().apply { @@ -43,7 +56,11 @@ class PlaceParsingAsyncConfig { private companion object { const val THREAD_NAME_PREFIX = "place-parsing-" const val POST_CONTENT_THREAD_NAME_PREFIX = "post-content-parsing-" + const val PLACE_SUPPLEMENT_THREAD_NAME_PREFIX = "place-supplement-" const val RETRY_THREAD_NAME_PREFIX = "parsing-retry-" const val RETRY_SCHEDULER_POOL_SIZE = 2 + const val PLACE_SUPPLEMENT_POOL_SIZE = 2 + const val PLACE_SUPPLEMENT_QUEUE_CAPACITY = 100 + const val PLACE_SUPPLEMENT_SHUTDOWN_TIMEOUT_SECONDS = 60 } } diff --git a/nook-api-presentation/src/main/kotlin/org/every/nook/api/place/PlaceParsingEventListener.kt b/nook-api-presentation/src/main/kotlin/org/every/nook/api/place/PlaceParsingEventListener.kt index fedd7b7..634e2c4 100644 --- a/nook-api-presentation/src/main/kotlin/org/every/nook/api/place/PlaceParsingEventListener.kt +++ b/nook-api-presentation/src/main/kotlin/org/every/nook/api/place/PlaceParsingEventListener.kt @@ -88,7 +88,7 @@ class PlaceParsingEventListener( } } - @Async("placeParsingTaskExecutor") + @Async("placeSupplementTaskExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) fun storeThumbnail(event: PlaceThumbnailRequestedEvent) { runCatching { diff --git a/nook-api-presentation/src/main/resources/application.yml b/nook-api-presentation/src/main/resources/application.yml index 5fa95ad..a3d2c56 100644 --- a/nook-api-presentation/src/main/resources/application.yml +++ b/nook-api-presentation/src/main/resources/application.yml @@ -87,12 +87,12 @@ external: rest-api-key: ${KAKAO_REST_API_KEY:} connect-timeout: ${KAKAO_LOCAL_CONNECT_TIMEOUT:3s} read-timeout: ${KAKAO_LOCAL_READ_TIMEOUT:5s} - naver-map: - base-url: ${NAVER_MAP_BASE_URL:https://maps.apigw.ntruss.com} - client-id: ${NAVER_CLIENT_ID:} - client-secret: ${NAVER_CLIENT_SECRET:} - connect-timeout: ${NAVER_MAP_CONNECT_TIMEOUT:3s} - read-timeout: ${NAVER_MAP_READ_TIMEOUT:5s} + naver-api-hub: + base-url: ${NAVER_API_HUB_BASE_URL:https://naverapihub.apigw.ntruss.com} + client-id: ${NAVER_API_HUB_CLIENT_ID:} + client-secret: ${NAVER_API_HUB_CLIENT_SECRET:} + connect-timeout: ${NAVER_API_HUB_CONNECT_TIMEOUT:3s} + read-timeout: ${NAVER_API_HUB_READ_TIMEOUT:5s} google-place-photo: enabled: ${GOOGLE_PLACE_PHOTO_ENABLED:false} base-url: ${GOOGLE_PLACE_BASE_URL:https://places.googleapis.com}