본문으로 건너뛰기
김신건의 로그

Rails 에서의 Read/Write Database 분리

Rails 의 Multiple Databases 기능으로 HTTP 메서드에 따라 read/write DB 를 자동 분리하는 패턴, GET 인데 쓰기가 필요할 때 ForceWrite concern 으로 role 을 일시 승격시키는 방법, 그리고 replication lag 등 실전 함정까지 정리합니다.

· 📖 약 9분 · 4,014자/단어 #rails #activerecord #database #read-replica #scaling #performance

시작 의문

서비스 트래픽이 늘어나면 가장 먼저 부담을 느끼는 곳은 데이터베이스입니다. 특히 읽기 쿼리가 무거운 서비스라면, 단일 인스턴스로 모든 부하를 감당하는 것이 점점 어려워집니다. 이때 자연스럽게 떠오르는 대안은 읽기와 쓰기를 다른 인스턴스로 분리하는 것입니다.

Rails 는 6.0 부터 이 패턴을 공식 기능으로 지원합니다. 기본 정책은 단순합니다.

  • GET / HEAD 요청reading role 로 라우팅 → replica 에 SELECT 가 발행됩니다.
  • POST / PATCH / PUT / DELETE 요청writing role 로 라우팅 → primary 에 INSERT/UPDATE/DELETE 가 발행됩니다.

직관적입니다. 하지만 실제 서비스를 운영하다 보면 곧 다음과 같은 질문에 부딪힙니다.

  1. GET 요청인데 데이터를 써야 한다면? 예를 들어 조회수 카운터를 올리거나, OAuth 콜백을 GET 으로 받아 토큰을 저장해야 한다면 어떻게 해야 할까요?
  2. POST 직후의 GET 은 replica 에서 stale 한 데이터를 읽지 않을까요? Replication lag 때문에 방금 만든 레코드가 안 보이는 경우는?
  3. force_write { ... } 같은 블록을 코드 곳곳에 흩뿌리지 않고 깔끔하게 추상화할 방법은 없을까요?

한 줄로 답하면 “DatabaseSelector 미들웨어 + ForceWrite concern” 조합으로 풀립니다. 다만 그 조합이 되는지를 이해하려면 한 단계 더 들어가야 합니다. 이 글에서는 읽기 부하 분산이 가능한 이유, HTTP 메서드 기반 자동 라우팅의 동작, GET 안에서 쓰기가 필요한 케이스의 패턴화, 그리고 실전에서 마주치는 함정들을 차례로 풀어 보겠습니다.

먼저 가장 기초적인 곳부터 시작하겠습니다.


한 단계 뒤로, 왜 읽기와 쓰기를 분리하는가

Primary-Replica 복제 구조

DB 를 분리한다는 말은 보통 다음 구조를 의미합니다.

  • Primary: 쓰기(INSERT/UPDATE/DELETE) 와 읽기 모두 가능한 메인 인스턴스입니다.
  • Replica: primary 의 변경 사항을 비동기적으로 받아 동기화하는 읽기 전용 복제본입니다. PostgreSQL 이라면 WAL streaming, MySQL 이라면 binlog replication 으로 동작합니다.

App 서버는 쓰기 트래픽을 primary 에 보내고, 읽기 트래픽을 replica 에 분산합니다. Replica 는 여러 개 둘 수 있어 수평 확장이 가능합니다.

분리해서 얻는 것

  • 읽기 부하 분산: 리포트 / 검색 / 통계처럼 무거운 SELECT 를 replica 로 보내면 primary 부하가 줄어듭니다.
  • 장애 격리: replica 가 죽어도 쓰기는 살아 있고, primary 가 죽어도 읽기는 살아 있어 부분적으로 서비스를 유지할 수 있습니다.
  • 장기 트랜잭션 격리: 분석성 쿼리가 primary 의 락을 잡지 않으므로 OLTP 성능이 안정됩니다.

분리해서 잃는 것

