모두에게 공평한 모임 장소 추천하기 (1) - MOTIS로 대중교통 길찾기 서버 구축하기

2026년 8월 13일

들어가며

MeetLink는 모임의 시간과 장소를 함께 추천해주는 서비스이다. 참여자들이 각자 가능한 시간과 출발지를 입력하면, 가장 많은 사람이 모일 수 있는 시간대와 모두의 이동이 가장 공평한 장소를 계산하여 보여준다. 나는 이 프로젝트에서 백엔드와 인프라를 담당했다.

시간 추천은 결국 참여자들의 가능 시간을 겹쳐보는 집합 연산에 가깝다. 반면 장소 추천의 경우 도로망과 대중교통 시간표라는 외부 데이터에 기대야 하고, 그래서 구현 난이도와 시행 착오 대부분이 이쪽에 몰려있었다.

이번 글에서는 장소 추천의 토대가 되는 경로 조회를 외부 API에서 자체 운영 엔진으로 옮긴 과정과, 그 엔진을 실제로 운영하기 위해 필요했던 배포·운영 구성 과정을 다루고자 한다. 알고리즘 자체에 대한 이야기는 다음 글에서 이어서 다룰 예정이다.

좌표의 중간은 중간이 아니다

가장 먼저 떠올릴 수 있는 방법은 참여자 좌표의 평균을 내는 것이다. 하지만 이 방식에는 두 가지 문제가 있다.

첫째, 평균은 이상치에 약하다. 네 명이 강남 근처에서 모여 있고, 한 명이 인천에 있으면, 산술 평균은 인천 쪽으로 눈에 띄게 끌려간다. 다수가 만족하지 못하는 지점이 나온다.

둘째, 좌표상의 중간이 이동 시간상의 중간이 아니다. 두 사람의 정확히 가운데에 한강이 흐르고 있으면 그 지점은 양쪽 모두에게 최악의 장소이다. 산으로 막혀 있거나, 지하철 노선이 닿지 않아 버스를 두 번 갈아타야 하는 곳도 마찬가지이다. 지도 위에서 가까운 것과 실제로 빨리 도착하는 것은 전혀 다른 문제이다.

결국 판단 기준은 직선 거리가 아닌 실제 대중교통 소요 시간이어야 했다. 이 경우 하나의 좌표를 수식으로 계산해내는 문제가 아니라, 후보 좌표를 여러 개 만들어두고 각각의 실제 소요 시간을 조회해서 가장 나은 것을 고르는 탐색 문제가 된다.

그리고 탐색 문제라는 것은 곧 경로 조회를 아주 많이 하게 된다는 것을 의미하기도 한다.

쿼터에 맞춰진 알고리즘

초기 구현은 TMAP 대중교통 API를 사용했다. 국내 대중교통 경로를 다루는 가장 익숙한 선택지였다.

문제는 호출량이었다. 후보 좌표가 30개, 참여자가 5명이면 한 번의 계산에 150번의 경로 조회가 필요하다. 후보 수와 참여자 수의 곱으로 늘어나기 때문에 참여자가 조금만 늘어도 호출 수가 빠르게 불어난다. 이는 우리 상황에서 감당할 수 있는 규모가 아니었다.

그래서 초기 구현에는 쿼터를 아끼기 위한 장치들이 곳곳에 들어가 있었다.

  • 호출 사이에 의도적인 지연을 넣어 초당 호출 제한을 피했다.
  • 2차 필터링에서 기하중심으로부터 가장 먼 참여자 3명만 먼저 조회해보고, 해당 샘플이 60분을 넘으면 나머지 참여자는 조회하지 않고 후보를 탈락시켰다.
  • 실제 장소(POI)를 매칭한 뒤 그 좌표로 경로를 다시 계산하지 않고, 후보 좌표에서 구한 이동 시간을 그대로 재사용했다.

각 장치들은 나름 합리적으로 작동하였지만, 전부 쿼터와 정확도를 맞바꾸는 결정이었다. 샘플 3명만 보고 후보를 버리는 것은 나머지 참여자에게 좋았을 수도 있는 후보를 날리는 일이고, 지연을 넣는다는 것은 사용자가 그만큼 더 기다려야 한다는 것을 의미한다. 알고리즘의 품질이 외부 서비스의 과금 정책에 의존하고 있는 상태였다.

한동안은 이 제약 안에서 알고리즘을 개선하기 위한 방법을 고민했다. 그러다 결국 이러한 방식에도 한계가 있음을 인식하고, 제약 자체를 없앨 수 있는지를 먼저 따져보기로 했다.

