Skip to main content

ActiveSupport::CurrentAttributes — Rails가 request-scoped state를 다루는 방법

· 3 min read

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_runto_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 인스턴스가 격리되는 과정

1/5두 request가 동시에 도착

Puma의 thread pool에서 두 thread가 각각 하나의 request를 처리한다. 각 thread는 독립된 Current 인스턴스를 가진다.

Thread A — Alice대기
sessionnil
usernil
Thread B — Bob대기
sessionnil
usernil
동시 실행 중 — 각 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 코드에 다음과 같은 경고가 있다.

"A word of caution: It's easy to overdo a global singleton like Current and tangle your model as a result. Current should only be used for a few, top-level globals, like account, user, and request details. The attributes stuck in Current should be used by more or less all actions on all requests. If you start sticking controller-specific attributes in there, you're going to create a mess."

판단 기준은 단순하다. 거의 모든 request에서 필요하지 않다면 Current에 넣지 않는다.