
1. 프로젝트 개요
여러 명이 만남을 약속할 때 모두에게 공정한 최적의 만남 장소를 추천하는 API를 만들었습니다.
1.1. 핵심 로직
- 참가자들의 위치를 받아 지리적 중심점 계산 (3D 좌표 변환)
- 중심점 반경 5km 내 지하철역 5개 검색 (Kakao API)
- 각 지하철역까지 모든 참가자의 대중교통 경로 탐색 (ODsay API)
- 총 소요시간(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원
전체 흐름 요약
- 입력: "우리 3명 각자 여기 있어요"
- 처리: 중심점 계산 → 근처 지하철역 5개 찾기 → 각 역까지 모두의 경로 탐색
- 출력: "압구정역이 가장 좋아요! 합계 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": "요청이 성공적으로 처리되었습니다."
}
