Skip to content

Commit 0671277

Browse files
committed
feat: 연관 관광지 조회 외부 API 호출을 RestClient에서 WebClient로 전환
부하테스트(Test 1: RestClient + 전용 스레드풀)로 병렬 호출 시 톰캣 스레드 블로킹 문제를 확인한 뒤, WebClient + Mono.zip 기반 비동기 논블로킹 호출로 전환(Test 2). koreaTourismApiExecutor 전용 스레드풀은 더 이상 필요 없어 제거하고, WebClient 커넥션 풀은 기존 스레드풀과 동일한 10개로 제한해 비교 조건을 맞춤. - KoreaTourismRelatedSpotClient.searchRelatedSpots: RestClient -> WebClient, Mono<RelatedSpotResult> 반환. 실패는 기존과 동일하게 흡수해 fallback 유지 - RelatedSpotService: CompletableFuture 병렬 호출 -> Mono.zip 기반으로 변경 - WebClientConfiguration 신설: 전용 WebClient 빈(커넥션 풀 10), 기존 ExternalApiLoggingInterceptor와 동일한 형식의 요청/응답 로그 필터 추가 - 스케줄러(KoreaTourismApiHealthCheckScheduler)는 hot path가 아니라 .block() 으로 동기 유지, findLatestBaseYm은 RestClient 그대로 유지 부하테스트 응답시간 비교용 k6 스크립트(loadtest-scripts/related-spots.js) 추가: 서울/인천/부산(시군구 코드 3개 지역만) 순환 요청으로 캐싱 편향 완화 RelatedSpotApiTest는 WebClient 전환에 따른 재정비가 필요해 임시 비활성화
1 parent 08442e8 commit 0671277

10 files changed

