우리가 Treasure Hunt Engine에 42를 하드코딩한 날
Source: Dev.to
우리가 실제로 해결하려던 문제
우리는 Veltrix 보물찾기 엔진을 구축하여 수천 명의 사용자가 실시간으로 퍼즐을 풀며 경쟁하는 라이브 이벤트 플랫폼을 지원했습니다. 구성 레이어는 자신 있게 확장할 수 있게 해주는 비밀 무기로 설계되었습니다.
하지만 우리가 간과한 점은 초기 구성 방식이 단순히 코드베이스에 존재하는 Ruby 해시, 사용자에게 노출되는 값을 환경 변수에 넣은 것, 그리고 출시 주에 맨해튼만큼 커진 단일 YAML 파일에 불과했다는 점입니다.
프로덕션에 배포한 날, 가장 큰 문제는 규모가 아니라 구성 변경마다 재시작이 필요했다는 점이었습니다. 구성 파일이 바뀔 때마다 Ruby 프로세스가 상수를 다시 컴파일해야 했기 때문입니다.
오전 2시 17분, 첫 번째 성장 전환점이 찾아왔습니다:
- 동시 사용자 1,024명
- 30초 동안의 가비지 컬렉션 일시정지
- 구성 파서가 15 MB까지 부풀어 올라 Redis 연결 풀 고갈
시스템은 부하 때문에 멈춘 것이 아니라 구성 때문에 멈춘 것이었습니다.
Source: …
우리가 처음 시도한 것 (그리고 왜 실패했는지)
1. 환경 변수와 Twelve‑Factor App 체크리스트
- 서로 다른
.env파일 11개, Docker‑Compose 오버라이드, 그리고 빌드 시점에 값을 주입하는 CI 파이프라인. - 깨끗한 분리라는 환상은 정확히 한 스프린트만 지속되었다.
두 번째 스프린트가 되자 우리는 170개의 환경 변수(그 중 절반은 비밀) 를 세 개의 레포에 흩어지게 만들었다. 이유는 다음과 같다:
- 제품팀은 기능 플래그를 원했고
- 운영팀은 튜닝 파라미터를 원했으며
- 마케팅팀은 A/B 분할을 원했다
우리는 16시간의 엔지니어링 시간을 소모해 스테이징 환경의 Redis 클러스터가 연결은 허용하지만 명령은 거부하는 이유를 디버깅했다. 결국 엔지니어가 .env.example을 복사‑붙여넣고 한 글자를 바꾸지 않아 스테이징 환경이 프로덕션 데이터베이스 이름을 물려받은 것이 원인이었다.
2. 동적 구성 백엔드로서 Consul
- 처음엔 강력했지만, 모든 구성 변경이 전체 플릿의 롤링 재시작을 트리거한다는 것을 깨달았다. Ruby 프로세스는 캐시된 상수를 날리지 않고는 아무것도 다시 로드할 수 없었다.
- 새로운 장애 영역을 도입했다: Consul 리더가 죽으면 보물 찾기 엔진이 퍼즐 중간에 멈추고 재선출을 기다리게 되는데, 이는 종종 US‑East 장애가 발생하고 US‑West 트래픽이 급증할 때 일어났다.
3. 전용 구성 서비스가 포함된 모노레포 접근법
- 각 팀이 자신들의 YAML 파일을 기여했다.
- 구성 파일에서 발생한 병합 충돌이 프로덕션을 깨뜨리기 시작했고, YAML 앵커의 오타 하나가 23분 동안 전체 이벤트를 중단시켰다.
“
config.yaml:32: found character that cannot start any token” – 아직도 가지고 있는 Slack 메시지.
아키텍처 결정
우리는 설정을 동적(dynamic) 으로 만들려는 시도를 멈추고 일회성(disposable) 으로 만들기 시작했습니다.
- Ruby 상수를 가벼운 Lua 샌드박스 로 교체했으며, 이는 Redis 내부에서 실행됩니다.
- 모든 설정 값은 TTL이 캐시‑플러시 간격과 동일한 Redis 키가 되었습니다.
- 각 워커 프로세스는 매 요청마다 Lua 호출을 통해 설정을 로드합니다.
핵심 통찰은 성능이 아니라 Redis가 이미 제공하는 다음 기능이었습니다:
- 네트워크 프로토콜
- 영속성
- 내장 장애 감지기
우리는 Consul이나 Kubernetes ConfigMaps가 필요하지 않았고, 빠른 재로드와 단일 진실 소스가 필요했습니다.
트레이드‑오프
- 설정이 이제 Redis 클러스터 내 일급 객체(first‑class citizen) 가 되었습니다. Redis가 다운되면 전체 서비스도 중단됩니다.
- 실제로 Redis는 이전 방식보다 훨씬 안정적이며, 이제는 아무것도 재시작하지 않고도 설정 변경을 푸시할 수 있습니다.
- 원자성을 확보했습니다: 모든 설정 값은 버전이 있는 키 로 관리되므로 최신 버전을 삭제하고 워커가 다시 로드하도록 하면 롤백이 가능합니다.
숫자들이 말한 결과
| 측정항목 | 전 | 후 |
|---|---|---|
| P99 지연 시간 (동시 사용자 2,000명) | 800 ms | 240 ms |
| GC 일시정지 지속 시간 | 30 s | < 200 ms |
| Redis 메모리 오버헤드 | – | + 18 MB (허용 범위) |
| 구성 재로드 (블랙 프라이데이) | – | 42 reloads / s (“42” 버전이 지속적인 농담이 되었습니다) |
우리는 Lua 샌드박스에 간단한 Prometheus 메트릭을 도입했습니다: veltrix_config_reloads_total.
내가 다르게 할 것
- 구성 레이어를 인프라스트럭처 원시(primitives)로 취급, 코드 레이어가 아니라.
- 플랫폼 런타임에 내장, 버전을 관리하고 엔지니어에게 원시 키‑값 쌍을 절대 노출하지 않음.
- 첫날부터 Lua 샌드박스로 시작하고 Ruby 상수는 완전히 생략.
- TTL이 있는 Lua 테이블로 표현할 수 없는 모든 구성 값(피처 플래그 포함)을 금지, 포함.
- 모든 변경 사항을 버전 관리하고 롤백을 일급 연산으로 간주.
— End of post
환경 변수는 저장 시 암호화되어야 하고 매주 감사를 받아야 한다. 실제 실패 영역은 Redis가 아니라 환경 변수를 버전 관리 형태로 생각한 사람들이다. 그리고 마지막으로, 제품 매니저가 42라는 구성 버전을 공식 변경 기록 없이 지정하도록 다시는 허용하지 않을 것이다. 그 번호는 몇 달 동안 우리를 저주했다.
