diff --git a/src/main/java/com/offway/core/common/logging/SensitiveParams.java b/src/main/java/com/offway/core/common/logging/SensitiveParams.java index 7258a1f6..71078ea0 100644 --- a/src/main/java/com/offway/core/common/logging/SensitiveParams.java +++ b/src/main/java/com/offway/core/common/logging/SensitiveParams.java @@ -90,7 +90,7 @@ public static String readableParams(String query) { private static String readablePair(String pair) { String[] parts = pair.split(NAME_VALUE_DELIMITER, NAME_VALUE_LIMIT); - String name = forLog(parts[0]); + String name = decodedForLog(parts[0]); if (parts.length < NAME_VALUE_LIMIT) { return name; // 등호 없는 조각 — 이름만 남긴다 } @@ -100,18 +100,39 @@ private static String readablePair(String pair) { if (MASKED_NAMES.contains(name.trim().toLowerCase(Locale.ROOT))) { return name + NAME_VALUE_DELIMITER + MASK; } - return name + NAME_VALUE_DELIMITER + forLog(parts[1]); + return name + NAME_VALUE_DELIMITER + decodedForLog(parts[1]); } /** 디코딩 → 제어문자 제거 → 길이 제한. 이 순서를 지켜야 디코딩으로 생긴 개행이 걸러진다. */ - private static String forLog(String raw) { + private static String decodedForLog(String raw) { String decoded; try { decoded = URLDecoder.decode(raw, StandardCharsets.UTF_8); } catch (IllegalArgumentException e) { decoded = raw; // 깨진 인코딩 — 로그를 찍다가 요청을 죽이지 않는다 } - String safe = stripControlChars(decoded); + return forLog(decoded); + } + + /** + * 이미 디코딩된 값 하나를 로그에 실을 수 있게 — 제어문자 제거 → 길이 제한. + * + *
왜 필요한가. {@code @PathVariable}·외부 응답 필드처럼 우리가 만들지 않은 문자열을 로그에 + * 그대로 끼우면 두 가지가 깨진다. ① {@code %0A} 는 서블릿이 이미 디코딩해 실제 개행으로 오므로 + * 값 하나가 로그를 여러 줄로 쪼개 가짜 로그 줄을 지어낼 수 있다. ② 길이 상한이 없어 값 하나가 줄 + * 전체를 밀어낸다. {@link #readableParams}·{@code RootCause} 가 쿼리·예외 메시지에 대해 이미 하는 + * 처리를 값 하나에도 같은 기준으로 적용한다 — 규칙이 두 벌이 되면 한쪽이 반드시 뒤처진다. + * + *
여기서 다시 디코딩하지 않는다. 이미 디코딩된 값을 또 풀면 값 안의 {@code %41} 이 + * {@code A} 로 바뀌어, 로그가 실제 요청과 다른 값을 가리킨다. + * + *
가리지는 않는다 — 마스킹 대상은 {@code serviceKey} 류 비밀값이지, 지역 id·콘텐츠 id 같은 공개
+ * 식별자가 아니다. 그것까지 가리면 "어느 것이 실패했나" 에 답하지 못해 로그의 존재 이유가 사라진다.
+ *
+ * @param value 로그에 실을 값. null 이면 빈 문자열
+ */
+ public static String forLog(String value) {
+ String safe = stripControlChars(value);
return safe.length() <= MAX_VALUE_LENGTH ? safe : safe.substring(0, MAX_VALUE_LENGTH) + TRUNCATED;
}
diff --git a/src/main/java/com/offway/core/trip/infrastructure/gallery/GalleryPhotoClientImpl.java b/src/main/java/com/offway/core/trip/infrastructure/gallery/GalleryPhotoClientImpl.java
index 056dc63f..a8d7e521 100644
--- a/src/main/java/com/offway/core/trip/infrastructure/gallery/GalleryPhotoClientImpl.java
+++ b/src/main/java/com/offway/core/trip/infrastructure/gallery/GalleryPhotoClientImpl.java
@@ -3,6 +3,7 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.offway.core.common.config.ExternalApiProperties;
+import com.offway.core.common.logging.SensitiveParams;
import com.offway.core.trip.domain.TourApiException;
import com.offway.core.trip.infrastructure.gallery.dto.GalleryPhotoItem;
import java.net.URI;
@@ -120,7 +121,8 @@ private List TourAPI 는 read-timeout 이 길어 트랜잭션 밖에서 호출한다(persistence-convention). 장소가 없으면
* {@link TourApiException#poiNotFound()}(404).
*/
+@Slf4j
@Service
@RequiredArgsConstructor
public class PoiDetailService {
@@ -34,6 +42,51 @@ public class PoiDetailService {
/** TourAPI 콘텐츠가 아님을 뜻하는 타입 — 인허가·국가유산이 함께 쓴다. 실제 contentTypeId 는 12·32·39 처럼 모두 양수다. */
private static final int NON_TOUR_CONTENT_TYPE = 0;
+ /**
+ * 성공 캐시 TTL — 상세는 느리게 변하는 값이다(주소·개요·운영시간·휴무일).
+ *
+ * 실측(2026-08-12, 표본 30건) p50 109ms · p95 151ms 로 평시엔 빠르지만, 값이 빠른 것과 자주
+ * 부를 이유가 있는 것은 다르다. 같은 장소를 누를 때마다 외부를 치면 일일 한도를 그만큼 태우고,
+ * 외부가 멈춘 순간을 만날 확률도 호출 수에 비례해 오른다.
+ */
+ private static final Duration CACHE_TTL = Duration.ofHours(6);
+
+ /**
+ * 조회 실패 TTL — 짧게 둬 재시도를 유도한다.
+ *
+ * 실패를 성공 TTL 로 누르면 그 장소가 6시간 동안 죽는다. 반대로 아예 안 누르면 외부가 느린 동안
+ * 모든 요청이 각자 8초를 기다린다(호출 하나의 상한 6초 + 429 재시도). 1분이 그 사이다.
+ */
+ private static final Duration FAILURE_CACHE_TTL = Duration.ofMinutes(1);
+
+ /**
+ * 없는 콘텐츠 TTL — 실패보다는 길게, 성공보다는 짧게.
+ *
+ * "없다" 는 안정된 답이라 매번 물을 이유가 없다. 다만 우리가 코스에 실어 보낸 식별자가 404 라면
+ * 그건 우리 쪽 버그 신호라, 오래 굳혀 두면 고친 뒤에도 한동안 404 가 나간다.
+ */
+ private static final Duration NOT_FOUND_CACHE_TTL = Duration.ofMinutes(10);
+
+ /**
+ * 캐시 엔트리 상한 — 키 공간은 TourAPI contentId 다.
+ *
+ * 89곳 전체 콘텐츠는 실측 기준 지역당 100건 안팎(최대 평창군 446건)이라 만 단위까지 갈 수 있다.
+ * 다만 실제로 눌리는 것은 코스에 실린 장소뿐이라 훨씬 적다 — 코스 하나가 슬롯 20개 남짓이다.
+ * 상한을 두는 이유는 TTL 이 엔트리를 지우지 않기 때문이다(성능 규약).
+ */
+ private static final int MAX_CACHED_DETAILS = 2_000;
+
+ /**
+ * 빈 키에 동시 요청이 몰렸을 때 첫 적재를 기다릴 상한.
+ *
+ * loader 는 외부를 두 번 부른다(공통상세 → 소개정보). 호출 하나의 상한이 8초라 최악 16초인데,
+ * 그만큼 기다리게 하면 사용자는 이미 떠났다. 평시 왕복이 220ms 라 8초면 정상 경로에 닿지 않는다 —
+ * 느려졌을 때만 끊는 안전망이다.
+ */
+ private static final Duration FIRST_LOAD_WAIT = Duration.ofSeconds(8);
+
+ /** 캐시가 잡아 둔 실패를 502 로 올릴 때의 사유 — 실제 원인은 적재 시점 로그에 남아 있다. */
+ private static final String CACHED_FAILURE_REASON = "관광 API 상세 조회 실패(캐시된 결과)";
/** 혜택 기간 판정은 KST — 사용자가 서 있는 시간대다. */
private static final ZoneId SERVICE_ZONE = ZoneId.of("Asia/Seoul");
@@ -43,6 +96,63 @@ public class PoiDetailService {
private final HeritagePlaceRepository heritagePlaceRepository;
private final PolicyService policyService;
+ /**
+ * 관광 API 상세 캐시 — 인허가·국가유산은 우리 DB 라 캐시하지 않는다.
+ *
+ * stale 을 허용한다. 상세는 느리게 변하므로 6시간 전 값이 502 보다 낫다. 실제로 그 차이가
+ * 났다 — 외부가 6초 안에 답하지 않은 순간(2026-08-11 04:14) 사용자는 화면을 통째로 못 봤는데,
+ * 캐시가 있었다면 직전 값이 나갔다.
+ */
+ private final ExternalDataCache 세 상태의 클라이언트 계약이 각각 다르다(200·404·502). 상태마다 무엇을 내릴지는 상수 자신이 안다.
+ */
+ private enum DetailStatus {
+ FOUND,
+ NOT_FOUND,
+ LOOKUP_FAILED
+ }
+
+ /**
+ * 캐시에 담는 조회 결과.
+ *
+ * {@code detail} 만 담으면 "없는 콘텐츠(404)" 와 "조회 실패(502)" 가 둘 다 null 이 돼 구분되지
+ * 않는다. 클라이언트 계약이 갈리는 자리라 상태를 함께 담는다.
+ */
+ private record CachedDetail(PoiDetail detail, DetailStatus status) {
+
+ static CachedDetail found(PoiDetail detail) {
+ return new CachedDetail(Objects.requireNonNull(detail, "detail"), DetailStatus.FOUND);
+ }
+
+ static CachedDetail notFound() {
+ return new CachedDetail(null, DetailStatus.NOT_FOUND);
+ }
+
+ static CachedDetail failed() {
+ return new CachedDetail(null, DetailStatus.LOOKUP_FAILED);
+ }
+
+ boolean isFound() {
+ return status == DetailStatus.FOUND;
+ }
+
+ /** 캐시된 상태를 그대로 계약으로 옮긴다 — 서비스에 상태 해석 분기를 남기지 않는다. */
+ PoiDetail orThrow() {
+ return switch (status) {
+ case FOUND -> detail;
+ case NOT_FOUND -> throw TourApiException.poiNotFound();
+ // 캐시가 잡아 둔 실패다. 원인은 loader 안에서 이미 로그로 남았다.
+ case LOOKUP_FAILED -> throw TourApiException.lookupFailed(
+ new IllegalStateException(CACHED_FAILURE_REASON));
+ };
+ }
+ }
+
public PoiDetail detail(String contentId) {
// 코스 응답에는 두 출처의 식별자가 섞여 나간다. 인허가 장소를 TourAPI 에 물으면 없는 콘텐츠라
// 404 가 떨어지므로, 우리 식별자는 우리 DB 가 답한다(#144). 사진·소개는 없지만 상호·주소·전화는 있다.
@@ -55,9 +165,52 @@ public PoiDetail detail(String contentId) {
return heritageDetail(heritageId.get());
}
- TourPoiDetail detail = tourApiClient.findDetail(contentId).orElseThrow(TourApiException::poiNotFound);
+ return tourDetail(contentId);
+ }
+
+ /**
+ * 관광 API 상세 — 캐시를 거친다.
+ *
+ * 캐시가 없던 때는 같은 장소를 누를 때마다 외부를 두 번씩 쳤다. 운영 로그에서 같은 contentId 가
+ * 40초 안에 세 번 조회되는 것을 봤는데, 호출이 세 배면 외부가 멈춘 순간을 만날 확률도 세 배다.
+ */
+ private PoiDetail tourDetail(String contentId) {
+ return detailCache
+ .get(contentId, this::loadDetail, CachedDetail.failed(), StalePolicy.ALLOW_STALE)
+ .orThrow();
+ }
- // 외부 응답을 여기서 도메인으로 옮긴다 — 상위 레이어(서비스 dto·응답 dto)가 어댑터 DTO 를 들지 않게.
+ /**
+ * 캐시 loader — 외부 예외를 스스로 잡는다(캐시 프리미티브의 계약).
+ *
+ * 실패했는데 직전 성공값이 있으면 그걸 그대로 돌려준다. 상세는 느리게 변하므로 6시간 전 값이
+ * 502 보다 낫다. 다만 TTL 은 짧게 줘, 외부가 돌아오면 곧 다시 받아온다.
+ */
+ private Loaded 운영 로그에서 같은 contentId 가 40초 안에 세 번 조회됐다. 호출이 세 배면 외부가 멈춘 순간을
+ * 만날 확률도 세 배고, 일일 한도도 그만큼 탄다.
+ */
+ @Test
+ void 같은_장소를_다시_조회하면_외부를_부르지_않는다() throws Exception {
+ poiDetailService.evictCache();
+ tourApiClient.respondDetail(() -> Optional.of(new TourPoiDetail(
+ "126508", 12, "완도타워", "전남 완도군", "061-1", 34.3, 126.7, "http://img/1.jpg", "전망대 소개")));
+ tourApiClient.respondIntro(Optional::empty);
+ tourApiClient.resetDetailCallCount();
+
+ mockMvc.perform(get("/api/v1/pois/{id}", "126508")).andExpect(status().isOk());
+ mockMvc.perform(get("/api/v1/pois/{id}", "126508"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.title").value("완도타워"));
+
+ assertEquals(1, tourApiClient.detailCallCount());
+ }
+
+ /**
+ * 외부가 죽었는데 내려보낼 직전 값도 없으면 502 로 알린다 — 조용히 빈 화면을 주지 않는다.
+ *
+ * 반대편(직전 값이 있으면 그걸 내린다)은 여기서 재현하지 않는다. TTL 이 6시간이라 만료를 기다릴
+ * 수 없고, 시계를 주입하는 seam 도 두지 않았다. stale-while-error 자체는 캐시 프리미티브의 단위
+ * 테스트가 짧은 TTL 로 덮는다({@code ExternalDataCacheTest}). 여기서 확인할 것은 그 정책을 이
+ * 서비스가 골라 쓰는지이고, 정책이 갈리는 지점인 "직전 값 없음" 이 이 테스트다.
+ */
+ @Test
+ void 외부가_실패했는데_직전_값도_없으면_502로_알린다() throws Exception {
+ poiDetailService.evictCache();
+ tourApiClient.respondDetail(() -> Optional.of(new TourPoiDetail(
+ "777", 12, "청령포", "강원 영월군", null, 37.1, 128.4, "http://img/7.jpg", "명승 소개")));
+ tourApiClient.respondIntro(Optional::empty);
+ mockMvc.perform(get("/api/v1/pois/{id}", "777")).andExpect(status().isOk());
+
+ // 캐시를 만료시키지 않고는 재조회가 안 일어나므로, 캐시를 비우고 실패하게 만든다.
+ poiDetailService.evictCache();
+ tourApiClient.respondDetail(() -> {
+ throw TourApiException.lookupFailed(new IllegalStateException("read timeout"));
+ });
+
+ // 비운 뒤라 직전 값이 없다 — 이때는 502 가 맞다. 조용히 빈 화면을 주지 않는다.
+ mockMvc.perform(get("/api/v1/pois/{id}", "777"))
+ .andExpect(status().isBadGateway())
+ .andExpect(jsonPath("$.code").value("TOUR-001"));
+ }
+
+ /** 조회 실패를 성공으로 굳히지 않는다 — 외부가 돌아오면 다시 받아온다. */
+ @Test
+ void 실패한_뒤_외부가_돌아오면_다시_받아온다() throws Exception {
+ poiDetailService.evictCache();
+ tourApiClient.respondDetail(() -> {
+ throw TourApiException.lookupFailed(new IllegalStateException("read timeout"));
+ });
+ mockMvc.perform(get("/api/v1/pois/{id}", "888")).andExpect(status().isBadGateway());
+
+ poiDetailService.evictCache();
+ tourApiClient.respondDetail(() -> Optional.of(new TourPoiDetail(
+ "888", 12, "장릉", "강원 영월군", null, 37.1, 128.4, "http://img/8.jpg", "사적 소개")));
+ tourApiClient.respondIntro(Optional::empty);
+
+ mockMvc.perform(get("/api/v1/pois/{id}", "888"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.title").value("장릉"));
+ }
+
+ /** 우리 DB 가 답하는 식별자는 캐시를 타지 않는다 — 외부를 애초에 안 부른다. */
+ @Test
+ void 국가유산_식별자는_외부를_부르지_않는다() throws Exception {
+ poiDetailService.evictCache();
+ HeritagePlace heritage = heritagePlaceRepository.findVisitableCandidates(UISEONG, 1).getFirst();
+ tourApiClient.resetDetailCallCount();
+
+ mockMvc.perform(get("/api/v1/pois/{id}", heritage.publicId())).andExpect(status().isOk());
+
+ assertEquals(0, tourApiClient.detailCallCount());
+ }
}
diff --git a/src/test/java/com/offway/core/trip/infrastructure/tour/StubTourApiClient.java b/src/test/java/com/offway/core/trip/infrastructure/tour/StubTourApiClient.java
index 9e648642..b56f8be5 100644
--- a/src/test/java/com/offway/core/trip/infrastructure/tour/StubTourApiClient.java
+++ b/src/test/java/com/offway/core/trip/infrastructure/tour/StubTourApiClient.java
@@ -26,6 +26,9 @@ public class StubTourApiClient implements TourApiClient {
/** 지역기반 조회 호출 횟수 — 재생성이 후보를 몇 번 모으는지 세는 데 쓴다(#114). */
private final java.util.concurrent.atomic.AtomicInteger areaCalls = new java.util.concurrent.atomic.AtomicInteger();
+ /** 공통상세 호출 횟수 — 캐시가 실제로 외부를 아끼는지 세는 데 쓴다. */
+ private final java.util.concurrent.atomic.AtomicInteger detailCalls = new java.util.concurrent.atomic.AtomicInteger();
+
private Supplier