Skip to content
29 changes: 25 additions & 4 deletions src/main/java/com/offway/core/common/logging/SensitiveParams.java
Original file line number Diff line number Diff line change
Expand Up @@ -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; // 등호 없는 조각 — 이름만 남긴다
}
Expand All @@ -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);
}

/**
* <b>이미 디코딩된 값 하나</b>를 로그에 실을 수 있게 — 제어문자 제거 → 길이 제한.
*
* <p><b>왜 필요한가.</b> {@code @PathVariable}·외부 응답 필드처럼 우리가 만들지 않은 문자열을 로그에
* 그대로 끼우면 두 가지가 깨진다. ① {@code %0A} 는 서블릿이 이미 디코딩해 <b>실제 개행</b>으로 오므로
* 값 하나가 로그를 여러 줄로 쪼개 가짜 로그 줄을 지어낼 수 있다. ② 길이 상한이 없어 값 하나가 줄
* 전체를 밀어낸다. {@link #readableParams}·{@code RootCause} 가 쿼리·예외 메시지에 대해 이미 하는
* 처리를 <b>값 하나</b>에도 같은 기준으로 적용한다 — 규칙이 두 벌이 되면 한쪽이 반드시 뒤처진다.
*
* <p><b>여기서 다시 디코딩하지 않는다.</b> 이미 디코딩된 값을 또 풀면 값 안의 {@code %41} 이
* {@code A} 로 바뀌어, 로그가 실제 요청과 다른 값을 가리킨다.
*
* <p>가리지는 않는다 — 마스킹 대상은 {@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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -120,7 +121,8 @@ private List<GalleryPhotoItem> parse(String body) throws Exception {
private static void addIfComplete(List<GalleryPhotoItem> 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);
Expand Down
164 changes: 161 additions & 3 deletions src/main/java/com/offway/core/trip/service/PoiDetailService.java
Original file line number Diff line number Diff line change
@@ -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;

/**
Expand All @@ -27,13 +34,59 @@
* <p>TourAPI 는 read-timeout 이 길어 트랜잭션 밖에서 호출한다(persistence-convention). 장소가 없으면
* {@link TourApiException#poiNotFound()}(404).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PoiDetailService {

/** TourAPI 콘텐츠가 아님을 뜻하는 타입 — 인허가·국가유산이 함께 쓴다. 실제 contentTypeId 는 12·32·39 처럼 모두 양수다. */
private static final int NON_TOUR_CONTENT_TYPE = 0;

/**
* 성공 캐시 TTL — 상세는 <b>느리게 변하는 값</b>이다(주소·개요·운영시간·휴무일).
*
* <p>실측(2026-08-12, 표본 30건) p50 109ms · p95 151ms 로 평시엔 빠르지만, 값이 빠른 것과 자주
* 부를 이유가 있는 것은 다르다. 같은 장소를 누를 때마다 외부를 치면 일일 한도를 그만큼 태우고,
* 외부가 멈춘 순간을 만날 확률도 호출 수에 비례해 오른다.
*/
private static final Duration CACHE_TTL = Duration.ofHours(6);

/**
* 조회 실패 TTL — 짧게 둬 재시도를 유도한다.
*
* <p>실패를 성공 TTL 로 누르면 그 장소가 6시간 동안 죽는다. 반대로 아예 안 누르면 외부가 느린 동안
* 모든 요청이 각자 8초를 기다린다(호출 하나의 상한 6초 + 429 재시도). 1분이 그 사이다.
*/
private static final Duration FAILURE_CACHE_TTL = Duration.ofMinutes(1);

/**
* 없는 콘텐츠 TTL — 실패보다는 길게, 성공보다는 짧게.
*
* <p>"없다" 는 안정된 답이라 매번 물을 이유가 없다. 다만 우리가 코스에 실어 보낸 식별자가 404 라면
* 그건 우리 쪽 버그 신호라, 오래 굳혀 두면 고친 뒤에도 한동안 404 가 나간다.
*/
private static final Duration NOT_FOUND_CACHE_TTL = Duration.ofMinutes(10);

