손호영
Production2023.07 – 현재백엔드·인프라 담당 · 2인 팀

RailNetwork

서울 지하철 실시간 추적 서비스입니다. 3년 넘게 운영해 월간 사용자가 12개월 만에 3,035명→6,094명으로 2배가 됐고(GA4, 2026.09.05 조회), 2025.11–2026.02 90일간 활성 사용자 6,375명을 기록했습니다. 팀에서는 백엔드(TrainAccAPI)를 맡았고, 팀원들이 떠난 뒤 서비스 전체를 이어받아 한 달은 Flutter·watchOS·iOS 위젯까지 혼자 운영했습니다. 원래 클라이언트를 맡던 동료가 복귀한 뒤로는 2인 팀에서 백엔드·인프라를 전담하며, 서버를 운영 중단 없이 TypeScript·Fastify·gRPC로 전면 교체했습니다.

6,094
월간 사용자 (2026.08)
2.0배
12개월 성장 (2025.09→2026.08)
29.0%
DAU/MAU (지난 28일)
25
앱 공개 노선
143→42ms
모의 클라 100대 p95 · v1→v2 (라이브 실측)
414만
이벤트 (지난 28일)
RailNetwork 아키텍처 다이어그램
시스템 아키텍처 — 클릭하면 원본으로 열립니다

무엇을 했나

01

TrainAccAPI 리팩터링 (JS·Express → TS·Fastify, 운영 중단 없음)

자동 테스트가 없던 JavaScript 서버를 TypeScript·Fastify로 옮기는 동안 앱은 멈추지 않았습니다. 열차 데이터 하나가 어디를 지나는지로 전후를 비교합니다.

리팩터링 전: apicall.js와 trainService.js

외부 API 호출부터 응답 변환·폴백·데이터 병합·노선별 예외까지 apicall.js 하나에 모여 있었습니다. trainService.js는 15초 폴링, 열차 생성과 종료, 시간표 찾기, 지연 계산, MongoDB 이력 저장까지 담당했습니다. 기능은 동작했지만, 외부 API 하나를 고치려고 해도 지연 계산과 노선 특수 규칙까지 함께 확인해야 했습니다. 열차·편성 상태 일부가 프로세스 메모리에 있어 서버 재시작 시 사라지는 문제도 있었습니다.

리팩터링 후: Provider·Policy·Registry·Strategy·Store

2호선 실시간 갱신 한 건이 리팩터링 전에는 apicall.js와 trainService.js 두 파일과 프로세스 메모리를 지나 Express로 나갔고, 리팩터링 후에는 Line Registry, Provider+Mapper, Realtime Policy, Line Strategy, Tracking, Train/Vote Store(Redis 저장·복원), Presenter, Fastify/gRPC를 순서대로 지나는 흐름 비교
같은 갱신 한 건이 지나는 경로. 왼쪽이 리팩터링 전, 오른쪽이 후입니다. 열번 보정이 깨지면 test/contract의 HTTP 계약 스냅샷이, 신분당선 규칙은 line-sb 단위 테스트가 잡습니다. 클릭하면 원본 SVG가 열립니다.

Provider는 외부 API를 호출하고, Mapper는 서로 다른 응답을 공통 형식으로 바꿉니다. Realtime Policy는 어느 소스를 먼저 쓰고 실패하면 어떻게 합칠지 정합니다. Registry에는 노선 설정을, Strategy에는 2호선·신분당선처럼 노선마다 다른 규칙을 뒀습니다. Redis 저장과 부팅 복원은 Persistence가 맡습니다. app.ts는 이 객체들을 만들고 연결하는 서버 조립 코드로 정리했습니다.

무엇이 바뀌었나

외부 API 변경은 Provider·Mapper에서, 노선별 규칙은 Registry·Strategy에서 찾을 수 있게 됐습니다. Train/Vote Store의 상태는 Redis에 저장하고 부팅할 때 복원합니다. TypeScript 타입 검사를 빌드에 넣어 외부 응답 필드 실수를 실행 전에 찾게 했습니다. Express를 Fastify로 바꾸는 동안 기존 v1 응답과 헤더는 계약 테스트로 비교했습니다. 자동 테스트 0개였던 서버에 테스트 파일 151개와 ADR 7편이 생겼습니다(2026.09 기준). 계약 테스트 19개(Express 패리티, HTTP 헤더·본문 스냅샷, gRPC v1/v2 계약)와 단위 테스트 128개입니다. 덮지 않는 곳도 있습니다. trainService의 도시철도 틱 경로는 41개 파일이 간접으로만 지나가고, 2호선 열번 보정 규칙은 전용 단위 테스트 없이 계약 스냅샷이 결과로만 잡습니다. 신분당선 규칙은 line-sb 단위 테스트가 있습니다. v2는 계획·예측·실적을 구분하고, SSE는 변경된 필드만 보냅니다.

배운 점

