1. 작업 내용
SQLite로 버티던 백엔드를 PostgreSQL 위로 옮겼습니다.
DATABASE_URL 환경변수화 - SQLite와 Postgres를 같은 코드로 나란히 돌리기 위해
docker-compose에 db 서비스 (postgres:17-alpine, healthcheck, service_healthy 대기)
- 이전 스크립트 - 고아 검사 → 구간 이전 → 시퀀스 되감기 → 검증
- 동시 쓰기 벤치 - writer를 1→16으로 늘리며 두 DB에 같은 부하
- 측정 환경을 실제 배포 구성(compose 스택)까지 끌어올리고, 단계별로 무엇이 달라지는지 비교
그런데 이 글은 "옮겼다"에 대한 글이 아닙니다. 옮기는 게 맞는 결정인지 따져본 기록입니다. 결론부터 쓰면 이렇습니다.
Postgres는 우리 워크로드에서 SQLite보다 5배 느립니다. 그걸 알고도 옮겼습니다.
성능을 산 게 아니라 선택지를 샀기 때문입니다.
2. Deep Dive 해본 것
결정해야 했던 것
로드맵에 "5주차: Postgres 이전"이라고 적어뒀습니다. 그건 일정이지 근거가 아닙니다.
LLM에 물으면 "확장성", "동시성", "프로덕션 표준" 같은 답이 나옵니다. 전부 맞는 말인데 우리 서비스에 대해서는 아무것도 안 알려줍니다. 확장성이 우리 도메인에서 구체적으로 무엇을 뜻하는지, 지금 그게 부족한지는 재봐야 압니다.
선택지는 셋이었습니다.
- A. 안 옮긴다 - SQLite로 계속 간다
- B. 지금 옮긴다 - 아직 안 아픈데 미리
- C. 필요해지면 옮긴다 - 문제가 생기면 그때
직관적으로 가장 그럴듯한 건 C입니다. YAGNI에도 맞고, 안 아픈 걸 미리 고치는 건 보통 나쁜 습관이니까요. 그래서 이번 주 작업은 C를 반증할 수 있는지 확인하는 것으로 잡았습니다.
판단하려면 사실 세 개가 필요합니다. 각각을 측정으로 답했습니다.
|
물어야 할 것 |
무엇을 무너뜨리나 |
| ① |
SQLite가 지금 부족한가 |
부족하면 A·C가 죽음 |
| ② |
언제, 어떤 모양으로 부족해지는가 |
여기가 C의 급소 |
| ③ |
나중에 옮기면 얼마나 비싼가 |
여기도 C의 급소 |
사실 ① SQLite는 지금 부족하지 않습니다. 오히려 5배 빠릅니다
writer당 20라운드 × 1,000행(= writer 하나가 20,000행), 매 실행마다 DB 재생성, SQLite는 WAL, 두 DB 모두 같은 스키마·같은 인덱스·같은 executemany 경로.
| DB |
W |
행 |
소요(s) |
행/초 |
p50(ms) |
p99(ms) |
실패 |
| SQLite (WAL) |
1 |
20,000 |
0.16 |
123,616 |
7.8 |
13.5 |
0 |
| SQLite (WAL) |
2 |
40,000 |
0.40 |
100,566 |
10.0 |
134.8 |
0 |
| SQLite (WAL) |
4 |
80,000 |
1.00 |
79,647 |
14.5 |
302.7 |
0 |
| SQLite (WAL) |
8 |
160,000 |
1.84 |
86,950 |
23.8 |
659.4 |
0 |
| SQLite (WAL) |
16 |
320,000 |
4.04 |
79,190 |
26.2 |
1,800.1 |
0 |
| PostgreSQL 17 |
1 |
20,000 |
0.79 |
25,371 |
36.3 |
60.0 |
0 |
| PostgreSQL 17 |
2 |
40,000 |
1.30 |
30,877 |
60.1 |
119.9 |
0 |
| PostgreSQL 17 |
4 |
80,000 |
2.90 |
27,592 |
143.5 |
193.8 |
0 |
| PostgreSQL 17 |
8 |
160,000 |
7.26 |
22,026 |
357.8 |
550.5 |
0 |
| PostgreSQL 17 |
16 |
320,000 |
17.71 |
18,072 |
885.3 |
1,105.6 |
0 |
W=1에서 123,616 vs 25,371 행/초. SQLite가 4.9배 빠릅니다.
그리고 제 가설이 틀렸습니다. database is locked로 실패가 날 줄 알았는데 한 건도 안 났습니다. SQLAlchemy가 sqlite3 드라이버에 기본 timeout=5.0을 주기 때문입니다. 락을 만나면 예외를 던지는 게 아니라 최대 5초까지 재시도합니다.
즉 A가 유혹적입니다. 지금 우리에게는 SQLite가 그냥 더 나은 선택입니다. 여기서 멈추면 "Postgres 이전은 낭비"가 결론입니다.
사실 ② SQLite는 실패하지 않고 조용히 느려집니다
절대값은 머신 상태에 좌우되므로(아래 「측정이 오염됐다」 참고) W=1 대비 배수로 봅니다. 그리고 같은 벤치를 두 환경에서 돌렸습니다 - 호스트에서, 그리고 실제 배포와 같은 조건(컨테이너 안)에서.
| W |
SQLite 처리량 |
PG 처리량 |
SQLite p99 |
PG p99 |
| 1 |
1.00× |
1.00× |
1.0× |
1.0× |
| 2 |
0.81× |
1.22× |
10.0× |
2.0× |
| 4 |
0.64× |
1.09× |
22.4× |
3.2× |
| 8 |
0.70× |
0.87× |
48.8× |
9.2× |
| 16 |
0.64× |
0.71× |
133.3× |
18.4× |
두 환경 모두에서 일관되게 나온 것이 셋입니다.
(1) 실패는 한 건도 안 났습니다. 제 가설(database is locked)이 틀렸어요. SQLAlchemy가 sqlite3 드라이버에 기본 timeout=5.0을 주기 때문입니다. 락을 만나면 예외를 던지는 게 아니라 최대 5초까지 재시도합니다.
(2) 어느 쪽도 처리량이 안 늘었습니다. writer를 16배로 늘렸는데 둘 다 줄었습니다. 저는 "Postgres는 동시 쓰기가 되니까 늘어날 것"이라고 막연히 생각했는데, MVCC가 푸는 건 읽기 vs 쓰기이지 쓰기끼리는 여전히 같은 디스크를 다툽니다. 확장성이라는 단어를 잘못 쓰고 있었습니다.
(3) SQLite의 p99가 자기 자신 대비 폭발합니다. 호스트 133배, 컨테이너 119배. Postgres는 각각 18배, 27배입니다.
여기가 C(필요해지면 옮긴다)의 급소입니다.
"문제가 생기면 그때 옮기자"가 성립하려면 문제가 생겼다는 걸 알아챌 수 있어야 합니다. 그런데 SQLite는 에러로 안 죽습니다. 실패 카운터는 0, 평균 응답도 멀쩡, 가끔 1.8초 멈출 뿐입니다. 에러율 알림으로는 안 잡힙니다. p99를 이미 재고 있는 팀이 아니면 "옮겨야 할 때"를 지나쳐도 모릅니다. C는 알아챌 수 있다는 전제 위에 서 있는데, 그 전제가 이 실패 모드에서는 성립하지 않습니다.
철회: "그래서 Postgres의 꼬리가 더 낫다"는 못 씁니다
초고에는 "처리량은 SQLite가 4배 앞서지만 꼬리는 Postgres가 7배 낫다"고 썼습니다. 환경을 바꿔서 재보니 부호가 뒤집혀서 철회합니다.
| W=16 p99 |
SQLite |
PostgreSQL |
누가 나은가 |
| 호스트에서 실행 |
1,800 ms |
1,106 ms |
Postgres |
| 컨테이너 안에서 실행 |
1,723 ms |
3,424 ms |
SQLite |
배수로 보면 일관되고(119133배 vs 1827배), 절대 비교는 일관되지 않습니다. 두 DB의 꼬리를 직접 견주는 주장은 이 측정으로 뒷받침되지 않습니다.
Week 03에서 "추론이 그럴듯하다고 측정이 되는 건 아니다"를 배우고 시간 기반 주장을 전부 철회했는데, 같은 실수를 다른 축에서 또 했습니다. 이번엔 환경을 하나 더 늘려서 잡혔습니다.
그리고 측정으로는 답할 수 없는 것이 하나 있습니다
위 표는 전부 한 머신 안에서 writer를 늘린 결과입니다. 그런데 우리가 실제로 하려는 건 서버를 여러 대로 늘리는 것이고, 거기서는 다른 문제가 먼저 옵니다.
SQLite의 writer들은 같은 파일시스템을 공유해야 합니다. 서버를 다른 머신에 두면 선택지 자체가 없습니다.
네트워크 파일시스템 위의 SQLite는 공식 문서가 명시적으로 권하지 않습니다. 락이 제대로 동작한다는 보장이 없어서, 느려지는 게 아니라 조용히 데이터가 깨질 수 있는 종류의 문제입니다.
이건 벤치로 잴 수 있는 성질이 아닙니다. 아키텍처의 제약이고, 이번 결정의 진짜 근거는 여기에 있습니다. 위의 측정들은 "SQLite가 지금 충분히 빠르다"(사실 ①)와 "그런데 한계가 눈에 안 보인다"(사실 ②)를 보여줄 뿐, B를 고르는 이유 자체를 만들어주지는 않습니다.
사실 ③ 나중에 옮기면 더 비쌉니다 - 그런데 이 근거는 절반 무너졌습니다
이전하려고 SQLite 파일을 열었더니 15.9 GB, telemetry 1억 1,907만 행이었습니다. 8/11~8/13, 서버를 켜둔 채 방치한 결과입니다.
처음 돌린 결과가 이랬습니다.
telemetry 360,000행 / 100.2s ( 3,594행/초) → 전량이면 9시간
9시간. "그러니 나중에 하면 더 비싸다"의 근거로 쓰려던 숫자입니다. 그런데 쓰기 전에 한 번 더 물었습니다 - 이게 왜 느린가?
쓰는 쪽(Postgres)이 아니라 읽는 쪽(SQLite)이 병목이었습니다. 제 절단 조건이 이랬거든요.
WHERE recorded_at >= ? -- 인덱스가 (robot_id, recorded_at) 라 선두 컬럼이 robot_id
-- → 이 조건에는 못 씀. 1억 1,907만 행 풀스캔
telemetry는 append-only라 id 순서가 곧 삽입 순서이고 삽입 순서가 곧 시간 순서입니다. "최근 N시간"과 "최근 N행"이 같은 것을 가리켜요. 그래서 조건만 바꿨습니다.
WHERE id > ? -- id 는 기본키. 인덱스를 그대로 탐
같은 360,000행, 같은 결과.
| 절단 기준 |
소요 |
처리량 |
전량 추정 |
recorded_at >= |
100.2 s |
3,594행/초 |
9시간 |
id > |
12.4 s |
28,990행/초 |
68분 |
8.1배. 9시간은 Postgres도 데이터 크기도 아니고 제 쿼리 탓이었습니다.
그래서 ③은 약해진 채로 남깁니다. 68분도 데이터에 비례해 계속 커지고 "필요해진 시점"은 곧 서비스 중이라는 뜻이지만, 9시간과 68분은 결정에 다르게 작용합니다. 68분이면 "필요할 때 점검 시간에 하면 되지"가 꽤 현실적인 반론이 됩니다.
C(필요해지면 옮긴다)를 무너뜨린 건 결국 ②(한계를 알아챌 수 없다)와 아래의 구조적 사실이지, ③이 아니었습니다. ③은 보조 근거로 격하합니다.
⚠️ 그리고 id 순서 = 시간 순서는 가정입니다. 백필이나 지연 도착 데이터가 생기면 깨집니다. 우리 시뮬레이터는 항상 현재 시각으로만 쌓으므로 지금은 성립하고, 안 성립하게 되는 날을 대비해 스크립트에 주석으로 남겼습니다.
결정: 전량은 안 옮깁니다
68분이면 할 수 있는데도 안 했습니다. "할 수 있나"와 "해야 하나"가 다른 질문이기 때문입니다.
텔레메트리는 로그성 데이터입니다. 이틀 전 로봇의 1초 단위 좌표를 Postgres로 옮겨서 얻는 게 무엇인지 답할 수 없다면 옮기지 않는 게 맞습니다. 통째로 들고 가는 건 이전이 아니라 문제의 이월입니다. 옮기고 나면 Postgres에서 똑같이 1억 행을 안고 살아야 하고, 19GB를 쓰고, 인덱스도 그만큼 커집니다.
그래서 최근 구간만 옮기고, 잘라낸 나머지를 어떻게 할지(보관 덤프 / 집계 후 폐기 / 그냥 폐기)는 정하지 않았습니다. 셋 다 다른 결정이라, 정하지 않은 걸 정한 척하지 않으려고 스크립트에서 손대지 않았습니다.
그래서 B - 다만 성능을 산 게 아닙니다
C가 무너지면 남는 건 A와 B입니다. B의 비용을 따져봅니다.
5배 느려지는 게 실제로 얼마나 아픈가? 앱을 Postgres 위에서 띄워 재봤습니다.
flushes 2 / rows_written 10,000 / rows_dropped 0
last_flush 279ms / max_flush 639ms
배치 주기는 5초, flush는 279ms. 예산의 5.6%입니다. 게다가 flush는 워커 스레드에서 돌아 이벤트 루프를 막지 않습니다(Week 02에서 그렇게 만들었습니다). 시뮬레이터 tick(100ms)과도 무관합니다.
즉 지금 이 5배는 아무도 못 느낍니다. 정리하면 이렇습니다.
|
지금 치르는 비용 |
나중에 치를 비용 |
| A. 안 옮김 |
없음 |
머신을 못 늘림 + 한계를 못 알아챔 |
| B. 지금 옮김 |
5배 느림 (체감 0) |
없음 |
| C. 필요해지면 |
없음 |
A와 같음 + 이전 9시간 이상 (그때는 서비스 중) |
그래서 Postgres 이전은 성능 결정이 아니라 옵션을 사는 결정이었습니다. 지금 못 느끼는 5배를 지불하고, "서버를 다른 머신에 띄울 수 있다"는 선택지를 삽니다. 보험료를 내는 구조이고, 보험료가 지금 제일 싸다는 게 ③의 내용입니다.
⚠️ 이 표에서 제가 측정으로 뒷받침한 건 왼쪽 열(지금의 비용)과 ③(이전 비용)뿐입니다. 오른쪽 열의 "실패를 못 알아챔"은 사실 ②에서, "머신을 못 늘림"은 측정이 아니라 SQLite의 구조에서 옵니다.
이 논증이 틀리는 조건
정직하게 적으면, 위 표는 "언젠가 서버를 2대 이상으로 늘린다"를 가정합니다. 절대 안 늘린다면 B는 순손실이고 A가 정답입니다.
그럼 우리는 언제 늘리게 될까요. 저는 처음에 처리량 때문일 거라 생각했는데, Week 01에 남겨둔 baseline을 다시 보니 아니었습니다.
| 로봇 수 |
tick 1회 |
예산(100ms) 대비 |
| 1,000 |
1.91 ms |
1.9% |
| 5,000 |
5.36 ms |
5.4% |
| 20,000 |
23.07 ms |
23.1% |
로봇 2만 대까지 단일 프로세스로 예산 안입니다. 처리량 때문에 서버를 늘릴 일은 당분간 없습니다.
서버를 늘리는 진짜 이유는 가용성입니다. 지금 구조는 프로세스 하나가 죽으면 관제 화면 전체가 멈춥니다. 관제 대시보드에서 그건 곧 "로봇이 어디 있는지 아무도 모르는 시간"입니다.
그리고 여기서 Week 02에 받은 반론과 만납니다.
"인메모리 상태를 많이 쓰는 서버 구조는 수평 확장에 불리하다"
당시 제 대답은 "단일 프로세스를 전제한다"였습니다. 그런데 그 전제를 깔면 SQLite도 같이 정당화됩니다. 쓰기가 배치 flush 하나뿐이라 경합할 상대가 없으니까요.
두 제약의 유효기간이 똑같습니다. 가용성 요구가 생기는 순간 인메모리 fleet과 SQLite가 동시에 걸립니다. 별개의 문제 두 개인 줄 알았는데 하나였습니다.
그리고 이번에 푼 건 절반입니다
Postgres로 옮겨도 인메모리 fleet은 그대로입니다. 서버를 2대로 늘리면 여전히 두 서버가 서로 다른 로봇 위치를 들고 있습니다. 이번 이전은 두 제약 중 하나만 풀었습니다.
순서를 이렇게 정한 이유는 비용 구조가 다르기 때문입니다.
- DB 이전: 비용이 데이터 크기에 비례해 계속 커짐 → 지금이 제일 싸고, 한가할 때 끝낼 수 있음
- 인메모리 상태 해제: 설계 변경(공유 상태 저장소 도입)이라 데이터가 쌓인다고 비싸지지 않음 → 미룰 수 있음
⚠️ 다만 이건 미룬 것이지 답한 게 아닙니다. "그래서 인메모리 상태를 어떻게 할 것인가"는 여전히 미해결이고, 이번 이전만으로 수평 확장이 가능해진 게 아닙니다.
서버를 2대로 늘려봤습니다 - 말로만 하던 걸 실제로
위 논증의 핵심이 "가용성 요구가 생기는 순간 인메모리 fleet과 SQLite가 동시에 걸린다"인데, 말로만 하고 실제로 안 해봤습니다. 해봤습니다.
API_PORT=8055 API_PORT_MAX=8056 docker compose up -d --scale api=2
compose 포트를 범위로 바꿔야 했습니다. 하나로 고정돼 있으면 두 번째 컨테이너가 port is already allocated로 안 뜹니다.
먼저 - 이건 "같은 로봇이 두 군데 있는" 게 아닙니다
결과를 보기 전에 제가 처음에 잘못 이해했던 걸 적어둡니다. 정확히는 서로 다른 로봇 1,000대씩, 총 2,000대가 돌고 있는 상황입니다.
우리 앱이 역할 두 개를 한 프로세스에 섞어놨기 때문입니다.
| 역할 |
하는 일 |
실제 서비스에서는 누가 |
| 데이터 생성자 (시뮬레이터) |
로봇의 진짜 위치를 만들어냄 |
로봇 자신 |
| 데이터 전달자 (API·WebSocket) |
그 위치를 받아 브라우저에 뿌림 |
서버 |
서버를 2대로 늘리면 시뮬레이터도 2개가 됩니다. 함대가 통째로 하나 더 생기는 것이라, 두 함대가 다른 곳에 있는 건 버그가 아니라 당연한 결과입니다.
그리고 이건 시뮬레이터를 쓰는 프로젝트라서 생긴 문제이기도 합니다. 실제 로봇이 있다면 로봇이 자기 위치를 보고하고 서버는 받은 것을 전달할 뿐이라, 서버가 몇 대든 같은 값을 말합니다.
결과 ① 텔레메트리가 2배로 쌓입니다
select count(*), count(distinct robot_id) from telemetry
where recorded_at between now() - interval '70 seconds' and now() - interval '15 seconds';
총 105,000행 / 로봇 1,000종 = 로봇당 105행
기대값 55행 (초당 1행 × 55초) → 정확히 2배
서버마다 자기 시뮬레이터가 돌기 때문입니다. 같은 로봇 1번의 위치를 서버 A도 쓰고 서버 B도 씁니다. 두 값이 다른데 둘 다 저장되고, 나중에 조회하면 같은 시각에 로봇 하나가 두 군데 있습니다.
결과 ② 상태가 갈라집니다 - 시드를 고정했는데도
같은 시각 같은 로봇 1번을 두 서버에 각각 물어본 것입니다.
| 시점 |
api-1 |
api-2 |
| 기동 직후 |
x=2.5011 y=27.5029 bat=78.710 |
완전히 동일 |
| 25초 후 |
x, y 동일 / bat=41.111 |
x, y 동일 / bat=52.216 |
| api-2만 재시작 후 15초 |
x=0.5404 y=20.9167 bat=48.88 |
x=0.0463 y=19.9089 bat=44.13 |
RANDOM_SEED = 42가 같으니 처음엔 똑같이 나옵니다. 여기서 잠깐 헷갈렸습니다 - "어? 상태가 공유되나?" 아닙니다. 같은 시드로 같은 계산을 각자 돌려서 우연히 일치한 것이고, tick 타이밍이 미세하게 달라 오차가 누적되면 25초 만에 배터리가 11%p 벌어집니다.
관제 화면 두 개를 나란히 띄우면 다른 값이 보입니다. 그리고 어느 쪽이 맞는지 판단할 근거가 없습니다 - 둘 다 자기 계산으로는 맞으니까요.
결과 ③ 그런데 robots 테이블은 충돌하지 않습니다
robots 갱신 충돌: 최근 10초 내 갱신된 행 0
앱이 런타임에 그 테이블을 안 건드리기 때문입니다(기동 시 없는 것만 insert). 즉 지금 깨지는 건 DB 정합성이 아니라 메모리 발산과 중복 기록입니다.
이게 이번 실험에서 제일 뜻밖이었습니다. Postgres로 옮긴 게 수평 확장을 위해서였는데, 정작 서버를 늘렸을 때 문제가 된 건 DB가 아니었습니다.
폴백은 되는데, "다른 우주로" 폴백됩니다
서버를 2대 두는 이유는 하나가 죽어도 나머지가 받는 것입니다. 지금 구조로도 api-1이 죽으면 브라우저는 api-2로 붙습니다. 그런데 붙는 순간 로봇 1,000대가 전부 순간이동합니다. api-2는 자기만의 함대를 굴리고 있었으니까요.
가용성은 "서버가 응답하느냐"만이 아니라 **"응답이 이어지느냐"**입니다. 지금은 앞의 것만 되고 뒤의 것이 안 됩니다. 이걸 확인한 게 이번 실험의 소득입니다 - 늘려두기만 하면 가용성이 생기는 줄 알았습니다.
그래서 다음 설계 질문이 정해졌습니다
문제는 "상태를 공유 안 한다"가 아니라 **"쓰기 주체가 둘"**입니다. 시뮬레이터가 서버마다 하나씩 돌고 있어요.
- 읽기(WebSocket 브로드캐스트, REST 조회)는 몇 대로 늘려도 됩니다.
- 쓰기(시뮬레이터)는 하나여야 합니다.
방향이 둘로 갈립니다. 아직 안 골랐습니다.
그림으로는 이렇게 됩니다.
[지금] [고친 뒤]
api-1 (시뮬 + API) simulator ×1 ──┐
api-2 (시뮬 + API) ├─→ 공유 상태
↑ 함대가 2개 api-1 (API만) ←─┤
api-2 (API만) ←─┘
↑ 무상태. 몇 대든 OK
시뮬레이터 컨테이너를 하나만 띄우면 그게 "로봇들" 역할이 되고, api 서버는 읽기만 하므로 무상태가 됩니다. 그러면 하나가 죽어도 나머지가 같은 데이터를 서빙합니다.
"시뮬레이터가 단일 장애점 아니냐"는 반론이 바로 나오는데, 실제 서비스에선 문제가 안 됩니다. 그 자리에 있는 건 로봇이고 로봇은 원래 데이터의 원천이니까요. 시뮬레이터는 개발용 가짜 데이터원일 뿐입니다.
남는 건 공유 상태를 어디에 두느냐입니다.
| 방향 |
대가 |
| Redis 같은 인메모리 저장소 |
컴포넌트가 하나 늘어남. 운영 부담 |
| Postgres에 최신 상태 테이블 |
매 tick 1,000건 왕복. Week 02에서 피하려던 그 비용이 돌아옴 |
Week 02에서 "인메모리를 쓰는 이유는 매 tick DB 왕복을 피하려고"라고 답했는데, 두 번째는 정확히 그걸 되돌립니다. 같은 트레이드오프가 한 층 위에서 다시 나왔습니다. 아직 안 골랐고 다음 주차 주제로 가져갑니다.
인덱스는 Postgres에 맞게 고쳤습니다 - BRIN
이전 직후에는 스키마를 그대로 복사만 했습니다. (robot_id, recorded_at) 하나뿐이었어요. "전체 기간" 쿼리가 SQLite에서 5분 13초 걸린 걸 이번 주에 직접 발견해놓고 Postgres에서 똑같이 뒀습니다.
Postgres에서 실행 계획을 봤더니 예상대로였습니다.
Parallel Seq Scan on telemetry (actual time=17.092..58.393 rows=139000 loops=3)
Filter: recorded_at >= ... AND recorded_at <= ...
Rows Removed by Filter: 367333
Buffers: shared hit=9331 read=9423
인덱스를 하나도 안 씁니다. 147MB를 통째로 읽고 워커당 36만 행을 버려요.
왜 B-tree를 하나 더 만들지 않고 BRIN인가
(recorded_at) B-tree를 추가해도 되긴 합니다. 그런데 telemetry는 append-only이고 시간순으로 쌓입니다. 이럴 때 B-tree는 모든 행의 위치를 일일이 기록하는데, 사실 "이 블록 묶음은 10:00~10:01 범위" 정도만 알면 충분합니다.
BRIN(Block Range INdex)이 정확히 그걸 합니다. 블록 묶음마다 최소·최대값만 저장해요.
telemetry 151.9만 행 기준입니다.
|
B-tree (robot_id, recorded_at) |
BRIN (recorded_at) |
| 인덱스 크기 |
64 MB |
24 kB |
| 읽는 버퍼 |
18,754 |
6,536 |
| 디스크 읽기 |
매번 ~7,000 |
0 (캐시에 들어감) |
| 실행 계획 |
Parallel Seq Scan |
Bitmap Index Scan |
| 실행 시간 (5회 중앙값) |
79 ms |
55 ms |
인덱스가 2,750배 작습니다.
⚠️ 다만 시간 이득은 1.4배뿐이고 노이즈도 큽니다(45~95ms로 흔들림). 151만 행에선 seq scan도 어차피 빠르거든요. 이 표에서 믿을 만한 건 시간이 아니라 크기와 버퍼입니다. 그리고 그게 데이터가 커질수록 벌어지는 축입니다 - seq scan은 행 수에 선형으로 늘어나지만 BRIN은 매칭되는 블록 범위에만 비례합니다.
BRIN이 통하는 전제
물리적 저장 순서가 recorded_at 순서와 거의 같아야 합니다. 우리는 append-only라 성립합니다. 과거 시각 행을 나중에 끼워 넣거나(백필) 대량 UPDATE로 행이 재배치되면 블록 범위가 넓어져 효과가 사라집니다.
이전 스크립트의 id > ? 절단과 같은 가정에 기대고 있습니다. 두 군데가 같은 전제를 공유하는데, 그 전제가 깨지면 둘 다 조용히 나빠집니다. 스크립트와 모델 양쪽에 주석으로 적어뒀습니다.
이름에 brin을 안 넣었습니다
postgresql_using="brin"은 Postgres에서만 적용되고 SQLite는 무시하고 일반 B-tree를 만듭니다. 처음에 ix_telemetry_recorded_brin이라고 지었다가 바꿨어요 - SQLite에선 그 이름이 거짓말이 됩니다.
같은 코드가 DB마다 다른 것이 되는 사례가 이걸로 네 번째입니다(Enum, 시퀀스, datetime, 인덱스 종류).
그래도 스키마 설계는 절반만 했습니다
인덱스는 고쳤지만 나머지는 그대로입니다. 테이블도 컬럼도 SQLite 때와 동일해요.
아직 안 한 것들입니다.
|
무엇 |
왜 우리에게 맞나 |
| 파티셔닝 |
telemetry를 시간 단위로 분할 |
Week 02 피드백에서 받은 "테이블 분할" 조언 그대로. 오래된 구간을 통째로 떼어낼 수 있음 |
| 시계열 집계 |
다운샘플링 사전 계산 |
"지난 1시간 경로"를 매번 원본에서 긁지 않아도 됨 |
| Alembic |
스키마 변경 관리 |
지금은 create_all 뿐이라 컬럼 추가를 못 함 |
이번 주에 판 건 **"옮길 것인가"**였습니다. **"옮긴 뒤에 무엇을 바꿀 것인가"**는 인덱스 하나까지만 갔습니다.
이전하며 부딪힌 것들
설계 논증과는 별개로, "ORM을 쓰면 DB 교체가 쉽다"가 어디까지 참인지가 이번에 여러 번 깨졌습니다.
인덱스가 있다와 인덱스가 쓰인다는 다른 이야기입니다.
| 쿼리 |
소요 |
SELECT MAX(id) FROM telemetry |
40 ms |
SELECT MIN(recorded_at), MAX(recorded_at) FROM telemetry |
313,128 ms (5분 13초) |
인덱스는 (robot_id, recorded_at) 복합 인덱스이고 선두 컬럼이 robot_id입니다. "3번 로봇의 어제 경로"에는 완벽한데, "전체 기간이 언제부터냐"에는 하나도 못 씁니다.
같은 함정이 제 이전 스크립트에도 있었습니다. 고아 검사에 SELECT DISTINCT robot_id를 썼는데, 인덱스가 있어도 인덱스 전체(1억 엔트리)를 훑습니다. 몇 분이 지나도 안 끝났고 디스크를 다 잡아먹어 다른 작업까지 멈췄습니다. 원하는 건 "종류 1,000개"이지 "1억 번 읽기"가 아닙니다.
WITH RECURSIVE d(rid) AS (
SELECT MIN(robot_id) FROM telemetry
UNION ALL
SELECT (SELECT MIN(robot_id) FROM telemetry WHERE robot_id > d.rid)
FROM d WHERE d.rid IS NOT NULL
)
SELECT rid FROM d WHERE rid IS NOT NULL
몇 분 이상 → 0.11초. loose index scan(skip scan)이라 부르는 기법인데 SQLite도 Postgres도 자동으로 해주지 않습니다. 쿼리가 원하는 답은 그대로고 인덱스를 어떻게 걸을지만 바꿨습니다.
덤으로 고아 데이터 0건이 확인됐습니다. Week 01에서 "SQLite는 FK를 조용히 무시하니 PRAGMA foreign_keys=ON을 켜야 한다"고 써두고 미뤄둔 숙제인데, 실제로 켜져 있었다는 증거입니다. Postgres에는 FK를 끄는 스위치가 아예 없어서, 위반이 쌓여 있었다면 이전 도중에 처음 터졌을 겁니다.
그리고 ORM이 안 감춰준 것 네 가지.
하나, 같은 Enum이 DB마다 다른 것이 됩니다. SQLite는 테이블마다 CHECK 제약이 따로 붙고(이름 중복 무관), Postgres는 타입을 한 번만 만들어 두 테이블이 공유합니다.
-- SQLite: robots 와 telemetry 에 각각
CONSTRAINT robotstatus CHECK (status IN ('idle','moving','charging','error','offline'))
-- PostgreSQL: 딱 한 번
CREATE TYPE robotstatus AS ENUM ('idle','moving','charging','error','offline')
값을 하나 추가할 때 영향 범위가 달라집니다. Postgres에서 ALTER TYPE robotstatus ADD VALUE 'returning'은 두 테이블에 동시에 적용됩니다. robots.status와 telemetry.status가 앞으로도 항상 같은 값 집합이어야 한다면 맞는 설계지만, 갈라질 수 있다면 떼어내기 어려운 결합입니다. 핵심은 모델 파일만 봐서는 안 보이는 결정이 DB에서 내려지고 있었다는 것입니다.
둘, 시퀀스는 따라 올라가지 않습니다. id를 명시해 INSERT하면 Postgres 시퀀스는 그대로입니다.
telemetry.id 시퀀스 → 119,074,000 (테이블에는 360,000행뿐)
되감지 않았다면 robots는 id를 1부터 다시 발급해 앱의 첫 INSERT에서 중복키로 터집니다. 이전 스크립트가 성공했다는 사실만으로는 안 잡힙니다. 터지는 시점이 이전 중이 아니라 이전 후니까요.
셋, connect_args={"check_same_thread": False}는 psycopg에서 TypeError입니다. db.py가 SQLite 전용이었다는 걸 여기서 알았습니다.
넷, SQLite에는 datetime 타입이 없습니다. 이전 스크립트를 처음 돌렸더니 여기서 죽었습니다.
TypeError: can't compare offset-naive and offset-aware datetimes
저장할 땐 timezone-aware 값을 넣었는데 읽으면 naive로 돌아옵니다. SQLite가 값을 문자열로 저장하고 SQLAlchemy가 다시 파싱하면서 시간대 정보가 사라지기 때문입니다. Postgres는 timestamptz가 진짜 타입이라 aware로 돌아옵니다.
이게 넷 중 제일 무서운 종류입니다. 앞의 셋은 즉시 예외로 터지는데 이건 타입이 조용히 다를 뿐이라, 그 값을 비교하지만 않으면 아무 일도 안 일어납니다. 저는 마침 비교를 해서 잡았습니다.
네 번 다 같은 모양입니다. SQLAlchemy의 추상화는 쿼리 레벨까지고, 연결 설정·타입 시스템·시퀀스는 그대로 새어 나옵니다.
"실제 환경과 비슷하게"를 정의하는 것 자체가 설계였습니다
지난 스터디에서 "테스트 환경을 실제 환경과 비슷하게 맞춰두라"는 피드백을 받았습니다. 이번에 실제로 맞춰보니, "비슷하게"가 어디까지인지 정하는 게 작업의 대부분이었습니다. 무한히 가까이 갈 수 있고, 각 단계마다 비용과 답할 수 있는 질문이 다릅니다.
네 단계로 나눠봤습니다.
| 단계 |
무엇이 실제와 같아지나 |
답할 수 있는 것 |
여전히 못 답하는 것 |
| 1. 호스트에서 스크립트 → PG(포트포워딩) |
거의 없음 |
두 DB의 대략적 성격 |
앞으로 안 쓸 경로를 재고 있음 |
| 2. 컨테이너에서 스크립트 → PG(도커 네트워크) |
네트워크 경로, 이미지 |
배포 경로에서의 DB 성능 |
워크로드가 여전히 합성 |
| 3. compose 스택 전체 + 실제 워크로드 |
네트워크 + 앱이 실제로 하는 일 |
우리가 진짜 겪을 지연 |
머신이 하나 |
| 4. 여러 머신 |
전부 |
수평 확장 |
여기선 SQLite가 후보 탈락 |
1→2는 이미 했고(아래), 이번에 3단계까지 올렸습니다. 4단계는 못 했습니다.
1 → 2: 컨테이너 안에서 재기
실제 배포(Week 06: Docker + Nginx)에서는 api도 컨테이너 안이라 Postgres와 같은 도커 네트워크를 씁니다. 호스트 측정은 앞으로 쓰지 않을 경로를 재고 있었습니다.
| DB |
W |
행/초 |
p50(ms) |
p99(ms) |
실패 |
| SQLite (WAL) |
1 |
107,809 |
8.0 |
14.5 |
0 |
| SQLite (WAL) |
16 |
75,939 |
32.8 |
1,723.3 |
0 |
| PostgreSQL 17 |
1 |
16,888 |
55.3 |
127.1 |
0 |
| PostgreSQL 17 |
16 |
12,402 |
1,175.3 |
3,423.6 |
0 |
격차가 줄기는커녕 벌어졌습니다 (W=1 기준 4.9배 → 6.4배). "네트워크 왕복이 원인"이라던 제 설명이 지지되지 않습니다. 원인은 아직 모릅니다 - 컨테이너의 CPU·디스크 제약, SQLite 저장 위치 변경(호스트 APFS vs 도커 볼륨)이 같이 바뀌어서 변수를 하나만 못 바꾼 측정입니다.
그리고 앞의 「철회」가 여기서 나왔습니다. 절대 p99 비교가 뒤집힌 게 이 표예요.
2 → 3: 합성 부하 대신 앱이 실제로 하는 일
여기가 이번에 새로 한 것입니다. 벤치는 "writer 16개가 동시에 때린다"는 가상의 상황인데, 우리 앱이 실제로 하는 건 5초마다 5,000행을 한 번 넣는 것입니다. 잰 적이 없었어요.
API_PORT=8055 docker compose up -d # api + db 둘 다 컨테이너
| 실행 방식 |
last_flush |
max_flush |
유실 |
| 호스트 앱 → 포트포워딩 PG |
279 ms |
639 ms |
0 |
| 컨테이너 앱 → 도커 네트워크 PG |
280 ms |
335 ms |
0 |
평균은 같은데 최악값이 절반입니다. 배포 구성이 더 빠른 게 아니라 더 안정적입니다. 벤치의 합성 부하로는 이 차이가 안 보였습니다 - 벤치는 처리량과 경합을 재고, 이건 실제 부하의 지터를 재니까요.
그리고 3단계에서만 확인할 수 있는 게 하나 더 있었습니다.
PG telemetry: 380,000행 = 이전한 360,000 + 앱이 새로 쌓은 20,000
이전한 데이터 위에 앱이 이어서 씁니다. 시퀀스를 안 되감았다면 여기서 중복키로 터졌을 겁니다. 이전 스크립트만 돌려서는 절대 안 잡히는 검증이고, 앱을 배포 모양으로 띄워야만 나옵니다.
3 → 4: 못 했고, 여기서 비교가 끝납니다
진짜 실제 환경은 "서버 2대가 각자 다른 컨테이너에서 같은 DB를 두드리는 것"입니다. 그런데 거기서는 SQLite가 아예 후보가 아니라 비교 자체가 성립하지 않습니다.
"실제 환경과 비슷하게"를 끝까지 밀면 이 벤치의 SQLite 열이 사라집니다. 재서 고른 게 아니라 후보가 하나만 남은 것 - 그게 이번 결정의 답이기도 합니다.
뜻밖의 소득 - 컨테이너가 더 조용합니다
측정 중 컨테이너 안 load average가 2.49였습니다. 호스트는 같은 시각에 60~90이었어요. Docker VM이 호스트의 iCloud 소음(아래 참고)에서 격리돼 있습니다.
실제 환경에 가까울 뿐 아니라 측정 환경으로도 더 낫습니다. 다음 주차부터 벤치는 컨테이너 안에서 돌리려 합니다.
그래도 "실제 배포와 같다"고는 못 씁니다
정직하게 남는 차이입니다.
- macOS의 Docker Desktop은 리눅스 VM 안에서 돕니다. 디스크 I/O가 가상화 계층을 거쳐서 실제 리눅스 서버와 fsync 특성이 다릅니다.
- 네트워크 지연이 0에 가깝습니다. 실제로는 DB가 다른 머신일 수 있습니다.
- 자원 한도, 데이터 규모, 부하 패턴이 다릅니다.
- 여전히 한 머신입니다.
"실제 배포와 같은 조건"이 아니라 "한 단계 더 가까운 조건"이 맞는 표현입니다.
측정이 앱 데이터를 지웠습니다
이전을 끝내고 컨테이너 벤치를 돌린 뒤 DB를 열어봤더니, telemetry가 360,000행이 아니라 320,000행이었습니다. robots도 1,000이 아니라 200이었고요.
벤치가 매 실행마다 이걸 하고 있었습니다.
c.execute(text("DROP SCHEMA public CASCADE; CREATE SCHEMA public;"))
벤치와 앱이 같은 데이터베이스를 쓰고 있었습니다. 애써 이전해둔 데이터가 벤치 한 번에 사라졌습니다.
깨끗한 상태에서 시작하는 것 자체는 맞습니다 (인덱스 크기가 실행마다 달라지면 측정이 오염됨). 틀린 건 그 파괴가 닿는 범위였습니다.
→ fleet_bench 를 따로 만들어 붙고, URL에 bench가 없으면 거부하는 안전장치를 뒀습니다.
측정이 오염됐다 - 그런데 이번엔 재현조차 안 됐습니다
첫 벤치를 돌리고 표를 만들려다 uptime을 찍어봤습니다.
벤치 전 load average: 76.32
벤치 후 load average: 172.72
다음 실행 load average: 258.97
원인은 DB가 아니었습니다.
cloudd 55.3% fileproviderd 36.8% iCloudDriveCore 27.0% nsurlsessiond 11.9%
~/Desktop이 iCloud 동기화 대상이라, 15.9GB짜리 fleet.db를 iCloud가 계속 동기화하려 하고 있었습니다. 그 상태에서 잰 숫자는 DB를 잰 게 아니라 iCloud를 잰 것입니다. 같은 부하 때문에 import app.main 한 줄이 215초 걸리기도 했습니다.
Week 03에서 happy-dom에 레이아웃 엔진이 없어 clientHeight가 0이라 가상화 효과를 5배 부풀렸던 것과 같은 종류의 함정입니다. 측정 대상이 아닌 것이 측정에 들어와 있는데 에러도 경고도 안 납니다.
다만 결정적으로 다른 점이 있습니다.
|
Week 03 (clientHeight) |
Week 05 (iCloud) |
| 재현성 |
완벽 (매번 정확히 5.0) |
없음 (실행마다 흔들림) |
| 발견 계기 |
값이 너무 커서 의심 |
uptime을 찍어봐서 |
Week 03에서 "재현성은 정확성의 증거가 아니다"를 배웠는데, 이번엔 재현조차 안 되는 오염이었습니다. 완벽한 재현이 안심의 근거가 못 되듯 흔들림도 그 자체로는 원인을 알려주지 않습니다. 환경을 명시적으로 재봐야 알 수 있었습니다.
DB 파일을 iCloud 밖으로 옮기고 다시 쟀고, 위의 표가 그것입니다. 그리고 벤치가 스스로 측정 전후의 load average를 찍도록 고쳤습니다. 다음부터는 잊어도 표에 남습니다.
⚠️ 그래도 호스트 load가 완전히 가라앉진 않았습니다. 그래서 이 글의 결론은 절대값이 아니라 W=1 대비 배수에만 걸었습니다. 유일한 예외가 "5배 느리다"인데, 세 번의 독립 실행에서 4.9~6.4배로 나와 방향은 일관됐습니다.
3. 알게된 것
1. 경합이 없는 곳에 동시성 기술을 넣으면 오버헤드만 남는다.
Postgres가 5배 느린 게 이상한 일이 아니었습니다. 우리는 쓰기가 배치 flush 하나뿐이라 애초에 다툴 상대가 없었어요. 그런데도 옮긴 건 지금의 손해(체감 0)와 나중의 손해를 비교한 결과지, 빨라져서가 아닙니다.
2. YAGNI를 쓰려면 "필요해진 걸 언제 아는가"를 먼저 물어야 한다.
SQLite는 database is locked로 안 죽고 줄을 섭니다. 실패 0건, p99만 119~133배. 에러율 알림에 안 잡히니 "문제 생기면 그때"가 성립을 안 합니다. → 이번 결정의 절반이 여기서 나왔습니다.
3. 결정의 진짜 근거가 측정 밖에 있었다.
벤치로 잰 건 "SQLite가 지금 충분하다"와 "한계가 안 보인다"까지입니다. 정작 B를 고르게 만든 건 SQLite writer는 파일시스템을 공유해야 한다는 구조적 제약이고, 이건 잴 수 있는 게 아닙니다. 숫자를 모으다 보면 숫자가 답할 수 있는 질문만 묻게 됩니다.
4. "실제 환경과 비슷하게"는 정의해야 하는 것이지 주어지는 게 아니다.
네 단계로 나눠보니 답할 수 있는 질문이 단계마다 달랐습니다. compose 스택에 실제 워크로드를 태운 3단계에서만 나온 게 둘 - max_flush 639 → 335ms(평균은 같고 최악값만 절반), 그리고 이전 데이터 위에 앱이 이어서 쓰는 것의 검증. 합성 부하로는 안 보였습니다. 4단계까지 밀면 SQLite가 후보에서 빠져 비교 자체가 사라지고요.
5. 별개인 줄 알았던 제약 두 개가 하나였다.
SQLite와 인메모리 fleet은 둘 다 "단일 프로세스" 전제 위에 있고 유효기간이 같습니다. Week 02 반론에 "단일 프로세스를 전제한다"고 답한 순간 SQLite도 같이 정당화하고 있었어요. 그리고 이번에 푼 건 둘 중 하나뿐입니다.
6. 인덱스가 있다 ≠ 인덱스를 쓴다.
복합 인덱스의 선두 컬럼이 정합니다. 이 하나가 이번 주에 세 번 발목을 잡았습니다 - 통계 쿼리(40ms vs 5분 13초), 고아 검사(→ 0.11초), 이전 속도(→ 8.1배). 세 번째는 하마터면 설계 근거로 쓸 뻔했고요.
7. 수평 확장을 막고 있던 게 DB가 아니었다.
서버를 2대로 띄워보니 텔레메트리가 정확히 2배로 쌓이고(로봇당 55행 → 105행), 시드를 고정했는데도 25초 만에 배터리가 11%p 갈라졌습니다. 정작 robots 테이블은 충돌 0건이었고요. Postgres로 옮긴 이유가 수평 확장이었는데, 막상 늘려보니 걸린 건 DB가 아니었습니다. 원인은 앱이 데이터 생성자(시뮬레이터)와 전달자(API)를 한 프로세스에 섞어둔 것 - 서버를 늘리면 함대가 통째로 하나 더 생깁니다.
8. 서버를 늘려두는 것과 가용성이 생기는 것은 다르다.
api-1이 죽으면 브라우저는 api-2로 붙습니다. 그런데 붙는 순간 로봇 1,000대가 순간이동합니다. "서버가 응답하느냐"는 되고 "응답이 이어지느냐"는 안 됩니다. 대수를 늘리기 전에 무엇을 늘리는지를 먼저 갈라야 했습니다 - 읽기는 늘려도 되고 쓰기는 하나여야 합니다.
틀린 채로 남긴 것
- "꼬리 지연은 Postgres가 낫다" → 철회. 환경 바꾸니 부호가 뒤집혔습니다. 배수는 일관, 절대 비교는 불가.
- "격차 5배는 네트워크 왕복 탓" → 미확인. 컨테이너에서 오히려 벌어졌고 변수를 하나만 못 바꿔서 원인을 모릅니다.
- 잘라낸 telemetry 1억 행의 처분 → 미정.
- 인메모리 fleet → 그대로. 서버 2대 실험으로 어떻게 깨지는지까지는 봤고, 고치는 방향(시뮬레이터 분리 vs 공유 저장소)은 안 골랐습니다.
- 스키마 설계 → 인덱스만. BRIN은 붙였지만 파티셔닝·시계열 집계·Alembic은 미착수.
- BRIN의 시간 이득 1.4배는 노이즈가 커서 크기·버퍼만 근거로 씁니다.
📎 코드·재현 스크립트: One-HyeWon/fleet-ops#4
cd backend
PYTHONPATH=. python scripts/bench_concurrency.py # 위 표 재현
PYTHONPATH=. python scripts/migrate_sqlite_to_pg.py --check-only # 고아 검사만
docker compose run --rm api python scripts/bench_concurrency.py # 컨테이너 안에서
docker compose up -d db && docker compose up api # Postgres 위에서 기동
1. 작업 내용
SQLite로 버티던 백엔드를 PostgreSQL 위로 옮겼습니다.
DATABASE_URL환경변수화 - SQLite와 Postgres를 같은 코드로 나란히 돌리기 위해docker-compose에db서비스 (postgres:17-alpine, healthcheck,service_healthy대기)그런데 이 글은 "옮겼다"에 대한 글이 아닙니다. 옮기는 게 맞는 결정인지 따져본 기록입니다. 결론부터 쓰면 이렇습니다.
2. Deep Dive 해본 것
결정해야 했던 것
로드맵에 "5주차: Postgres 이전"이라고 적어뒀습니다. 그건 일정이지 근거가 아닙니다.
LLM에 물으면 "확장성", "동시성", "프로덕션 표준" 같은 답이 나옵니다. 전부 맞는 말인데 우리 서비스에 대해서는 아무것도 안 알려줍니다. 확장성이 우리 도메인에서 구체적으로 무엇을 뜻하는지, 지금 그게 부족한지는 재봐야 압니다.
선택지는 셋이었습니다.
직관적으로 가장 그럴듯한 건 C입니다. YAGNI에도 맞고, 안 아픈 걸 미리 고치는 건 보통 나쁜 습관이니까요. 그래서 이번 주 작업은 C를 반증할 수 있는지 확인하는 것으로 잡았습니다.
판단하려면 사실 세 개가 필요합니다. 각각을 측정으로 답했습니다.
사실 ① SQLite는 지금 부족하지 않습니다. 오히려 5배 빠릅니다
writer당 20라운드 × 1,000행(= writer 하나가 20,000행), 매 실행마다 DB 재생성, SQLite는 WAL, 두 DB 모두 같은 스키마·같은 인덱스·같은
executemany경로.W=1에서 123,616 vs 25,371 행/초. SQLite가 4.9배 빠릅니다.
그리고 제 가설이 틀렸습니다.
database is locked로 실패가 날 줄 알았는데 한 건도 안 났습니다. SQLAlchemy가 sqlite3 드라이버에 기본timeout=5.0을 주기 때문입니다. 락을 만나면 예외를 던지는 게 아니라 최대 5초까지 재시도합니다.즉 A가 유혹적입니다. 지금 우리에게는 SQLite가 그냥 더 나은 선택입니다. 여기서 멈추면 "Postgres 이전은 낭비"가 결론입니다.
사실 ② SQLite는 실패하지 않고 조용히 느려집니다
절대값은 머신 상태에 좌우되므로(아래 「측정이 오염됐다」 참고) W=1 대비 배수로 봅니다. 그리고 같은 벤치를 두 환경에서 돌렸습니다 - 호스트에서, 그리고 실제 배포와 같은 조건(컨테이너 안)에서.
두 환경 모두에서 일관되게 나온 것이 셋입니다.
(1) 실패는 한 건도 안 났습니다. 제 가설(
database is locked)이 틀렸어요. SQLAlchemy가 sqlite3 드라이버에 기본timeout=5.0을 주기 때문입니다. 락을 만나면 예외를 던지는 게 아니라 최대 5초까지 재시도합니다.(2) 어느 쪽도 처리량이 안 늘었습니다. writer를 16배로 늘렸는데 둘 다 줄었습니다. 저는 "Postgres는 동시 쓰기가 되니까 늘어날 것"이라고 막연히 생각했는데, MVCC가 푸는 건 읽기 vs 쓰기이지 쓰기끼리는 여전히 같은 디스크를 다툽니다. 확장성이라는 단어를 잘못 쓰고 있었습니다.
(3) SQLite의 p99가 자기 자신 대비 폭발합니다. 호스트 133배, 컨테이너 119배. Postgres는 각각 18배, 27배입니다.
여기가 C(필요해지면 옮긴다)의 급소입니다.
"문제가 생기면 그때 옮기자"가 성립하려면 문제가 생겼다는 걸 알아챌 수 있어야 합니다. 그런데 SQLite는 에러로 안 죽습니다. 실패 카운터는 0, 평균 응답도 멀쩡, 가끔 1.8초 멈출 뿐입니다. 에러율 알림으로는 안 잡힙니다. p99를 이미 재고 있는 팀이 아니면 "옮겨야 할 때"를 지나쳐도 모릅니다. C는 알아챌 수 있다는 전제 위에 서 있는데, 그 전제가 이 실패 모드에서는 성립하지 않습니다.
철회: "그래서 Postgres의 꼬리가 더 낫다"는 못 씁니다
초고에는 "처리량은 SQLite가 4배 앞서지만 꼬리는 Postgres가 7배 낫다"고 썼습니다. 환경을 바꿔서 재보니 부호가 뒤집혀서 철회합니다.
배수로 보면 일관되고(119
133배 vs 1827배), 절대 비교는 일관되지 않습니다. 두 DB의 꼬리를 직접 견주는 주장은 이 측정으로 뒷받침되지 않습니다.Week 03에서 "추론이 그럴듯하다고 측정이 되는 건 아니다"를 배우고 시간 기반 주장을 전부 철회했는데, 같은 실수를 다른 축에서 또 했습니다. 이번엔 환경을 하나 더 늘려서 잡혔습니다.
그리고 측정으로는 답할 수 없는 것이 하나 있습니다
위 표는 전부 한 머신 안에서 writer를 늘린 결과입니다. 그런데 우리가 실제로 하려는 건 서버를 여러 대로 늘리는 것이고, 거기서는 다른 문제가 먼저 옵니다.
SQLite의 writer들은 같은 파일시스템을 공유해야 합니다. 서버를 다른 머신에 두면 선택지 자체가 없습니다.
네트워크 파일시스템 위의 SQLite는 공식 문서가 명시적으로 권하지 않습니다. 락이 제대로 동작한다는 보장이 없어서, 느려지는 게 아니라 조용히 데이터가 깨질 수 있는 종류의 문제입니다.
이건 벤치로 잴 수 있는 성질이 아닙니다. 아키텍처의 제약이고, 이번 결정의 진짜 근거는 여기에 있습니다. 위의 측정들은 "SQLite가 지금 충분히 빠르다"(사실 ①)와 "그런데 한계가 눈에 안 보인다"(사실 ②)를 보여줄 뿐, B를 고르는 이유 자체를 만들어주지는 않습니다.
사실 ③ 나중에 옮기면 더 비쌉니다 - 그런데 이 근거는 절반 무너졌습니다
이전하려고 SQLite 파일을 열었더니 15.9 GB, telemetry 1억 1,907만 행이었습니다. 8/11~8/13, 서버를 켜둔 채 방치한 결과입니다.
처음 돌린 결과가 이랬습니다.
9시간. "그러니 나중에 하면 더 비싸다"의 근거로 쓰려던 숫자입니다. 그런데 쓰기 전에 한 번 더 물었습니다 - 이게 왜 느린가?
쓰는 쪽(Postgres)이 아니라 읽는 쪽(SQLite)이 병목이었습니다. 제 절단 조건이 이랬거든요.
telemetry는 append-only라 id 순서가 곧 삽입 순서이고 삽입 순서가 곧 시간 순서입니다. "최근 N시간"과 "최근 N행"이 같은 것을 가리켜요. 그래서 조건만 바꿨습니다.
같은 360,000행, 같은 결과.
recorded_at >=id >8.1배. 9시간은 Postgres도 데이터 크기도 아니고 제 쿼리 탓이었습니다.
그래서 ③은 약해진 채로 남깁니다. 68분도 데이터에 비례해 계속 커지고 "필요해진 시점"은 곧 서비스 중이라는 뜻이지만, 9시간과 68분은 결정에 다르게 작용합니다. 68분이면 "필요할 때 점검 시간에 하면 되지"가 꽤 현실적인 반론이 됩니다.
C(필요해지면 옮긴다)를 무너뜨린 건 결국 ②(한계를 알아챌 수 없다)와 아래의 구조적 사실이지, ③이 아니었습니다. ③은 보조 근거로 격하합니다.
id 순서 = 시간 순서는 가정입니다. 백필이나 지연 도착 데이터가 생기면 깨집니다. 우리 시뮬레이터는 항상 현재 시각으로만 쌓으므로 지금은 성립하고, 안 성립하게 되는 날을 대비해 스크립트에 주석으로 남겼습니다.결정: 전량은 안 옮깁니다
68분이면 할 수 있는데도 안 했습니다. "할 수 있나"와 "해야 하나"가 다른 질문이기 때문입니다.
텔레메트리는 로그성 데이터입니다. 이틀 전 로봇의 1초 단위 좌표를 Postgres로 옮겨서 얻는 게 무엇인지 답할 수 없다면 옮기지 않는 게 맞습니다. 통째로 들고 가는 건 이전이 아니라 문제의 이월입니다. 옮기고 나면 Postgres에서 똑같이 1억 행을 안고 살아야 하고, 19GB를 쓰고, 인덱스도 그만큼 커집니다.
그래서 최근 구간만 옮기고, 잘라낸 나머지를 어떻게 할지(보관 덤프 / 집계 후 폐기 / 그냥 폐기)는 정하지 않았습니다. 셋 다 다른 결정이라, 정하지 않은 걸 정한 척하지 않으려고 스크립트에서 손대지 않았습니다.
그래서 B - 다만 성능을 산 게 아닙니다
C가 무너지면 남는 건 A와 B입니다. B의 비용을 따져봅니다.
5배 느려지는 게 실제로 얼마나 아픈가? 앱을 Postgres 위에서 띄워 재봤습니다.
배치 주기는 5초, flush는 279ms. 예산의 5.6%입니다. 게다가 flush는 워커 스레드에서 돌아 이벤트 루프를 막지 않습니다(Week 02에서 그렇게 만들었습니다). 시뮬레이터 tick(100ms)과도 무관합니다.
즉 지금 이 5배는 아무도 못 느낍니다. 정리하면 이렇습니다.
그래서 Postgres 이전은 성능 결정이 아니라 옵션을 사는 결정이었습니다. 지금 못 느끼는 5배를 지불하고, "서버를 다른 머신에 띄울 수 있다"는 선택지를 삽니다. 보험료를 내는 구조이고, 보험료가 지금 제일 싸다는 게 ③의 내용입니다.
이 논증이 틀리는 조건
정직하게 적으면, 위 표는 "언젠가 서버를 2대 이상으로 늘린다"를 가정합니다. 절대 안 늘린다면 B는 순손실이고 A가 정답입니다.
그럼 우리는 언제 늘리게 될까요. 저는 처음에 처리량 때문일 거라 생각했는데, Week 01에 남겨둔 baseline을 다시 보니 아니었습니다.
로봇 2만 대까지 단일 프로세스로 예산 안입니다. 처리량 때문에 서버를 늘릴 일은 당분간 없습니다.
서버를 늘리는 진짜 이유는 가용성입니다. 지금 구조는 프로세스 하나가 죽으면 관제 화면 전체가 멈춥니다. 관제 대시보드에서 그건 곧 "로봇이 어디 있는지 아무도 모르는 시간"입니다.
그리고 여기서 Week 02에 받은 반론과 만납니다.
당시 제 대답은 "단일 프로세스를 전제한다"였습니다. 그런데 그 전제를 깔면 SQLite도 같이 정당화됩니다. 쓰기가 배치 flush 하나뿐이라 경합할 상대가 없으니까요.
두 제약의 유효기간이 똑같습니다. 가용성 요구가 생기는 순간 인메모리 fleet과 SQLite가 동시에 걸립니다. 별개의 문제 두 개인 줄 알았는데 하나였습니다.
그리고 이번에 푼 건 절반입니다
Postgres로 옮겨도 인메모리 fleet은 그대로입니다. 서버를 2대로 늘리면 여전히 두 서버가 서로 다른 로봇 위치를 들고 있습니다. 이번 이전은 두 제약 중 하나만 풀었습니다.
순서를 이렇게 정한 이유는 비용 구조가 다르기 때문입니다.
서버를 2대로 늘려봤습니다 - 말로만 하던 걸 실제로
위 논증의 핵심이 "가용성 요구가 생기는 순간 인메모리 fleet과 SQLite가 동시에 걸린다"인데, 말로만 하고 실제로 안 해봤습니다. 해봤습니다.
compose 포트를 범위로 바꿔야 했습니다. 하나로 고정돼 있으면 두 번째 컨테이너가
port is already allocated로 안 뜹니다.먼저 - 이건 "같은 로봇이 두 군데 있는" 게 아닙니다
결과를 보기 전에 제가 처음에 잘못 이해했던 걸 적어둡니다. 정확히는 서로 다른 로봇 1,000대씩, 총 2,000대가 돌고 있는 상황입니다.
우리 앱이 역할 두 개를 한 프로세스에 섞어놨기 때문입니다.
서버를 2대로 늘리면 시뮬레이터도 2개가 됩니다. 함대가 통째로 하나 더 생기는 것이라, 두 함대가 다른 곳에 있는 건 버그가 아니라 당연한 결과입니다.
그리고 이건 시뮬레이터를 쓰는 프로젝트라서 생긴 문제이기도 합니다. 실제 로봇이 있다면 로봇이 자기 위치를 보고하고 서버는 받은 것을 전달할 뿐이라, 서버가 몇 대든 같은 값을 말합니다.
결과 ① 텔레메트리가 2배로 쌓입니다
서버마다 자기 시뮬레이터가 돌기 때문입니다. 같은 로봇 1번의 위치를 서버 A도 쓰고 서버 B도 씁니다. 두 값이 다른데 둘 다 저장되고, 나중에 조회하면 같은 시각에 로봇 하나가 두 군데 있습니다.
결과 ② 상태가 갈라집니다 - 시드를 고정했는데도
같은 시각 같은 로봇 1번을 두 서버에 각각 물어본 것입니다.
RANDOM_SEED = 42가 같으니 처음엔 똑같이 나옵니다. 여기서 잠깐 헷갈렸습니다 - "어? 상태가 공유되나?" 아닙니다. 같은 시드로 같은 계산을 각자 돌려서 우연히 일치한 것이고, tick 타이밍이 미세하게 달라 오차가 누적되면 25초 만에 배터리가 11%p 벌어집니다.관제 화면 두 개를 나란히 띄우면 다른 값이 보입니다. 그리고 어느 쪽이 맞는지 판단할 근거가 없습니다 - 둘 다 자기 계산으로는 맞으니까요.
결과 ③ 그런데
robots테이블은 충돌하지 않습니다앱이 런타임에 그 테이블을 안 건드리기 때문입니다(기동 시 없는 것만 insert). 즉 지금 깨지는 건 DB 정합성이 아니라 메모리 발산과 중복 기록입니다.
이게 이번 실험에서 제일 뜻밖이었습니다. Postgres로 옮긴 게 수평 확장을 위해서였는데, 정작 서버를 늘렸을 때 문제가 된 건 DB가 아니었습니다.
폴백은 되는데, "다른 우주로" 폴백됩니다
서버를 2대 두는 이유는 하나가 죽어도 나머지가 받는 것입니다. 지금 구조로도 api-1이 죽으면 브라우저는 api-2로 붙습니다. 그런데 붙는 순간 로봇 1,000대가 전부 순간이동합니다. api-2는 자기만의 함대를 굴리고 있었으니까요.
가용성은 "서버가 응답하느냐"만이 아니라 **"응답이 이어지느냐"**입니다. 지금은 앞의 것만 되고 뒤의 것이 안 됩니다. 이걸 확인한 게 이번 실험의 소득입니다 - 늘려두기만 하면 가용성이 생기는 줄 알았습니다.
그래서 다음 설계 질문이 정해졌습니다
문제는 "상태를 공유 안 한다"가 아니라 **"쓰기 주체가 둘"**입니다. 시뮬레이터가 서버마다 하나씩 돌고 있어요.
방향이 둘로 갈립니다. 아직 안 골랐습니다.
그림으로는 이렇게 됩니다.
시뮬레이터 컨테이너를 하나만 띄우면 그게 "로봇들" 역할이 되고, api 서버는 읽기만 하므로 무상태가 됩니다. 그러면 하나가 죽어도 나머지가 같은 데이터를 서빙합니다.
"시뮬레이터가 단일 장애점 아니냐"는 반론이 바로 나오는데, 실제 서비스에선 문제가 안 됩니다. 그 자리에 있는 건 로봇이고 로봇은 원래 데이터의 원천이니까요. 시뮬레이터는 개발용 가짜 데이터원일 뿐입니다.
남는 건 공유 상태를 어디에 두느냐입니다.
Week 02에서 "인메모리를 쓰는 이유는 매 tick DB 왕복을 피하려고"라고 답했는데, 두 번째는 정확히 그걸 되돌립니다. 같은 트레이드오프가 한 층 위에서 다시 나왔습니다. 아직 안 골랐고 다음 주차 주제로 가져갑니다.
인덱스는 Postgres에 맞게 고쳤습니다 - BRIN
이전 직후에는 스키마를 그대로 복사만 했습니다.
(robot_id, recorded_at)하나뿐이었어요. "전체 기간" 쿼리가 SQLite에서 5분 13초 걸린 걸 이번 주에 직접 발견해놓고 Postgres에서 똑같이 뒀습니다.Postgres에서 실행 계획을 봤더니 예상대로였습니다.
인덱스를 하나도 안 씁니다. 147MB를 통째로 읽고 워커당 36만 행을 버려요.
왜 B-tree를 하나 더 만들지 않고 BRIN인가
(recorded_at)B-tree를 추가해도 되긴 합니다. 그런데 telemetry는 append-only이고 시간순으로 쌓입니다. 이럴 때 B-tree는 모든 행의 위치를 일일이 기록하는데, 사실 "이 블록 묶음은 10:00~10:01 범위" 정도만 알면 충분합니다.BRIN(Block Range INdex)이 정확히 그걸 합니다. 블록 묶음마다 최소·최대값만 저장해요.
telemetry 151.9만 행 기준입니다.
(robot_id, recorded_at)(recorded_at)인덱스가 2,750배 작습니다.
BRIN이 통하는 전제
물리적 저장 순서가
recorded_at순서와 거의 같아야 합니다. 우리는 append-only라 성립합니다. 과거 시각 행을 나중에 끼워 넣거나(백필) 대량 UPDATE로 행이 재배치되면 블록 범위가 넓어져 효과가 사라집니다.이전 스크립트의
id > ?절단과 같은 가정에 기대고 있습니다. 두 군데가 같은 전제를 공유하는데, 그 전제가 깨지면 둘 다 조용히 나빠집니다. 스크립트와 모델 양쪽에 주석으로 적어뒀습니다.이름에 brin을 안 넣었습니다
postgresql_using="brin"은 Postgres에서만 적용되고 SQLite는 무시하고 일반 B-tree를 만듭니다. 처음에ix_telemetry_recorded_brin이라고 지었다가 바꿨어요 - SQLite에선 그 이름이 거짓말이 됩니다.같은 코드가 DB마다 다른 것이 되는 사례가 이걸로 네 번째입니다(Enum, 시퀀스, datetime, 인덱스 종류).
그래도 스키마 설계는 절반만 했습니다
인덱스는 고쳤지만 나머지는 그대로입니다. 테이블도 컬럼도 SQLite 때와 동일해요.
아직 안 한 것들입니다.
create_all뿐이라 컬럼 추가를 못 함이번 주에 판 건 **"옮길 것인가"**였습니다. **"옮긴 뒤에 무엇을 바꿀 것인가"**는 인덱스 하나까지만 갔습니다.
이전하며 부딪힌 것들
설계 논증과는 별개로, "ORM을 쓰면 DB 교체가 쉽다"가 어디까지 참인지가 이번에 여러 번 깨졌습니다.
인덱스가 있다와 인덱스가 쓰인다는 다른 이야기입니다.
SELECT MAX(id) FROM telemetrySELECT MIN(recorded_at), MAX(recorded_at) FROM telemetry인덱스는
(robot_id, recorded_at)복합 인덱스이고 선두 컬럼이robot_id입니다. "3번 로봇의 어제 경로"에는 완벽한데, "전체 기간이 언제부터냐"에는 하나도 못 씁니다.같은 함정이 제 이전 스크립트에도 있었습니다. 고아 검사에
SELECT DISTINCT robot_id를 썼는데, 인덱스가 있어도 인덱스 전체(1억 엔트리)를 훑습니다. 몇 분이 지나도 안 끝났고 디스크를 다 잡아먹어 다른 작업까지 멈췄습니다. 원하는 건 "종류 1,000개"이지 "1억 번 읽기"가 아닙니다.몇 분 이상 → 0.11초. loose index scan(skip scan)이라 부르는 기법인데 SQLite도 Postgres도 자동으로 해주지 않습니다. 쿼리가 원하는 답은 그대로고 인덱스를 어떻게 걸을지만 바꿨습니다.
덤으로 고아 데이터 0건이 확인됐습니다. Week 01에서 "SQLite는 FK를 조용히 무시하니
PRAGMA foreign_keys=ON을 켜야 한다"고 써두고 미뤄둔 숙제인데, 실제로 켜져 있었다는 증거입니다. Postgres에는 FK를 끄는 스위치가 아예 없어서, 위반이 쌓여 있었다면 이전 도중에 처음 터졌을 겁니다.그리고 ORM이 안 감춰준 것 네 가지.
하나, 같은 Enum이 DB마다 다른 것이 됩니다. SQLite는 테이블마다 CHECK 제약이 따로 붙고(이름 중복 무관), Postgres는 타입을 한 번만 만들어 두 테이블이 공유합니다.
값을 하나 추가할 때 영향 범위가 달라집니다. Postgres에서
ALTER TYPE robotstatus ADD VALUE 'returning'은 두 테이블에 동시에 적용됩니다.robots.status와telemetry.status가 앞으로도 항상 같은 값 집합이어야 한다면 맞는 설계지만, 갈라질 수 있다면 떼어내기 어려운 결합입니다. 핵심은 모델 파일만 봐서는 안 보이는 결정이 DB에서 내려지고 있었다는 것입니다.둘, 시퀀스는 따라 올라가지 않습니다.
id를 명시해 INSERT하면 Postgres 시퀀스는 그대로입니다.되감지 않았다면
robots는 id를 1부터 다시 발급해 앱의 첫 INSERT에서 중복키로 터집니다. 이전 스크립트가 성공했다는 사실만으로는 안 잡힙니다. 터지는 시점이 이전 중이 아니라 이전 후니까요.셋,
connect_args={"check_same_thread": False}는 psycopg에서TypeError입니다.db.py가 SQLite 전용이었다는 걸 여기서 알았습니다.넷, SQLite에는 datetime 타입이 없습니다. 이전 스크립트를 처음 돌렸더니 여기서 죽었습니다.
저장할 땐 timezone-aware 값을 넣었는데 읽으면 naive로 돌아옵니다. SQLite가 값을 문자열로 저장하고 SQLAlchemy가 다시 파싱하면서 시간대 정보가 사라지기 때문입니다. Postgres는
timestamptz가 진짜 타입이라 aware로 돌아옵니다.이게 넷 중 제일 무서운 종류입니다. 앞의 셋은 즉시 예외로 터지는데 이건 타입이 조용히 다를 뿐이라, 그 값을 비교하지만 않으면 아무 일도 안 일어납니다. 저는 마침 비교를 해서 잡았습니다.
네 번 다 같은 모양입니다. SQLAlchemy의 추상화는 쿼리 레벨까지고, 연결 설정·타입 시스템·시퀀스는 그대로 새어 나옵니다.
"실제 환경과 비슷하게"를 정의하는 것 자체가 설계였습니다
지난 스터디에서 "테스트 환경을 실제 환경과 비슷하게 맞춰두라"는 피드백을 받았습니다. 이번에 실제로 맞춰보니, "비슷하게"가 어디까지인지 정하는 게 작업의 대부분이었습니다. 무한히 가까이 갈 수 있고, 각 단계마다 비용과 답할 수 있는 질문이 다릅니다.
네 단계로 나눠봤습니다.
1→2는 이미 했고(아래), 이번에 3단계까지 올렸습니다. 4단계는 못 했습니다.
1 → 2: 컨테이너 안에서 재기
실제 배포(Week 06: Docker + Nginx)에서는 api도 컨테이너 안이라 Postgres와 같은 도커 네트워크를 씁니다. 호스트 측정은 앞으로 쓰지 않을 경로를 재고 있었습니다.
격차가 줄기는커녕 벌어졌습니다 (W=1 기준 4.9배 → 6.4배). "네트워크 왕복이 원인"이라던 제 설명이 지지되지 않습니다. 원인은 아직 모릅니다 - 컨테이너의 CPU·디스크 제약, SQLite 저장 위치 변경(호스트 APFS vs 도커 볼륨)이 같이 바뀌어서 변수를 하나만 못 바꾼 측정입니다.
그리고 앞의 「철회」가 여기서 나왔습니다. 절대 p99 비교가 뒤집힌 게 이 표예요.
2 → 3: 합성 부하 대신 앱이 실제로 하는 일
여기가 이번에 새로 한 것입니다. 벤치는 "writer 16개가 동시에 때린다"는 가상의 상황인데, 우리 앱이 실제로 하는 건 5초마다 5,000행을 한 번 넣는 것입니다. 잰 적이 없었어요.
API_PORT=8055 docker compose up -d # api + db 둘 다 컨테이너평균은 같은데 최악값이 절반입니다. 배포 구성이 더 빠른 게 아니라 더 안정적입니다. 벤치의 합성 부하로는 이 차이가 안 보였습니다 - 벤치는 처리량과 경합을 재고, 이건 실제 부하의 지터를 재니까요.
그리고 3단계에서만 확인할 수 있는 게 하나 더 있었습니다.
이전한 데이터 위에 앱이 이어서 씁니다. 시퀀스를 안 되감았다면 여기서 중복키로 터졌을 겁니다. 이전 스크립트만 돌려서는 절대 안 잡히는 검증이고, 앱을 배포 모양으로 띄워야만 나옵니다.
3 → 4: 못 했고, 여기서 비교가 끝납니다
진짜 실제 환경은 "서버 2대가 각자 다른 컨테이너에서 같은 DB를 두드리는 것"입니다. 그런데 거기서는 SQLite가 아예 후보가 아니라 비교 자체가 성립하지 않습니다.
"실제 환경과 비슷하게"를 끝까지 밀면 이 벤치의 SQLite 열이 사라집니다. 재서 고른 게 아니라 후보가 하나만 남은 것 - 그게 이번 결정의 답이기도 합니다.
뜻밖의 소득 - 컨테이너가 더 조용합니다
측정 중 컨테이너 안 load average가 2.49였습니다. 호스트는 같은 시각에 60~90이었어요. Docker VM이 호스트의 iCloud 소음(아래 참고)에서 격리돼 있습니다.
실제 환경에 가까울 뿐 아니라 측정 환경으로도 더 낫습니다. 다음 주차부터 벤치는 컨테이너 안에서 돌리려 합니다.
그래도 "실제 배포와 같다"고는 못 씁니다
정직하게 남는 차이입니다.
"실제 배포와 같은 조건"이 아니라 "한 단계 더 가까운 조건"이 맞는 표현입니다.
측정이 앱 데이터를 지웠습니다
이전을 끝내고 컨테이너 벤치를 돌린 뒤 DB를 열어봤더니, telemetry가 360,000행이 아니라 320,000행이었습니다. robots도 1,000이 아니라 200이었고요.
벤치가 매 실행마다 이걸 하고 있었습니다.
벤치와 앱이 같은 데이터베이스를 쓰고 있었습니다. 애써 이전해둔 데이터가 벤치 한 번에 사라졌습니다.
깨끗한 상태에서 시작하는 것 자체는 맞습니다 (인덱스 크기가 실행마다 달라지면 측정이 오염됨). 틀린 건 그 파괴가 닿는 범위였습니다.
→
fleet_bench를 따로 만들어 붙고, URL에bench가 없으면 거부하는 안전장치를 뒀습니다.측정이 오염됐다 - 그런데 이번엔 재현조차 안 됐습니다
첫 벤치를 돌리고 표를 만들려다
uptime을 찍어봤습니다.원인은 DB가 아니었습니다.
~/Desktop이 iCloud 동기화 대상이라, 15.9GB짜리fleet.db를 iCloud가 계속 동기화하려 하고 있었습니다. 그 상태에서 잰 숫자는 DB를 잰 게 아니라 iCloud를 잰 것입니다. 같은 부하 때문에import app.main한 줄이 215초 걸리기도 했습니다.Week 03에서
happy-dom에 레이아웃 엔진이 없어clientHeight가 0이라 가상화 효과를 5배 부풀렸던 것과 같은 종류의 함정입니다. 측정 대상이 아닌 것이 측정에 들어와 있는데 에러도 경고도 안 납니다.다만 결정적으로 다른 점이 있습니다.
clientHeight)uptime을 찍어봐서Week 03에서 "재현성은 정확성의 증거가 아니다"를 배웠는데, 이번엔 재현조차 안 되는 오염이었습니다. 완벽한 재현이 안심의 근거가 못 되듯 흔들림도 그 자체로는 원인을 알려주지 않습니다. 환경을 명시적으로 재봐야 알 수 있었습니다.
DB 파일을 iCloud 밖으로 옮기고 다시 쟀고, 위의 표가 그것입니다. 그리고 벤치가 스스로 측정 전후의 load average를 찍도록 고쳤습니다. 다음부터는 잊어도 표에 남습니다.
3. 알게된 것
1. 경합이 없는 곳에 동시성 기술을 넣으면 오버헤드만 남는다.
Postgres가 5배 느린 게 이상한 일이 아니었습니다. 우리는 쓰기가 배치 flush 하나뿐이라 애초에 다툴 상대가 없었어요. 그런데도 옮긴 건 지금의 손해(체감 0)와 나중의 손해를 비교한 결과지, 빨라져서가 아닙니다.
2. YAGNI를 쓰려면 "필요해진 걸 언제 아는가"를 먼저 물어야 한다.
SQLite는
database is locked로 안 죽고 줄을 섭니다. 실패 0건, p99만 119~133배. 에러율 알림에 안 잡히니 "문제 생기면 그때"가 성립을 안 합니다. → 이번 결정의 절반이 여기서 나왔습니다.3. 결정의 진짜 근거가 측정 밖에 있었다.
벤치로 잰 건 "SQLite가 지금 충분하다"와 "한계가 안 보인다"까지입니다. 정작 B를 고르게 만든 건 SQLite writer는 파일시스템을 공유해야 한다는 구조적 제약이고, 이건 잴 수 있는 게 아닙니다. 숫자를 모으다 보면 숫자가 답할 수 있는 질문만 묻게 됩니다.
4. "실제 환경과 비슷하게"는 정의해야 하는 것이지 주어지는 게 아니다.
네 단계로 나눠보니 답할 수 있는 질문이 단계마다 달랐습니다. compose 스택에 실제 워크로드를 태운 3단계에서만 나온 게 둘 - max_flush 639 → 335ms(평균은 같고 최악값만 절반), 그리고 이전 데이터 위에 앱이 이어서 쓰는 것의 검증. 합성 부하로는 안 보였습니다. 4단계까지 밀면 SQLite가 후보에서 빠져 비교 자체가 사라지고요.
5. 별개인 줄 알았던 제약 두 개가 하나였다.
SQLite와 인메모리 fleet은 둘 다 "단일 프로세스" 전제 위에 있고 유효기간이 같습니다. Week 02 반론에 "단일 프로세스를 전제한다"고 답한 순간 SQLite도 같이 정당화하고 있었어요. 그리고 이번에 푼 건 둘 중 하나뿐입니다.
6. 인덱스가 있다 ≠ 인덱스를 쓴다.
복합 인덱스의 선두 컬럼이 정합니다. 이 하나가 이번 주에 세 번 발목을 잡았습니다 - 통계 쿼리(40ms vs 5분 13초), 고아 검사(→ 0.11초), 이전 속도(→ 8.1배). 세 번째는 하마터면 설계 근거로 쓸 뻔했고요.
7. 수평 확장을 막고 있던 게 DB가 아니었다.
서버를 2대로 띄워보니 텔레메트리가 정확히 2배로 쌓이고(로봇당 55행 → 105행), 시드를 고정했는데도 25초 만에 배터리가 11%p 갈라졌습니다. 정작
robots테이블은 충돌 0건이었고요. Postgres로 옮긴 이유가 수평 확장이었는데, 막상 늘려보니 걸린 건 DB가 아니었습니다. 원인은 앱이 데이터 생성자(시뮬레이터)와 전달자(API)를 한 프로세스에 섞어둔 것 - 서버를 늘리면 함대가 통째로 하나 더 생깁니다.8. 서버를 늘려두는 것과 가용성이 생기는 것은 다르다.
api-1이 죽으면 브라우저는 api-2로 붙습니다. 그런데 붙는 순간 로봇 1,000대가 순간이동합니다. "서버가 응답하느냐"는 되고 "응답이 이어지느냐"는 안 됩니다. 대수를 늘리기 전에 무엇을 늘리는지를 먼저 갈라야 했습니다 - 읽기는 늘려도 되고 쓰기는 하나여야 합니다.
틀린 채로 남긴 것
📎 코드·재현 스크립트: One-HyeWon/fleet-ops#4