Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package com.example.backend.bootstrap.config
import org.springframework.boot.context.properties.ConfigurationProperties

/**
* 코스 개수 폴백 큐(SQS) 설정 — 값은 배포 시 env 로 주입한다.
* [courseCountQueueUrl] 가 비면(로컬·CI) 폴백 발행([CourseCountFallbackAdapter])은 no-op,
* 수신 리스너([CourseCountSqsConsumer])는 @ConditionalOnExpression 으로 생성되지 않는다(fail-soft).
* 폴백 큐(SQS) 설정 — 값은 배포 시 env 로 주입한다.
* 각 큐 url 이 비면(로컬·CI) 해당 폴백 발행 어댑터는 no-op, 수신 리스너는 @ConditionalOnExpression 으로 생성되지 않는다(fail-soft).
* 클라이언트·리전·자격증명은 spring-cloud-aws 가 `spring.cloud.aws.*` 로 자동 배선한다.
* - [courseCountQueueUrl]: 코스 개수 반영 폴백([CourseCountFallbackAdapter]/[CourseCountSqsConsumer]).
* - [savedPlaceQueueUrl]: 저장 장소 색인 폴백([SavedPlaceIndexFallbackAdapter]/[SavedPlaceIndexSqsConsumer]).
*/
@ConfigurationProperties(prefix = "aws.sqs")
data class SqsProperties(
val courseCountQueueUrl: String = "",
val savedPlaceQueueUrl: String = "",
)
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ class OpenSearchIndexInitializer(
index = properties.withPrefix("course_v2"),
mappingResource = "opensearch/course.json",
),
IndexDef(
alias = properties.withPrefix("saved_place"),
index = properties.withPrefix("saved_place_v1"),
mappingResource = "opensearch/saved_place.json",
),
)

