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 parse(String body) throws Exception { private static void addIfComplete(List parsed, JsonNode node) { GalleryPhotoItem item = toItem(node); if (!item.isComplete()) { - log.warn("관광사진 항목에 필수 값이 없어 건너뜁니다 contentId={}", item.contentId()); + // 외부 응답의 문자열이라 개행·과길이가 섞여 올 수 있다 — 경로 변수와 같은 새니타이저를 탄다. + log.warn("관광사진 항목에 필수 값이 없어 건너뜁니다 contentId={}", SensitiveParams.forLog(item.contentId())); return; } parsed.add(item); diff --git a/src/main/java/com/offway/core/trip/service/PoiDetailService.java b/src/main/java/com/offway/core/trip/service/PoiDetailService.java index b3259530..d433e4d9 100644 --- a/src/main/java/com/offway/core/trip/service/PoiDetailService.java +++ b/src/main/java/com/offway/core/trip/service/PoiDetailService.java @@ -1,23 +1,30 @@ package com.offway.core.trip.service; +import com.offway.core.common.cache.ExternalDataCache; +import com.offway.core.common.cache.ExternalDataCache.Loaded; +import com.offway.core.common.cache.ExternalDataCache.StalePolicy; +import com.offway.core.common.logging.SensitiveParams; import com.offway.core.itinerary.domain.SlotKind; import com.offway.core.policy.service.PolicyService; import com.offway.core.trip.domain.HeritagePlace; import com.offway.core.trip.domain.LicensedPlace; import com.offway.core.trip.domain.MapSearchLink; import com.offway.core.trip.domain.PoiContentType; +import com.offway.core.trip.domain.PoiIntro; import com.offway.core.trip.domain.TourApiException; import com.offway.core.trip.infrastructure.tour.TourApiClient; -import com.offway.core.trip.domain.PoiIntro; import com.offway.core.trip.infrastructure.tour.dto.TourIntro; import com.offway.core.trip.infrastructure.tour.dto.TourPoiDetail; import com.offway.core.trip.repository.HeritagePlaceRepository; import com.offway.core.trip.repository.LicensedPlaceRepository; import com.offway.core.trip.service.dto.PoiDetail; +import java.time.Duration; import java.time.LocalDate; import java.time.ZoneId; +import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; /** @@ -27,6 +34,7 @@ *

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 detailCache = + new ExternalDataCache<>(MAX_CACHED_DETAILS, FIRST_LOAD_WAIT); + + /** + * 캐시에 담긴 조회 결과의 상태 — 상태는 boolean 조합이 아니라 이름으로 든다. + * + *

세 상태의 클라이언트 계약이 각각 다르다(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 loadDetail(String contentId, CachedDetail stale) { + try { + Optional found = tourApiClient.findDetail(contentId); + if (found.isEmpty()) { + return new Loaded<>(CachedDetail.notFound(), NOT_FOUND_CACHE_TTL); + } + return new Loaded<>(CachedDetail.found(toPoiDetail(contentId, found.get())), CACHE_TTL); + } catch (RuntimeException e) { + // contentId 는 공개 콘텐츠 식별자라 가리지 않는다 — 어느 장소가 degrade 했는지가 이 로그의 존재 + // 이유다. 다만 경로 변수라 서블릿이 퍼센트 디코딩을 마친 값이 그대로 온다. 그대로 찍으면 개행 + // 하나로 로그가 여러 줄로 쪼개지므로, 다른 외부 문자열과 같은 새니타이저를 통과시킨다. + if (stale != null && stale.isFound()) { + log.warn("관광 API 상세 조회 실패 — 직전 값으로 내려보냅니다 contentId={} cause={}", + SensitiveParams.forLog(contentId), e.getClass().getSimpleName()); + return new Loaded<>(stale, FAILURE_CACHE_TTL); + } + // degrade 를 조용히 넘기지 않는다 — 폴백이 정상처럼 보이면 장애를 아무도 모른다. + log.warn("관광 API 상세 조회 실패 — 내려보낼 직전 값이 없습니다 contentId={} cause={}", + SensitiveParams.forLog(contentId), e.getClass().getSimpleName()); + return new Loaded<>(CachedDetail.failed(), FAILURE_CACHE_TTL); + } + } + + /** 외부 응답을 도메인으로 옮긴다 — 상위 레이어(서비스 dto·응답 dto)가 어댑터 DTO 를 들지 않게. */ + private PoiDetail toPoiDetail(String contentId, TourPoiDetail detail) { PoiIntro intro = detail.contentTypeId() == null ? null : tourApiClient.findIntro(contentId, detail.contentTypeId()) @@ -82,6 +235,11 @@ public PoiDetail detail(String contentId) { catchphraseProvider.forContentId(contentId).orElse(null)); } + /** 강제 갱신·통합 테스트 격리용. 공유 컨텍스트에서 앞 테스트의 캐시가 뒤 테스트를 통과시키지 않게. */ + public void evictCache() { + detailCache.evictAll(); + } + /** * 국가유산의 상세(#160) — 인허가와 달리 사진과 설명이 있다. * diff --git a/src/test/java/com/offway/core/common/logging/SensitiveParamsTest.java b/src/test/java/com/offway/core/common/logging/SensitiveParamsTest.java index b4664aac..3bd1de3c 100644 --- a/src/test/java/com/offway/core/common/logging/SensitiveParamsTest.java +++ b/src/test/java/com/offway/core/common/logging/SensitiveParamsTest.java @@ -134,4 +134,38 @@ class SensitiveParamsTest { assertEquals("", SensitiveParams.maskSecretsInText("")); assertEquals(null, SensitiveParams.maskSecretsInText(null)); } + + @Test + void 값_하나에_개행을_넣어_가짜_로그_줄을_만들_수_없다() { + // 경로 변수(@PathVariable)는 서블릿이 퍼센트 디코딩을 마친 값이라, %0A 가 실제 개행으로 온다. + String rendered = SensitiveParams.forLog("126508\n2026-01-01 INFO fake"); + + assertFalse(rendered.contains("\n"), "개행이 남으면 로그 줄이 쪼개진다. 실제=" + rendered); + assertEquals("1265082026-01-01 INFO fake", rendered); + } + + @Test + void 값_하나가_아주_길면_잘라낸다() { + String rendered = SensitiveParams.forLog("가".repeat(200)); + + assertTrue(rendered.endsWith("…"), "잘렸다는 표식이 있어야 한다. 실제 길이=" + rendered.length()); + assertTrue(rendered.length() < 80, "실제 길이=" + rendered.length()); + } + + @Test + void 값_하나는_다시_디코딩하지_않는다() { + // 이미 디코딩된 값을 또 풀면 %41 이 A 가 돼, 로그가 실제 요청과 다른 값을 가리킨다. + assertEquals("126508%41", SensitiveParams.forLog("126508%41")); + } + + @Test + void 공개_식별자는_가리지_않는다() { + // 마스킹 대상은 비밀값이지 콘텐츠 id 가 아니다. 가리면 어느 것이 실패했는지 알 수 없다. + assertEquals("126508", SensitiveParams.forLog("126508")); + } + + @Test + void 값이_null_이어도_깨지지_않는다() { + assertEquals("", SensitiveParams.forLog(null)); + } } diff --git a/src/test/java/com/offway/core/trip/controller/PoiDetailIntegrationTest.java b/src/test/java/com/offway/core/trip/controller/PoiDetailIntegrationTest.java index b8c39fe8..17b011c1 100644 --- a/src/test/java/com/offway/core/trip/controller/PoiDetailIntegrationTest.java +++ b/src/test/java/com/offway/core/trip/controller/PoiDetailIntegrationTest.java @@ -1,6 +1,7 @@ package com.offway.core.trip.controller; import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -12,6 +13,7 @@ import java.util.Optional; import org.junit.jupiter.api.Test; import com.offway.core.trip.domain.HeritagePlace; +import com.offway.core.trip.domain.TourApiException; import com.offway.core.trip.domain.LicensedPlace; import com.offway.core.trip.domain.PlaceKind; import org.springframework.beans.factory.annotation.Autowired; @@ -34,6 +36,9 @@ class PoiDetailIntegrationTest { @Autowired private StubTourApiClient tourApiClient; + @Autowired + private com.offway.core.trip.service.PoiDetailService poiDetailService; + @Autowired private com.offway.core.trip.repository.HeritagePlaceRepository heritagePlaceRepository; @@ -55,6 +60,7 @@ TourApiClient stubTourApiClient() { @Test void 장소_상세를_운영시간과_함께_200으로_내린다() 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.of(TourIntro.builder().contentId("126508").useTime("09:00~18:00").restDate("연중무휴").parking("가능").build())); @@ -79,6 +85,7 @@ TourApiClient stubTourApiClient() { @Test void 음식점이면_대표메뉴와_영업시간이_food_블록으로_나간다() throws Exception { + poiDetailService.evictCache(); // 영업시간·휴무일·대표메뉴는 우리 89곳에서 실측 95~100% 로 채워진다 — 안 읽고 버릴 값이 아니다. tourApiClient.respondDetail(() -> Optional.of(new TourPoiDetail( "111", 39, "벽오동", "경북 청도군", "054-1", 35.6, 128.7, "http://img/f.jpg", "한우 전문점"))); @@ -102,6 +109,7 @@ TourApiClient stubTourApiClient() { @Test void 숙소면_입퇴실_시각과_객실수가_stay_블록으로_나간다() throws Exception { + poiDetailService.evictCache(); tourApiClient.respondDetail(() -> Optional.of(new TourPoiDetail( "222", 32, "더스터닝", "경북 안동시", "054-2", 36.5, 128.7, "http://img/s.jpg", "펜션"))); tourApiClient.respondIntro(() -> Optional.of(TourIntro.builder() @@ -118,6 +126,7 @@ TourApiClient stubTourApiClient() { @Test void 문화시설이면_요금이_culture_블록으로_나간다() throws Exception { + poiDetailService.evictCache(); tourApiClient.respondDetail(() -> Optional.of(new TourPoiDetail( "333", 14, "신안갯벌박물관", "전남 신안군", "061-3", 34.8, 126.1, "http://img/c.jpg", "박물관"))); tourApiClient.respondIntro(() -> Optional.of(TourIntro.builder() @@ -133,6 +142,7 @@ TourApiClient stubTourApiClient() { @Test void 레포츠면_요금과_이용시간이_leports_블록으로_나간다() throws Exception { + poiDetailService.evictCache(); // 다섯 블록 중 이것만 양수 검증이 없었다. 표본 24건이 전부 비어 있던 카테고리라 // 값이 왔을 때의 매핑을 아무도 안 보고 있었다 — 정작 편차가 커서 값이 오면 그대로 쓰는 쪽이다. tourApiClient.respondDetail(() -> Optional.of(new TourPoiDetail( @@ -152,6 +162,7 @@ TourApiClient stubTourApiClient() { @Test void 보조정보가_없으면_어떤_블록도_만들지_않는다() throws Exception { + poiDetailService.evictCache(); // 우리 DB 출처(인허가·국가유산)나 관광 API 가 소개정보를 안 주는 경우. 빈 블록을 만들면 // 화면이 "정보 있음" 으로 읽고 빈 줄을 그린다. tourApiClient.respondDetail(() -> Optional.of(new TourPoiDetail( @@ -169,6 +180,7 @@ TourApiClient stubTourApiClient() { @Test void 캐치프레이즈가_있는_장소면_data에_함께_내린다() throws Exception { + poiDetailService.evictCache(); // 126508 은 시드 CSV(구석구석 캐치프레이즈)에 실제 존재한다. tourApiClient.respondDetail(() -> Optional.of(new TourPoiDetail( "126508", 12, "경복궁", "서울 종로구", null, 37.5, 126.9, null, null))); @@ -181,6 +193,7 @@ TourApiClient stubTourApiClient() { @Test void 국가유산_스팟은_우리_DB가_사진과_설명까지_답한다() throws Exception { + poiDetailService.evictCache(); // 코스에 국가유산이 나가기 시작했으므로 상세도 함께 답해야 한다. 이 분기가 없으면 `HER-` 식별자가 // TourAPI 로 넘어가 404 가 난다 — 코스에는 있는데 누르면 없다고 하는 셈이다. tourApiClient.respondDetail(() -> { @@ -210,6 +223,7 @@ TourApiClient stubTourApiClient() { @Test void 인허가_장소는_업종_분류가_뱃지로_나간다() throws Exception { + poiDetailService.evictCache(); // 인허가 12만 건이 통째로 "기타" 로 나가고 있었다. 지어낼 값이 없어서가 아니라 가진 값을 안 썼다. tourApiClient.respondDetail(() -> { throw new AssertionError("인허가 식별자를 TourAPI 에 물었다"); @@ -224,6 +238,7 @@ TourApiClient stubTourApiClient() { @Test void 인허가_장소는_지도_검색_링크로_넘긴다() throws Exception { + poiDetailService.evictCache(); // 인허가 데이터에는 영업시간·사진이 애초에 없고 다른 공식 API 로도 못 얻는다. 낡은 영업시간을 // 우리가 보여주는 것보다 지도로 넘기는 편이 낫다. tourApiClient.respondDetail(() -> { @@ -240,6 +255,7 @@ TourApiClient stubTourApiClient() { @Test void 관광_API_콘텐츠에는_지도_링크를_붙이지_않는다() throws Exception { + poiDetailService.evictCache(); // 그쪽은 사진·소개·운영시간이 우리 응답에 이미 있다. 링크를 함께 주면 어디를 봐야 할지 갈린다. tourApiClient.respondDetail(() -> Optional.of(new TourPoiDetail( "126508", 12, "완도타워", "전남 완도군", "061-1", 34.3, 126.7, "http://img/1.jpg", "전망대 소개"))); @@ -278,6 +294,7 @@ TourApiClient stubTourApiClient() { @Test void 없는_국가유산이면_404_TOUR_003() throws Exception { + poiDetailService.evictCache(); mockMvc.perform(get("/api/v1/pois/{id}", "HER-99999999")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("TOUR-003")); @@ -285,10 +302,92 @@ TourApiClient stubTourApiClient() { @Test void 없는_장소면_404_TOUR_003() throws Exception { + poiDetailService.evictCache(); tourApiClient.respondDetail(Optional::empty); mockMvc.perform(get("/api/v1/pois/{id}", "999")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("TOUR-003")); } + + /** + * 같은 장소를 다시 눌러도 외부를 다시 부르지 않는다. + * + *

운영 로그에서 같은 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> detailBehavior = Optional::empty; private Supplier> introBehavior = Optional::empty; private Supplier> accessibilityBehavior = Optional::empty; @@ -45,6 +48,16 @@ public void resetAreaCallCount() { areaCalls.set(0); } + /** 지금까지의 공통상세 조회 횟수. */ + public int detailCallCount() { + return detailCalls.get(); + } + + /** 호출 횟수를 0 으로 — 공유 컨텍스트라 테스트마다 자기 시나리오만 세게 한다. */ + public void resetDetailCallCount() { + detailCalls.set(0); + } + /** 공통상세(detailCommon2) 응답을 지정한다. */ public void respondDetail(Supplier> detailBehavior) { this.detailBehavior = detailBehavior; @@ -88,6 +101,7 @@ public Optional findIntro(String contentId, int contentTypeId) { @Override public Optional findDetail(String contentId) { + detailCalls.incrementAndGet(); return detailBehavior.get(); }