Lines changed: 325 additions & 191 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// 연관 관광지 조회 API 응답시간 측정용 소규모 요청 스크립트 (부하테스트 아님)
2+
//
3+
// 목적: RestClient(블로킹) vs WebClient(논블로킹) 구조 변경 전후의 "응답 시간"만 비교한다.
4+
// 서울/인천/부산은 모두 TourApiAreaCode 기준 시군구 코드 3개를 가지고 있어서,
5+
// 요청 1건당 한국관광공사 API를 정확히 3회 병렬 호출한다. 즉 "외부 API 3회 병렬 호출" 시나리오를 그대로 재현한다.
6+
// REGION을 매 iteration마다 순환시키는 이유: 동일 지역만 반복 요청하면 캐싱 등으로 인해
7+
// 뒤로 갈수록 응답이 빨라지는 왜곡이 발생하기 때문 (지역을 다양화해 이를 줄인다).
8+
//
9+
// 외부 API(apis.data.go.kr) 호출 한도 보호를 위해, 이 스크립트는 전체 실행에서
10+
// TOTAL_REQUESTS(기본 30) × 3 = 90회 정도만 실제 한국관광공사 API를 호출하도록 총 요청 수를 제한한다.
11+
// (VU/기간 기반이 아니라 "총 요청 수 기반"으로 실행되는 이유가 이것이다.)
12+
//
13+
// 실행 예시:
14+
// BASE_URL=http://localhost:8080 k6 run loadtest-scripts/related-spots.js
15+
// BASE_URL=http://localhost:8080 TOTAL_REQUESTS=30 CONCURRENCY=4 k6 run loadtest-scripts/related-spots.js
16+
17+
import http from "k6/http";
18+
import { check } from "k6";
19+
import { Trend } from "k6/metrics";
20+
21+
const BASE_URL = __ENV.BASE_URL || "http://localhost:8080";
22+
// 시군구 코드 3개씩 -> 외부 API 3회 병렬 호출인 지역만 사용, 매 요청마다 순환
23+
const REGIONS = ["서울", "인천", "부산"];
24+
const TOTAL_REQUESTS = parseInt(__ENV.TOTAL_REQUESTS || "30", 10); // 총 호출할 our-API 요청 수 (외부 API 호출 수 = 이 값 x 3)
25+
const CONCURRENCY = parseInt(__ENV.CONCURRENCY || "4", 10); // 동시에 날아가는 요청 수
26+
27+
const relatedSpotsDuration = new Trend("related_spots_duration", true);
28+
const regionDurations = {
29+
"서울": new Trend("related_spots_duration_seoul", true),
30+
"인천": new Trend("related_spots_duration_incheon", true),
31+
"부산": new Trend("related_spots_duration_busan", true),
32+
};
33+
34+
export const options = {
35+
scenarios: {
36+
related_spots_probe: {
37+
executor: "shared-iterations",
38+
vus: CONCURRENCY,
39+
iterations: TOTAL_REQUESTS,
40+
maxDuration: "5m",
41+
},
42+
},
43+
// 소규모 프로브이므로 실패율에 대한 pass/fail 기준만 최소로 둔다.
44+
thresholds: {
45+
http_req_failed: ["rate<0.1"],
46+
},
47+
};
48+
49+
export default function () {
50+
const region = REGIONS[__ITER % REGIONS.length];
51+
const url = `${BASE_URL}/api/v1/related-spots?regionCategory=${encodeURIComponent(region)}`;
52+
const res = http.get(url, {
53+
tags: { name: "related-spots", region },
54+
});
55+
56+
relatedSpotsDuration.add(res.timings.duration);
57+
regionDurations[region].add(res.timings.duration);
58+
59+
check(res, {
60+
"status is 200": (r) => r.status === 200,
61+
});
62+
}
63+
64+
function formatTrend(label, trend) {
65+
if (!trend) {
66+
return `${label} : duration metric 없음`;
67+
}
68+
const v = trend.values;
69+
return [
70+
`${label}`,
71+
` avg : ${v.avg.toFixed(1)} ms`,
72+
` min : ${v.min.toFixed(1)} ms`,
73+
` med : ${v.med.toFixed(1)} ms`,
74+
` p90 : ${v["p(90)"].toFixed(1)} ms`,
75+
` p95 : ${v["p(95)"].toFixed(1)} ms`,
76+
` max : ${v.max.toFixed(1)} ms`,
77+
].join("\n");
78+
}
79+
80+
export function handleSummary(data) {
81+
const overall = data.metrics.related_spots_duration;
82+
const failed = data.metrics.http_req_failed;
83+
84+
const summaryLines = [
85+
"",
86+
"=== 연관 관광지 조회 응답시간 요약 ===",
87+
`BASE_URL : ${BASE_URL}`,
88+
`REGIONS : ${REGIONS.join(", ")} (요청마다 순환, 지역별 외부 API 병렬 호출 수: 3)`,
89+
`총 요청 수 : ${TOTAL_REQUESTS} (외부 API 총 호출 수 약 ${TOTAL_REQUESTS * 3}회)`,
90+
`동시 실행 수(VU) : ${CONCURRENCY}`,
91+
"",
92+
formatTrend("[전체]", overall),
93+
"",
94+
formatTrend("[서울]", data.metrics.related_spots_duration_seoul),
95+
"",
96+
formatTrend("[인천]", data.metrics.related_spots_duration_incheon),
97+
"",
98+
formatTrend("[부산]", data.metrics.related_spots_duration_busan),
99+
"",
100+
failed ? `실패율 : ${(failed.values.rate * 100).toFixed(2)}%` : "",
101+
"",
102+
].join("\n");
103+
104+
return {
105+
stdout: summaryLines,
106+
};
107+
}

