1P by GN⁺ | ★ favorite | 댓글 1개
  • Wafris는 Rails 미들웨어 클라이언트 v1의 사용자 소유 Redis 저장소를 v2에서 SQLite 기반 저장소로 바꾸며, 배포 난이도와 요청 평가 지연을 함께 줄이려 함
  • 기존 Redis 선택은 Heroku와 Sidekiq 같은 Rails 생태계의 관성에 영향을 받았지만, 실제 운영에서는 사용자가 Redis 관리자 역할까지 떠안는 문제가 커짐
  • 모든 인바운드 HTTP 요청을 규칙과 비교하는 읽기 경로가 핵심 병목이며, 보고용 쓰기는 느리게 처리하거나 배치·비동기로 분리할 수 있음
  • 로컬 MacBook Air M2에서 120만 개 범위 데이터셋으로 최악의 IP 범위 조회를 테스트한 결과, SQLite는 로컬 Redis보다 약 3배 빠른 성능을 보였고 네트워크 지연은 포함되지 않음
  • v2는 일정 시간이나 요청 수 기준으로 새 규칙을 확인한 뒤 새 SQLite DB 전체를 내려받는 동기화 구조를 쓰며, 성공적인 설치가 약 3배 증가

Redis 기반 v1에서 드러난 배포 마찰

  • Wafris는 오픈소스 웹 애플리케이션 방화벽 회사로, Rails 애플리케이션에 붙는 미들웨어 클라이언트를 제공함
  • v1 클라이언트는 애플리케이션과 함께 배포되는 사용자 소유 Redis 저장소를 필요로 했음
  • 초기 Redis 선택에는 Heroku에서 Redis를 쉽게 붙일 수 있었던 환경, 원격 접근의 편의성, Sidekiq 같은 성공 사례가 영향을 줌
  • 실제 사용자 환경은 더 다양했고, 많은 사용자가 Redis 배포와 설정 문제를 디버깅하는 데 어려움을 겪음
  • RailsWorld 2023에서도 Rails 애플리케이션 옆에 Redis 서버가 당연히 필요하다는 가정에 부정적인 분위기가 있었음

속도 문제의 핵심은 네트워크 지연

  • Redis는 전통적인 RDBMS와 비교하면 빠르지만, 별도 데이터베이스인 만큼 연결·메모리·프로세스 관리를 요구함
  • 클라우드 환경에서는 네트워크 지연이 요청 처리 성능을 직접 흔듦
    • Wafris는 애플리케이션으로 들어오는 모든 HTTP 요청을 저장된 규칙과 비교해야 함
    • v1 클라이언트를 최대한 빠르게 만들어도, 애플리케이션이 배치된 네트워크가 느리면 전체 응답이 느려질 수 있었음
  • Rails 앱이 항상 단일한 “majestic monolith” 형태로 배포되는 것도 아니었음
    • 여러 존에 배포되는 앱
    • 책임이 겹치는 여러 서버로 나뉜 앱
    • 일부만 Rails이고 다른 언어나 프레임워크와 함께 배포되는 앱
  • 이런 운영 환경에서는 Redis 사용 마찰이 더 커짐

Wafris 요청 처리에서 읽기와 쓰기 분리

  • Wafris는 Rails 미들웨어로 설치되며, “IP 1.2.3.4 차단” 같은 규칙을 설정한 뒤 들어오는 요청을 해당 규칙과 비교함
  • 단순화한 처리 흐름은 두 단계임
    1. HTTP 요청을 규칙과 비교해 매칭되면 403, 아니면 200 처리
    2. 차단·허용·통과 같은 처리 결과를 보고
  • 첫 번째 단계인 규칙 읽기는 보고 쓰기보다 훨씬 중요함
    • 요청은 순차적으로 처리되어야 함
    • 필터링이 동작하지 않으면 나쁜 요청이 통과할 수 있음
    • 요청 평가는 사용자가 체감하는 사이트 성능에 영향을 줌
    • 보고 쓰기는 더 느리게, 배치로, 비동기로 처리 가능함