공짜는 아닙니다.

  • Replication lag: primary 에서 쓰기가 완료된 직후 replica 에는 아직 그 변경이 반영되지 않은 상태가 있습니다. 보통 수십 ms 에서 길게는 수 초까지 갈 수 있습니다. 이 동안 같은 데이터를 replica 에서 읽으면 stale read 가 발생합니다.
  • 일관성 모델 복잡화: 트랜잭션이 두 연결에 걸치면 일관성을 보장하기 어렵습니다.
  • 운영 복잡도: replica 의 lag 모니터링, failover, 백업 등 운영 부담이 늘어납니다.

이 글의 후반부에서 보겠지만, Rails 의 DatabaseSelector 는 stale read 문제를 부분적으로 해결합니다. 다만 완전히 해결하지는 않으니 트레이드오프를 기억해 두는 것이 좋습니다.


Rails 설정, database.ymlconnects_to

두 connection 정의

config/database.yml 에 primary 와 replica 두 connection 을 정의합니다.

production:
  primary:
    database: my_app_production
    adapter: postgresql
    host: <%= ENV["DB_PRIMARY_HOST"] %>
    username: <%= ENV["DB_USER"] %>
    password: <%= ENV["DB_PASSWORD"] %>
  primary_replica:
    database: my_app_production
    adapter: postgresql
    host: <%= ENV["DB_REPLICA_HOST"] %>
    username: <%= ENV["DB_USER"] %>
    password: <%= ENV["DB_PASSWORD"] %>
    replica: true

replica: true 옵션은 마이그레이션 대상에서 이 connection 을 제외하라는 표시입니다. Replica 는 primary 의 복제본이므로 별도로 스키마를 변경하면 안 됩니다.

모델에서 role 매핑

ApplicationRecord 에서 두 connection 을 role 에 묶어 줍니다.

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  connects_to database: {
    writing: :primary,
    reading: :primary_replica
  }
end

여기서 두 가지가 중요합니다.

  • primary_abstract_class: 모든 모델이 이 추상 클래스를 통해 connection 을 공유하도록 만듭니다. 이게 없으면 모델마다 connection 풀이 따로 잡혀 메모리를 낭비합니다.
  • connects_to database: { ... }: writingreading 이라는 role 이름에 실제 connection 을 매핑합니다. 이 이름은 자유롭게 정할 수 있지만 writing / reading 이 관례입니다.

이제 모델 입장에서는 “지금 어느 role 인가” 에 따라 어느 connection 으로 쿼리가 발행될지가 결정됩니다.


자동 라우팅, DatabaseSelector 미들웨어

미들웨어 활성화

config/environments/production.rb 에 다음을 추가합니다.

config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session

이 세 줄이 DatabaseSelector 미들웨어를 Rack 스택에 끼워 넣습니다. 모든 요청은 이 미들웨어를 거치면서 role 이 결정된 다음 컨트롤러로 전달됩니다.

동작 방식

미들웨어가 보는 것은 두 가지입니다.

  1. HTTP 메서드: GET 또는 HEADreading role, 그 외면 writing role.
  2. 세션의 마지막 쓰기 시각: 같은 세션에서 최근에 쓰기가 있었다면, delay 윈도우 안의 모든 GET 도 writing role 로 강제됩니다.

두 번째가 핵심입니다. 사용자가 게시글을 작성한 직후 (POST), 페이지를 새로고침해서 자기 글을 확인하면 (GET) replica 의 lag 때문에 게시글이 안 보일 수 있습니다. delay: 2.seconds 는 “쓰기 후 2 초 동안은 같은 세션의 GET 도 primary 로 보내라” 는 뜻입니다. Replica lag 가 1 초 이내라면 이 윈도우 안에서 stale read 가 거의 사라집니다.

NOTE

delay세션 단위 입니다. 다른 사용자의 GET 은 영향을 받지 않습니다. 즉, 자기가 방금 쓴 글은 자기에게 즉시 보이지만, 다른 사용자에게는 lag 만큼 지연되어 보일 수 있습니다. 이 정도면 대부분의 서비스에서 받아들일 만한 일관성 수준입니다.

미들웨어가 컨트롤러 액션 전체를 감싼다

여기서 미묘한 함정이 하나 있습니다. DatabaseSelector컨트롤러 액션이 시작되기 에 role 을 결정 합니다. 그리고 액션이 끝날 때까지 그 role 을 유지합니다. 즉, GET 요청으로 들어온 액션 내부에서는 모든 쿼리가 reading role 로 가게 됩니다.

