손호영
개발 중2026.05 – 현재1인 개발

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
RailNetwork-JP (일본 철도 실시간 API) 아키텍처 다이어그램
시스템 아키텍처 — 클릭하면 원본으로 열립니다

무엇을 했나

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도 갱신할 때 한 번만 만듭니다.

회귀 테스트로 고정한 도메인 엣지 케이스

운영사 경계 핸드오프·부분 실패·상태머신 전이처럼 다시 만들기 어려운 조건은 회귀 테스트로 남겼습니다. 운영 데이터 확인과 함께 고정 입력 테스트를 사용해 같은 문제가 돌아오는지 확인합니다.

풀어낸 케이스

마주친 문제 · 고민하고 적용한 접근 · 실제 결과를 한 세트로 정리했습니다.

CASE 01SWR 10s · 갱신 1회
문제
전체 열차 조회가 Redis 키 약 7,800개를 MGET으로 읽고 3MB 안팎의 JSON을 만들면서, 2-OCPU 서버의 캐시 갱신이 요청 처리와 CPU를 나눠 썼습니다.
고민 · 접근
TTL을 데이터 갱신 주기(~15초)에 맞춰 10초로 늘리고 stale-while-revalidate로 바꿨습니다. 기존 캐시를 먼저 반환하고 백그라운드 갱신과 gzip 압축은 한 번만 실행합니다.
결과
캐시가 있는 요청은 기존 응답을 먼저 받고, 재생성과 gzip 작업은 한 번만 실행됩니다.
CASE 02경계역 기록 있을 때만 병합
문제
직통운행 열차를 열차번호 문자열 일치만으로 이으면, 다른 회사의 무관한 열차가 하나의 주행으로 오결합되는 사고가 났습니다.
고민 · 접근
운영사·구간 순서·경계역 정보와 현재 열차·경계 통과 기록을 분리했습니다. 다른 회사 구간임을 경계역 기록으로 확인한 경우에만 열차를 연결했습니다.
결과
케이큐 공항선·소테츠-JR 직통 등 확인한 경계 사례를 회귀 테스트에 추가했습니다.
CASE 03운영사별 독립 수집
문제
22개 운영사 피드 중 하나가 타임아웃·레이트리밋에 걸리면 전체 수집 루프가 밀릴 수 있었습니다.
고민 · 접근
운영사별 독립 goroutine + 독립 서킷 브레이커로 격리하고, 부분 실패(PartialError)는 성공분만 반영 + health degraded 기록으로 처리했습니다.
결과
한 피드의 실패 처리와 재시도가 다른 운영사의 수집 루프와 분리됐습니다.
CASE 04Redis WATCH 재시도
문제
server와 dia-worker가 같은 열차 키를 동시에 수정하면 더 최신인 위치 정보를 오래된 쓰기가 덮는 race가 있었습니다.
고민 · 접근
Redis WATCH 트랜잭션 + 재시도로 낙관적 동시성 제어를 걸고, dia-worker는 planned schedule만 재병합하며 위치·지연·실적 필드는 보존하도록 병합 규칙을 명시했습니다.
결과
동시 갱신 하에서도 최신 위치가 유실되지 않습니다.
CASE 05런타임 원본 DB · 시작 시 검증
문제
운영사 설정·역·번역을 파일과 DB 양쪽에서 읽을 수 있어, 오래된 파일을 보고 잘못 진단하는 일이 반복됐습니다.
고민 · 접근
런타임 데이터는 DB에서만 읽도록 정하고, 소스를 지정하지 않거나 환경변수가 틀리면 서버가 시작되지 않게 했습니다. YAML 시드 도구는 dry-run을 기본값으로 뒀습니다.
결과
운영 중 확인할 데이터 위치가 DB로 통일됐고 설정 오류가 시작 단계에서 드러납니다.

주요 성과

기술 스택