From 86dc1df097506b649f7688fe9e86bdc20459df21 Mon Sep 17 00:00:00 2001 From: sevineleven Date: Wed, 12 Aug 2026 10:09:33 +0900 Subject: [PATCH 1/4] =?UTF-8?q?perf:=20=EC=9E=A5=EC=86=8C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EB=A5=BC=20ExternalDataCache=20=EC=9C=84=EC=97=90=20?= =?UTF-8?q?=EC=98=AC=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 장소를 누를 때마다 외부를 두 번씩(공통상세 → 소개정보) 쳤다. 운영 로그에서 같은 contentId 가 40초 안에 세 번 조회되는 것을 봤다 — 호출이 세 배면 일일 한도도 세 배로 타고, 외부가 멈춘 순간을 만날 확률도 세 배다. 어제 그 순간이 실제로 왔다: 04:14:28 WARN TourAPI 공통상세 조회 실패 cause=TimeoutException ... 6000ms 04:14:28 WARN 도메인 예외(5xx) code=TOUR-001 status=502 detailCommon2 실측(표본 30건) p50 109ms · p95 151ms 라, 6초를 넘겼다는 것은 느려진 게 아니라 그 순간 응답이 안 온 것이다. 외부가 멈추는 것은 우리가 못 고치지만, 그 노출을 줄이는 것은 우리 몫이다. stale 을 허용한다 — 상세는 주소·개요·운영시간이라 느리게 변하고, 6시간 전 값이 502 보다 낫다. 캐시가 있었다면 위 요청은 직전 값을 받았다. TTL 은 값의 성격에서 도출한다. 성공 6시간 · 조회 실패 1분(재시도 유도) · 없는 콘텐츠 10분. 실패를 성공 TTL 로 누르면 그 장소가 6시간 죽고, 아예 안 누르면 외부가 느린 동안 모든 요청이 각자 8초를 기다린다. 키 공간은 TourAPI contentId 라 만 단위까지 갈 수 있어 상한을 2,000 으로 둔다 — TTL 은 엔트리를 지우지 않는다(성능 규약). "없는 콘텐츠(404)" 와 "조회 실패(502)" 를 캐시 값이 구분한다. detail 만 담으면 둘 다 null 이 돼 클라이언트 계약이 갈리는 자리에서 섞인다. 인허가·국가유산 식별자는 우리 DB 가 답하므로 캐시를 타지 않는다. 테스트는 공유 컨텍스트라 각 테스트가 본문에서 캐시를 비운다 — 안 비우면 앞 테스트가 넣은 값으로 뒤 테스트가 통과한다. --- .../core/trip/service/PoiDetailService.java | 136 +++++++++++++++++- .../controller/PoiDetailIntegrationTest.java | 97 +++++++++++++ .../tour/StubTourApiClient.java | 14 ++ 3 files changed, 245 insertions(+), 2 deletions(-) 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 a196e596..441772e5 100644 --- a/src/main/java/com/offway/core/trip/service/PoiDetailService.java +++ b/src/main/java/com/offway/core/trip/service/PoiDetailService.java @@ -1,5 +1,8 @@ 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.trip.domain.HeritagePlace; import com.offway.core.trip.domain.LicensedPlace; import com.offway.core.trip.domain.MapSearchLink; @@ -12,8 +15,10 @@ 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.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; /** @@ -23,6 +28,7 @@ *

TourAPI 는 read-timeout 이 길어 트랜잭션 밖에서 호출한다(persistence-convention). 장소가 없으면 * {@link TourApiException#poiNotFound()}(404). */ +@Slf4j @Service @RequiredArgsConstructor public class PoiDetailService { @@ -30,11 +36,85 @@ 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); + private final TourApiClient tourApiClient; private final CatchphraseProvider catchphraseProvider; private final LicensedPlaceRepository licensedPlaceRepository; private final HeritagePlaceRepository heritagePlaceRepository; + /** + * 관광 API 상세 캐시 — 인허가·국가유산은 우리 DB 라 캐시하지 않는다. + * + *

stale 을 허용한다. 상세는 느리게 변하므로 6시간 전 값이 502 보다 낫다. 실제로 그 차이가 + * 났다 — 외부가 6초 안에 답하지 않은 순간(2026-08-11 04:14) 사용자는 화면을 통째로 못 봤는데, + * 캐시가 있었다면 직전 값이 나갔다. + */ + private final ExternalDataCache detailCache = + new ExternalDataCache<>(MAX_CACHED_DETAILS, FIRST_LOAD_WAIT); + + /** + * 캐시에 담는 조회 결과. + * + *

{@code detail} 만 담으면 "없는 콘텐츠(404)" 와 "조회 실패(502)" 가 둘 다 null 이 돼 구분되지 + * 않는다. 클라이언트 계약이 갈리는 자리라 상태를 함께 담는다. + */ + private record CachedDetail(PoiDetail detail, boolean lookupFailed) { + + static CachedDetail found(PoiDetail detail) { + return new CachedDetail(detail, false); + } + + static CachedDetail notFound() { + return new CachedDetail(null, false); + } + + static CachedDetail failed() { + return new CachedDetail(null, true); + } + } + public PoiDetail detail(String contentId) { // 코스 응답에는 두 출처의 식별자가 섞여 나간다. 인허가 장소를 TourAPI 에 물으면 없는 콘텐츠라 // 404 가 떨어지므로, 우리 식별자는 우리 DB 가 답한다(#144). 사진·소개는 없지만 상호·주소·전화는 있다. @@ -47,9 +127,56 @@ public PoiDetail detail(String contentId) { return heritageDetail(heritageId.get()); } - TourPoiDetail detail = tourApiClient.findDetail(contentId).orElseThrow(TourApiException::poiNotFound); + return tourDetail(contentId); + } - // 외부 응답을 여기서 도메인으로 옮긴다 — 상위 레이어(서비스 dto·응답 dto)가 어댑터 DTO 를 들지 않게. + /** + * 관광 API 상세 — 캐시를 거친다. + * + *

캐시가 없던 때는 같은 장소를 누를 때마다 외부를 두 번씩 쳤다. 운영 로그에서 같은 contentId 가 + * 40초 안에 세 번 조회되는 것을 봤는데, 호출이 세 배면 외부가 멈춘 순간을 만날 확률도 세 배다. + */ + private PoiDetail tourDetail(String contentId) { + CachedDetail cached = + detailCache.get(contentId, this::loadDetail, CachedDetail.failed(), StalePolicy.ALLOW_STALE); + if (cached.lookupFailed()) { + // 캐시가 잡아 둔 실패다. 원인은 loader 안에서 이미 로그로 남았다. + throw TourApiException.lookupFailed(new IllegalStateException("관광 API 상세 조회 실패(캐시된 결과)")); + } + if (cached.detail() == null) { + throw TourApiException.poiNotFound(); + } + return cached.detail(); + } + + /** + * 캐시 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) { + if (stale != null && stale.detail() != null) { + log.warn("관광 API 상세 조회 실패 — 직전 값으로 내려보냅니다 contentId={} cause={}", + contentId, e.getClass().getSimpleName()); + return new Loaded<>(stale, FAILURE_CACHE_TTL); + } + // degrade 를 조용히 넘기지 않는다 — 폴백이 정상처럼 보이면 장애를 아무도 모른다. + log.warn("관광 API 상세 조회 실패 — 내려보낼 직전 값이 없습니다 contentId={} cause={}", + 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()) @@ -72,6 +199,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/trip/controller/PoiDetailIntegrationTest.java b/src/test/java/com/offway/core/trip/controller/PoiDetailIntegrationTest.java index 554a93de..c60c74be 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", "전망대 소개"))); @@ -252,6 +268,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")); @@ -259,10 +276,90 @@ 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()); + } + + /** + * 외부가 죽어도 직전 값을 내린다 — 상세는 느리게 변하므로 6시간 전 값이 502 보다 낫다. + * + *

2026-08-11 04:14 에 실제로 그 반대가 났다. 공통상세가 6초 안에 답하지 않아 사용자가 화면을 + * 통째로 못 봤는데, 그때 캐시에 직전 값이 있었다면 그게 나갔다. + */ + @Test + void 외부가_실패해도_직전_값을_내린다() 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(); } From 905cbb70441db4e1d3838a90a93f0e9388fad593 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 16:29:26 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EC=83=81=EC=84=B8=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EC=9D=98=20=EC=84=B8=20=EC=83=81=ED=83=9C=EB=A5=BC=20?= =?UTF-8?q?enum=20=EC=9C=BC=EB=A1=9C=20=EB=93=A0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `detail == null` 과 `lookupFailed` 를 함께 읽어야 상태를 알 수 있었다. 셋의 클라이언트 계약이 각각 다른데(200·404·502) 판별이 두 필드의 조합에 흩어져 있어, 새 상태를 더하면 조합이 늘고 잘못된 조합(`detail != null` 인데 실패)도 표현 가능했다. `DetailStatus`(FOUND·NOT_FOUND·LOOKUP_FAILED) 로 상태를 이름으로 들고, 무엇을 내릴지는 `CachedDetail.orThrow()` 가 switch 로 소유한다. 서비스에 남아 있던 상태 해석 분기 두 개가 사라졌다. `found()` 는 detail 을 requireNonNull 로 받아 "FOUND 인데 값이 없는" 조합을 막는다. --- .../core/trip/service/PoiDetailService.java | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) 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 441772e5..eadf712b 100644 --- a/src/main/java/com/offway/core/trip/service/PoiDetailService.java +++ b/src/main/java/com/offway/core/trip/service/PoiDetailService.java @@ -16,6 +16,7 @@ import com.offway.core.trip.repository.LicensedPlaceRepository; import com.offway.core.trip.service.dto.PoiDetail; import java.time.Duration; +import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -79,6 +80,9 @@ public class PoiDetailService { */ private static final Duration FIRST_LOAD_WAIT = Duration.ofSeconds(8); + /** 캐시가 잡아 둔 실패를 502 로 올릴 때의 사유 — 실제 원인은 적재 시점 로그에 남아 있다. */ + private static final String CACHED_FAILURE_REASON = "관광 API 상세 조회 실패(캐시된 결과)"; + private final TourApiClient tourApiClient; private final CatchphraseProvider catchphraseProvider; private final LicensedPlaceRepository licensedPlaceRepository; @@ -94,24 +98,50 @@ public class PoiDetailService { 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, boolean lookupFailed) { + private record CachedDetail(PoiDetail detail, DetailStatus status) { static CachedDetail found(PoiDetail detail) { - return new CachedDetail(detail, false); + return new CachedDetail(Objects.requireNonNull(detail, "detail"), DetailStatus.FOUND); } static CachedDetail notFound() { - return new CachedDetail(null, false); + return new CachedDetail(null, DetailStatus.NOT_FOUND); } static CachedDetail failed() { - return new CachedDetail(null, true); + 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)); + }; } } @@ -137,16 +167,9 @@ public PoiDetail detail(String contentId) { * 40초 안에 세 번 조회되는 것을 봤는데, 호출이 세 배면 외부가 멈춘 순간을 만날 확률도 세 배다. */ private PoiDetail tourDetail(String contentId) { - CachedDetail cached = - detailCache.get(contentId, this::loadDetail, CachedDetail.failed(), StalePolicy.ALLOW_STALE); - if (cached.lookupFailed()) { - // 캐시가 잡아 둔 실패다. 원인은 loader 안에서 이미 로그로 남았다. - throw TourApiException.lookupFailed(new IllegalStateException("관광 API 상세 조회 실패(캐시된 결과)")); - } - if (cached.detail() == null) { - throw TourApiException.poiNotFound(); - } - return cached.detail(); + return detailCache + .get(contentId, this::loadDetail, CachedDetail.failed(), StalePolicy.ALLOW_STALE) + .orThrow(); } /** @@ -163,7 +186,7 @@ private Loaded loadDetail(String contentId, CachedDetail stale) { } return new Loaded<>(CachedDetail.found(toPoiDetail(contentId, found.get())), CACHE_TTL); } catch (RuntimeException e) { - if (stale != null && stale.detail() != null) { + if (stale != null && stale.isFound()) { log.warn("관광 API 상세 조회 실패 — 직전 값으로 내려보냅니다 contentId={} cause={}", contentId, e.getClass().getSimpleName()); return new Loaded<>(stale, FAILURE_CACHE_TTL); From cc15966893b3b3629e1101fee426899eb8bdcb74 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 16:29:51 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=97=90=20?= =?UTF-8?q?=EA=B0=92=20=ED=95=98=EB=82=98=EB=A5=BC=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EB=81=BC=EC=9B=8C=20=EB=84=A3=EB=8D=98=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EA=B3=B5=ED=86=B5=20=EC=83=88=EB=8B=88?= =?UTF-8?q?=ED=83=80=EC=9D=B4=EC=A0=80=EC=97=90=20=ED=83=9C=EC=9A=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 경로 변수 `contentId` 는 서블릿이 퍼센트 디코딩을 마친 값이라 `%0A` 가 **실제 개행**으로 온다. 그대로 log.warn 에 실으면 값 하나가 로그를 여러 줄로 쪼개, 있지도 않은 로그 줄을 지어낼 수 있다(log forging). 길이 상한도 없어 값 하나가 줄 전체를 밀어낸다. 쿼리스트링(`readableParams`)과 예외 메시지(`RootCause`)는 이미 제어문자 제거·길이 제한을 거치는데, **값 하나**를 찍는 경로에만 그 기준이 없었다. `SensitiveParams.forLog(String)` 로 같은 기준을 노출하고(디코딩은 하지 않는다 — 이미 디코딩된 값을 또 풀면 `%41` 이 `A` 가 돼 로그가 실제 요청과 다른 값을 가리킨다), 같은 모양의 자리를 함께 고쳤다. - `PoiDetailService` 상세 조회 실패 로그 2곳 (경로 변수) - `GalleryPhotoClientImpl` 필수 값 누락 로그 (외부 응답 문자열) 값을 **가리지는 않는다.** contentId 는 공개 콘텐츠 식별자이고 우리가 코스 응답에 실어 보내는 값이라, 마스킹하면 "어느 장소가 degrade 했나" 에 답하지 못해 로그의 존재 이유가 사라진다. 레포도 regionId·contentTypeId 를 같은 기준으로 남기고 있다. --- .../core/common/logging/SensitiveParams.java | 29 +++++++++++++--- .../gallery/GalleryPhotoClientImpl.java | 4 ++- .../core/trip/service/PoiDetailService.java | 8 +++-- .../common/logging/SensitiveParamsTest.java | 34 +++++++++++++++++++ 4 files changed, 68 insertions(+), 7 deletions(-) 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 b9a7304b..9ceb5f8b 100644 --- a/src/main/java/com/offway/core/common/logging/SensitiveParams.java +++ b/src/main/java/com/offway/core/common/logging/SensitiveParams.java @@ -80,7 +80,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; // 등호 없는 조각 — 이름만 남긴다 } @@ -90,18 +90,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 eadf712b..cb22394f 100644 --- a/src/main/java/com/offway/core/trip/service/PoiDetailService.java +++ b/src/main/java/com/offway/core/trip/service/PoiDetailService.java @@ -3,6 +3,7 @@ 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.trip.domain.HeritagePlace; import com.offway.core.trip.domain.LicensedPlace; import com.offway.core.trip.domain.MapSearchLink; @@ -186,14 +187,17 @@ private Loaded loadDetail(String contentId, CachedDetail stale) { } 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={}", - contentId, e.getClass().getSimpleName()); + SensitiveParams.forLog(contentId), e.getClass().getSimpleName()); return new Loaded<>(stale, FAILURE_CACHE_TTL); } // degrade 를 조용히 넘기지 않는다 — 폴백이 정상처럼 보이면 장애를 아무도 모른다. log.warn("관광 API 상세 조회 실패 — 내려보낼 직전 값이 없습니다 contentId={} cause={}", - contentId, e.getClass().getSimpleName()); + SensitiveParams.forLog(contentId), e.getClass().getSimpleName()); return new Loaded<>(CachedDetail.failed(), FAILURE_CACHE_TTL); } } 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 7e399397..c8e226d4 100644 --- a/src/test/java/com/offway/core/common/logging/SensitiveParamsTest.java +++ b/src/test/java/com/offway/core/common/logging/SensitiveParamsTest.java @@ -122,4 +122,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)); + } } From 30084f4368eb17cfa383e5ebc531d86f4a2f6a64 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 16:29:59 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=EC=83=81=EC=84=B8=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A8=EC=96=B8=ED=95=98=EB=8A=94=20=EA=B2=83?= =?UTF-8?q?=EA=B3=BC=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `외부가_실패해도_직전_값을_내린다` 는 이름과 달리 본문이 캐시를 비운 뒤 502 를 단언한다. 이름만 읽으면 stale 경로가 덮인 줄 알아, 정작 그 경로가 깨져도 아무도 모른다. 단언하는 것(직전 값이 없으면 502)으로 이름을 바꾸고, 반대편(직전 값이 있으면 내린다)을 여기서 재현하지 않는 이유를 문서에 적었다 — TTL 이 6시간이라 만료를 기다릴 수 없고 시계 seam 도 없다. stale-while-error 자체는 `ExternalDataCacheTest` 가 짧은 TTL 로 덮는다. --- .../core/trip/controller/PoiDetailIntegrationTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 c60c74be..7dabf424 100644 --- a/src/test/java/com/offway/core/trip/controller/PoiDetailIntegrationTest.java +++ b/src/test/java/com/offway/core/trip/controller/PoiDetailIntegrationTest.java @@ -307,13 +307,15 @@ TourApiClient stubTourApiClient() { } /** - * 외부가 죽어도 직전 값을 내린다 — 상세는 느리게 변하므로 6시간 전 값이 502 보다 낫다. + * 외부가 죽었는데 내려보낼 직전 값도 없으면 502 로 알린다 — 조용히 빈 화면을 주지 않는다. * - *

2026-08-11 04:14 에 실제로 그 반대가 났다. 공통상세가 6초 안에 답하지 않아 사용자가 화면을 - * 통째로 못 봤는데, 그때 캐시에 직전 값이 있었다면 그게 나갔다. + *

반대편(직전 값이 있으면 그걸 내린다)은 여기서 재현하지 않는다. TTL 이 6시간이라 만료를 기다릴 + * 수 없고, 시계를 주입하는 seam 도 두지 않았다. stale-while-error 자체는 캐시 프리미티브의 단위 + * 테스트가 짧은 TTL 로 덮는다({@code ExternalDataCacheTest}). 여기서 확인할 것은 그 정책을 이 + * 서비스가 골라 쓰는지이고, 정책이 갈리는 지점인 "직전 값 없음" 이 이 테스트다. */ @Test - void 외부가_실패해도_직전_값을_내린다() throws Exception { + void 외부가_실패했는데_직전_값도_없으면_502로_알린다() throws Exception { poiDetailService.evictCache(); tourApiClient.respondDetail(() -> Optional.of(new TourPoiDetail( "777", 12, "청령포", "강원 영월군", null, 37.1, 128.4, "http://img/7.jpg", "명승 소개")));