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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions docs/tasks/NOOK-171/README.md
Original file line number Diff line number Diff line change
@@ -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`을 적용한다.
2 changes: 2 additions & 0 deletions docs/tasks/NOOK-171/ddl/rollback.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE places
DROP COLUMN google_place_id;
2 changes: 2 additions & 0 deletions docs/tasks/NOOK-171/ddl/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE places
ADD COLUMN google_place_id VARCHAR(255) NULL COMMENT 'Google Places 장소 식별자' AFTER phone_number;
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ data class PlaceCandidate(
val providerUrl: String?,
val distanceMeters: Int? = null,
val city: String? = KoreanCityNameExtractor.extract(address),
val googlePlaceId: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
data class PlaceSupplement(
val openingHours: PlaceOpeningHours?,
val photoUrls: List<String>,
val googlePlaceId: String? = null,
) {
init {
require(photoUrls.size <= MAX_PHOTO_COUNT) { "Place photos must not exceed $MAX_PHOTO_COUNT" }
}
Expand All @@ -14,9 +18,9 @@ data class PlaceSupplement(val openingHours: PlaceOpeningHours?, val photoUrls:
}

data class PlaceOpeningHours(
val timeZone: String,
val periods: List<PlaceOpeningPeriod>,
val weekdayDescriptions: List<String>,
val timeZone: String = "",
val periods: List<PlaceOpeningPeriod> = emptyList(),
val weekdayDescriptions: List<String> = emptyList(),
) {
fun isOpenAt(instant: Instant): Boolean {
val local = instant.atZone(ZoneId.of(timeZone))
Expand Down Expand Up @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<PlaceThumbnailParsingStatus>()
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<IllegalStateException> { 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<String>) : PlaceThumbnailUpdatePort {
var status: PlaceThumbnailParsingStatus = PlaceThumbnailParsingStatus.PENDING

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -111,8 +98,4 @@ class PlaceSearchConfig {
@Bean
fun getPlaceDetailUseCase(placeDetailQueryPort: PlaceDetailQueryPort): GetPlaceDetailUseCase =
GetPlaceDetailUseCase(placeDetailQueryPort)

private companion object {
const val PLACE_SEARCH_THREAD_COUNT = 2
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {}
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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))
}
Expand Down Expand Up @@ -199,6 +202,7 @@ class PlaceParsingPersistenceAdapter(
longitude = longitude,
category = category,
phoneNumber = phoneNumber,
googlePlaceId = googlePlaceId,
)

private companion object {
Expand Down
Loading