SQLite를 선택한 이유

  • Wafris의 주요 병목은 네트워크 I/O였고, SQLite 문서의 “SQLite는 클라이언트/서버 데이터베이스와 경쟁하지 않는다. SQLite는 fopen()과 경쟁한다”는 문장이 선택에 영향을 줌
  • 네트워크 왕복을 없애는 것만으로도 Redis 기반 구조보다 빨라질 수 있다는 가정 아래 SQLite와 Redis를 벤치마크함
  • 참고 자료는 다음과 같음

벤치마크 범위와 의도적 한계

  • 벤치마크는 보편적인 데이터베이스 성능 비교가 아니라, Wafris의 핫 패스와 최악의 쿼리를 겨냥한 편향된 테스트였음
  • 최악의 쿼리는 IP 범위와 카테고리를 매핑하는 “lexical decimal” 데이터 구조에 대한 조회였음
    • 단순한 예로, IP 주소가 두 주소 사이 범위에 있는지 확인해 국가를 반환하는 IP → 국가 매핑이 있음
    • 이 구조는 수백만 행 규모이고, IPv6에서는 각 항목이 큼
  • 범위 조회는 미리 계산한 뒤 두 저장소에 기록함
  • 병적인 경우 각 인바운드 HTTP 요청은 요청 IP를 다음 범위들과 비교해야 함
    1. 사용자 정의 허용 범위
    2. 사용자 정의 차단 범위
    3. GeoIP 범위
    4. IP 평판 범위
  • 이 쿼리 유형이 충분히 중요했기 때문에, 다른 쿼리나 기능은 포팅하지 않고 이 한 종류만 테스트함

테스트 방식과 결과

  • 테스트는 로컬 MacBook Air M2에서 Homebrew로 설치한 Redis와 로컬 SQLite DB를 사용함
  • 프로토콜은 다음과 같았음
    1. 기존 범위 데이터셋 120만 개 항목을 사용
    2. 여러 IP 집합을 같은 순서로 SQLite와 Redis에 실행
    3. 각 배수마다 테스트를 5회 실행하고 평균을 사용
  • Wafris의 특정 사용 사례에서는 SQLite가 로컬 Redis보다 약 3배 빠른 성능을 보임
  • 이 결과는 네트워크 지연을 고려하기 전의 수치임
  • 테스트는 의도적으로 단순한 설정과 현실 사용의 결함을 반영했으며, 보편적인 데이터베이스 비교로 일반화하기는 어려움

차트에 드러나지 않는 운영상의 차이

  • SQLite 성능이 벤치마크에서 Redis보다 크게 나빴더라도, 같은 데이터센터나 리전에 있는 Redis까지의 네트워크 지연 때문에 실제 환경에서는 더 빠를 수 있다고 판단함
  • Redis 서버가 클러스터나 샤딩으로 견고하게 구성돼도 네트워크 대역폭, 연결 수, 리전 간 지연 같은 제한은 남아 있음
  • SQLite는 각 컴퓨트 인스턴스에 로컬로 존재하므로, Wafris의 이 사용 사례에서는 수평 확장 비용이 거의 사라짐
  • 온보딩도 SQLite 쪽이 더 단순함
    • 사용자는 SQLite가 쓰이는지 몰라도 gem을 웹 앱에 추가하고 실행할 수 있음
  • Redis에도 추가 최적화 여지는 많지만, Wafris는 사용자에게 캐시 축출 정책 같은 기본 설정 변경조차 일관되게 설득하기 어려웠음

SQLite 전환 뒤 필요한 구조 변경

  • Redis 기반 v1의 업데이트 흐름은 단순했음
    1. 사용자가 Wafris Hub에서 규칙을 업데이트
    2. Wafris Hub가 사용자 Redis 저장소의 규칙을 업데이트
  • SQLite에서는 Wafris Hub가 웹 서버로 SQLite 데이터베이스를 직접 “푸시”할 수 없었음
  • 일부 SQLite as a service 제공자는 유사한 방식을 가능하게 하지만, 비용·성능·보안 고려 때문에 Wafris에는 맞지 않았음
    • 개별 사용자가 이를 배포해야 함
    • 포트를 열어야 함
    • 인바운드 연결을 허용해야 함
  • SQLite 기반 v2의 업데이트 흐름은 다음과 같음
    1. 사용자가 Wafris Hub에서 규칙을 업데이트
    2. 클라이언트가 시간 또는 요청 수 기준의 일정 간격으로 업데이트된 규칙을 확인
    3. 규칙이 바뀌었으면 클라이언트가 완전히 새로운 SQLite 데이터베이스를 다운로드
  • 이 구조는 사용자 설치와 설정 책임을 크게 줄였고, v2 클라이언트의 성공적인 설치가 약 3배 증가