/**
* 캐시 엔트리 상한 — <b>키 공간은 TourAPI contentId 다</b>.
*
* <p>89곳 전체 콘텐츠는 실측 기준 지역당 100건 안팎(최대 평창군 446건)이라 만 단위까지 갈 수 있다.
* 다만 실제로 눌리는 것은 코스에 실린 장소뿐이라 훨씬 적다 — 코스 하나가 슬롯 20개 남짓이다.
* 상한을 두는 이유는 <b>TTL 이 엔트리를 지우지 않기 때문</b>이다(성능 규약).
*/
private static final int MAX_CACHED_DETAILS = 2_000;

/**
* 빈 키에 동시 요청이 몰렸을 때 첫 적재를 기다릴 상한.
*
* <p>loader 는 외부를 <b>두 번</b> 부른다(공통상세 → 소개정보). 호출 하나의 상한이 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");

Expand All @@ -43,6 +96,63 @@ public class PoiDetailService {
private final HeritagePlaceRepository heritagePlaceRepository;
private final PolicyService policyService;

/**
* 관광 API 상세 캐시 — 인허가·국가유산은 우리 DB 라 캐시하지 않는다.
*
* <p><b>stale 을 허용한다.</b> 상세는 느리게 변하므로 6시간 전 값이 502 보다 낫다. 실제로 그 차이가
* 났다 — 외부가 6초 안에 답하지 않은 순간(2026-08-11 04:14) 사용자는 화면을 통째로 못 봤는데,
* 캐시가 있었다면 직전 값이 나갔다.
*/
private final ExternalDataCache<String, CachedDetail> detailCache =
new ExternalDataCache<>(MAX_CACHED_DETAILS, FIRST_LOAD_WAIT);

/**
* 캐시에 담긴 조회 결과의 상태 — 상태는 boolean 조합이 아니라 이름으로 든다.
*
* <p>세 상태의 클라이언트 계약이 각각 다르다(200·404·502). 상태마다 무엇을 내릴지는 상수 자신이 안다.
*/
private enum DetailStatus {
FOUND,
NOT_FOUND,
LOOKUP_FAILED
}

/**
* 캐시에 담는 조회 결과.
*
* <p>{@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). 사진·소개는 없지만 상호·주소·전화는 있다.
Expand All @@ -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 상세 — 캐시를 거친다.
*
* <p>캐시가 없던 때는 같은 장소를 누를 때마다 외부를 두 번씩 쳤다. 운영 로그에서 <b>같은 contentId 가
* 40초 안에 세 번</b> 조회되는 것을 봤는데, 호출이 세 배면 외부가 멈춘 순간을 만날 확률도 세 배다.
*/
private PoiDetail tourDetail(String contentId) {
return detailCache
.get(contentId, this::loadDetail, CachedDetail.failed(), StalePolicy.ALLOW_STALE)
.orThrow();
}

// 외부 응답을 여기서 도메인으로 옮긴다 — 상위 레이어(서비스 dto·응답 dto)가 어댑터 DTO 를 들지 않게.
/**
* 캐시 loader — <b>외부 예외를 스스로 잡는다</b>(캐시 프리미티브의 계약).
*
* <p>실패했는데 직전 성공값이 있으면 그걸 그대로 돌려준다. 상세는 느리게 변하므로 6시간 전 값이
* 502 보다 낫다. 다만 TTL 은 짧게 줘, 외부가 돌아오면 곧 다시 받아온다.
*/
private Loaded<CachedDetail> loadDetail(String contentId, CachedDetail stale) {
try {
Optional<TourPoiDetail> 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())
Expand All @@ -82,6 +235,11 @@ public PoiDetail detail(String contentId) {
catchphraseProvider.forContentId(contentId).orElse(null));
}

/** 강제 갱신·통합 테스트 격리용. 공유 컨텍스트에서 앞 테스트의 캐시가 뒤 테스트를 통과시키지 않게. */
public void evictCache() {
detailCache.evictAll();
}

/**
* 국가유산의 상세(#160) — 인허가와 달리 <b>사진과 설명이 있다</b>.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Loading