경로 엔진을 직접 운영하기

MOTIS는 OSM 지도 데이터와 GTFS 대중교통 시간표를 넣으면 멀티모달 경로 탐색을 해주는 오픈소스 라우팅 엔진이다. 직접 띄우면 호출당 비용은 사라지고, 대신 우리 서버의 CPU와 메모리를 쓰게 된다.

트레이드오프는 분명했다.

외부 API자체 운영
호출 비용쿼터 / 과금없음
운영 부담없음데이터 준비, 갱신, 서버 리소스
데이터 통제불가가능

호출 횟수 제한이 사라진다는 점이 결정적이었다. 지연도, 샘플링도, 재계산 생략도 전부 쿼터 때문에 존재하던 코드였으므로, 제약이 사라지면 그 코드들도 함께 사라진다.

두 개의 컨테이너로 나누기

MOTIS를 띄우려면 두 종류의 원본 데이터가 필요하다.

  • OSM(OpenStreetMap) 지도 데이터 — 도로, 보행로, 건물 등이 담긴 오픈 지도 데이터이다. 지역별로 잘라둔 .osm.pbf 파일을 Geofabrik에서 받을 수 있다.
  • GTFS(General Transit Feed Specification) 시간표 — 노선, 정류장, 운행 시각을 담는 대중교통 데이터 표준이다. 국내 데이터는 국가교통DB의 교통분석자료 신청을 통해 받을 수 있다. 다만 이 글을 쓰는 시점 기준 가장 최신 자료가 2024년 3월 기준이라, 이후에 바뀐 노선은 반영되어 있지 않다.

그리고 이 원본을 그대로 쓰는 게 아니라, 탐색에 쓸 수 있는 형태로 미리 가공(import)하는 과정을 거쳐야 한다. 전국 단위 데이터를 다루기 때문에 이 작업은 무겁고 오래 걸린다. 반면 한 번 만들어두면 원본이 갱신되기 전까지는 다시 할 필요가 없다. 가공이 끝난 뒤 그 결과를 올려두고 경로 요청을 처리하는 일(serve)은 반대로 가볍지만 항상 떠 있어야 한다.

import와 serve는 수명도 리소스 사용 패턴도 완전히 다른 작업이다. 그래서 두 컨테이너를 분리하고, import는 별도 프로필로 묶어 평소에는 아예 뜨지 않도록 했다. 두 컨테이너가 나란히 떠 있는 것이 아니라, import 컨테이너는 데이터를 갱신할 때만 잠깐 떴다가 작업이 끝나면 스스로 종료된다.

motis-import:
  image: ghcr.io/motis-project/motis:latest
  profiles: ["import"]
  volumes:
    - /srv/meetlink/motis/input:/input:ro
    - ./config/motis/config.yml:/config.yml:ro
    - motis-data:/data
  command: /motis import
motis:
  image: ghcr.io/motis-project/motis:latest
  restart: unless-stopped
  volumes:
    - motis-data:/data
  command: /motis server

핵심은 두 컨테이너가 motis-data 볼륨을 통해서만 연결된다는 점이다. import 컨테이너는 원본을 읽어 가공 결과를 볼륨에 쌓고, 서버 컨테이너는 원본이 어디 있는지 알 필요 없이 이 볼륨만 바라본다. 원본 파일은 호스트의 /srv/meetlink 아래에만 두고 저장소에는 올리지 않는다.

데이터 갱신 자동화

처음에는 import를 서버에 직접 접속해 수동으로 실행했다. 명령어 순서를 기억해야 하고, 순서를 틀리면 데이터가 깨질 수 있다. 특히 import 도중에는 서버가 같은 데이터 디렉터리를 잡고 있으면 안 된다.

이 순서를 사람의 기억에 맡기지 않고 워크플로우에 명시해두기로 했다.

- name: Stop MOTIS
  run: docker compose stop motis || true
- name: Run MOTIS Import
  run: docker compose --profile import up motis-import
- name: Start MOTIS
  run: docker compose start motis

workflow_dispatch로 실행 대상 환경만 고르면 되고, 실행은 해당 서버에 등록된 self-hosted runner가 맡는다.

on:
  workflow_dispatch:
    inputs:
      target:
        description: "Select environment"
        required: true
        type: environment
jobs:
  import:
    runs-on: [self-hosted, "${{ inputs.target }}"]

배포 파이프라인 전체가 self-hosted runner 위에서 동작하는 구조라, MOTIS import도 같은 방식으로 추가할 수 있었다.

배포와 카나리

