테넌트 분리 쿼리 패턴 — Model.where 대신 organization 스코프로 시작하기
마루티테넌트 Rails 앱에서 데이터 유출을 구조적으로 차단하는 쿼리 작성법
실제 PR 리뷰에서 반복적으로 지적하게 되는 패턴이다.
문제가 되는 코드
assets_with_log = Contract::Asset
.joins(:contract)
.where(contracts: { organization_id: @organization.id })
.where(contract_id: contract_ids)
Contract::Asset이라는 모델 클래스에서 시작해서 JOIN으로 organization을 건다. 이게 왜 위험한가?
contract_ids가 외부 입력에서 온다고 치자. 만약 다른 조직의 contract_id가 섞여 있으면? organization_id 조건이 JOIN으로 걸려 있긴 한데, 쿼리가 복잡해질수록 이 조건이 빠지거나 잘못 적용될 가능성이 커진다. 새 개발자가 코드를 수정할 때 .where(contracts: { organization_id: ... }) 줄을 실수로 빼먹으면 전 조직의 데이터가 노출된다.
안전한 패턴 — 3가지 선택지
1. 서브쿼리 방식 (차선)
assets_with_log = Contract::Asset
.where(contract_id: @organization.contracts.where(id: contract_ids).select(:id))
JOIN보다는 안전하다. 서브쿼리가 조직 범위를 보장하니까. 근데 여전히 Contract::Asset이라는 모델 클래스에서 시작한다. 코드를 읽는 사람 입장에서 첫 줄만 보면 "이거 글로벌 쿼리 아닌가?"라는 의심이 든다. 서브쿼리 안을 들여다봐야 비로소 조직 스코프가 걸려 있다는 걸 확인할 수 있다.
안전성은 확보되지만, 의도가 코드 표면에 드러나지 않는다는 문제가 있다.
2. has_many :through 방식 (정답)
# Organization 모델에 추가
has_many :contract_assets, through: :contracts, source: :assets
# 사용
assets_with_log = @organization.contract_assets
.where(contract_id: contract_ids)
.joins(:lease_accounting_log)
.where(buyer_lease_accounting_logs: { status: ... })
@organization.contract_assets에서 시작하면 모델 클래스가 아예 안 나온다. 첫 줄만 보면 "이건 이 조직의 데이터만 다룬다"가 한눈에 보인다.
Contract::Asset.where(...)가 불안하게 느껴지는 건 비합리적인 감각이 아니다. 모델 클래스에서 시작하는 쿼리는 어떤 조건을 걸든 "전체 테이블에서 필터링한다"는 의미다. organization.contract_assets는 "이 조직의 asset만 존재하는 스코프"에서 시작한다. 출발점이 다르다.
association 추가가 필요하지만, 한 줄이다. 이후 모든 쿼리가 깔끔해진다.
3. 서비스 클래스 패턴
class ContractAssetExporter
def initialize(organization)
@organization = organization
end
def export(contract_ids)
assets = @organization.contract_assets
.where(contract_id: contract_ids)
# ...
end
end
서비스 클래스의 initialize에서 @organization을 받으면, 클래스 내 모든 메서드가 자동으로 조직 범위 안에서 동작한다. 메서드 하나 추가할 때마다 organization 스코프를 잊어버릴 걱정이 없다.
핵심 원칙
그로벌 쿼리(Model.where(...))는 ID의 정확성에 의존한다. ID가 오염되면 데이터가 새어나간다.
조직 스코프 쿼리(organization.association.where(...))는 쿼리의 구조 자체가 테넌트 분리를 보장한다. ID가 오염되더라도 서브쿼리/association이 조직 밖의 데이터에 도달하는 걸 차단한다.
"ID를 신뢰하지 말고, 쿼리 구조를 신뢰하라."
"3단 쿼리에서도 매번 @organization으로 시작해야 해?"
이런 코드를 생각해보자.
def export(contract_ids)
# 1단: 계약 조회 — @organization 스코프
contracts = @organization.contracts.where(id: contract_ids)
# 2단: 자산 조회 — 모델 클래스에서 시작
assets = Contract::Asset.where(contract_id: contracts.pluck(:id))
# 3단: 로그 조회 — 모델 클래스에서 시작
logs = LeaseAccountingLog.where(asset_id: assets.pluck(:id))
end
1단은 조직 스코프로 시작해서 안전하다. 근데 2단과 3단은? "contracts가 이미 조직 스코프니까 여기서 나온 ID도 안전하지 않나?" 맞다. 지금은 안전하다.
근데 6개월 뒤에 누군가 이 클래스에 메서드를 추가하면서 assets 변수를 다른 곳에서도 쓰기 시작하면? contracts 조회 로직이 바뀌면서 스코프가 빠지면? 2단과 3단은 1단 결과가 "올바르다"는 가정에 의존하고 있다. 이 가정이 깨지면 연쇄적으로 전부 깨진다.
안전한 버전:
def export(contract_ids)
contracts = @organization.contracts.where(id: contract_ids)
assets = @organization.contract_assets.where(contract_id: contract_ids)
logs = @organization.lease_accounting_logs.where(asset_id: assets.select(:id))
end
매 단계가 독립적으로 조직 스코프를 보장한다. 1단이 잘못되어도 2단, 3단이 각각 조직 밖으로 빠져나가지 않는다.
"앞 단계의 결과를 신뢰하지 마라." 방어적으로 쓰면 오버헤드가 좀 있을 수 있지만, 마루티테넌트에서 데이터 유출 한 번이면 서비스 신뢰가 끝난다.
안전성 말고 또 하나 이유가 있다. "이 클래스는 테넌트 분리를 하고 있다"를 코드로 선언하는 효과다. 모든 쿼리가 @organization.으로 시작하면, 코드를 처음 보는 사람이 클래스를 훑어보는 것만으로 "아, 이건 전부 조직 스코프 안에서 동작하는구나"를 즉시 파악할 수 있다. 일부만 @organization이고 나머지가 Model.where이면 "이건 스코프가 걸려 있나? 저건?"을 한 줄씩 확인해야 한다. 통일적으로 쓰는 건 안전장치이자 동시에 의도를 명시하는 규칙이다.
위험한 패턴 vs 안전한 패턴
| 패턴 | 코드 | 안전성 |
|---|---|---|
| 글로벌 + JOIN | Contract::Asset.joins(:contract).where(contracts: { organization_id: ... }) | ✗ |
| 서브쿼리 | Contract::Asset.where(contract_id: org.contracts.select(:id)) | ▲ 안전하지만 의도가 안 보임 |
| has_many :through | @organization.contract_assets.where(...) | ✓ |
왜 JOIN 방식이 위험한가
시나리오: 새 개발자가 기능 추가 시 쿼리를 수정한다. 복잡한 JOIN 체인에서 .where(contracts: { organization_id: ... }) 조건을 실수로 빠뜨린다.
결과: 다른 조직의 데이터가 응답에 포함된다. 테스트에서도 잡기 어렵다 — 테스트 DB에 조직이 하나뿐이면 문제가 드러나지 않는다.
근본 원인: 테넌트 분리가 조건(condition)에 의존하고 있다. 조건은 빠뜨릴 수 있다.
has_many :through 설정 (한 줄)
# app/models/organization.rb class Organization < ApplicationRecord has_many :contracts has_many :contract_assets, through: :contracts, source: :assets # ← 이 한 줄 추가 end # 사용 — 조직 범위가 쿼리 시작점에 내장 @organization.contract_assets.where(contract_id: ids) # → SELECT "contract_assets".* FROM "contract_assets" # INNER JOIN "contracts" ON "contract_assets"."contract_id" = "contracts"."id" # WHERE "contracts"."organization_id" = 42 # AND "contract_assets"."contract_id" IN (1, 2, 3)
Rails가 자동으로 organization_id 조건을 걸어준다. 빠뜨릴 수가 없다.
서비스 클래스 패턴
# initialize에서 @organization을 받으면
# 클래스 내 모든 메서드가 자동으로 조직 범위 안에서 동작한다
class Contract::AssetExportService
def initialize(organization)
@organization = organization
end
def assets_with_log(contract_ids)
@organization.contract_assets
.where(contract_id: contract_ids)
.joins(:lease_accounting_log)
.where(buyer_lease_accounting_logs: { status: ... })
end
def assets_summary(contract_ids)
# 새 메서드를 추가해도 @organization 스코프가 자동 적용
@organization.contract_assets
.where(contract_id: contract_ids)
.group(:status).count
end
end
3단 쿼리에서도 매번 @organization?
위험: 앞 단계 결과에 의존하는 체인
contracts = @organization.contracts.where(id: ids) # 1단 ✓ assets = Contract::Asset.where(contract_id: contracts.pluck(:id)) # 2단 ✗ logs = LeaseAccountingLog.where(asset_id: assets.pluck(:id)) # 3단 ✗
2단, 3단은 1단이 올바르다는 가정에 의존. 1단이 깨지면 연쇄적으로 전부 깨진다.
안전: 매 단계가 독립적으로 조직 스코프
contracts = @organization.contracts.where(id: ids) # 1단 ✓ assets = @organization.contract_assets.where(contract_id: ids) # 2단 ✓ logs = @organization.lease_accounting_logs.where(asset: assets) # 3단 ✓
1단이 잘못되어도 2단, 3단은 각각 조직 밖으로 빠져나가지 않는다.
원칙: \"앞 단계의 결과를 신뢰하지 마라.\" 안전장치이자 동시에 \"이 클래스는 전부 테넌트 분리를 하고 있다\"를 코드로 선언하는 규칙이다. 모든 쿼리가 @organization.으로 시작하면 코드를 훑어보는 것만으로 의도가 전달된다.
리뷰 체크리스트
-
✗
SomeModel.where(...)로 시작하는 쿼리 → organization 스코프 누락 의심 -
✗
.joins(:parent).where(parents: { organization_id: ... })→ JOIN 기반 테넌트 필터링 → 서브쿼리 또는 through로 전환 -
✓
@organization.association.where(...)→ 쿼리 시작점이 조직 스코프 → OK -
✓
서비스 클래스의
initialize(organization)→ 전체 메서드 자동 스코프 → OK
핵심 포인트
Model.where(...)로 시작하는 그로벌 쿼리를 찾는다
organization.association으로 시작하도록 변경한다 (has_many :through 추가 검토)
서비스 클래스는 initialize에서 @organization을 받아 전체 메서드에 적용
외부 입력 ID는 반드시 서브쿼리로 조직 범위를 통과시킨다
장점
- ✓ ID가 오염되어도 조직 밖 데이터에 도달 불가 — 구조적 안전
- ✓ 코드 리뷰에서 \"organization 스코프가 걸려 있는가\"를 시작점만 보면 판단 가능
- ✓ 새 메서드 추가 시 스코프 누락 가능성이 원천 차단 (서비스 클래스 + has_many through)
단점
- ✗ has_many :through 추가가 기존 코드베이스에서 마이그레이션 비용 발생
- ✗ 서브쿼리 방식은 쿼리 플랜이 JOIN보다 비효율적일 수 있다 (대량 데이터)