프로젝트

[프로젝트] N명의 최적 만남 장소 찾기: 지리 알고리즘 설계와 실시간 응답 개선 (feat. CompletableFuture, Semaphore)

투웨이코더 2026. 2. 2. 03:38

최적 중간 모임 장소 구하기

 

 

1. 프로젝트 개요

여러 명이 만남을 약속할 때 모두에게 공정한 최적의 만남 장소를 추천하는 API를 만들었습니다.

 

1.1. 핵심 로직

  1. 참가자들의 위치를 받아 지리적 중심점 계산 (3D 좌표 변환)
  2. 중심점 반경 5km 내 지하철역 5개 검색 (Kakao API)
  3. 각 지하철역까지 모든 참가자의 대중교통 경로 탐색 (ODsay API)
  4. 총 소요시간(MinSum) 기준으로 Top 3 추천

 

 

1.2. 사용 기술

  • Spring Boot, RestTemplate
  • Kakao Local API (지하철역 검색)
  • ODsay API (대중교통 경로 탐색)
  • CompletableFuture (병렬 처리)
  • Semaphore (동시 요청 제한)
 

 

2. 요청 프로세스

 

 

2.1. 요청 본문

{
  "participants": [
    { "name": "현우", "latitude": 37.5665, "longitude": 126.9780 },
    { "name": "지수", "latitude": 37.4979, "longitude": 127.0276 },
    { "name": "민호", "latitude": 37.5407, "longitude": 127.0696 }
  ]
}

 

 

  • 만남을 약속한 3명의 참가자 정보
  • 각자의 현재 위치(위도, 경도) 제공
  • 현우: 광화문 근처 (37.5665, 126.9780)
  • 지수: 강남 근처 (37.4979, 127.0276)
  • 민호: 건대 근처 (37.5407, 127.0696)

 

 

2.2. 응답 예시

{
  "code": "SUCCESS",
  "message": "요청이 정상적으로 처리되었습니다.",
  "data": {
    "centerPoint": { "latitude": 37.5350, "longitude": 127.0250 },
    "recommendations": [
      {
        "rank": 1,
        "name": "압구정역",
        "minSum": 72,
        "minMax": 30,
        "avgDuration": 24.0,
        "routes": [
          { "participantName": "현우", "duration": 25, "payment": 1250 },
          { "participantName": "지수", "duration": 22, "payment": 1250 },
          { "participantName": "민호", "duration": 25, "payment": 1250 }
        ]
      }
    ]
  }
}

 

centerPoint 의미

  • 3명의 위치를 기반으로 계산한 지리적 중심점
  • 3D 좌표 변환 알고리즘으로 계산된 결과
  • 이 중심점 기준 반경 5km 내에서 지하철역을 검색

평가 지표 의미

  • minSum: 72분 - 3명의 이동시간 합계 (25+22+25=72)
    • 가장 중요한 지표: "모두의 총 이동시간이 최소"
  • minMax: 30분 - 가장 오래 걸리는 사람의 시간
    • "가장 먼 사람도 30분 안에 도착 가능"
  • avgDuration: 24.0분 - 평균 이동시간
    • "1인당 평균 24분 소요"

routes 의미

  • 각 참가자별 상세 경로 정보
  • 현우: 압구정역까지 25분, 교통비 1,250원
  • 지수: 압구정역까지 22분, 교통비 1,250원
  • 민호: 압구정역까지 25분, 교통비 1,250원

전체 흐름 요약

  1. 입력: "우리 3명 각자 여기 있어요"
  2. 처리: 중심점 계산 → 근처 지하철역 5개 찾기 → 각 역까지 모두의 경로 탐색
  3. 출력: "압구정역이 가장 좋아요! 합계 72분, 평균 24분이에요. 각자 경로는 이래요"

3. [버전 1] 순차 호출의 한계

3.1. 초기 구현 - 모든 것이 순차적

// V1: 역 5개 × 참가자 N명 = 순차 호출
for (SubwayStation station : stations) {         // 5개 역
    for (ParticipantInfo participant : participants) {  // N명
        OdsayPathInfo pathInfo = odsayClient.searchRoute(...);
        Thread.sleep(100); // API 보호용 딜레이
    }
}

 

 

3.2. 문제점

 


