ActiveSupport::CurrentAttributes — Rails가 request-scoped state를 다루는 방법
rails generate authentication을 실행하면 app/models/current.rb가 생성된다. 3줄짜리 파일이다. 이 3줄이 어떤 문제를 풀고 있고, 내부에서 어떻게 동작하는지를 다룬다. CurrentAttributes는 DHH의 PR #29180으로 Rails에 도입되었다.
문제: request context의 전파
현재 사용자 정보는 애플리케이션 전체에서 필요하지만, request가 끝나면 사라져야 한다. 이 정보를 필요한 곳마다 메서드 인자로 넘기는 것은 가능하지만 번거롭다.
# 인자 전달 방식 - 동작하지만 모든 레이어에 user를 넘겨줘야 한다
def create(message_params, user)
message = Message.new(message_params)
message.creator = user
Event.create(record: message, user: user)
end
ActiveSupport::CurrentAttributes가 이 문제를 해결한다. request 동안만 유효한 저장소를 제공하고, request가 끝나면 자동으로 비운다.
Current Model
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end
Current는 request 동안만 유효한 저장소다. Current.new로 인스턴스를 만들지 않는다. Current.session처럼 클래스 메서드로 읽고 쓰며, request가 끝나면 자동으로 비워진다.
attribute :session
attribute를 선언하면 내부적으로 인스턴스의 @attributes Hash에 값을 저장하고 꺼낸다. Hash 하나에 모든 값을 담는 이유는 reset 때 Hash만 교체하면 전체 초기화가 끝나기 때문이다.
Current.session = session # 쓰기
Current.session # 읽기
delegate :user, to: :session, allow_nil: true
이 줄은 Rails의 Module#delegate를 사용한다. attribute와는 다른 메커니즘이다. attribute는 저장소에 독립된 값을 추가하고, delegate는 이미 존재하는 값에서 특정 정보를 읽는 단축키를 만든다.
# delegate가 생성하는 코드의 의미
def user
session&.user # session이 nil이면 nil 반환, 아니면 session.user 호출
end
# attribute로 선언한 경우 - 두 번 설정해야 한다
Current.session = session
Current.user = session.user
# delegate로 선언한 경우 - 한 번이면 된다
Current.session = session
Current.user # → session.user 자동 반환
Request lifecycle
clear_all이 언제 호출되는지는 activesupport/lib/active_support/railtie.rb에서 확인할 수 있다. Executor는 framework 코드와 application 코드를 분리하는 래퍼다. HTTP request 처리 시 Rails가 자동으로 감싸며, to_run과 to_complete 두 callback을 제공한다.
app.executor.to_run do
ActiveSupport::ExecutionContext.push
end
app.executor.to_complete do
ActiveSupport::CurrentAttributes.clear_all
ActiveSupport::ExecutionContext.pop
ActiveSupport.event_reporter.clear_context
end
하나의 HTTP request 동안 Current의 상태는 다음과 같이 변한다.
Request 도착
│
├─ 1. Executor 진입 (to_run)
│ attribute는 이미 비어 있다.
│ 이전 request의 to_complete가 정리했거나, 첫 request라면 초기값이 nil이다.
│
├─ 2. before_action: require_authentication
│ 세션 쿠키를 검증하고 Current.session을 설정한다.
│
├─ 3. Controller / Model / View
│ Current.session, Current.user를 자유롭게 읽는다.
│
├─ 4. Response 전송
│
└─ 5. Executor 퇴장 (to_complete) → clear_all
모든 attribute가 nil로 돌아간다.
Thread-safety
Puma는 thread pool에서 각 request를 처리한다. Current 인스턴스는 thread마다 격리되므로, 동시에 처리되는 request 간에 값이 섞이지 않는다.
Thread-Safety
동시 request에서 Current 인스턴스가 격리되는 과정
Puma의 thread pool에서 두 thread가 각각 하나의 request를 처리한다. 각 thread는 독립된 Current 인스턴스를 가진다.
주의사항
Background job에서 Current 값은 전달되지 않는다
Current는 request 단위로 격리되는 저장소다. Background job은 enqueue한 request와 다른 스레드에서 실행되므로 Current 값이 존재하지 않는다.
# Current.user는 nil — request에서 설정한 값이 전달되지 않는다
class PublishMessageJob < ApplicationJob
def perform(message_id)
message = Message.find(message_id)
Event.create(record: message, user: Current.user, action: :publish)
end
end
필요한 값을 인자로 명시적으로 전달하는 것이 확실한 해결법이다.
PublishMessageJob.perform_later(message_id: message.id, user_id: Current.user.id)
class PublishMessageJob < ApplicationJob
def perform(message_id:, user_id:)
message = Message.find(message_id)
Event.create(record: message, user_id: user_id, action: :publish)
end
end
과도한 attribute 추가
Rails 코드에 다음과 같은 경고가 있다.
판단 기준은 단순하다. 거의 모든 request에서 필요하지 않다면 Current에 넣지 않는다.