처음에는 파일을 나누는 것만 생각했지만, 실제로는 변경 이유를 나누는 일이 더 중요했습니다. 외부 API, 노선 규칙, 저장 방식이 각각 다른 파일과 테스트에 있어야 한 부분만 고칠 수 있었습니다. 운영 중인 앱을 깨뜨리지 않으려고 기존 동작을 먼저 테스트로 남기고 연결을 조금씩 바꿨습니다. trainService.ts는 아직 큰 파일입니다. 이번 작업으로 다음 수정 위치를 찾기 쉬워졌습니다.

02

v1 REST → v2 gRPC: 시간표는 하루 1회, 실적만 10초마다

계획과 실적을 갈라 같은 값을 다시 보내지 않게 했습니다. 실시간은 여전히 10초마다 받고, 줄어든 것은 시간표 쪽입니다. 수치는 모두 실측이고 조건을 같이 적었습니다.

v1의 문제: 시간표를 10분마다 통째로

v1 앱은 10분마다 하루치 시간표를 통째로 다시 받았습니다(1호선 gzip 256.8KB). 실시간 응답도 열차마다 행선지·정차역처럼 시간표에 이미 있는 값을 매 틱 다시 실었습니다. 시간표 gRPC(GetSched)는 정차 시각을 절대 unix 초로 내서 내용이 같아도 날마다 전 바이트가 바뀌었고, 시간표 세대 번호는 프로세스 안의 정수 하나여서 한 노선을 다시 읽으면 모든 노선 클라이언트가 다시 받았습니다. 시간표를 하루 144번 보내는 것이 문제였습니다.

v2 설계: 계획과 실적 분리, 내용 해시 버전

v1은 앱이 10분마다 시간표 전체를, 10초마다 실시간을 REST로 받는 흐름. v2는 운행일당 한 번 GetTimetables로 계획을 받아 버전이 같으면 NO_CHANGE, 다르면 FULL을 받고, 실적은 GetSnapshot 또는 Subscribe로만 받으며 실패 시 REST v1로 폴백하는 흐름
v1(아래)과 v2(위)의 데이터 흐름. 크기는 1호선 기준 앱 실측(2026.09.04, 개발 서버, gzip).

REST와 SSE를 그대로 두는 안도 있었습니다. 저는 gRPC 전용으로 정했습니다. 계획과 실적을 나누는 안은 제가 내고 동료를 설득해 진행했습니다. 앱이 시간표를 받는 경로를 바꿔야 해서 논의가 필요했습니다. GTFS static과 GTFS-RT의 분업을 따라 계획(TimetableServiceV2.GetTimetables)과 실적(RealtimeServiceV2.GetSnapshot·Subscribe)을 나눴습니다. 계획의 버전은 정수 대신 (노선, 운행일) 내용 해시로 바꿔 blue-green 두 슬롯이 같은 값을 내고, 응답은 FULL 또는 NO_CHANGE 둘뿐이라 델타 계산이 없습니다. 정차 시각은 자정 기준 초, 역은 노선별 사전의 인덱스, 열차마다 packed 열로 실었습니다. 인천2호선 하선 236편은 gzip 40.9KB에서 2.0KB(-95%)로 줄었고, 정차 패턴이 편마다 다른 KTX는 -60%에 머물렀습니다. 그 뒤 앱 실측(2026.09.04, 개발 서버, gzip 적용)에서 1호선 계획은 10분마다 256.8KB → 하루 1회 20.6KB, 실적은 35.2KB → 15.6KB가 됐습니다.

REST JSON과 gRPC protobuf 비교 (개발 서버)

같은 6노선 시간표를 개발 서버에서 재면(2026.09.04) REST JSON 무압축 4.37MB·759ms, gzip 575KB·316ms, gRPC 383KB·58ms였습니다. 압축하지 않은 gRPC가 gzip REST보다 1.5배 작고 5.4배 빨랐습니다. 실시간 SSE에는 Accept-Encoding 협상 뒤 프레임마다 flush하는 압축을 넣어 노선 1개 snapshot 142.6KB가 gzip 19.5KB(-86%), Brotli 11.4KB(-92%)로 줄었습니다. 프로덕션 CPU 사용량과 모바일 재접속률은 아직 측정하지 않았습니다.

grpc-js 응답 압축 미지원과 우회

서버에 압축 옵션을 걸어도 응답 크기가 그대로였습니다. @grpc/grpc-js 소스에 'response compression is currently not supported'가 적혀 있고, 프레임의 compressed 비트를 항상 0으로 씁니다. 그래서 표준 grpc-accept-encoding 대신 커스텀 헤더(x-rn-accept-encoding: gzip)로 옵트인을 받아 직렬화 뒤 직접 gzip을 씌우고, 앱(Dart codecRegistry)과 iOS 위젯(Swift enabledAlgorithms)의 복호화는 클라이언트 동료가 켰습니다. 측정한 이득은 시간표 383KB → 60KB(6.3배, 1.2ms)였습니다. 압축 스위치는 별도 배포로 분리했습니다.

라이브 부하 테스트 (k6, 2026.09.05)