문제는 GET 요청이라고 해서 항상 읽기만 하는 것이 아니라는 점입니다.


문제: GET 인데 쓰기가 필요한 경우

자동 라우팅의 한계가 드러나는 케이스들입니다.

케이스예시
조회수 / 카운터 증가GET /posts/:id 에서 post.increment!(:view_count)
Lazy migration첫 GET 때 누락된 메타 데이터를 채워 넣기
검색 로그 기록GET /search?q=... 에서 검색 기록 INSERT
OAuth / Webhook 콜백외부 서비스가 GET 으로 콜백, 내부에서 토큰 저장
마지막 접근 시각 갱신GET /me 에서 current_user.touch(:last_seen_at)

이런 액션 내부에서 그냥 post.increment!(:view_count) 를 호출하면 어떻게 될까요? Reading role 의 connection 은 replica 를 가리키고 있고, 대부분의 replica 는 read-only 로 설정되어 있습니다. 결과는 다음과 같습니다.

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
    @post.increment!(:view_count)
    # => ActiveRecord::ReadOnlyError:
    #    Write query attempted while in readonly mode
  end
end

명시적으로 예외가 발생합니다. 친절하지만 막막합니다. “그러면 이 액션 전체를 POST 로 바꿔야 하나?” 라고 묻고 싶지만, REST 의 관점에서 조회 는 GET 이 맞고 카운터는 사이드이펙트일 뿐입니다. 그렇다고 컨트롤러 전체를 writing role 로 묶어 버리면 읽기 부하 분산 효과가 사라집니다.

필요한 것은 쓰기가 발생하는 그 블록만 writing role 로 일시 승격 시키는 방법입니다.


connected_to 로 role 수동 전환

ActiveRecord 는 connected_to 라는 메서드를 통해 role 을 수동으로 전환할 수 있게 해 줍니다.

ActiveRecord::Base.connected_to(role: :writing) do
  @post.increment!(:view_count)
end

블록 안에서 발행되는 모든 쿼리는 writing role 의 connection 을 사용합니다. 블록이 끝나면 원래 role 로 돌아갑니다. 이게 핵심 메커니즘입니다.

다만 매번 ActiveRecord::Base.connected_to(role: :writing) do ... end 를 적는 것은 길고 노출이 잦아집니다. 컨트롤러 곳곳에 흩어져 있으면 의도가 잘 안 보이기도 합니다. 이걸 한 줄로 줄이는 것이 다음 단계입니다.


ForceWrite Concern

Rails 의 관용구를 따라 concern 으로 추출합니다.

# app/controllers/concerns/force_write.rb
module ForceWrite
  extend ActiveSupport::Concern

  def force_write(&block)
    ActiveRecord::Base.connected_to(role: :writing, &block)
  end
end

ApplicationController 에 include 하면 모든 컨트롤러에서 force_write 메서드를 쓸 수 있습니다.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include ForceWrite
end

블록 안에서 일어나는 일

GET 요청이 들어오면 처음에는 reading role 입니다. Post.find(1) 같은 SELECT 는 replica 로 갑니다. force_write do ... end 블록 안으로 들어가는 순간 role 이 writing 으로 승격되면서, 그 안의 모든 쿼리는 primary 로 발행됩니다. 블록이 끝나면 다시 reading role 로 복귀합니다.

타임라인으로 보면 한 GET 요청 안에서 role 이 reading → writing → reading 으로 두 번 전환됩니다. 이 전환 자체에는 거의 비용이 없습니다. ActiveRecord 의 connection handler 가 thread-local 변수로 현재 role 을 추적할 뿐입니다.


실전 패턴

세 가지 대표적인 사용 예시를 보겠습니다.

예시 1: 조회수 증가

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])    # replica 에서 SELECT

    force_write do
      @post.increment!(:view_count)   # primary 에 UPDATE
    end

    # render 는 그 다음, delay 윈도우 안이라면 primary 유지
  end
end

가장 흔한 케이스입니다. 조회수가 정확히 +1 일 필요는 없으니 카운터 손실에 관대해도 되고, 매 GET 마다 UPDATE 하는 비용이 부담이라면 Redis 의 INCR 로 버퍼링한 다음 주기적으로 flush 하는 패턴도 자주 씁니다. 다만 그 flush 도 결국 writing role 에서 일어나야 합니다.

