Shardines - Rails에서 SQLite로 테넌트별 데이터베이스 관리하기
(blog.julik.nl)- Rails에서 테넌트마다 별도 데이터베이스를 사용하는 구조를 구축하는 방법과 그 도전 과정을 설명
- ActiveRecord는 기본적으로 단일 DB 연결을 전제로 설계되어 있어, 테넌트별 연결 전환이 복잡하고 까다로움
- Rails 6 이상의 connected_to 기능을 활용해 런타임에 연결을 동적으로 전환하는 방법을 제안함
- SQLite3는 소규모, 다수의 독립 DB를 다루기에 적합하여 백업, 디버깅, 삭제 등 관리가 용이함
- 대규모 시스템 최적화 중심으로 발전한 Rails 인프라와 달리, 작고 독립적인 데이터베이스 중심 아키텍처가 가능함을 강조함
테넌트마다 별도 데이터베이스를 사용하는 이유
- 데이터 모델 안에서 독립적으로 동작하는 테넌트(
Site
) 단위로 분리하면, 데이터 격리와 관리가 수월해짐 - 테넌트별로 데이터를 별도 DB에 저장하면, 대규모 사이트 확장이나 보안 이슈에도 유리함
- SQLite를 활용하면 서버 설정 없이 파일 하나만으로 데이터베이스를 운용할 수 있어 간편하고 유연함
Rails에서 어려운 점
- SQLite의 기본
open/close
작업은 매우 간단하지만, ActiveRecord는 내부적으로 복잡한 커넥션 관리 구조를 가짐 - ActiveRecord는 연결을 모델에 고정해서 사용하는 구조로 설계되어 있어, 런타임에 테넌트 전환이 어려움
- 커넥션 풀, 쿼리 캐시, 스키마 캐시 등이 모두 연결에 종속되어 있어, 매번 연결 변경이 부담스러움
Rails 다중 데이터베이스 관리의 역사
- Rails 1:
ActiveRecord::Base
단위로 DB 지정 가능 - Rails 3: 커넥션 풀 도입
- Rails 4:
connection_handling
추가 - Rails 6:
connected_to
도입 - Rails 7:
connected_to
기능 확장 및 샤딩 지원 - 하지만 여전히 "런타임에 동적으로 테넌트 추가/삭제" 같은 시나리오는 기본 지원되지 않음
테넌트별 데이터베이스의 장점
- 테넌트별 파일만 백업하거나 복원할 수 있어, 운영과 디버깅이 간단해짐
- 테넌트 제거는 단순히 파일 삭제(
unlink
)로 가능 - 대규모 데이터베이스 서버는 수십 테라바이트 규모의 DB를 최적화하지만, SQLite는 수천 개의 소규모 DB에 최적화되어 있음
- 실제로 iCloud도 수백만 개의 작은 SQLite DB를 Cassandra 위에 저장하는 구조를 채택함
문제 해결 과정
- 기존 방식(수동
establish_connection
)은 다중 접속 환경에서 ConnectionNotEstablished 오류를 유발 - Rails 6 이후의 방식에 맞춰, 커넥션 풀을 수동 관리하는 대신 Rails에게 맡기는 구조로 변경
- 각 테넌트마다 동적으로 connection pool을 만들고,
connected_to
블록으로 작업을 감쌈 - 미들웨어를 활용해 요청 시점에 필요한 DB 연결을 동적으로 준비하고 해제하는 방식으로 개선
핵심 코드 패턴
- 커넥션 풀 체크 후 없으면 생성
MUX.synchronize do
if ActiveRecord::Base.connection_handler.connection_pool_list(role_name).none?
ActiveRecord::Base.connection_handler.establish_connection(database_config_hash, role: role_name)
end
end
- 연결 후
connected_to
블록 내에서 안전하게 쿼리 수행
ActiveRecord::Base.connected_to(role: role_name) do
pages = Page.order(created_at: :desc).limit(10)
end
Rack 스트리밍 처리
- Rack 응답이 스트리밍일 경우, 연결 관리를 위해
Rack::BodyProxy
와Fiber
를 활용하여 안전하게 커넥션을 닫음
connected_to_context_fiber = Fiber.new do
ActiveRecord::Base.connected_to(role: role_name) do
Fiber.yield
end
end
connected_to_context_fiber.resume
status, headers, body = @app.call(env)
body_with_close = Rack::BodyProxy.new(body) { connected_to_context_fiber.resume }
[status, headers, body_with_close]
최종 미들웨어 구조
- 요청마다 적절한 DB 연결을 찾아
connected_to
로 전환하고, 응답이 끝나면 정리하는 미들웨어Shardine::Middleware
를 작성함 - Rails 프로젝트의
config.ru
파일에 다음처럼 적용 가능
use Shardine::Middleware do |env|
site_name = env["SERVER_NAME"]
{adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}
end
남은 과제
- ActiveRecord 6에서는 아직
shard
기능을 활용하지 않았지만, 이후 버전에서는 읽기/쓰기 분리도 가능함 - 테넌트 삭제 시 커넥션 풀 정리 기능은 아직 필요하지 않아 구현하지 않음
- 앞으로 "작은 데이터베이스 다수"를 다루는 아키텍처가 더 주목받을 가능성이 큼
Hacker News 의견
-
"database-per-tenant" 방식을 약 100만 명의 사용자와 함께 사용 중임
- 이 방식은 읽기 중심의 앱에 적합하며, 대부분의 테넌트는 작고 테이블에 많은 레코드가 없어 복잡한 조인도 매우 빠름
- 주요 문제는 개별 데이터베이스를 하나씩 마이그레이션해야 하므로 릴리스 시간이 크게 증가할 수 있음
- 스키마나 데이터 드리프트가 발생하면 릴리스가 중단되고, 일부 테넌트에서 기능이 작동하지 않는 이유를 찾아야 함
-
SQLite를 좋아하지만, 기존 OLTP 데이터베이스가 인덱스의 일부를 메모리에서 언로드할 필요가 있는지 궁금함
- 사용자별 데이터베이스를 사용하면 비활성 사용자나 다른 인스턴스에서만 활성화된 사용자를 위해 메모리에 아무것도 유지하지 않음
- 이는 Mongo의 JSON 상황과 유사하며, Postgres가 Mongo보다 두 배 빠름
-
대부분의 사람들은 테넌트별 데이터베이스가 필요하지 않으며, 이는 일반적인 방식이 아님
- 마이그레이션과 스키마 드리프트와 같은 단점을 상쇄해야 하는 특정 사례가 있음
- 사용할 수 있다고 해서 반드시 사용해야 하는 것은 아님
- 주의해서 진행하고 테넌트별 데이터베이스가 필요하다는 사실을 알아야 함
-
중간 접근 방식으로 다음을 고려할 수 있음
- 상위 N개의 테넌트를 식별함
- 이 테넌트를 위한 DB를 분리함
- 상위 N개는 IOPS, 중요도(수익 측면) 등을 기준으로 결정됨
- 데이터 모델은 각 테넌트에 해당하는 행을 추출할 수 있도록 설계되어야 함
-
우연히도 Elixir를 위한 FeebDB를 작업 중임
- 이는 Ecto의 대체물로 볼 수 있으며, 수천 개의 데이터베이스가 있을 때 잘 작동하지 않음
- 주로 재미있는 실험으로 시작했지만, 과거에 일했던 모든 곳에서 이러한 아키텍처가 큰 도움이 될 것임
- 데이터베이스-테넌트 접근 방식의 일반적인 문제점을 제거하거나 줄이는 것이 목표임
- 각 데이터베이스에 단일 작성자 보장
- 모든 테넌트에 대한 향상된 연결 관리
- 필요 시 마이그레이션 및 백업 지원
- 여러 DB에 대한 맵/리듀스/필터 작업 지원
- 클러스터 배포 지원
-
Forward Email은 각 메일박스/사용자별로 암호화된 sqlite db를 사용하여 유사한 작업을 수행함
- 사용자별 보호를 차별화하는 훌륭한 방법임
-
이름이 매우 훌륭함. Sean Connery를 연상시킴
-
"database per tenant" 워크플로우는 이제 시작임
- James Edward Gray가 2012년 RailsConf에서 이에 대해 이야기함
-
과거에 유사한 것을 사용했으며, 매우 만족했음
- 사용자가 데이터를 원하면 전체 데이터베이스를 제공할 수 있음
- 사용자가 계정을 삭제하면
rm username.sql
로 간단히 처리 가능 - 컴플라이언스가 매우 쉬워짐
-
데이터가 서로 격리되고 단일 테넌트 내에서 확장 문제가 없을 때 잘못된 설계를 하기 어려움
- 거의 모든 것이 작동할 것임