참가자가 6명이면? → 30회 호출, 12초 이상 사용자 경험이 매우 나빠집니다.

 

4. [버전 2] 병렬 처리 도입

 

4.1. 핵심 아이디어

5개 역의 평가는 서로 독립적이다 > 역 단위로 병렬 처리

 

// V2: CompletableFuture로 역 단위 병렬 처리
ExecutorService executor = Executors.newFixedThreadPool(stations.size());

List<CompletableFuture<EvaluatedPlace>> futures = stations.stream()
    .map(station -> evaluateStation(station, participants, executor))
    .toList();

// 모든 역 평가 완료 대기
List<EvaluatedPlace> evaluated = futures.stream()
    .map(CompletableFuture::join)
    .sorted(Comparator.comparingInt(EvaluatedPlace::minSum))
    .toList();

 

4.2. 역 단위 비동기 평가

private CompletableFuture<EvaluatedPlace> evaluateStation(
    SubwayStation station,
    List<ParticipantInfo> participants,
    ExecutorService executor
) {
    return CompletableFuture.supplyAsync(() -> {
        List<RouteDetail> routes = new ArrayList<>();

        // 역 내부에서는 참가자별 순차 호출 (API 보호)
        for (ParticipantInfo participant : participants) {
            routes.add(searchRoute(participant, station));
            Thread.sleep(200); // Rate limit 보호
        }

        // 평가 지표 계산
        int minSum = routes.stream().mapToInt(RouteDetail::duration).sum();
        int minMax = routes.stream().mapToInt(RouteDetail::duration).max().orElse(0);
        // ...
        return new EvaluatedPlace(...);
    }, executor);
}

 

 

4.3. 개선 효과

 

 

5. [버전 3] 재시도와 Rate Limiting

병렬 처리를 도입하니 새로운 문제가 생겼습니다: ODsay API의 Rate Limit(429)

 

5.1. Semaphore 기반 동시 요청 제한

private static final int MAX_CONCURRENT_REQUESTS = 5;
private final Semaphore rateLimiter = new Semaphore(MAX_CONCURRENT_REQUESTS);

public OdsayPathInfo searchRoute(...) {
    rateLimiter.acquire();  // 5개 넘으면 대기
    try {
        String response = odsayRestTemplate.getForObject(url, String.class);
        // ...
    } finally {
        rateLimiter.release();  // 슬롯 반환
        Thread.sleep(200);       // 추가 딜레이
    }
}

 

5.2. 지수 백오프 재시도

private static final int MAX_RETRIES = 3;
private static final long INITIAL_BACKOFF_MS = 500;

for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
    // API 호출 시도
    String rawResponse = odsayRestTemplate.getForObject(url, String.class);

    // 429 감지 시 재시도
    if (rawResponse.contains("429")) {
        long backoff = INITIAL_BACKOFF_MS * (1L << attempt);
        // attempt 0: 500ms
        // attempt 1: 1000ms
        // attempt 2: 2000ms
        // attempt 3: 4000ms
        Thread.sleep(backoff);
        continue;
    }
    // 정상 응답 처리...
}

 

재시도 로그

5.3. 실패 안전장치

모든 재시도가 실패하면 기본값을 반환하여 전체 요청이 죽지 않게 합니다.

// 실패 시 duration=999로 마킹 → 평가에서 자연스럽게 불이익
return new OdsayPathInfo(999, 0, 0, 0, 0, 0, 0);

 

6. 시퀀스 다이어그램 비교 

 

7. 호출 횟수 공식

총 ODsay API 호출 = 후보역 수(5) × 참가자 수(N)
총 Kakao API 호출 = 1 (중심점 기반 검색)

 

8. 배운 점

8.1. 병렬 처리는 독립성 확인부터

5개 역의 평가는 서로 영향을 주지 않기 때문에 병렬화할 수 있었습니다. 하지만 같은 역 내의 참가자별 호출은 API Rate Limit 때문에 순차로 유지했습니다.

 

8.2. 외부 API 호출 시 방어적 프로그래밍

  • Semaphore: 동시 요청 수를 제한하여 429를 예방
  • Exponential Backoff: 재시도 간격을 점점 늘려 API 서버 부담 감소
  • Graceful Degradation: 실패해도 전체 서비스가 죽지 않도록 기본값 반환