분산 배포에서의 SQLite 구조

  • AWS, Heroku, Fly 같은 클라우드 환경에서 Rails 앱이 오토스케일링되면 컴퓨트 인스턴스는 늘어나지만 데이터베이스가 함께 늘어나지는 않는 경우가 많음
  • 요청이 100 req/s에서 10,000 req/s로 늘면 dyno, machine, EC2 인스턴스는 올라오지만 데이터베이스 병목은 그대로 남을 수 있음
  • Wafris가 관찰한 Rails 앱 장애의 주요 원인은 실제 DDoS보다 크리덴셜 스터핑이나 나쁜 봇 트래픽인 경우가 많았음
    • 이런 트래픽은 오토스케일링을 유발함
    • 이후 데이터베이스 연결이 소진되며 앱이 넘어질 수 있음
  • SQLite DB를 각 컴퓨트 인스턴스로 동기화하면, 새 인스턴스의 모든 호출을 로컬로 유지할 수 있음

쓰기 경로는 클라이언트에서 제거

  • 앞선 테스트는 쓰기 작업을 고려하지 않았고, SQLite가 모든 역할에 적합하다고 보지도 않음
  • Wafris는 SQLite를 읽기 중심 역할에 맞춰 사용하고, 쓰기 경로는 별도로 다시 설계함
  • v2의 보고 경로는 다음처럼 바뀜
    • Wafris Hub로 비동기 연결해 보고
    • 보고 데이터를 배치 전송
    • 클라이언트에서 데이터베이스 쓰기를 완전히 제거
  • 이 구조는 다른 대부분의 서비스에는 맞지 않을 수 있지만, Wafris 사용자가 원하는 쉬운 배포와 빠른 클라이언트라는 목표에는 맞음
  • SQLite 기반 v2 아키텍처는 이미 여러 사이트가 공격을 견디고 온라인 상태를 유지하는 데 도움을 줬고, Wafris의 지원 작업과 사용자 불편도 줄였음

댓글과 토론