같은 부하(모의 클라이언트 100대)에서 v2 gRPC의 p95는 42ms로 v1 REST 143ms의 3.4분의 1이었고, 서버 이벤트루프 지연 p99도 51.5ms에서 21.2ms로 줄었습니다. 2026.09.05 새벽 라이브 서버에 앱과 같은 경로(api.railnetwork.kr, Cloudflare 경유)로 k6 부하를 넣어 재었습니다. 부하 생성은 대상 호스트 밖 관측 서버에서 했고, p95가 1초를 넘거나 실패율 5%를 넘으면 자동 중단하는 가드를 걸었습니다. v1 계획(/sched)은 개발 컨테이너(CPU 가중치 512·메모리 1GB, 라이브와 호스트 공유)에서 약 10 req/s에서 p95 2.04초로 가드가 발동하고 그 직후 컨테이너가 종료됐기 때문에, 라이브에서는 2→8 req/s 저램프로만 재고 v2는 5→40 req/s로 잤습니다. 그래서 표의 계획 채널 p95(v1 196ms, v2 FULL 511ms)는 부하가 달라 직접 비교하면 안 되고, 같은 부하를 준 비교는 모의 클라이언트 100대 시나리오 하나입니다. 모의 클라이언트 시나리오는 계획 1회 + 10초마다 실적, 60초에 걸쳐 점진 투입 후 60초 유지입니다. 전 시나리오에서 실패 0건, 라이브 health 응답은 최대 53ms였습니다.

라이브 실측, 2026.09.05 05:42~05:56 KST, k6 v0.54, 관측 서버 → api.railnetwork.kr. 지연은 클라이언트 관측값(Cloudflare 포함).
시나리오요청 수p50p95p99전송량서버 이벤트루프 p99 최대
v1 계획 GET /sched/{line} · 2→8 req/s510148ms196ms351ms79.2MB67ms
v2 계획 GetTimetables FULL · 5→40 req/s1,68643ms511ms811ms220.6MB23ms
v1 실적 GET /line/multiline · 2→8 req/s51021ms25ms30ms4.5MB34ms
v2 실적 GetSnapshot · 5→40 req/s1,72415ms19.5ms28.5ms7.2MB17ms
v1 모의 클라 100대 (계획 1회 + 10초 폴링)1,04324ms143ms204ms26.0MB51.5ms
v2 모의 클라 100대 (계획 1회 + 10초 스냅샷)1,04416.5ms42ms65ms18.3MB21.2ms

안 쓰는 gRPC 서비스 3개 삭제

v1에도 LineService·LineServiceV2·ScheduleService gRPC가 있었습니다. 오리진 nginx 14일 로그에서 세 서비스 호출은 14건이고 전부 같은 날의 내부 점검이었으며, 같은 기간 편성 제보 HenseiService는 27,343건이었습니다. 삭제 시점(2026.09.04)에 출하된 앱의 gRPC 채널은 편성 제보 하나뿐이었고, v2 gRPC 연동은 그 뒤 앱에 들어가 지금 스토어 버전이 쓰고 있습니다. 아무도 부르지 않는 사본은 판정·캐시·계약 테스트를 두 벌 유지하게 만들어 2026.09.04에 뺐고, proto 파일은 메시지만 남겼습니다. 앱의 v2 연동(실패 시 REST 자동 폴백·디버그 로그·설정 토글)은 클라이언트 동료가 구현했고, 저는 proto 계약과 폴백 기준을 정했습니다.

03

일반열차 추적 (코레일 GIS + OSM 선로)

공공 API가 주지 않는 일반열차의 역 통과 시각을 코레일 GIS 좌표와 OSM 선로 34,062개로 산출합니다. 통과 커버리지 KTX 94%·ITX 100%, 라이브 무단절 배포.

수집: 전국 1회 호출과 응답 검증

코레일 GIS는 bbox 하나로 전국 열차를 한 번에 줍니다(약 85KB, 147~153편). 노선별 폴링을 만들지 않고 5초마다 전국 1회 호출하는 별도 스케줄러 틱으로 돌립니다. 인증 토큰은 없지만 Referer 헤더가 없으면 403과 HTML 오류 페이지가 옵니다. 그래서 응답이 GeoJSON FeatureCollection인지 반드시 확인하고 아니면 예외를 던집니다. HTML을 JSON인 척 흘려보내면 상위 계층에서 '열차 0편'으로 조용히 둔갑하기 때문입니다.

추적 모드 3종과 공통 Train 타입

노선마다 확보 가능한 데이터가 다릅니다. 도시철도 20개 노선은 REALTIME(서울 열린데이터·SMSS), 코레일은 POSITION_REPORT(GIS 위치 + 시간표), 실시간 정보가 없는 노선은 SIMULATED(시간표만)로 갱신 루프를 나눴습니다. 세 모드 모두 같은 Train 형태를 만들어 Store·라우트·gRPC는 어느 모드가 만든 열차인지 알 필요가 없습니다. 추적 모듈은 네트워크·파일·환경변수 접근 없이 require할 수 있고, 시계와 저장소는 인자로 주입합니다.