애플리케이션 배포는 CI에서 이미지를 빌드해 GHCR에 올리고, release/ 브랜치의 빌드가 성공했을 때만 배포 워크플로우가 이어서 도는 구조다.

여기서 한 가지 고민이 있었다. 새 버전이 잘못되었을 때 되돌릴 방법이 필요한데, 서버는 한 대뿐이었다. 그래서 인스턴스를 통째로 교체하는 대신, 새 이미지를 카나리 컨테이너로 먼저 띄우고 기존 stable은 그대로 두는 방식을 택했다.

- name: Deploy canary
  run: |
    NEW_IMAGE=ghcr.io/${{ github.repository }}:prod-${{ github.event.workflow_run.head_sha }}
    CURRENT_STABLE=$(docker inspect backend-prod --format='{{.Config.Image}}' 2>/dev/null || true)
    [ -z "$CURRENT_STABLE" ] && CURRENT_STABLE="$NEW_IMAGE"
    export APP_IMAGE="$CURRENT_STABLE"
    export CANARY_APP_IMAGE="$NEW_IMAGE"

현재 stable이 어떤 이미지로 떠 있는지는 서버에게 물어본다. 배포 이력을 어딘가에 따로 기록해두면 그 기록과 실제 상태가 어긋날 수 있으니, 실제로 돌아가고 있는 컨테이너를 사실의 출처로 삼는 것이 안전하다고 보았다.

카나리가 healthy가 될 때까지 기다린 뒤에야 nginx 설정을 바꿔 트래픽을 흘려보낸다. 실패하면 원래 설정으로 되돌리고 카나리를 제거한다.

- name: Switch nginx to canary
  run: |
    cp config/nginx/nginx.prod.canary.conf /srv/meetlink/nginx/conf/default.conf
    docker exec nginx-prod nginx -s reload
- name: Rollback on failure
  if: failure()
  run: |
    cp config/nginx/nginx.prod.conf /srv/meetlink/nginx/conf/default.conf
    docker exec nginx-prod nginx -s reload 2>/dev/null || true
    docker rm -f backend-canary-prod 2>/dev/null || true

이후 promote와 rollback은 자동화하지 않고 수동 dispatch로 남겨두었다. 카나리를 얼마나 지켜볼지는 변경 내용에 따라 다르고, 그 판단은 사람이 하는 게 맞다고 생각했다. promote를 실행하면 카나리 이미지가 stable로 승격되고, 새 stable이 healthy가 된 뒤 nginx 설정을 원복하고 카나리 컨테이너를 정리한다.

카나리가 드러낸 문제

장소 추천은 참여자가 위치를 제출할 때마다 자동으로 계산된다. 그런데 다섯 명이 링크를 받고 비슷한 시점에 위치를 입력하면 계산이 다섯 번 돌고, 앞선 네 번의 결과는 어차피 마지막 계산에 덮인다. 이건 카나리와 무관하게 이전부터 있던 문제였고, 애플리케이션 안에 3초 debounce를 두어 해결해둔 상태였다. 새 제출이 들어오면 이미 예약된 계산을 취소하고 다시 예약하는 방식이다.

문제는 이 debounce 상태를 ConcurrentHashMap에, 즉 프로세스 메모리 안에 들고 있었다는 점이다. 인스턴스가 하나뿐이던 시절에는 이 사실이 드러날 일이 없었다.

카나리 배포로 stable과 canary 두 인스턴스가 동시에 뜨고 nginx가 weight 기반으로 트래픽을 나누자, 같은 모임의 위치 제출 요청이 서로 다른 인스턴스로 흩어졌다. 각 프로세스가 자기 메모리 안의 예약만 볼 수 있으니, 상대가 예약한 계산을 취소할 방법이 없다. 이미 막아두었다고 생각한 중복 계산이 다시 나타난 것이다.

따라서 같은 모임의 요청이 항상 같은 인스턴스로 가도록 nginx 단에서 라우팅을 바꾸도록 처리했다.

upstream backend_pool {
    hash $uri consistent;
    server backend:8080        max_fails=1 fail_timeout=10s;
    server backend-canary:8080 max_fails=1 fail_timeout=10s;
}

URI에 모임 코드가 들어 있으므로 hash consistent로 바꾸면 같은 모임의 요청은 항상 같은 인스턴스로 라우팅된다. 대신 카나리에 10%만 보내는 식의 트래픽 비율 제어는 불가능해지고, 모임 단위로 stable과 canary가 나뉜다. 카나리 배포의 목적이 새 버전을 일부 실사용 트래픽에 노출해 검증하는 것이라면 이 방식으로도 충족된다고 판단했다.

