33import java .util .HashMap ;
44import java .util .List ;
55import java .util .Map ;
6+ import java .util .concurrent .CompletableFuture ;
7+ import java .util .concurrent .ExecutionException ;
8+ import java .util .concurrent .Executors ;
9+ import java .util .concurrent .Future ;
10+ import java .util .stream .Collectors ;
611import lombok .RequiredArgsConstructor ;
12+ import lombok .extern .slf4j .Slf4j ;
713import org .springframework .beans .factory .annotation .Value ;
814import org .springframework .data .geo .Point ;
15+ import org .springframework .scheduling .annotation .Async ;
916import org .springframework .stereotype .Service ;
1017import org .springframework .web .reactive .function .client .WebClient ;
11- import reactor .core .publisher .Flux ;
12- import reactor .core .publisher .Mono ;
1318
19+ /**
20+ * Virtual Thread 기반 Kakao Map API 서비스 - WebClient.block()은 Virtual Thread에서 안전하게 사용 가능 - 블로킹 호출이지만
21+ * Virtual Thread 덕분에 높은 동시성 유지
22+ */
23+ @ Slf4j
1424@ Service
1525@ RequiredArgsConstructor
1626public class EtaService {
@@ -22,69 +32,117 @@ public class EtaService {
2232
2333 private static final String KAKAO_BASE_URL = "https://apis-navi.kakaomobility.com/v1" ;
2434
25- // Kakao Map API 호출 (Reactive)
26- public Mono <Map <String , Double >> getEtaForMultipleReactive (
27- // double storeLat, double storeLon,
35+ /**
36+ * 여러 라이더의 ETA 계산 (동기식 + 병렬 처리) - Virtual Thread에서 병렬로 실행 - @Async로 각 API 호출을 독립적인 Virtual
37+ * Thread에서 처리
38+ */
39+ public Map <String , Double > getEtaForMultiple (
2840 double userLat , double userLon ,
2941 List <Point > riderPoints ,
3042 List <String > riderIds
3143 ) {
32- WebClient webClient = webClientBuilder .baseUrl (KAKAO_BASE_URL ).build ();
44+ // 각 라이더의 ETA를 병렬로 계산 (Virtual Thread)
45+ List <CompletableFuture <Map .Entry <String , Double >>> futures = new java .util .ArrayList <>();
46+
47+ for (int i = 0 ; i < riderIds .size (); i ++) {
48+ final int idx = i ;
49+ final String riderId = riderIds .get (idx );
50+ final Point riderPoint = riderPoints .get (idx );
51+
52+ CompletableFuture <Map .Entry <String , Double >> future = calculateSingleEta (
53+ riderId , riderPoint , userLat , userLon
54+ );
55+ futures .add (future );
56+ }
57+
58+ // 모든 결과 대기 및 맵으로 변환
59+ Map <String , Double > result = futures .stream ()
60+ .map (CompletableFuture ::join )
61+ .filter (entry -> entry != null && entry .getValue () != null )
62+ .collect (Collectors .toMap (Map .Entry ::getKey , Map .Entry ::getValue ));
3363
34- return Flux .fromIterable (riderIds )
35- .index () // (index, riderId)
36- .flatMap (tuple -> {
37- long idx = tuple .getT1 ();
38- String riderId = tuple .getT2 ();
39- Point riderPoint = riderPoints .get ((int ) idx );
40-
41- return webClient .get ()
42- .uri (uriBuilder -> uriBuilder
43- .path ("/directions" )
44- .queryParam ("origin" , riderPoint .getX () + "," + riderPoint .getY ()) // lon,lat
45- .queryParam ("destination" , userLon + "," + userLat )
46- .build ())
47- .header ("Authorization" , "KakaoAK " + kakaoApiKey )
48- .retrieve ()
49- .bodyToMono (Map .class )
50- .map (response -> {
51- Map <String , Object > routes
52- = (Map <String , Object >) ((List <?>) response .get ("routes" )).get (0 );
53- Map <String , Object > summary = (Map <String , Object >) routes .get ("summary" );
54- Double duration = ((Number ) summary .get ("duration" )).doubleValue (); // 초 단위
55- return Map .entry (riderId , duration / 60.0 ); // 분 단위 변환
56- });
57- })
58- .collectMap (Map .Entry ::getKey , Map .Entry ::getValue ); // Map<String, Double>
64+ log .info ("Calculated ETA for {} out of {} riders" , result .size (), riderIds .size ());
65+
66+ return result ;
5967 }
6068
61- // 상점 <-> 주문자 사이 거리 (eta 기준)
62- public Mono <Map <String , Double >> getDistance (
69+ /**
70+ * 단일 라이더의 ETA 계산 (비동기) - @Async로 Virtual Thread에서 실행
71+ */
72+ @ Async ("deliveryVirtualThreadExecutor" )
73+ public CompletableFuture <Map .Entry <String , Double >> calculateSingleEta (
74+ String riderId , Point riderPoint , double userLat , double userLon
75+ ) {
76+ try {
77+ WebClient webClient = webClientBuilder .baseUrl (KAKAO_BASE_URL ).build ();
78+
79+ Map <String , Object > response = webClient .get ()
80+ .uri (uriBuilder -> uriBuilder
81+ .path ("/directions" )
82+ .queryParam ("origin" , riderPoint .getX () + "," + riderPoint .getY ()) // lon,lat
83+ .queryParam ("destination" , userLon + "," + userLat )
84+ .build ())
85+ .header ("Authorization" , "KakaoAK " + kakaoApiKey )
86+ .retrieve ()
87+ .bodyToMono (Map .class )
88+ .block (); // Virtual Thread에서는 block() 안전!
89+
90+ if (response != null ) {
91+ Map <String , Object > routes = (Map <String , Object >) ((List <?>) response .get ("routes" ))
92+ .get (0 );
93+ Map <String , Object > summary = (Map <String , Object >) routes .get ("summary" );
94+ Double duration = ((Number ) summary .get ("duration" )).doubleValue (); // 초 단위
95+ double etaMinutes = duration / 60.0 ; // 분 단위 변환
96+
97+ return CompletableFuture .completedFuture (Map .entry (riderId , etaMinutes ));
98+ }
99+ } catch (Exception e ) {
100+ log .warn ("Failed to calculate ETA for rider {}: {}" , riderId , e .getMessage ());
101+ }
102+ return CompletableFuture .completedFuture (null );
103+ }
104+
105+ /**
106+ * 상점 <-> 주문자 사이 거리 계산 (동기식) - Virtual Thread에서 블로킹 호출해도 효율적
107+ */
108+ public Map <String , Double > getDistance (
63109 double storeLat , double storeLon ,
64110 double userLat , double userLon
65111 ) {
66112 WebClient webClient = webClientBuilder .baseUrl (KAKAO_BASE_URL ).build ();
67113
68- return webClient .get ()
69- .uri (uriBuilder -> uriBuilder
70- .path ("/directions" )
71- .queryParam ("origin" , storeLon + "," + storeLat )
72- .queryParam ("destination" , userLon + "," + userLat )
73- .build ())
74- .header ("Authorization" , "KakaoAK " + kakaoApiKey )
75- .retrieve ()
76- .bodyToMono (Map .class )
77- .map (response -> {
78- Map <String , Object > routes = (Map <String , Object >) ((List <?>) response .get ("routes" )).get (
79- 0 );
80- Map <String , Object > summary = (Map <String , Object >) routes .get ("summary" );
81- Double distanceM = ((Number ) summary .get ("distance" )).doubleValue (); // m 단위
82- double distanceKm =
83- Math .round ((distanceM / 1000.0 ) * 100.0 ) / 100.0 ; // km 단위, 소수 둘째 자리 반올림
84-
85- Map <String , Double > result = new HashMap <>();
86- result .put ("distance" , distanceKm );
87- return result ;
88- });
114+ // 가상 스레드 풀 사용
115+ try (var executor = Executors .newVirtualThreadPerTaskExecutor ()) {
116+ Future <Map <String , Double >> future = executor .submit (() -> {
117+ Map <String , Object > response = webClient .get ()
118+ .uri (uriBuilder -> uriBuilder
119+ .path ("/directions" )
120+ .queryParam ("origin" , storeLon + "," + storeLat )
121+ .queryParam ("destination" , userLon + "," + userLat )
122+ .build ())
123+ .header ("Authorization" , "KakaoAK " + kakaoApiKey )
124+ .retrieve ()
125+ .bodyToMono (Map .class )
126+ .block ();
127+
128+ if (response == null || !response .containsKey ("routes" )) {
129+ throw new IllegalStateException ("Invalid API response" );
130+ }
131+
132+ Map <String , Object > routes = (Map <String , Object >) ((List <?>) response .get ("routes" )).get (
133+ 0 );
134+ Map <String , Object > summary = (Map <String , Object >) routes .get ("summary" );
135+ Double distanceM = ((Number ) summary .get ("distance" )).doubleValue (); // m 단위
136+ double distanceKm = Math .round ((distanceM / 1000.0 ) * 100.0 ) / 100.0 ;
137+
138+ Map <String , Double > result = new HashMap <>();
139+ result .put ("distance" , distanceKm );
140+ return result ;
141+ });
142+ return future .get ();
143+
144+ } catch (ExecutionException | InterruptedException e ) {
145+ throw new RuntimeException ("Distance calculation failed" , e );
146+ }
89147 }
90148}
0 commit comments