통과 판정: 반경 250m에서 선로거리로

처음에는 연속 두 GPS 점을 잇는 직선과 역 좌표의 거리가 250m 이내면 통과로 봤습니다. 56,202개 fix·186편·30분을 오프라인으로 재생하니 KTX 커버리지가 61~83%, ITX 73%였고, 놓친 85건 중 73건은 경로가 역 2km 안으로 오지 않은 경우였습니다. GIS 위치가 정체하다 수십 km를 점프해(전체 이동 선분의 8.3%가 550km/h 초과) 직선 현이 곡선 선로 위 역을 멀리 지나쳤기 때문입니다. 그래서 OSM 선로 데이터로 역 436개를 선로에 스냅하고 역쌍 930개의 폴리라인을 만들어, 통과를 '연속 두 fix의 선로거리 구간이 역의 선로거리를 포함하는가'로 바꿨습니다. 점프도 1차원 구간이라 그 안의 역은 모두 통과이고 시각은 비례 보간합니다. 재생에서 경로 판정 단독은 2차원보다 1~3역 뒤져서 둘의 합집합으로 확정했습니다(KTX 136≥134, ITX 74=74, 순서 위반 0). 2026.08.30 blue 슬롯 전환으로 무단절 배포했습니다.

재생 결과로 다시 본 것

재생 결과를 '지나간 선로거리 구간 안의 역' 기준으로 다시 세니 2차원·경로 모두 KTX 94%·ITX 100%였습니다. 놓침의 대부분은 GIS 위치가 그 구간에 닿지 않은 경우였고, 남은 6%는 전부 계획(시간표)이 틀린 열차의 역이었습니다. 경로 모드의 실제 이득은 점프 구간 역의 시각을 폐기하지 않고 보간하는 것, 그리고 계획과 실제 경로가 다른 열차를 즉시 드러내는 것(KTX 68: 계획은 경부선, 실제는 울산 고속선)이었습니다. 후자는 시간표 품질 신호로 쓰기로 했습니다.

지연 계산과 upstream_lost

코레일 지연은 상류가 주는 값과 시간표 대비 서버 계산값 중 큰 쪽을 씁니다. 상류 API는 언제든 부정확해질 수 있다는 전제로, 상류에서 사라진 열차를 '완주'로 기록하지 않는 규칙(upstream_lost)을 정했습니다. completed만 finished이고 나머지는 unknown으로 둡니다. 추적을 놓친 열차와 실제로 종착한 열차를 같은 값으로 기록하지 않기 위해서입니다. v1 응답은 원칙적으로 동결이지만 잘못된 '완주' 기록만은 예외로 두고 정확성을 택했습니다. 앱 쪽 표시를 확인한 뒤 적용할 예정이고, 2026.09 현재 결정은 끝났지만 구현은 대기 중입니다.

04

설계 결정

데이터 소스, 편성 데이터, 경로 탐색 알고리즘. 세 가지 선택의 이유입니다.

두 공공 API를 같은 형식으로 정규화

SMSS와 열린데이터는 응답 구조와 필드 의미가 다릅니다. 데이터 출처를 서비스 코드 곳곳에서 확인하지 않도록 두 응답을 같은 형식(열린데이터 응답의 열차 목록 필드, ttcVOList)으로 바꿨습니다. 그래서 주 소스를 SMSS에서 열린데이터로 바꿀 때 열차 처리 코드는 그대로 둘 수 있었습니다.

편성 정보: 사용자 제보와 투표

공공 API는 열차 편성(어떤 차량이 몇 량인지) 정보를 주지 않습니다. 크롤링으로 얻을 수 없는 데이터라 사용자가 제보하고 다른 사용자가 투표로 검증하는 흐름을 만들었습니다.

경로 탐색 (RAPTOR)

환승 경로 탐색은 평일 27노선·운행분 9,661개·승하차 가능 정차 242,865개의 시간표 위에서 돕니다. 노드를 '역+노선'으로 두어 재승차가 곧 라운드 증가가 되게 했고, 그래서 같은 노선의 급행↔완행 갈아타기도 예외 없이 환승으로 셉니다. 라운드 격리는 배열 셋(prev·cur·best)으로 강제합니다. 승차 판정이 prev 대신 min(prev, cur)를 읽으면 '용산에 06:35 도착해서 06:28에 떠난 열차를 탄다'는 답이 에러 없이 그럴듯한 숫자로 나오고 진짜 1회 환승 96분 답을 가립니다. 계획 단계에서 실제로 재현한 반례입니다. 결과는 (환승수, 도착시각) 두 기준의 Pareto 집합만 남기고, 도착이 같은데 환승만 하나 많은 여정은 지배당한 것으로 버립니다. 실측(2026.08.21)은 인덱스 재빌드 콜드 345~392ms·캐시 적중 85~94ms, 탐색 지연 p50 0.6ms·p99 1.3ms였습니다. RoutingServiceV2.Search와 여정을 따라가는 WatchJourney 스트림으로 앱에 출시했습니다.