backend/turip-app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ jar {
88

99
dependencies {
1010
implementation 'org.springframework.boot:spring-boot-starter-actuator'
11+
implementation 'org.springframework.boot:spring-boot-starter-webflux'
1112
implementation "io.micrometer:micrometer-registry-prometheus"
1213
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
1314
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'

backend/turip-app/src/main/java/turip/common/configuration/AsyncConfiguration.java

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package turip.common.configuration;
22

33
import java.util.concurrent.Executor;
4-
import java.util.concurrent.RejectedExecutionException;
54
import lombok.extern.slf4j.Slf4j;
65
import org.springframework.context.annotation.Bean;
76
import org.springframework.context.annotation.Configuration;
@@ -54,27 +53,4 @@ public Executor fcmEventExecutor() {
5453

5554
return executor;
5655
}
57-
58-
@Bean(name = "koreaTourismApiExecutor")
59-
public Executor koreaTourismApiExecutor() {
60-
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
61-
executor.setCorePoolSize(10);
62-
executor.setMaxPoolSize(10);
63-
executor.setQueueCapacity(50);
64-
executor.setThreadNamePrefix("KOREA-TOURISM-API-");
65-
66-
executor.setRejectedExecutionHandler((r, executorInstance) -> {
67-
log.warn(
68-
"[KOREA-TOURISM-API-ThreadPool] API 호출 거부됨 - Thread pool 포화 상태 (현재 활성 스레드: {}, 잔여 큐 용량: {})",
69-
executorInstance.getActiveCount(),
70-
executorInstance.getQueue().remainingCapacity()
71-
);
72-
throw new RejectedExecutionException("Thread pool 포화로 인한 API 호출 거부");
73-
});
74-
75-
executor.setWaitForTasksToCompleteOnShutdown(true);
76-
executor.setAwaitTerminationSeconds(30);
77-
78-
return executor;
79-
}
8056
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package turip.common.configuration;
2+
3+
import io.netty.channel.ChannelOption;
4+
import java.net.URI;
5+
import java.util.Arrays;
6+
import java.util.Set;
7+
import java.util.stream.Collectors;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.context.annotation.Bean;
10+
import org.springframework.context.annotation.Configuration;
11+
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
12+
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
13+
import org.springframework.web.reactive.function.client.WebClient;
14+
import reactor.netty.http.client.HttpClient;
15+
import reactor.netty.resources.ConnectionProvider;
16+
17+
@Slf4j
18+
@Configuration
19+
public class WebClientConfiguration {
20+
21+
private static final Set<String> SENSITIVE_PARAMS = Set.of("key", "apikey", "api_key", "token");
22+
private static final int MAX_CONNECTIONS = 10;
23+
private static final int CONNECT_TIMEOUT_MILLIS = 5000;
24+
25+
@Bean
26+
public WebClient koreaTourismWebClient() {
27+
ConnectionProvider connectionProvider = ConnectionProvider.builder("korea-tourism-webclient")
28+
.maxConnections(MAX_CONNECTIONS)
29+
.build();
30+
31+
HttpClient httpClient = HttpClient.create(connectionProvider)
32+
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT_MILLIS);
33+
34+
return WebClient.builder()
35+
.clientConnector(new ReactorClientHttpConnector(httpClient))
36+
.filter(loggingFilter())
37+
.build();
38+
}
39+
40+
private ExchangeFilterFunction loggingFilter() {
41+
return (request, next) -> {
42+
long startTime = System.currentTimeMillis();
43+
log.info("[외부 API 요청] method: {}, uri: {}", request.method(), maskSensitiveParams(request.url()));
44+
45+
return next.exchange(request)
46+
.doOnNext(response -> {
47+
long duration = System.currentTimeMillis() - startTime;
48+
log.info("[외부 API 응답] status: {}, duration: {}ms", response.statusCode(), duration);
49+
});
50+
};
51+
}
52+
53+
private String maskSensitiveParams(URI uri) {
54+
String query = uri.getQuery();
55+
if (query == null) {
56+
return uri.toString();
57+
}
58+
String maskedQuery = Arrays.stream(query.split("&"))
59+
.map(param -> {
60+
String[] kv = param.split("=", 2);
61+
if (kv.length == 2 && SENSITIVE_PARAMS.contains(kv[0].toLowerCase())) {
62+
return kv[0] + "=***";
63+
}
64+
return param;
65+
})
66+
.collect(Collectors.joining("&"));
67+
return uri.toString().replace(query, maskedQuery);
68+
}
69+
}

backend/turip-app/src/main/java/turip/infrastructure/client/KoreaTourismRelatedSpotClient.java

Lines changed: 47 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import java.time.YearMonth;
55
import java.time.format.DateTimeFormatter;
66
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.beans.factory.annotation.Qualifier;
78
import org.springframework.beans.factory.annotation.Value;
89
import org.springframework.stereotype.Component;
910
import org.springframework.web.client.RestClient;
10-
import org.springframework.web.client.RestClientResponseException;
11+
import org.springframework.web.reactive.function.client.WebClient;
12+
import reactor.core.publisher.Mono;
1113
import turip.infrastructure.client.dto.KoreaTourismRelatedSpotResponse;
1214
import turip.infrastructure.client.dto.RelatedSpotResult;
1315

@@ -25,66 +27,66 @@ public class KoreaTourismRelatedSpotClient {
2527
private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyyMM");
2628

2729
private final RestClient restClient;
30+
private final WebClient webClient;
2831
private final String koreaTourismApiKey;
2932
private final String koreaTourismApiUrl;
3033

3134
private volatile String currentBaseYm = DEFAULT_BASE_YM; // 동적으로 관리되는 기준월
3235

3336
public KoreaTourismRelatedSpotClient(RestClient baseRestClient,
37+
@Qualifier("koreaTourismWebClient") WebClient koreaTourismWebClient,
3438
@Value("${korea-tourism.api.key}") String koreaTourismApiKey,
3539
@Value("${korea-tourism.api.url}") String koreaTourismApiUrl) {
3640
this.restClient = baseRestClient;
41+
this.webClient = koreaTourismWebClient;
3742
this.koreaTourismApiKey = koreaTourismApiKey;
3843
this.koreaTourismApiUrl = koreaTourismApiUrl;
3944
}
4045

41-
public RelatedSpotResult searchRelatedSpots(int areaCode, int sigunguCode) {
42-
try {
43-
// URI를 완전히 수동으로 생성 (이중 인코딩 방지)
44-
StringBuilder uriString = new StringBuilder(koreaTourismApiUrl)
45-
.append("?serviceKey=").append(koreaTourismApiKey)
46-
.append("&pageNo=").append(DEFAULT_PAGE_NO)
47-
.append("&numOfRows=").append(DEFAULT_NUM_OF_ROWS)
48-
.append("&MobileOS=").append(MOBILE_OS)
49-
.append("&MobileApp=").append(MOBILE_APP)
50-
.append("&_type=").append(RESPONSE_TYPE)
51-
.append("&baseYm=").append(currentBaseYm)
52-
.append("&areaCd=").append(areaCode)
53-
.append("&signguCd=").append(sigunguCode);
54-
55-
URI uri = URI.create(uriString.toString());
56-
KoreaTourismRelatedSpotResponse response = restClient.get()
57-
.uri(uri)
58-
.retrieve()
59-
.body(KoreaTourismRelatedSpotResponse.class);
60-
61-
if (response == null) {
62-
log.warn("한국관광공사 연관 관광지 API 응답 파싱 실패");
63-
return RelatedSpotResult.failure();
64-
}
65-
66-
if (!response.isSuccess()) {
67-
log.warn("한국관광공사 연관 관광지 API 응답 실패: resultCode={}, resultMsg={}",
68-
response.getResultCode(), response.getResultMsg());
69-
return RelatedSpotResult.failure();
70-
}
46+
public Mono<RelatedSpotResult> searchRelatedSpots(int areaCode, int sigunguCode) {
47+
URI uri = buildUri(currentBaseYm, areaCode, sigunguCode, DEFAULT_NUM_OF_ROWS);
48+
49+
return webClient.get()
50+
.uri(uri)
51+
.retrieve()
52+
.bodyToMono(KoreaTourismRelatedSpotResponse.class)
53+
.map(response -> toResult(response, areaCode, sigunguCode))
54+
.onErrorResume(e -> {
55+
log.warn("한국관광공사 연관 관광지 API 호출 실패: areaCode={}, sigunguCode={}, message={}",
56+
areaCode, sigunguCode, e.getMessage());
57+
return Mono.just(RelatedSpotResult.failure());
58+
});
59+
}
7160

72-
// API 호출 성공, 데이터가 비어있어도 success로 반환
73-
return RelatedSpotResult.success(response.getRelatedSpots());
74-
} catch (RestClientResponseException e) {
75-
log.warn("한국관광공사 연관 관광지 API 호출 실패: areaCode={}, sigunguCode={}, statusCode={}, message={}",
76-
areaCode,
77-
sigunguCode,
78-
e.getStatusCode().value(),
79-
e.getMessage());
61+
private RelatedSpotResult toResult(KoreaTourismRelatedSpotResponse response, int areaCode, int sigunguCode) {
62+
if (response == null) {
63+
log.warn("한국관광공사 연관 관광지 API 응답 파싱 실패");
8064
return RelatedSpotResult.failure();
81-
} catch (Exception e) {
82-
log.warn("한국관광공사 연관 관광지 API 호출 실패: areaCode={}, sigunguCode={}, message={}",
83-
areaCode,
84-
sigunguCode,
85-
e.getMessage());
65+
}
66+
67+
if (!response.isSuccess()) {
68+
log.warn("한국관광공사 연관 관광지 API 응답 실패: areaCode={}, sigunguCode={}, resultCode={}, resultMsg={}",
69+
areaCode, sigunguCode, response.getResultCode(), response.getResultMsg());
8670
return RelatedSpotResult.failure();
8771
}
72+
73+
// API 호출 성공, 데이터가 비어있어도 success로 반환
74+
return RelatedSpotResult.success(response.getRelatedSpots());
75+
}
76+
77+
private URI buildUri(String baseYm, int areaCode, int sigunguCode, int numOfRows) {
78+
// URI를 완전히 수동으로 생성 (이중 인코딩 방지)
79+
StringBuilder uriString = new StringBuilder(koreaTourismApiUrl)
80+
.append("?serviceKey=").append(koreaTourismApiKey)
81+
.append("&pageNo=").append(DEFAULT_PAGE_NO)
82+
.append("&numOfRows=").append(numOfRows)
83+
.append("&MobileOS=").append(MOBILE_OS)
84+
.append("&MobileApp=").append(MOBILE_APP)
85+
.append("&_type=").append(RESPONSE_TYPE)
86+
.append("&baseYm=").append(baseYm)
87+
.append("&areaCd=").append(areaCode)
88+
.append("&signguCd=").append(sigunguCode);
89+
return URI.create(uriString.toString());
8890
}
8991

9092
/**
@@ -107,18 +109,7 @@ public String findLatestBaseYm(int areaCode, int sigunguCode) {
107109
}
108110

109111
try {
110-
StringBuilder uriString = new StringBuilder(koreaTourismApiUrl)
111-
.append("?serviceKey=").append(koreaTourismApiKey)
112-
.append("&pageNo=").append(DEFAULT_PAGE_NO)
113-
.append("&numOfRows=").append(1) // 1개만 조회
114-
.append("&MobileOS=").append(MOBILE_OS)
115-
.append("&MobileApp=").append(MOBILE_APP)
116-
.append("&_type=").append(RESPONSE_TYPE)
117-
.append("&baseYm=").append(baseYm)
118-
.append("&areaCd=").append(areaCode)
119-
.append("&signguCd=").append(sigunguCode);
120-
121-
URI uri = URI.create(uriString.toString());
112+
URI uri = buildUri(baseYm, areaCode, sigunguCode, 1); // 1개만 조회
122113
KoreaTourismRelatedSpotResponse response = restClient.get()
123114
.uri(uri)
124115
.retrieve()

backend/turip-app/src/main/java/turip/infrastructure/scheduler/KoreaTourismApiHealthCheckScheduler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public void checkKoreaTourismApiHealth() {
2626
RelatedSpotResult result = koreaTourismRelatedSpotClient.searchRelatedSpots(
2727
TourApiAreaCode.SEOUL.getAreaCode(),
2828
TourApiAreaCode.SEOUL.getSigunguCodes().get(0)
29-
);
29+
).block();
3030

3131
if (!result.isSuccess()) {
3232
log.error("{} 헬스체크 실패 - API 응답 실패", LOG_HEADER);

0 commit comments

Comments
 (0)