Hacker News 의견들
  • 각 애플리케이션 서버가 SQLite 데이터베이스 파일 사본을 갖고, 일정 주기로 통째로 교체하는 모델이 정말 흥미로움
    여기서는 웹 애플리케이션 방화벽 규칙에 쓰고 있지만, 기능 플래그 설정에도 잘 맞을 것 같음. 기능 플래그는 요청당 수십 번 확인될 수 있고, “사용자가 A 그룹에 속하며 IP가 B 국가에 있음” 같은 질의가 필요할 때가 많아서 로컬 SQLite가 잘 처리할 수 있음. 기능 플래그는 업데이트가 몇 초, 혹은 그 이상 늦게 전파돼도 대체로 허용 가능함

    • 이 방식은 CDN에서도 쓰는 모델임. 모든 고객의 인증서, HTTP 라우팅 규칙 등을 담은 전역 설정 파일을 단일 파일 B-트리 구조로 갱신한 뒤, 그 “번들”을 모든 엣지 위치에 자주 배포함
      다만 이 용도로 SQLite를 쓰는 사례는 아직 못 봤고, 보통은 LMDB나 Kyoto Cabinet 같은 DBM 스타일 데이터베이스를 씀
    • Airbnb에서도 이 모델로 번역, 기능 플래그, 설정, 검색 색인 등을 배포했음. SQLite 대신 Spotify가 만든 키-값 파일 형식인 Sparkey를 사용함
      초기에는 각 서버의 Cron 작업이 해당 서비스의 데이터를 가져왔고, Kubernetes로 옮긴 뒤에는 DaemonSet과 호스트 태깅/taints 비슷한 방식으로 여러 데이터를 각 호스트에 내려받게 한 다음, 그 데이터를 쓰는 서비스가 해당 호스트에서만 실행되도록 보장했음. Ruby에서는 이를 “hammerspace”라고 불렀음: https://github.com/airbnb/hammerspace
    • 기능 플래그 값을 요청당 여러 번 조회하는 문제는, 플래그 값을 한 번만 가져와 요청 객체에 저장하는 방식으로 해결함
      이렇게 하면 요청 하나에서 비싼 조회 비용을 두 번 이상 내지 않아도 되고, 요청 처리 중 플래그가 갱신되어 값이 일관되지 않게 되는 위험도 없음
    • 긴 마이그레이션 동안 기능 플래그를 오래 다뤄보니, 정말 유용한 기본 요소는 “SQLite이지만 모든 변경마다 전역 버전 카운터가 증가하고, 버전 번호 사이의 변경분만 가져오거나 적용할 수 있는 방식”이라고 확신하게 됨
      그 위에 가십 네트워크나 명시적인 트리형 캐시/배포망 같은 다양한 배포 메커니즘을 얹을 수 있음. 더 최신 버전을 가진 누구든 업데이트를 줄 수 있음
      현재 DB 버전을 애플리케이션 산출물에 포함하되, 앱이 실행된 뒤에는 효율적으로 갱신할 수 있음. 기능 플래그나 라이브 설정에서는 코드 수준 기본값 대신 최근 상태로 fallback할 수 있음
      어떤 클라이언트든 전역 카운터를 ETAG처럼 보내고 변경분만 받을 수 있으며, 네트워크가 잠깐 끊긴 뒤 재연결도 단순하고 저렴해짐
      몇 분 정도의 이력을 유지할 수 있다면 W3C Baggage 헤더 같은 방식으로 마이크로서비스 호출에 카운터를 붙여 여러 서비스에서 같은 버전 번호 기준으로 플래그/설정/데이터를 평가할 수 있음. 시간 기준 평가 기능이 없더라도 생성 번호를 로그에 남기면 사후 디버깅에 큰 도움이 됨
    • 배포용 SQLite는 멋짐. 이 아이디어는 어느 정도 Datasette 프로젝트에서 영감을 받은 것으로 보이고, 나중에 리포팅과 데이터 탐색 쪽에서 여전히 뭔가 해볼 수 있을 것 같음
  • 네트워크 지연 외에도 Redis에서는 읽기/쓰기 지연이 조회하는 키 수에 거의 선형으로 비례하는 경향을 봤고, 글의 차트에서도 비슷하게 보임
    모놀리식 앱에서 Postgres와 Redis를 용도별로 함께 썼을 때는 꽤 잘 동작했지만, 새 기능을 공유 Redis 클러스터에 밀어 넣기가 너무 쉬웠음. Redis는 단일 스레드라서, 10만 개 이상 키를 대량 조회하는 배려 없는 기능 하나가 다른 기능까지 느리게 만들 수 있음
    그래서 Redis는 한 번에 키 하나나 작고 고정된 수의 키를 읽고 쓸 때, 예를 들어 인기 엔드포인트의 락이나 속도 제한처럼 여러 랜덤 기능이 Redis를 쓸 때 특히 좋다는 가이드라인을 제안했음
    이 경우 Redis는 단순한 단일 키(IP 주소) 조회에서는 빛나겠지만, 범위 질의 표현처럼 더 복잡한 읽기에는 잘 맞지 않는 듯함. 로컬 Redis와 비교해 SQLite가 이렇게 잘 동작하는 이유를 깊이 알지는 못해서 예상 밖이고 흥미로웠음

    • Redis는 사람들이 장단점을 오해하기 쉬워 함정이 되는 경우가 많았음
      Redis는 더 풍부한 기본 연산을 가진 캐시로 보는 게 가장 좋음. 적절히 쓰면 빠르고 안정적임
      하지만 기본 RDBMS에 잘 맞지 않는 것들을 Redis에 넣기 시작하면 곧 작업 큐, 여러 종류의 락 등이 생기고, 결국 성능이 절벽을 넘거나 다른 이유로 무너지는 건 시간문제가 됨. 복구 과정은 보통 지저분해지고, 일부 데이터 손실을 받아들이는 결말로 가기 쉽다
      이런 흐름은 조금씩 쉽게 벌어지므로 피하려면 규율이 필요함. SQLite 성능은 네트워크 오버헤드를 피하는 것뿐 아니라, 많은 사람이 직렬화/역직렬화 비용을 과소평가한다는 점도 큼. Redis 프로토콜이 꽤 미니멀해도 비용은 누적되고, SQLite에서는 많은 작업이 프로세스 내부 메모리 복사로 귀결됨
  • 몇 주 전 Neon 내부 해커톤에서 Redis의 유선 프로토콜인 RESP를 Postgres 질의로 바꾸는 작은 Node.js 서버를 만들었음
    아주 재미있는 해킹 프로젝트였음: https://github.com/btholt/redis-to-postgres

  • 데이터베이스가 읽기 전용이라 복제가 필요 없는 서버 측 SQLite가 꽤 잘 맞는 특수한 사용 사례처럼 들림
    다른 대안으로는 정적 파일을 메모리에 올리는 방식도 있겠지만, 이 경우에는 메모리에 계속 두기엔 데이터가 많아서 SQLite가 좋은 대안이 되는 듯함

    • 글에서 전달되었길 바라지만, 이건 특정 사용 사례에 잘 맞는 훌륭한 해법이지 Redis나 Postgres의 1:1 대체재는 아님
    • 정적 파일을 메모리에 올리는 방식에서 시작해, 일반적인 사용 사례에 동작시키는 데 필요한 기능만 충분히 더해가면 결국 SQLite 같은 관계형 데이터베이스가 됨
      차이는 SQLite가 매우 견고하고, 성능이 좋고, 예외적으로 잘 테스트되어 있다는 점임
  • RailsWorld 2023에서 Redis에 대해 “피 냄새를 맡은” 분위기가 있었다는 대목이 궁금함
    커리어에서 프로덕션 Rails 앱은 하나만 해봤고 그 앱은 Redis를 썼기 때문에 요즘 흐름은 잘 모름. 생태계가 비즈니스 관점에서 Redis에 등을 돌리는 중인지, 라이선스 변경 때문인지, 아니면 YAGNI에 가까운 상황인지 궁금함
    당시에는 Rescue와 함께 색인 생성, 트랜스코딩 같은 비동기 작업 예약에 주로 썼고, 그때는 꽤 괜찮은 도구처럼 보였음

    • 약간은 YAGNI에 가까움. Rails 커뮤니티에서 Redis를 많이 쓰게 된 가장 큰 이유는 비동기 작업이었고, 대부분이 집어 든 도구가 Sidekiq였음
      2024 Rails 커뮤니티 설문에서도 Redis는 여전히 앱에서 가장 많이 쓰는 데이터 저장소로 나옴
      다만 많은 앱이 Redis를 쓰고는 있어도 실제로는 Sidekiq 같은 용도에만 쓰고, 실시간 리더보드나 벡터 DB 기능 같은 데 활용하지 않는 경우가 많아서 실제 사용 양상은 조금 흐릿함
    • 순전히 단순성 문제라고 봄
      지금은 트래픽이 어느 정도 있는 Rails 구성이라면 프런트엔드 서버, SQL DB, 키-값 저장소(Redis 또는 Memcached), 그리고 그 키-값 저장소를 가리키는 캐시 저장소를 두는 경우가 많음. 그런데 API 할당량이나 속도 제한을 유지하는 일반적인 키-값 사용과 캐시 사용 패턴이 꽤 달라서 번거로움
      디스크 성능이 충분히 빨라지고 SQL 성능도 좋아져서, 키-값 저장소를 제거하고 전통적인 키-값 용도는 SQL DB로, 캐시 백엔드는 디스크로 나누려는 움직임이 있음. 새 NVMe 디스크는 RAM에 거의 근접할 만큼 빠르면서도 훨씬 싸기 때문에 더 많이 캐시할 수 있음
    • Redis는 과거에 만료 같은 기능이 내장된 아주 똑똑한 공유 메모리처럼 유용했음. 클러스터링을 하면 여러 머신 간 공유도 가능했음
      하지만 Kubernetes와 Redis-as-a-service 시대에는 그 “공유 메모리”가 다른 랙의 다른 VM에 있게 됨. 그쯤 되면 이미 자원 효율성을 포기한 셈이니 그냥 S3에서 파일을 읽고 쓰는 편이 낫다고 봄
    • 요즘 SSD가 빠르니 더 많은 사람이 RocksDB를 써야 함. 훨씬 큰 키-값 저장소를 SSD 위에서 돌릴 수 있음
  • “Redis는 전통적인 RDBMS에 비해 빠르지만, 여전히 연결, 메모리, 프로세스 등을 관리해야 하는 데이터베이스라 스택을 더 취약하게 만든다”는 대목에 대해, 장난감 수준을 넘는 트랜잭션을 다루기 시작하면 관계형이든 비관계형이든 모든 데이터베이스는 대략 비슷한 수준의 관리와 유지보수가 필요하다고 봄
    “빠르다”는 부분도 조금 재미있음. 조인을 신경 쓰지 않는다면 행 삽입과 조회도 꽤 빠름

    • 서버 환경에서 SQLite가 예외적으로 빠른 이유는 질의하거나 데이터를 가져올 때 네트워크 호출이 필요 없기 때문임
      로컬 디스크에서 직접 읽는다는 점을 이해하면, 조인이나 트랜잭션에 대한 이야기는 큰 의미가 없어짐. 이미 그 자체로 몇 자릿수 빠른 경로임
    • SQLite에는 일종의 가비지 컬렉션처럼 보이는 VACUUM 작업이 있음
      VACUUM을 언제 실행해야 하는지 문서를 읽을 때마다 헷갈림. 마지막으로 SQLite 기반 애플리케이션을 배포했을 때는 그냥 카운터를 두고, 많은 쓰기 작업 뒤에 VACUUM을 실행하는 방식으로 처리했음
    • “모든 데이터베이스가 대략 같은 수준의 관리와 유지보수를 요구한다”는 말에는 동의하지 않음
      독립적인 서버 프로세스를 운영해야 하는 데이터베이스라면 관리와 유지보수 노력에 차이가 생길 수밖에 없음. 극단적으로 보면 SQLite와 Oracle을 쓰는 게 정말 차이가 없다고 믿기는 어려움
  • SQLite로 Redis를 Go에서 다시 구현한 Redka에 관심 있을 수 있음: https://github.com/nalgeon/redka

    • 전환을 고려할 만큼 흥미로웠지만, “벤치마크에 따르면 Redka는 Redis보다 몇 배 느리다”는 부분을 보고 망설이게 됨
      멋진 프로젝트인 건 맞지만, 이 정도면 전환할 유인이 거의 없음
  • Redis 사용 방식이 맞는지 확인하고 싶음
    v1에서는 WAF와 Redis가 같은 서버에 있고, 클라이언트가 관리 패널에서 새 규칙을 설정하면 그 규칙이 관리 패널과 같은 서버의 Redis로 들어가며, Redis 내부 동기화 메커니즘 덕분에 전 세계 WAF 옆의 로컬 Redis들로 규칙이 업데이트되는 구조였는지 궁금함. 이후 새 요청이 어떤 WAF에 오면, WAF가 갱신된 Redis 규칙으로 요청/IP를 검증하는 흐름인지 확인하고 싶음
    v2에서는 Redis 클러스터를 제거하고, 모든 WAF 서버가 SQLite DB를 갖고, 관리 패널에서 각 WAF+SQLite 서버로 새 규칙을 퍼뜨리는 별도 동기화 메커니즘을 만들었으며, 새 요청이 오면 WAF가 갱신된 SQLite 규칙으로 빠르게 검증하는 구조인지 궁금함

  • 최고의 문장은 “SQLite는 클라이언트/서버 데이터베이스와 경쟁하지 않는다. SQLite는 fopen()과 경쟁한다”임