override fun run(args: ApplicationArguments) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.example.backend.bootstrap.config.OpenSearchConfig
import com.example.backend.bootstrap.config.OpenSearchProperties
import com.example.backend.course.application.port.inbound.CourseReindexUseCase
import com.example.backend.place.application.port.inbound.PlaceReindexUseCase
import com.example.backend.user.application.port.inbound.SavedPlaceReindexUseCase
import io.github.oshai.kotlinlogging.KotlinLogging
import org.opensearch.client.opensearch.OpenSearchClient
import org.springframework.beans.factory.ObjectProvider
Expand All @@ -24,6 +25,7 @@ class OpenSearchReindexRunner(
private val properties: OpenSearchProperties,
private val placeReindexUseCase: PlaceReindexUseCase,
private val courseReindexUseCase: CourseReindexUseCase,
private val savedPlaceReindexUseCase: SavedPlaceReindexUseCase,
) : ApplicationRunner {
private val log = KotlinLogging.logger {}

Expand All @@ -38,6 +40,7 @@ class OpenSearchReindexRunner(
listOf(
Target(properties.withPrefix("place"), placeReindexUseCase::reindexAll),
Target(properties.withPrefix("course"), courseReindexUseCase::reindexAll),
Target(properties.withPrefix("saved_place"), savedPlaceReindexUseCase::reindexAll),
).forEach { t ->
try {
val empty = client.count { c -> c.index(t.alias) }.count() == 0L
Expand Down
Original file line number Diff line number Diff line change
@@ -1,55 +1,48 @@
package com.example.backend.mobile.home.adapter.outbound

import com.example.backend.common.exception.BusinessException
import com.example.backend.common.geo.Coordinate
import com.example.backend.common.response.PlaceErrorCode
import com.example.backend.mobile.home.application.port.outbound.HomeNearbyPlacePort
import com.example.backend.mobile.home.application.port.outbound.dto.HomeNearbyPlace
import com.example.backend.place.application.port.inbound.PlaceQueryUseCase
import com.example.backend.user.application.port.inbound.SavedPlaceSearchQueryPort
import com.example.backend.user.application.port.inbound.SavedPlaceUseCase
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component

/**
* BFF 아웃바운드 어댑터 — 내 저장 장소(user, [SavedPlaceUseCase])를 거리순 조회(place, [PlaceQueryUseCase])에 넘기고
* 방문 여부를 합쳐 BFF 격리 DTO 로 매핑한다.
* 검색엔진 장애(`PLACE_SEARCH_UNAVAILABLE`)는 경고 로그만 남기고 빈 목록으로 흡수한다(홈 섹션 fail-soft).
* OS 에서 사용자·반경 내 저장 장소를 랭킹하고(거리순), 결과 ID 만 DB 에서 조회해 최신 상세를 합친다.
* 장소 상세(이름/평점)는 place 도메인에서, 방문 여부는 user 도메인(saved_places)에서 신선하게 읽는다 — 둘 다 상위 결과만.
*/
@Component
class HomeNearbyPlaceAdapter(
private val savedPlaceSearchQueryPort: SavedPlaceSearchQueryPort,
private val savedPlaceUseCase: SavedPlaceUseCase,
private val placeQueryUseCase: PlaceQueryUseCase,
) : HomeNearbyPlacePort {
private val log = KotlinLogging.logger {}

override fun findNearbySavedPlaces(
userId: Long,
origin: Coordinate,
radiusMeters: Double,
limit: Int,
): List<HomeNearbyPlace> {
val refs = savedPlaceUseCase.listSavedPlaceRefs(userId)
if (refs.isEmpty()) return emptyList()
val visitedByPlace = refs.associate { it.placeId to it.visited }

val nearest =
try {
placeQueryUseCase.findNearest(refs.map { it.placeId }, origin, limit)
} catch (e: BusinessException) {
if (e.errorCode != PlaceErrorCode.PLACE_SEARCH_UNAVAILABLE) throw e
log.warn { "홈 근처 저장 장소 조회 실패 — 빈 목록으로 대체: userId=$userId" }
return emptyList()
val hits = savedPlaceSearchQueryPort.nearestSaved(userId, origin, radiusMeters, limit)
if (hits.isEmpty()) return emptyList()
val placeIds = hits.map { it.placeId }
val places = placeQueryUseCase.findPlacesById(placeIds).associateBy { it.id }
val visitedByPlace = savedPlaceUseCase.findVisited(userId, placeIds)
// DB 조회 순서와 무관하게 OS 거리순을 보존한다. DB 에서 삭제된 장소는 제외한다.
return hits.mapNotNull { hit ->
places[hit.placeId]?.let { place ->
HomeNearbyPlace(
placeId = place.id,
name = place.name,
imageUrl = place.imageUrl,
category = place.category,
rating = place.rating,
ratingCount = place.ratingCount,
distanceMeters = hit.distanceMeters,
visited = visitedByPlace[place.id] ?: false,
)
}
return nearest.map {
HomeNearbyPlace(
placeId = it.id,
name = it.name,
imageUrl = it.imageUrl,
category = it.category,
rating = it.rating,
ratingCount = it.ratingCount,
distanceMeters = it.distanceMeters,
visited = visitedByPlace[it.id] ?: false,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import com.example.backend.common.geo.Coordinate
import com.example.backend.mobile.home.application.port.outbound.dto.HomeNearbyPlace

/**
* BFF 아웃바운드 포트 — 내 저장 장소 중 가까운 곳 조회. 저장 목록(user)과 거리순 조회(place)를 어댑터가 잇는다.
* 검색엔진 장애는 어댑터가 빈 목록으로 흡수한다(홈의 한 섹션 장애가 화면 전체를 죽이지 않게).
* BFF 아웃바운드 포트 — 내 저장 장소 중 가까운 곳 조회. 저장 관계 색인(user)과 장소 상세(place)를 어댑터가 잇는다.
* 검색엔진 장애는 검색 어댑터(OpenSearchSavedPlaceSearchAdapter)가 빈 목록으로 흡수한다(홈의 한 섹션 장애가 화면 전체를 죽이지 않게).
*/
interface HomeNearbyPlacePort {
/** [userId] 의 저장 장소 중 [origin] 에서 가까운 순으로 최대 [limit] 개. 저장 장소가 없으면 빈 목록. */
/** [userId] 의 저장 장소 중 [origin] 의 반경 [radiusMeters] 이내에서 가까운 순으로 최대 [limit] 개. 저장 장소가 없으면 빈 목록. */
fun findNearbySavedPlaces(
userId: Long,
origin: Coordinate,
radiusMeters: Double,
limit: Int,
): List<HomeNearbyPlace>
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ class HomeFeedService(
private val homeNearbyPlacePort: HomeNearbyPlacePort,
) : HomeFeedUseCase {
/**
* 바깥 트랜잭션 없이 조합한다 — 근처 장소 어댑터가 흡수하는 검색 503 이 참여 트랜잭션을 rollback-only 로 찍어
* 커밋 시 UnexpectedRollbackException 으로 홈 전체가 실패하지 않도록, 도메인 호출마다 각자의 트랜잭션을 쓴다.
* 바깥 트랜잭션 없이 조합한다 — OS 조회 동안 DB 트랜잭션을 잡아두지 않고,
* 프로필·추천 코스·장소 상세는 각 도메인의 읽기 트랜잭션으로 조회한다.
*/
@Transactional(propagation = Propagation.NOT_SUPPORTED)
override fun getHome(
Expand All @@ -45,15 +45,19 @@ class HomeFeedService(
if (viewerId == null || userLocation == null) {
emptyList()
} else {
// 포트가 가까운 순으로 주므로, 상한을 넘는 장소를 걸러도 더 가까운 장소가 빠지지 않는다.
// 반경 제한은 엔진 쿼리에 적용하고, 도보 시간은 표시용으로만 계산한다.
homeNearbyPlacePort
.findNearbySavedPlaces(viewerId, userLocation, HOME_SECTION_SIZE)
.map {
.findNearbySavedPlaces(
viewerId,
userLocation,
MAX_WALKING_MINUTES * WALKING_METERS_PER_MINUTE,
HOME_NEARBY_SIZE,
).map {
HomeResult.NearbySavedPlace(
place = it,
walkingMinutes = ceil(it.distanceMeters / WALKING_METERS_PER_MINUTE).toInt(),
)
}.filter { it.walkingMinutes <= MAX_WALKING_MINUTES }
}
},
)

Expand Down Expand Up @@ -85,6 +89,7 @@ class HomeFeedService(

private companion object {
const val HOME_SECTION_SIZE = 5
const val HOME_NEARBY_SIZE = 3

/** 도보 속도 근사(시속 4km = 분당 약 67m). 경로 API(TMAP)는 부르지 않는다. */
const val WALKING_METERS_PER_MINUTE = 67.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package com.example.backend.place.application.port.inbound.dto
* - [category] 장소 카테고리 코드(도메인 enum 이름). 코스 카테고리 도출 입력으로도 쓰인다.
* - [imageUrl] 대표 이미지 URL(없을 수 있음). 장소 검색 결과 카드 썸네일에 쓴다.
* - [latitude]·[longitude] 장소 좌표(지도 핀 표시용). 코스 상세 화면에서 장소별 위치를 찍는 데 쓴다.
* - [rating]·[ratingCount] DB 에서 읽은 최신 평점과 평점수.
* - [address] 장소 주소.
* - [areaCode] 법정동코드 10자리(미확인 시 null). 코스 지역(area_code) 도출 입력으로 쓰인다.
*/
Expand All @@ -21,4 +22,6 @@ data class PlaceSummary(
val longitude: Double,
val address: String,
val areaCode: String?,
val rating: Double = 0.0,
val ratingCount: Int = 0,
)
Original file line number Diff line number Diff line change
Expand Up @@ -179,5 +179,7 @@ class PlaceQueryService(
longitude = location.longitude,
address = address,
areaCode = areaCode,
rating = averageRating,
ratingCount = ratingCnt,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.example.backend.user.adapter.inbound.messaging

import com.example.backend.common.geo.Coordinate
import com.example.backend.user.adapter.messaging.SavedPlaceIndexMessage
import com.example.backend.user.application.port.outbound.SavedPlaceIndexDocument
import com.example.backend.user.application.port.outbound.SavedPlaceSearchIndexPort
import io.awspring.cloud.sqs.annotation.SqsListener
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression
import org.springframework.stereotype.Component
import java.time.Instant

/**
* 인바운드 어댑터 — 폴백 큐(SQS)의 저장 장소 색인 메시지를 수신해 [SavedPlaceSearchIndexPort] 로 재처리한다(동기 색인 실패분 재시도).
*
* 큐가 설정된 배포에서만 활성(@ConditionalOnExpression) — 로컬·CI 는 큐 미설정이라 이 빈이 생성되지 않는다.
* 반영 성공 시 spring-cloud-aws 가 자동 ack, 예외면 ack 하지 않아 (가시성 타임아웃 후) 재전송된다.
* upsert/delete 가 멱등이라 at-least-once 재전송에도 안전하다(CourseCountSqsConsumer 선례).
*/
@Component
@ConditionalOnExpression("'\${aws.sqs.saved-place-queue-url:}'.trim().length() > 0")
class SavedPlaceIndexSqsConsumer(
private val port: SavedPlaceSearchIndexPort,
) {
private val log = KotlinLogging.logger {}

@SqsListener("\${aws.sqs.saved-place-queue-url}")
fun onMessage(message: SavedPlaceIndexMessage) {
when (message.op) {
SavedPlaceIndexMessage.OP_INDEX -> {
port.index(
SavedPlaceIndexDocument(
userId = message.userId,
placeId = message.placeId,
location =
Coordinate(
latitude = requireNotNull(message.lat),
longitude = requireNotNull(message.lon),
),
savedAt = Instant.ofEpochMilli(requireNotNull(message.savedAtEpochMs)),
),
)
}

SavedPlaceIndexMessage.OP_DELETE -> {
port.delete(message.userId, message.placeId)
}

else -> {
log.warn { "알 수 없는 저장 장소 색인 연산 수신(무시): ${message.op}" }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.example.backend.user.adapter.messaging

/**
* 저장 장소 색인 폴백 큐(SQS) 와이어 메시지 — 발행(outbound)·수신(inbound) 어댑터가 공유하는 직렬화 계약.
* 중립 위치(adapter.messaging)에 둬 inbound↔outbound 결합을 만들지 않는다(CourseCountMessage 선례).
*
* 동기 색인(AFTER_COMMIT) 이 실패한 연산을 실어 컨슈머가 같은 색인 포트로 재처리한다. upsert/delete 가 멱등이라
* eventId 없이 연산 종류([op])와 문서 필드만 실으면 충분하다. INDEX 는 전 필드, DELETE 는 키([userId]/[placeId])만 쓴다.
*/
data class SavedPlaceIndexMessage(
val op: String,
val userId: Long,
val placeId: Long,
val lat: Double? = null,
val lon: Double? = null,
val savedAtEpochMs: Long? = null,
) {
companion object {
const val OP_INDEX = "INDEX"
const val OP_DELETE = "DELETE"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.example.backend.user.adapter.outbound.messaging

import com.example.backend.bootstrap.config.SqsProperties
import com.example.backend.user.adapter.messaging.SavedPlaceIndexMessage
import com.example.backend.user.application.port.outbound.SavedPlaceIndexDocument
import com.example.backend.user.application.port.outbound.SavedPlaceIndexFallbackPort
import io.awspring.cloud.sqs.operations.SqsTemplate
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.beans.factory.ObjectProvider
import org.springframework.stereotype.Component

/**
* 아웃바운드 어댑터 — 동기 색인 실패 시 저장 장소 색인 연산을 폴백 큐(SQS)로 발행한다([SavedPlaceIndexFallbackPort]).
*
* [SqsTemplate] 이 없거나(=SQS 미설정, 로컬·CI) queueUrl 이 비면 no-op. 폴백 발행마저 실패하면 색인 반영이
* 유실될 수 있으므로 error 로그를 남긴다(주 경로도 실패, 재처리 경로도 끊긴 상황 — 재색인 잡이 최종 보정).
*/
@Component
class SavedPlaceIndexFallbackAdapter(
private val sqsTemplateProvider: ObjectProvider<SqsTemplate>,
private val sqsProperties: SqsProperties,
) : SavedPlaceIndexFallbackPort {
private val log = KotlinLogging.logger {}

override fun enqueueIndex(document: SavedPlaceIndexDocument) =
send(
SavedPlaceIndexMessage(
op = SavedPlaceIndexMessage.OP_INDEX,
userId = document.userId,
placeId = document.placeId,
lat = document.location.latitude,
lon = document.location.longitude,
savedAtEpochMs = document.savedAt.toEpochMilli(),
),
)

override fun enqueueDelete(
userId: Long,
placeId: Long,
) = send(SavedPlaceIndexMessage(op = SavedPlaceIndexMessage.OP_DELETE, userId = userId, placeId = placeId))

private fun send(message: SavedPlaceIndexMessage) {
val template = sqsTemplateProvider.ifAvailable ?: return // SQS 미설정 → no-op
val queueUrl = sqsProperties.savedPlaceQueueUrl
if (queueUrl.isBlank()) {
log.warn { "SQS queueUrl 미설정으로 저장 장소 색인 폴백 발행 생략: ${message.op} ${message.userId}:${message.placeId}" }
return
}
try {
template.send { to -> to.queue(queueUrl).payload(message) }
} catch (e: Exception) {
log.error {
"저장 장소 색인 폴백 발행 실패(반영 유실 가능): ${message.op} ${message.userId}:${message.placeId} — ${e.message}"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,15 @@ class SavedPlacePersistenceAdapter(
limit: Int,
): List<SavedPlaceRow> = savedPlaceRepository.findPage(userId, visited, category, cursorId, limit)

override fun findForIndex(
afterId: Long?,
limit: Int,
): List<SavedPlace> = savedPlaceRepository.findForIndex(afterId, limit)

override fun findAllRefs(userId: Long): List<SavedPlaceRef> = savedPlaceRepository.findAllRefs(userId)

override fun findVisitedByPlaceIds(
userId: Long,
placeIds: List<Long>,
): Map<Long, Boolean> = savedPlaceRepository.findVisitedByPlaceIds(userId, placeIds)
}
Loading
Loading