예시 2: 검색 로그 기록

class SearchController < ApplicationController
  def index
    @results = Post.search(params[:q])    # replica

    force_write do
      SearchLog.create!(query: params[:q], user: current_user)
    end
  end
end

검색 자체는 replica 에서 수행하지만, 검색 기록은 primary 에 남깁니다. 검색 기록을 잃어도 비즈니스에 큰 영향이 없으면 이 기록도 비동기 job 으로 보내는 것이 더 낫습니다.

예시 3: OAuth 콜백

class CallbacksController < ApplicationController
  # OAuth provider 가 GET 으로 redirect 보내는 경우
  def oauth
    token = exchange_code(params[:code])

    force_write do
      current_user.update!(oauth_token: token)
    end

    redirect_to root_path
  end
end

외부 OAuth provider 가 GET 으로 콜백을 보내는 경우입니다. 우리 도메인에서는 분명히 쓰기 동작이지만 HTTP 메서드로는 GET 으로 들어옵니다. 이런 케이스는 force_write 로 명시적으로 감싸는 것이 자연스럽습니다.

모델 / 서비스 객체에서도 쓰기

컨트롤러를 거치지 않는 코드 (콜백, ActiveJob, Service Object 등) 에서도 같은 패턴이 필요합니다. 모델용 concern 도 같은 방식으로 만들 수 있습니다.

# app/models/concerns/force_write.rb
module ForceWrite
  extend ActiveSupport::Concern

  module ClassMethods
    def force_write(&block)
      ActiveRecord::Base.connected_to(role: :writing, &block)
    end
  end

  def force_write(&block)
    self.class.force_write(&block)
  end
end
class Post < ApplicationRecord
  include ForceWrite

  def record_view
    force_write { increment!(:view_count) }
  end
end

함정과 주의사항

1. 블록 안의 모든 쿼리가 writing 으로 갑니다

force_write do
  log_view(post)               # SearchLog INSERT (의도)
  post.related_posts.to_a      # 이것도 primary 로 감 (의도 아닐 수 있음)
end

force_write 블록 안에서는 SELECT 까지 모두 primary 로 갑니다. 블록의 범위를 최소한의 쓰기 한두 줄 로 좁혀 두는 것이 안전합니다.

2. 트랜잭션 경계와 role 은 별개입니다

# 위험한 패턴
ActiveRecord::Base.transaction do
  force_write do
    user.save!
  end
end

트랜잭션은 열린 시점 의 connection 에 묶입니다. Reading role 로 transaction 을 연 다음 안에서 writing role 로 전환하면 트랜잭션이 두 connection 에 걸쳐 일관성이 깨질 수 있습니다.

# 올바른 순서
force_write do
  ApplicationRecord.transaction do
    user.save!
    post.save!
  end
end

force_write 가 항상 트랜잭션을 감싸는 바깥 에 있어야 합니다.

3. GET 의 멱등성

HTTP/1.1 명세상 GET 은 멱등 (idempotent) 이어야 합니다. 즉 같은 GET 을 여러 번 보내도 서버 상태가 같은 결과로 수렴해야 합니다. force_write 로 사이드이펙트를 만드는 순간 이 약속이 깨집니다. 브라우저 prefetch, 검색 엔진 크롤러, retry 로직이 의도치 않게 카운터를 여러 번 증가시킬 수 있습니다.

이런 경우에는 다음 중 하나를 고려하는 것이 좋습니다.

  • 세션 / IP 단위 dedup: 같은 사용자가 짧은 시간 안에 여러 번 GET 해도 한 번만 카운트.
  • HyperLogLog 같은 근사 자료구조: 정확한 카운트가 아니어도 되는 케이스.
  • 비동기 job 으로 분리: GET 응답에서는 큐에 넣고 워커가 실제 쓰기 수행.

4. Delay 윈도우 안의 후속 GET

delay: 2.seconds 를 잡았다면, 쓰기 직후 2 초 안에 들어온 같은 세션의 GET 은 모두 primary 로 갑니다. 이 GET 의 컨트롤러 액션 안에서 SELECT 가 100 개 발행되면 그 100 개도 primary 에 부담을 줍니다.