05

시스템 설계

열차 데이터가 수집되어 앱에 전달되기까지의 흐름입니다.

아키텍처

외부 데이터는 Provider·Mapper에서 공통 형식으로 바꾸고, Realtime Policy가 소스 선택·폴백·병합을 결정합니다. 노선·운영사 설정은 Registry에, 신분당선 열번 추론 같은 예외는 Strategy에 둡니다. REALTIME·POSITION_REPORT·SIMULATED 세 가지 방식으로 열차를 추적합니다. 현재 런타임 상태는 Train/Vote Store가 관리하고 Redis는 매 틱 저장과 부팅 복원을, MongoDB는 운행 이력을 맡습니다. Fastify REST v1·gRPC v2·SSE로 결과를 제공하며, v1 REST는 기존 앱 호환성을 위해 바이트 단위로 동결했습니다.

제보 요청 검증과 제한

제보 요청은 SHA-256 해시를 비교하고 타임스탬프·Nonce를 확인해 변조된 요청과 같은 요청의 재사용을 제한합니다. gRPC 제보 경로에는 요청 횟수 제한과 IP 지역 제한을 적용했습니다. 오류는 Winston 구조화 로그로 남깁니다.

06

이관 후 운영 (한 달 단독 → 2인 팀)

팀원들이 떠난 뒤 서비스 전체를 이어받아 한 달간 앱·서버·인프라를 혼자 운영했고, 원래 클라이언트를 맡던 동료가 복귀한 뒤로는 백엔드·인프라를 전담합니다.

앱·watchOS·위젯까지 이어받은 한 달

팀에서는 백엔드(TrainAccAPI)를 담당했고, 이후 Flutter 앱(Dart 약 37k LOC, MVVM)·watchOS 앱·iOS 홈 위젯까지 포함한 서비스 전체를 이어받았습니다. 코드베이스를 읽어 구조를 파악하고, 스토어 배포(GitHub Actions)와 장애 대응을 유지하면서 라이브 서비스를 멈추지 않는 것이 목표였습니다. 약 한 달 뒤 원래 클라이언트를 개발하던 동료가 복귀해 지금은 2인 팀이고, 저는 백엔드·인프라를 전담합니다. 서버 계약은 proto 파일을 먼저 레포에 올리고 주석에 필드가 왜 있는지까지 적는 방식으로 정합니다. 동료는 그걸 읽고 구현하고, 애매한 건 채팅으로 그때그때 정했습니다. 별도 문서는 만들지 않았습니다.

신규 노선 추가 (엑셀 배선도 + MapTool)

노선 배선도를 엑셀로 관리하고 Python 변환 도구(MapTool)로 앱 노선도 데이터를 생성하는 팀의 워크플로를 이어받아, 경강선 등 신규 노선을 배선도 파일 작성과 변환 명령만으로 추가하고 있습니다. 변환기 코드는 240줄 남짓이고 실제 자산은 25개 노선의 승강장 위치·선로 연결이 들어 있는 엑셀 쪽입니다. 이 도구화로 25개 노선 규모를 2인 팀이 유지보수합니다.

레포 7개 점검 (2026.08)

레포가 7개로 늘어난 뒤에는 기억만으로 문제를 관리하기 어려웠습니다. 2026년 8월에 앱·백엔드·변환 도구·랜딩을 다시 확인하고 레포별 문제 목록을 만들었습니다. 이 과정에서 gRPC 델타 응답의 변경 열차 누락을 찾았습니다. 증상은 시간표 대비 지연이 바뀐 열차가 DELTA 응답에 한 번도 실리지 않는 것이었습니다. 원인은 둘이었습니다. 스토어가 열차 객체를 제자리에서 갱신해 직전 틱과 참조 비교를 하면 항상 같았고, 변경 감지 함수 protoFieldsEqual이 감시 필드를 손으로 나열해서 지연 필드가 빠져 있었습니다. 앱 로그에서 지연이 갱신되지 않는 열차를 보고 서버 쪽 델타 함수를 따라가 찾았습니다. v2에서는 원본 객체 대신 직전 틱의 proto(TripUpdate)끼리 비교하고, 필드 목록을 TRIP_FIELD 표 하나로 두어 proto 대조 테스트가 표에 없는 필드를 잡게 했습니다(test/unit/change-detect, v2-realtime-trip). 호출되지 않는 캐시 무효화 함수도 같은 점검에서 찾아 지웠습니다.

07

인프라 (OCI VM · Portainer · blue-green · Prometheus)

OCI ARM VM 위의 Docker 스택을 Jenkins·Portainer로 배포하고 blue-green 단일 flip으로 전환합니다. 2026.08.20 장애 이후 지표 2개, 알림 4개, 런북을 추가했습니다.

배포: PM2에서 blue-green으로

