전체 활성 열차 조회가 Redis 키 7.8k MGET + 약 3MB JSON 직렬화로 수 초가 걸려, 2-OCPU 서버에서 캐시 재빌드가 코어를 점유했습니다.
TTL을 데이터 갱신 주기(~15초)에 맞춰 10초로 늘리고 stale-while-revalidate로 전환 — 응답은 항상 캐시에서 즉시, 재빌드는 백그라운드 1회. gzip 압축도 재빌드당 1회로 제한했습니다.
응답 지연이 상수화되고 CPU 스파이크가 사라졌습니다.
JR 6사·도쿄메트로·대형 사철 등 19개 운영사의 실시간 열차를 하나의 API로 통합한 Go 백엔드입니다. 운영사별 피드를 수집해 Redis 핫티어에 활성 열차를 유지하고, 시각표(다이아)·직통운행 메타데이터와 결합해 REST·gRPC·SSE로 제공합니다. 별도 Python 파이프라인이 285개 마이그레이션과 시각표 데이터를 소유합니다.
프레임워크와 저장소 선택에는 전부 이유가 있습니다.
Gin·Echo가 주는 핵심 효용은 라우팅과 미들웨어 체인인데, Go 1.22부터 표준 ServeMux가 메서드·패스 패턴 라우팅을 지원하면서 그 효용의 대부분이 표준으로 흡수됐습니다. 라우트 35개 규모에서 미들웨어는 핸들러 데코레이터 함수 몇 개로 충분했고, 웹 계층 의존성 0은 취약점 패치 추적·메이저 버전 마이그레이션 비용을 통째로 없앱니다. 프레임워크가 무엇을 대신해주는지 알고 있었기 때문에 안 쓰는 선택을 할 수 있었습니다.
활성 열차는 수명이 몇 시간인 휘발성 상태(상태머신이 생성·소멸 관리)고, 노선·역·다이아는 연 단위로 사는 영속 마스터입니다. 수명이 다른 데이터를 한 저장소에 두면 둘 다 어중간해집니다. 그래서 상태는 Redis, 마스터는 MySQL로 나누고 소유권도 분리했습니다 — 스키마와 정적 데이터의 정본은 Python 파이프라인이 소유하고 Go 서버는 읽기만 합니다. 덕분에 시각표 마이그레이션과 서버 배포가 서로를 기다리지 않습니다.
외부 다이아 소스 조회는 느리고 실패 양상이 예측 불가라, 요청 경로나 수집 루프 안에 두면 그 tail latency가 API 응답으로 전파됩니다. 그래서 '느린 작업은 옆 프로세스에서'로 분리하고, 같은 열차 키를 두 프로세스가 만질 때의 충돌은 Redis WATCH 낙관적 재시도 + '위치·실적은 보존하고 planned만 재병합'이라는 명시적 병합 규칙으로 풀었습니다. 분리에는 반드시 동시성 비용이 따라온다는 걸 병합 규칙으로 지불한 셈입니다.
실시간 데이터와 정적 시각표 데이터를 어떻게 결합했는지 정리했습니다.
현재 달리는 열차는 Redis 핫티어(TrainState + 인덱스 + Pub/Sub)에, 노선·역·시각표·직통 메타데이터는 MySQL(OCI HeatWave)에 둡니다. dia-worker가 별도 프로세스로 외부 다이아 소스 조회를 요청 경로에서 분리해, 느린 외부 조회가 API 응답을 막지 않습니다. 데이터 정본은 Python 파이프라인이 소유하고 Go 서버는 런타임에 읽기만 하는 단방향 구조입니다.
운영사마다 독립 goroutine + 서킷 브레이커로 격리하고, PartialError는 성공한 데이터만 반영하며 해당 소스 health를 degraded로 기록합니다. 위치 피드와 운행정보 피드도 독립적으로 처리해 하나가 죽어도 나머지는 계속 갱신됩니다.
프로세스당 단일 Hub가 Redis Pub/Sub 채널을 구독하고 SSE·gRPC 스트림 리스너에 노선 필터로 분배합니다. 어느 인스턴스가 쓰기를 하든 모든 인스턴스가 동일한 이벤트를 받아 수평 확장이 가능합니다.
일본 철도 도메인에서 가장 어려웠던 문제들입니다.
케이큐→도에이→케이세이처럼 한 편성이 회사를 넘나드는 직통운행을, topology 마스터(운영사·구간 순서·경계역)와 live 엔진(활성 열차·열차번호·경계 증거) 2계층으로 조립합니다. 같은 열차번호 문자열만으로 타사 열차를 잇는 오결합을 막기 위해, foreign leg임이 입증된 정차만 제거하는 보수적 병합을 택했습니다. 운영사별 경계 핸드오프는 회귀 테스트로 고정했습니다.
정차 시각을 planned/predicted/actual로 구분하고 예측에는 근거 소스 필드를 함께 노출합니다. 실적이 없는데 계획값을 실적처럼 보여주는 것을 금지했고, 자정을 넘겨도 영업일 기준 순서를 보존합니다.
피드에서 한 번 빠졌다고 삭제하면 일시적 결측에 취약하고, 놔두면 좀비가 쌓입니다. grace 후 Lost 전이, 종착·마지막 예정시각 증거 우선, 증거가 없으면 절대 수명 상한으로 제거하는 3단 정책과 이전 프로세스의 orphan 키까지 정리하는 reaper로 해결했습니다.
작은 서버에서 큰 데이터를 다루기 위한 결정들입니다.
전체 열차 조회는 Redis 키 7.8k개를 MGET·디코드·조합해 약 3MB JSON을 만드는 무거운 작업입니다. 2초 TTL 캐시가 2-OCPU 코어를 점유하던 것을, 데이터 갱신 주기(~15초)에 맞춘 10초 SWR로 바꿔 응답은 항상 즉시 반환하고 재빌드 빈도만 제어했습니다. gzip도 재빌드당 1회만 수행합니다.
테스트 코드가 프로덕션 코드와 거의 1:1(149k vs 160k LOC)입니다. 운영사 경계 핸드오프·부분 실패·상태머신 전이 같은 도메인 엣지 케이스를 회귀 테스트 3,622개로 고정했고, 벤치마크 37개로 핫패스 성능을 추적합니다.
마주친 문제 · 고민하고 적용한 접근 · 실제 결과를 한 세트로 묶어 정리했습니다.
전체 활성 열차 조회가 Redis 키 7.8k MGET + 약 3MB JSON 직렬화로 수 초가 걸려, 2-OCPU 서버에서 캐시 재빌드가 코어를 점유했습니다.
TTL을 데이터 갱신 주기(~15초)에 맞춰 10초로 늘리고 stale-while-revalidate로 전환 — 응답은 항상 캐시에서 즉시, 재빌드는 백그라운드 1회. gzip 압축도 재빌드당 1회로 제한했습니다.
응답 지연이 상수화되고 CPU 스파이크가 사라졌습니다.
직통운행 열차를 열차번호 문자열 일치만으로 이으면, 다른 회사의 무관한 열차가 하나의 주행으로 오결합되는 사고가 났습니다.
topology 마스터(경계역·구간 순서)와 live 증거(활성 열차·경계 통과)를 분리한 2계층 체인 엔진을 만들고, foreign leg임이 입증된 정차만 제거하는 보수적 병합 정책을 채택했습니다.
케이큐 공항선·소테츠-JR 직통 등 실제 경계 케이스가 회귀 테스트로 고정된 채 안정 동작합니다.
19개 운영사 피드 중 하나가 타임아웃·레이트리밋에 걸리면 전체 수집 루프가 밀리는 구조적 위험이 있었습니다.
운영사별 독립 goroutine + 독립 서킷 브레이커로 격리하고, 부분 실패(PartialError)는 성공분만 반영 + health degraded 기록으로 처리했습니다.
단일 피드 장애가 다른 18개 운영사의 실시간성에 영향을 주지 않습니다.
server와 dia-worker가 같은 열차 키를 동시에 수정하면 더 최신인 위치 정보를 오래된 쓰기가 덮는 race가 있었습니다.
Redis WATCH 트랜잭션 + 재시도로 낙관적 동시성 제어를 걸고, dia-worker는 planned schedule만 재병합하며 위치·지연·실적 필드는 보존하도록 병합 규칙을 명시했습니다.
동시 갱신 하에서도 최신 위치가 유실되지 않습니다.