8.3. Thread Pool 크기는 상황에 맞게 

Executors.newFixedThreadPool(stations.size()) // 후보역 수만큼

외부 API I/O 대기가 대부분이므로, CPU 코어 수보다는 동시 처리할 작업 수에 맞추는 것이 효과적이었습니다.

 

8.4. sleep은 생각보다 중요하다

외부 API를 호출할 때 적절한 딜레이(200ms)를 두는 것만으로도 429 에러를 크게 줄일 수 있었습니다. 성능과 안정성 사이의 트레이드오프를 고려해야 합니다.


번외. 실제 이동시간 비교해보기 (ODsay API vs 네이버 지도 길찾기)

 

  • 같은 출발지/도착지 기준
  • 동일 시간대 검색

 

 

요청값

{
  "participants": [
    { "name": "참여자1", "latitude": 37.5621, "longitude": 126.8015 },
    { "name": "참여자2", "latitude": 37.4980, "longitude": 127.0276 },
    { "name": "참여자3", "latitude": 37.5663, "longitude": 126.9779 }
  ]
}

 

  • 참여자1 (김포공항역): 37.5621, 126.8015
  • 참여자2 (강남역): 37.4980, 127.0276
  • 참여자3 (시청역): 37.5663, 126.9779
{
    "code": "S100",
    "data": {
        "centerPoint": {
            "latitude": 37.54217301784178,
            "longitude": 126.93569223930636
        },
        "recommendations": [
            {
                "rank": 1,
                "name": "마포역 5호선",
                "category": "지하철역",
                "address": "서울 마포구 도화동 160",
                "latitude": 37.53955402908912,
                "longitude": 126.94586257221307,
                "distanceFromCenter": 944,
                "minSum": 82,
                "minMax": 35,
                "avgDuration": 27.333333333333332,
                "routes": [
                    {
                        "participantName": "참여자1",
                        "duration": 28,
                        "distance": 16128,
                        "payment": 1750,
                        "transitCount": 2
                    },
                    {
                        "participantName": "참여자2",
                        "duration": 35,
                        "distance": 13922,
                        "payment": 2350,
                        "transitCount": 3
                    },
                    {
                        "participantName": "참여자3",
                        "duration": 19,
                        "distance": 5217,
                        "payment": 1550,
                        "transitCount": 1
                    }
                ]
            },
            {
                "rank": 2,
                "name": "서강대역 경의중앙선",
                "category": "지하철역",
                "address": "서울 마포구 노고산동 112-5",
                "latitude": 37.5521384864879,
                "longitude": 126.935507621173,
                "distanceFromCenter": 1106,
                "minSum": 91,
                "minMax": 44,
                "avgDuration": 30.333333333333332,
                "routes": [
                    {
                        "participantName": "참여자1",
                        "duration": 27,
                        "distance": 15233,
                        "payment": 1650,
                        "transitCount": 2
                    },
                    {
                        "participantName": "참여자2",
                        "duration": 44,
                        "distance": 12033,
                        "payment": 1500,
                        "transitCount": 1
                    },
                    {
                        "participantName": "참여자3",
                        "duration": 20,
                        "distance": 4341,
                        "payment": 1550,
                        "transitCount": 1
                    }
                ]
            },
            {
                "rank": 3,
                "name": "대흥역 6호선",
                "category": "지하철역",
                "address": "서울 마포구 대흥동 128-1",
                "latitude": 37.5476479056751,
                "longitude": 126.942473188734,
                "distanceFromCenter": 853,
                "minSum": 100,
                "minMax": 39,
                "avgDuration": 33.333333333333336,
                "routes": [
                    {
                        "participantName": "참여자1",
                        "duration": 33,
                        "distance": 18028,
                        "payment": 1750,
                        "transitCount": 2
                    },
                    {
                        "participantName": "참여자2",
                        "duration": 39,
                        "distance": 11222,
                        "payment": 1500,
                        "transitCount": 1
                    },
                    {
                        "participantName": "참여자3",
                        "duration": 28,
                        "distance": 5589,
                        "payment": 1500,
                        "transitCount": 1
                    }
                ]
            }
        ]
    },
    "message": "요청이 성공적으로 처리되었습니다."
}