GitHub 푸시가 Jenkins를 거쳐 Portainer 스택으로 배포되고, lb 컨테이너가 blue/green 슬롯을 단일 flip으로 전환하며, Redis lease가 writer 슬롯을 정하고 Prometheus가 지표를 스크레이프하는 흐름
배포·전환·관측 흐름. dev 컨테이너는 같은 호스트에서 CPU 가중치 512로 돕니다.

라이브는 2026.08.22까지 PM2 프로세스였습니다. 지금은 Jenkins가 main·dev 푸시를 받아 typecheck·테스트·패리티를 거쳐 이미지를 만들고 Portainer 스택으로 올립니다. 라이브 스택은 lb(nginx)·redis·api-blue·api-green 넷이고, lb의 active-slot·writer-slot 마커 두 줄을 switch.sh가 한 번에 바꿔 전환합니다. 두 슬롯은 Redis lease로 writer를 하나만 두고, standby는 잡을 걸지 않고 lease 홀더만 등록합니다. 종료는 SIGTERM 뒤 8초 안에 진행 중 틱 대기 → 상태 큐 비움 → SSE·HTTP·gRPC 닫기 → 최종 저장 → lease 반납 순서로 끝냅니다. 2026.08.30 코레일 선로거리 판정(0.0.30)을 이 방식으로 무단절 전환했습니다.

2026.08.20 장애 (51분)

2026년 8월 20일 저녁, 운행 이력 조회 한 건이 인덱스 없는 컬렉션(317만 건)을 전수 스캔하며 142초를 썼고, 상한 없는 WiredTiger 캐시가 메모리를 밀어내 swap 0인 호스트 전체가 I/O 대기에 빠졌습니다. 디스크 I/O 대기는 평소 9~12ms/s에서 4,873ms/s까지 올랐고, 하드웨어 워치독이 펌웨어에서 꺼져 있어 51분 동안 스스로 복구하지 못했습니다. 재부팅 뒤 같은 질의가 한 번 더 들어와 앱 힙 2GB가 26분에 걸쳐 차고 FATAL로 죽었는데, /metrics에 힙 지표가 없어서 그동안 신호가 하나도 없었습니다. 커널 오류나 OOM 킬러 기록은 없었습니다.

장애 후 조치

당일 호스트에 swap 4GB, softdog 워치독(60초), WiredTiger 캐시 상한 3GB, mongod·PM2 로그 회전, 문제 경로 nginx 차단을 넣고 디스크 91%→71%, 가용 메모리 1.9GB→7.9GB로 회복했습니다. 앱에는 운행 이력 조회 인덱스와 조회 상한, 힙 사용량·상한 지표를 추가했고, Prometheus에 디스크 I/O 지연 알림 2개(0.5s/s·2s/s)와 앱 알림 2개(힙 85%·의존성 다운)를 걸었습니다. 임계값은 사고 곡선에서 역산했습니다. 0.5s/s 경고는 붕괴 25분 전, 2s/s 심각은 11분 전에 떴을 것입니다. 부하 생성기(k6)는 대상 호스트 밖의 관측 서버(noraneko)에 둡니다. 런북 첫 줄은 'Redis를 비우지 마라'입니다. 여기서 Redis는 캐시가 아니고 열차 상태 저장소여서, 지우면 전 노선 지연이 0부터 다시 쌓입니다.

08

사용자 분석 (GA4)

GA4(속성 railnetwork-app) 실측입니다. 2026.09.05에 조회했고 기간을 함께 적었습니다.

월간 사용자 추이 (2025.09~2026.08)

2025.09부터 2026.08까지 월 단위 총 사용자는 3,035명에서 6,094명으로 12개월 동안 한 번도 줄지 않고 2.0배가 됐습니다. 같은 기간 총 사용자 15,838명, 새 사용자 10,377명, 재방문 12,207명, 이벤트 4,823만 건, 사용자당 평균 참여 시간 3시간 45분이었습니다. 유입은 Organic Search 55.7%·Direct 46.7%(중복 포함)로 광고 없이 검색과 직접 진입만으로 늘었습니다.

GA4 사용자 획득 보고서 · 월 단위 총 사용자 · 2025.09.01~2026.08.31 · 2026.09.05 조회
총 사용자총 사용자
2025.093,0352026.034,839
2025.103,4202026.044,851
2025.113,8202026.055,325
2025.124,2702026.065,349
2026.014,3372026.075,704
2026.024,4112026.086,094

재방문·참여·유지 (지난 28일)

지난 28일(2026.08.08~09.04) 활성 사용자 5,997명 중 재방문이 5,246명(87.5%), 새 사용자는 1,016명이었습니다. DAU/MAU 29.0%, DAU/WAU 45.7%, WAU/MAU 63.5%로, 30일 활성 6천 명 중 하루 평균 1.7천 명이 들어옵니다. 활성 사용자당 평균 참여 시간은 28일 동안 55분 19초, 참여 세션 17회, 세션당 2분 53초였습니다. 열차를 확인하고 바로 닫는 짧은 세션이 하루 여러 번 반복되는 패턴입니다. 신규 사용자 유지는 1일째 30.4%, 8일째 15.1%, 34일째 약 11%로 첫 주 이후 완만한 꼬리를 유지합니다(9월 4일에 끝나는 42일 기준). 지난 28일 이벤트 414만 건 중 열차 상세 조회(page_trainDetails)가 129만 건으로 가장 많았습니다.