Delay 는 replica lag 의 상한보다 살짝 크게 잡는 것이 좋습니다. Lag 이 보통 200ms 라면 1초, 가끔 1초까지 튀면 2~3초 정도가 적절합니다. 무작정 크게 잡으면 분리 효과가 약해집니다.

5. 미들웨어 우선순위

DatabaseSelector 는 Rack 미들웨어 스택의 특정 위치에 끼워집니다. 다른 커스텀 미들웨어를 추가할 때 순서가 꼬이면 role 이 결정되기 전에 컨트롤러 코드가 실행될 수 있습니다.

rails middleware

명령으로 현재 스택을 출력해서 ActiveRecord::Middleware::DatabaseSelector 의 위치를 확인하는 것이 좋습니다.

6. 테스트 환경에서의 분기

테스트 환경에서는 보통 single DB 로 돌리므로 connects_to 를 환경별로 분기하는 것이 편합니다.

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  if Rails.env.production? || Rails.env.staging?
    connects_to database: { writing: :primary, reading: :primary_replica }
  end
end

또는 테스트 환경의 database.yml 에 replica 를 정의하지 않으면 reading role 도 primary 로 fallback 하므로 그대로 두는 방법도 있습니다.


자동 라우팅 vs 수동 전환, 언제 무엇을 쓸까요

지금까지 본 도구들을 정리하면 다음 표가 됩니다.

기준자동 (DatabaseSelector)수동 (connected_to / force_write)
HTTP 메서드가 의도와 일치적합합니다불필요합니다
GET 에서 쓰기 일부자동 + force_write 보조모든 쓰기 직접 감싸야 합니다
컨트롤러 외부 (Job, Service)동작하지 않습니다필수입니다
모든 요청을 writing 으로 고정미들웨어를 제거합니다항상 connected_to(:writing) 으로 감쌉니다

대부분의 Rails 앱은 자동 라우팅 + 예외 케이스만 force_write 조합이 균형점입니다. 자동 라우팅이 80% 이상의 케이스를 처리하고, 나머지 사이드이펙트 케이스만 명시적으로 표시하는 식입니다.


마무리

읽기와 쓰기를 분리한다는 결정은 단순히 “replica 를 띄우면 끝” 이 아닙니다. 다음 세 가지가 함께 와야 합니다.

  1. 인프라 레벨: primary-replica 복제 구성, replication lag 모니터링, failover 자동화.
  2. 앱 레벨: connects_to 로 role 매핑, DatabaseSelector 미들웨어 활성화, delay 윈도우 설정.
  3. 코드 레벨: GET 의 사이드이펙트를 force_write 같은 concern 으로 명시적으로 표시.

세 번째가 흔히 빠뜨리는 부분입니다. 미들웨어만 켜 두고 코드는 그대로 두면, 어딘가에서 ActiveRecord::ReadOnlyError 가 터지거나 카운터가 안 올라가는 미묘한 버그로 이어집니다. GET 안의 쓰기를 의도적으로 만든다면, 그 의도를 코드에 남기는 것이 결국 가장 중요합니다.

추가로 깊이 들어가고 싶다면 다음을 참고하시면 좋습니다.

이 글의 용어 (3개)
[DB Internals] Redis 8 / Valkey 9: 라이센스 분기, 신 데이터 구조, 실전 운영database-internals
정의 Redis 는 in-memory key-value 데이터 구조 서버. 2009년 Salvatore Sanfilippo (antirez) 가 Lua Manuscripts 의 …
[Rails] ActiveRecord 기초: Model, Migration, CRUDrails
정의 ActiveRecord는 Rails의 ORM. 클래스 ↔ 테이블 1:1 매핑(관례), 인스턴스 = 행. Active Record 패턴 그대로(데이터 + 행동). 마이그레이션…
[Rails] Concerns, Engines, Generatorsrails
정의 Concern은 모듈에 클래스 메서드와 콜백을 한 곳에 모으는 패턴. Engine은 별도 gem으로 분리 가능한 미니 Rails 앱. Generator는 코드 자동 생성 (…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기