- 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 기능을 활용하지 않았지만, 이후 버전에서는 읽기/쓰기 분리도 가능함
- 테넌트 삭제 시 커넥션 풀 정리 기능은 아직 필요하지 않아 구현하지 않음
- 앞으로 "작은 데이터베이스 다수"를 다루는 아키텍처가 더 주목받을 가능성이 큼