지역 분포

2025.11–2026.02 Firebase Analytics 기준으로 서울 68%·경기 22%·기타 10%였습니다. 같은 90일 동안 이벤트 1,200만 건 이상이 기록됐습니다.

09

개발 과정에서 배운 것

같은 서버를 2024년에 두 번, 2026년에 한 번 고쳤습니다. 앞의 둘은 라이브에 못 올렸습니다. git 이력과 ADR에 남은 것만 적었습니다.

리팩터링 순서: 2024년과 2026년

2024년 3월 커밋 메시지는 'code refactoring, apply later', 4월은 'still problem arises (ONLY USE IN LOCAL)'입니다. 구조를 먼저 바꾸고 나서 동작이 같은지 확인하려 했는데, 확인할 방법이 없어서 라이브에 올리지 못했습니다. 2026년 8월에는 거꾸로 했습니다. 노선·소스별 특수 규칙 115개를 목록으로 만들고, 기존 응답과 헤더를 특성화 테스트로 고정한 뒤(Phase 0), 그 테스트를 돌리면서 apicall.js와 trainService.js의 책임을 Provider·Policy·Registry·Strategy·Domain으로 옮겼습니다. 이때부터는 변경 전에 그 변경이 깨졌을 때 알려줄 테스트가 있는지 먼저 확인합니다.

레거시 호환: v1 응답 동결

리팩터링 중에도 스토어에 있는 앱은 v1 REST를 그대로 호출합니다. 그래서 v1은 필드 이름은 물론 헤더 순서와 오류 본문까지 바이트 단위로 동결했고, Express에서 Fastify로 옮기는 동안 두 서버를 같은 요청으로 두드려 응답을 비교하는 패리티 도구(parity·header-parity·live-parity)를 만들어 CI에 넣었습니다. JSON 파싱 오류 응답이 V8 엔진의 문구를 그대로 싣고 있어서 Node 버전이 바뀌면 계약이 깨지는 문제도 이때 발견했고, v2에서는 엔진 문구를 싣지 않는 봉투 형식으로 바꿨습니다(ADR-0003). 새 기능은 전부 v2 표면에만 두고 v1은 손대지 않는 것이 규칙입니다. 앱은 v2가 실패하면 v1으로 자동 폴백하므로 서버가 먼저 바뀌어도 사용자는 영향을 받지 않습니다.

무중단 배포 설계

먼저 상태 저장을 고쳤습니다. v1은 열차·편성 상태가 프로세스 메모리에만 있어서 재시작하면 전 노선이 '신규 열차'로 시작해 지연이 0부터 다시 쌓였습니다. Train/Vote Store의 변경분을 틱마다 Redis에 쓰고 부팅 때 복원하게 해서 재시작해도 상태가 이어지게 한 다음, blue-green을 올렸습니다. blue-green을 넣은 계기는 트래픽이 아니고 배포마다 앱 연결이 끊기고 편성 투표가 중간에 잘리던 문제였습니다. replica와 오토스케일은 메모리가 정본인 구조와 맞지 않아 넣지 않았습니다(ADR-0001). lb(nginx)가 active-slot과 writer-slot 마커 두 줄을 읽고, switch.sh가 둘을 한 번에 바꿉니다. 두 슬롯이 동시에 살아 있는 구간이 있으므로 Redis lease로 writer를 하나만 두고, standby는 잡을 걸지 않고 lease 홀더만 등록합니다. 전환 순간 두 슬롯 사이에 상태가 어긋날 수 있는 곳은 편성 투표 하나입니다. 열차 상태는 최종 저장 뒤에 flip하지만, 투표는 메모리에 쌓였다가 lease 홀더의 flush로만 Redis에 가기 때문에 겹치는 구간에 standby로 들어온 표는 사라질 수 있습니다. 그래서 편성 제보만 lb에서 writer 슬롯으로 고정 라우팅합니다. 종료 순서(인프라 절)를 정한 이유도 같습니다. 순서가 없을 때는 최종 저장 뒤에 writer가 메모리를 다시 바꾸거나 닫힌 Redis에 쓰는 구간이 있었습니다. 2026.08.30 코레일 선로거리 판정(0.0.30)을 이 방식으로 사용자 영향 없이 전환했습니다.

상태 소유권 (ADR-0001)

기존 문서는 Redis를 열차 상태의 단일 소스라고 불렀지만, 실제 코드는 요청·SSE·gRPC가 전부 프로세스 메모리를 읽고 Redis에는 틱이 끝난 뒤 변경분만 썼습니다. 이 어긋남이 실제 오판을 만들었습니다. /health/ready가 Redis 소켓만 보고 200을 줘서 복원이 실패해 스토어가 비어 있어도 '준비됨'이었고, 'API replica를 늘리면 된다'는 결론도 나왔습니다. 메모리가 정본이면 둘 다 틀립니다. 첫 ADR(0001)을 소유권 선언으로 쓴 이유이고, 이후 하드닝 작업은 이 문서를 게이트로 삼았습니다.

