RailNetwork-JP (일본 철도 실시간 API)
JR 6사·도쿄메트로·대형 사철 등 22개 운영사의 열차 데이터를 한 API로 제공하는 Go 백엔드입니다. 운영사별 피드를 Redis의 실시간 상태와 MySQL 시각표에 합쳐 REST·gRPC·SSE로 제공합니다. 현재 개발 서버에서 검증 중입니다.
- 22
- 운영사
- 4,428
- 테스트 함수
- 8
- gRPC RPC
- 7.8k
- 조회 대상 Redis 키
- 15s
- 갱신 주기
- 67
- ADR
무엇을 했나
- 외부 웹 프레임워크 없이 Go 표준 라이브러리 ServeMux로 REST 라우트, gRPC 3서비스 8 RPC(서버 스트리밍 3종), 노선별 SSE 구독을 구현했습니다.
- 피드 어댑터 27종(대체 소스·공용 GTFS 파서 포함)을 운영사별 독립 goroutine + 독립 서킷 브레이커로 격리해, 한 피드의 장애·타임아웃·레이트리밋이 다른 운영사에 전파되지 않도록 설계했습니다.
- 주 피드가 연속 실패하면 대체 소스를 시작하는 폴백 로직을 구현했습니다. 도쿄메트로·도에이 등 5개 운영사는 주·예비 피드 쌍을 사용합니다.
- 2·3개 회사를 걸쳐 달리는 직통운행(直通運転)을 물리 주행 단위로 잇는 체인 엔진을 구현했습니다. 경계역 통과 기록이 있을 때만 병합하고, 열차번호가 같다는 것만으로는 잇지 않습니다.
- 정차 시각을 planned(계획)·predicted(예측)·actual(실적)로 나누고 예측 근거도 함께 반환합니다. 실적이 없을 때는 계획값으로 채우지 않고 빈 값으로 둡니다.
- 열차를 운행 전·운행 중·소실·운행 종료 상태로 나눴습니다. 수집이 끝난 뒤 남은 Redis 키는 정리 작업으로 제거하고, server와 dia-worker가 동시에 수정할 때는 Redis WATCH로 충돌을 확인합니다.
- Redis 키 약 7,800개를 읽어 3MB 안팎의 JSON을 만드는 전체 열차 조회에 stale-while-revalidate 캐시를 적용했습니다. 캐시가 있으면 먼저 반환하고 한 번만 다시 만들며, 재생성에 실패하면 마지막 정상 응답을 유지합니다.
- Python 파이프라인(pytest 1,092개)이 pdfplumber로 운영사 PDF 시각표를 파싱해 다이아를 만들고, 역 시드와 ja/en/ko 번역 데이터를 채웁니다. 원래 별도 레포였는데 이 레포 안 pipeline/으로 다시 합쳤습니다.
- 운영 DB의 마이그레이션 버전이 329여서 새 마이그레이션은 0330부터 번호를 붙입니다. 더 작은 번호는 실행기가 이미 처리한 것으로 보고 건너뛰기 때문에 이 규칙을 문서와 주석에 남겼습니다.
- 운영사 설정·직통 체인·역·번역 데이터의 런타임 원본을 DB로 통일했습니다. 데이터 소스를 설정하지 않으면 서버가 시작되지 않게 해, 오래된 레포 파일을 읽는 실수를 막았습니다(ADR-0086).
- React 탐색기를 go:embed로 서버 바이너리에 내장하고, non-root alpine 이미지 2종(server·dia-worker)을 buildx로 arm64 크로스 빌드해 Noraneko Platform에서 배포합니다.
01
설계 결정
Go 표준 라이브러리와 저장소 구조를 고른 기준입니다.
Go 표준 라이브러리만 쓴 이유
Go 1.22의 ServeMux가 메서드와 경로 패턴을 지원해 라우트 35개는 표준 라이브러리로 처리할 수 있었습니다. 공통 로그·인증·오류 처리는 핸들러를 감싸는 미들웨어로 만들었습니다. 요청 검증 코드를 직접 작성해야 하는 단점은 있지만, 현재 규모에서는 별도 웹 프레임워크를 추가하지 않았습니다.
열차 상태는 Redis, 기준 정보는 MySQL
활성 열차는 몇 시간 뒤 사라지는 상태지만 노선·역·시각표는 오래 유지해야 합니다. 그래서 현재 열차 상태와 Pub/Sub은 Redis에, 노선·역·시각표·직통운행 설정은 MySQL에 저장했습니다.
레포를 둘로 나눴다가 다시 합친 이유
처음에는 DB 스키마와 정적 데이터를 Python 레포에서 관리하고 Go 서버는 읽기만 했습니다. 스키마가 바뀔 때 두 레포의 PR과 배포 순서를 맞춰야 했고, 서버가 아직 없는 컬럼을 읽는 문제도 생겼습니다. 혼자 관리하는 규모에서는 분리 비용이 더 커서 Python 파이프라인을 pipeline/으로 합치고 마이그레이션도 Go 레포에서 관리하게 했습니다.
dia-worker를 분리한 이유
외부 시각표 조회가 느려도 API 요청과 실시간 수집을 막지 않도록 dia-worker를 별도 프로세스로 분리했습니다. server와 dia-worker가 같은 열차를 수정할 때는 Redis WATCH로 충돌을 확인하고 재시도합니다. dia-worker는 planned 값만 갱신하고 위치·지연·실적은 보존합니다.
02
시스템 아키텍처
실시간 열차와 정적 시각표를 결합하는 흐름입니다.
실시간 상태와 기준 데이터 분리
현재 달리는 열차와 변경 알림은 Redis에, 노선·역·시각표·직통운행 설정은 MySQL에 둡니다. 외부 시각표 조회는 별도 dia-worker가 처리해 API 요청을 기다리게 하지 않습니다. Python 파이프라인은 같은 레포에서 MySQL 데이터를 채우고, 레포의 YAML은 최초 입력에만 사용합니다.
장애 격리와 부분 실패 허용
운영사마다 독립 goroutine + 서킷 브레이커로 격리하고, PartialError는 성공한 데이터만 반영하며 해당 소스 health를 degraded로 기록합니다. 위치 피드와 운행정보 피드도 독립적으로 처리해 하나가 죽어도 나머지는 계속 갱신됩니다.
실시간 변경 알림
각 서버의 Hub가 Redis Pub/Sub 채널을 구독하고 노선별로 SSE·gRPC 구독자에게 전달합니다. 서버가 여러 대일 때도 같은 Redis 채널을 통해 변경 이벤트를 받을 수 있게 구성했습니다.
03
일본 철도 도메인 문제
어려웠던 문제들입니다.
회사를 넘어 달리는 직통 열차 연결
운영사·구간 순서·경계역을 담은 기준 정보와 현재 열차·경계 통과 기록을 따로 관리합니다. 열차번호가 같다는 이유만으로 연결하지 않고, 경계역 통과를 확인한 경우에만 다른 회사의 구간을 이어 붙입니다. 확인한 운영사 경계 사례는 회귀 테스트로 남겼습니다.
계획·예측·실적 시각 분리
정차 시각을 planned/predicted/actual로 구분하고 예측에는 근거 소스 필드를 함께 노출합니다. 실적이 없는데 계획값을 실적처럼 보여주는 것을 금지했고, 자정을 넘겨도 영업일 기준 순서를 보존합니다.
수집이 끝난 열차 정리
피드에서 한 번 빠졌다고 바로 삭제하면 일시적인 누락에도 열차가 사라집니다. 먼저 대기 상태로 바꾸고, 종착역 도착이나 마지막 예정 시각을 확인한 뒤 삭제합니다. 판단할 정보가 없으면 최대 유지 시간을 적용하고, 이전 프로세스가 남긴 Redis 키도 정리합니다.
04
성능과 품질
작은 서버에서 큰 데이터를 다루기 위한 결정들입니다.
stale-while-revalidate 캐시
전체 열차 조회는 조회 대상 Redis 키 약 7,800개를 MGET으로 읽고 약 3MB JSON으로 조합합니다. 2초 TTL 캐시를 데이터 갱신 주기에 맞춘 10초 SWR로 바꿨습니다. 캐시가 있으면 먼저 반환하고 백그라운드에서 한 번만 갱신하며, gzip도 갱신할 때 한 번만 만듭니다.
회귀 테스트로 고정한 도메인 엣지 케이스
운영사 경계 핸드오프·부분 실패·상태머신 전이처럼 다시 만들기 어려운 조건은 회귀 테스트로 남겼습니다. 운영 데이터 확인과 함께 고정 입력 테스트를 사용해 같은 문제가 돌아오는지 확인합니다.
풀어낸 케이스
마주친 문제 · 고민하고 적용한 접근 · 실제 결과를 한 세트로 정리했습니다.
- 문제
- 전체 열차 조회가 Redis 키 약 7,800개를 MGET으로 읽고 3MB 안팎의 JSON을 만들면서, 2-OCPU 서버의 캐시 갱신이 요청 처리와 CPU를 나눠 썼습니다.
- 고민 · 접근
- TTL을 데이터 갱신 주기(~15초)에 맞춰 10초로 늘리고 stale-while-revalidate로 바꿨습니다. 기존 캐시를 먼저 반환하고 백그라운드 갱신과 gzip 압축은 한 번만 실행합니다.
- 결과
- 캐시가 있는 요청은 기존 응답을 먼저 받고, 재생성과 gzip 작업은 한 번만 실행됩니다.
- 문제
- 직통운행 열차를 열차번호 문자열 일치만으로 이으면, 다른 회사의 무관한 열차가 하나의 주행으로 오결합되는 사고가 났습니다.
- 고민 · 접근
- 운영사·구간 순서·경계역 정보와 현재 열차·경계 통과 기록을 분리했습니다. 다른 회사 구간임을 경계역 기록으로 확인한 경우에만 열차를 연결했습니다.
- 결과
- 케이큐 공항선·소테츠-JR 직통 등 확인한 경계 사례를 회귀 테스트에 추가했습니다.
- 문제
- 22개 운영사 피드 중 하나가 타임아웃·레이트리밋에 걸리면 전체 수집 루프가 밀릴 수 있었습니다.
- 고민 · 접근
- 운영사별 독립 goroutine + 독립 서킷 브레이커로 격리하고, 부분 실패(PartialError)는 성공분만 반영 + health degraded 기록으로 처리했습니다.
- 결과
- 한 피드의 실패 처리와 재시도가 다른 운영사의 수집 루프와 분리됐습니다.
- 문제
- server와 dia-worker가 같은 열차 키를 동시에 수정하면 더 최신인 위치 정보를 오래된 쓰기가 덮는 race가 있었습니다.
- 고민 · 접근
- Redis WATCH 트랜잭션 + 재시도로 낙관적 동시성 제어를 걸고, dia-worker는 planned schedule만 재병합하며 위치·지연·실적 필드는 보존하도록 병합 규칙을 명시했습니다.
- 결과
- 동시 갱신 하에서도 최신 위치가 유실되지 않습니다.
- 문제
- 운영사 설정·역·번역을 파일과 DB 양쪽에서 읽을 수 있어, 오래된 파일을 보고 잘못 진단하는 일이 반복됐습니다.
- 고민 · 접근
- 런타임 데이터는 DB에서만 읽도록 정하고, 소스를 지정하지 않거나 환경변수가 틀리면 서버가 시작되지 않게 했습니다. YAML 시드 도구는 dry-run을 기본값으로 뒀습니다.
- 결과
- 운영 중 확인할 데이터 위치가 DB로 통일됐고 설정 오류가 시작 단계에서 드러납니다.
주요 성과
- 22개 운영사 실시간 열차를 단일 API로 통합, 개발 서버(railnetwork-jp.noraneko.cc) 가동 중
- Go 테스트 함수 4,428개와 Python 파이프라인 테스트 1,092개
- 직통운행 물리 주행 엔진. 운영사 경계 핸드오프를 회귀 테스트로 고정
- Python 파이프라인을 레포 안으로 합치고 DB 스키마 소유권을 Go 레포로 이전. 시각표 마이그레이션과 서버 배포를 한 레포에서 관리
- 설계 결정 67건을 ADR로 기록
- dev→main PR 병합 시 CI가 이미지 digest를 만들고 Portainer에서 개발 서버에 배포
- 프라이빗 레포. API 탐색기는 서비스 링크에서 확인 가능
기술 스택
- Go 1.25 (stdlib net/http · 1.26 툴체인 빌드)
- gRPC (스트리밍 3종)
- SSE + Redis Pub/Sub
- Redis 7.4
- MySQL · OCI HeatWave
- sqlc
- golang-migrate
- React 18 · Vite (go:embed)
- Python 3.11 · uv · ruff
- pdfplumber
- Prometheus
- Docker (arm64)
- nginx