다만 이건 애플리케이션의 상태 의존성을 인프라 설정으로 덮은 것이라 구조적으로 해결한 것은 아니다. 인스턴스를 늘리거나 라우팅 규칙이 바뀌면 같은 문제가 다시 나올 가능성이 존재한다는 한계는 있다.

상태를 들여다볼 수 있게 만들기

경로 엔진을 직접 운영한다는 것은 그 엔진에 문제가 생겼을 때 원인을 찾고 복구하는 일까지 우리 몫이 된다는 뜻이기도 하다. 외부 API를 쓸 때는 응답이 안 오면 그쪽 문제라고 말할 수 있었지만, 이제는 컨테이너 하나하나가 우리 서버 위에 있다.

그래서 Prometheus와 Grafana를 함께 띄우고, 세 층위를 나눠서 보도록 구성했다.

대상수집보는 것
호스트node-exporterCPU, 메모리, 디스크
컨테이너cAdvisor컨테이너별 리소스
애플리케이션Actuator + Micrometer요청 지연, JVM, 서비스 지표

애플리케이션 메트릭은 stable과 canary를 별도 job으로 수집하고, 지표에 배포 종류를 태그로 붙였다. 카나리 배포 중에 지연이 늘었다면 그게 새 버전 때문인지 아닌지를 구분할 수 있어야 하기 때문이다.

management:
  metrics:
    tags:
      application: ${spring.application.name}
      deployment: ${DEPLOYMENT_TYPE:}

여기에 더해 서비스 자체의 상태를 보기 위한 커스텀 게이지를 몇 개 등록했다. 기술 지표만으로는 서비스가 실제로 쓰이고 있는지를 알 수 없기 때문이다.

Gauge.builder("meetlink.meetings.place_calculating",
                placeCandidateService,
                (s) -> (double) s.getCalculatingCount())
        .description("Meetings currently running the place recommendation algorithm")
        .register(registry);

특히 유용했던 것은 현재 장소 추천 계산이 몇 건 돌고 있는지를 보여주는 지표였다. 이 값이 줄어들지 않고 쌓이면 MOTIS 응답이 느려졌거나 응답하지 않고 있다는 신호가 된다. 이 외에도 전체 참여자 중 시간·위치를 실제로 제출한 수, 추천 결과가 산출된 모임 수처럼 서비스가 어느 단계까지 쓰이고 있는지를 가늠할 수 있는 지표들을 함께 등록했다.

Actuator 엔드포인트는 Prometheus만 컨테이너 네트워크 안에서 접근하면 되므로 외부에서는 접근할 수 없도록 막아두었다.

location /actuator {
    return 403;
}

남은 것들

데이터 갱신 — 지금은 사람이 원본 파일을 서버에 올리고 워크플로우를 실행해야 반영된다. 다만 이 과정을 자동화하더라도 갱신 주기는 결국 데이터를 배포하는 쪽에 달려 있어, 우리가 어떻게 할 수 없는 한계가 남는다.

단일 호스트 — 카나리 배포는 새 버전에 문제가 있을 때 되돌리기 위한 장치이지 가용성을 위한 장치가 아니다. 애플리케이션과 MOTIS, 데이터베이스가 전부 한 대의 서버 위에 올라가 있어서, 이 호스트에 문제가 생기면 서비스 전체가 함께 중단된다. 게다가 MOTIS가 바라보는 데이터 볼륨이 호스트에 묶여 있고 새로 띄우는 서버마다 무거운 import 과정을 다시 거쳐야 하기 때문에, 서버를 늘려 확장하기도 쉽지 않다.

인메모리 상태 — nginx 라우팅으로 당장의 문제는 막았지만, 실제로 스케일아웃하려면 외부 저장소로 옮겨야 한다.

마무리

돌아보면 이 기능에서 가장 크게 작용한 결정은 경로 엔진을 직접 운영하기로 한 것이었다. 쿼터 제약이 사라지자 지연을 넣을 이유도, 샘플 3명만 조회할 이유도, POI 좌표로 재계산하지 않을 이유도 함께 사라졌다. 아끼려고 만들었던 장치들이 하나씩 지워지면서 코드가 오히려 단순해졌다.

외부 제약에 맞춰 알고리즘을 타협하고 있다면, 그 제약 자체를 옮길 수 있는지부터 따져보는 게 더 나은 선택일 때가 있다는 걸 알게 되었다.

다음 글에서는 이렇게 확보한 경로 조회 위에서 실제로 장소 후보를 찾아내는 알고리즘과, 그 과정에서 마주친 문제들을 다룰 예정이다.

댓글