계획서와 실측

절 번호가 붙은 계획서를 먼저 쓰고 구현은 그 절 단위로 합니다. 계획서가 틀리는 지점은 매번 나옵니다. 이번 리팩터링에서 세 번 있었습니다. gRPC 시간표 인코딩의 바이트 계산에서 trip_id 열 하나를 빼고 세어 구현 후 재측정과 3~5% 어긋났고, 원인을 찾아 표 옆에 '세지 않은 열이 있었다'고 적었습니다. 관광열차를 새마을호와 가르던 열번 대역 규칙은 상류가 둘을 같은 코드로 준다는 걸 확인하고 지웠습니다. 코레일 선로거리 판정은 계획서에서 커버리지 90%를 기대했는데 재생하니 기존 방식보다 1~3역 뒤져서, 버리지 않고 둘을 합집합으로 확정했습니다. 셋 다 재생·재측정·로그가 잡았습니다. 이후로는 계획서 항목마다 '틀렸다면 무엇으로 알 수 있나'를 같이 적습니다. v2를 gRPC 전용으로 갈지, 메모리를 정본으로 두고 단일 프로세스를 유지할지, 상류에서 사라진 열차를 어떻게 기록할지는 대안을 늘어놓고 제가 골랐고, 근거를 ADR에 남겼습니다. 소비자 0인 gRPC 서비스 셋을 지운 것도 로그 14일을 본 뒤였습니다.

다음 단계

trainService.ts는 여전히 큽니다. 운영 중인 핵심 경로라 지금 쪼개는 건 이득보다 위험이 큽니다. 계약 테스트가 그 경로를 충분히 덮을 때만 잘라낼 생각이고, 그때까지는 변경이 생기는 지점만 떼어냅니다. dev와 live가 같은 2코어 호스트를 쓰고 있어 이번 부하 실측 중 dev 컨테이너가 종료됐고, 용량 계획이 필요합니다. upstream_lost는 결정만 끝났고 구현이 남았습니다. 다시 만든다면 SSE는 넣지 않을 겁니다. v2 Subscribe와 역할이 겹쳐 전송 표면이 셋이 됐고, 지금은 v1 앱 호환 때문에 유지합니다.

풀어낸 케이스

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

CASE 01추적 연속성 유지
문제
2호선 순환선은 지연이 발생하면 열번이 바뀌어, 같은 열차를 계속 추적할 수 없었습니다.
고민 · 접근
SMSS 응답을 열린데이터 응답과 합쳐 중복을 제거하고, trainNo 앞자리를 노선 규칙에 맞게 보정했습니다.
결과
지연 상황에서도 편성 추적 연속성이 유지됩니다.
CASE 02데이터 소스 2개
문제
단일 공공 API(SMSS)가 불안정해 장애 시 서비스 전체가 멈췄습니다.
고민 · 접근
서울시 열린데이터 광장 API를 동일 포맷(ttcVOList)으로 감싸 자동 폴백 계층을 만들고, 노선별 특수 파라미터를 객체로 분리했습니다.
결과
주 소스 호출이 실패하면 같은 요청을 대체 소스로 다시 시도합니다.
CASE 03p99 1.3ms
문제
경로 탐색 초안이 '용산 06:35 도착 → 06:28 출발 열차 탑승'처럼 시간을 거스르는 여정을 에러 없이 냈습니다. 같은 라운드 안에서 방금 갱신한 도착시각을 다시 승차 판정에 썼기 때문입니다.
고민 · 접근
RAPTOR 라운드 격리를 prev·cur·best 배열 셋으로 강제하고, 승차 판정은 prev만 읽게 했습니다. 결과는 (환승수, 도착시각) Pareto 집합으로 내고 지배당한 여정은 버립니다. 반례를 테스트로 고정했습니다.
결과
시간 역행 여정 0건. 탐색 p50 0.6ms·p99 1.3ms(평일 27노선, 2026.08.21 실측).
CASE 045.4배 빠름 · dev 실측
문제
시간표 응답이 REST JSON으로 6노선에 4.37MB·759ms였고, gzip을 켜도 575KB·316ms였습니다. 앱은 노선을 바꿀 때마다 이 비용을 냈습니다.
고민 · 접근
v2에서 계획과 실적을 분리하고, 시간표는 gRPC 전용 protobuf(packed 컬럼·역 사전·내용 해시)로 내려 변경이 없으면 NO_CHANGE만 보내게 했습니다.
결과
같은 6노선 시간표가 gRPC 383KB·58ms. 압축 없는 gRPC가 gzip REST보다 1.5배 작고 5.4배 빨랐습니다(개발 서버, 2026.09.04).

주요 성과

기술 스택

RailNetwork | Portfolio