Prisma $extends 테넌트 격리와 $transaction 사이의 타협: 수동 orgId 주입 전략
SaaS 모드에서 Project 쿼리에 organizationId를 자동 주입하던 $extends 확장이 $transaction 콜백 안에서는 동작하지 않았다. 테넌트 격리를 포기할 수도, 트랜잭션을 포기할 수도 없어서 '수동 orgId 주입' 패턴으로 절충한 기록.
1. 문제 상황
1.1 PR 리뷰에서 걸린 또 하나
주간 리포트 기능 PR의 리뷰어가 지적한 또 한 가지:
POST /api/projects에서prisma.project.create와logProjectCreated가 별개의 호출입니다. 두 호출 사이에 오류가 발생하면 프로젝트는 생성됐는데 상태 로그가 누락됩니다. 그러면 그 프로젝트는 주간 리포트에 영원히 나타나지 않습니다.
맞는 지적입니다. ProjectStatusLog는 리포트 API가 "active 과제"를 판정하는 근거인데, 초기 로그가 빠지면 해당 프로젝트는 historical query에서 배제됩니다. 두 작업은 하나의 트랜잭션으로 묶여야 합니다.
// 기존 코드 (atomic하지 않음)
const project = await db.project.create({...}); // tenant-scoped prisma
await logProjectCreated(prisma, project.id, ...); // 별개 호출
// ↑ 이 사이에 예외 → 프로젝트만 남고 로그 누락
1.2 단순한 트랜잭션 적용 시도
"트랜잭션으로 묶기"는 이 블로그에서 이전에 다룬 적이 있는 친숙한 기법입니다. 한 줄로 감싸면 될 것 같았습니다.
// ❌ 첫 번째 시도 — 동작하지 않음
const db = await getTenantPrisma(); // $extends로 감싼 tenant-scoped prisma
const project = await db.$transaction(async (tx) => {
const created = await tx.project.create({ data: { name, ... } });
await logProjectCreated(tx, created.id, initialStatus, ctx?.userId ?? null);
return created;
});
예상 동작: tx.project.create가 $extends에서 정의한 create 쿼리 훅을 거쳐 organizationId를 자동 주입. 하지만 실제로는 organizationId가 null로 저장됐습니다. 테넌트 extension이 트랜잭션 콜백 안에서 동작하지 않았습니다.
1.3 $extends의 제약
Prisma 공식 문서에 명시돼 있는 제약입니다:
Query extensions are applied only to the top-level Prisma Client instance. They are not applied to transaction callbacks started via
$transaction.
db.$transaction(async (tx) => {...})의 tx는 extension이 벗겨진 원본 클라이언트입니다. 이는 버그가 아니라 의도된 설계입니다 — 트랜잭션 콜백 내부에서는 확장된 훅이 중첩 실행되는 상황(특히 재귀)을 방지해야 하기 때문입니다.
결과: 테넌트 격리의 핵심 보증이 트랜잭션 안에서 사라집니다.
2. 원인 분석
2.1 테넌트 extension이 하던 일
lib/prisma-tenant.ts에서 Project 쿼리에 붙여둔 훅입니다:
export async function getTenantPrisma() {
if (!isFeatureEnabled("auth")) return prisma;
const orgId = await getCurrentOrgId();
if (!orgId) return prisma;
// orgId별 $extends 인스턴스 캐시 (LRU 100)
const cached = tenantCache.get(orgId);
if (cached) return cached;
const extended = prisma.$extends({
query: {
project: {
async findMany({ args, query }) {
args.where = { ...args.where, organizationId: orgId };
return query(args);
},
async findUnique({ args, query }) {
const result = await query(args);
if (result && result.organizationId !== orgId) return null;
return result;
},
async create({ args, query }) {
args.data = { ...args.data, organizationId: orgId };
return query(args);
},
// update, delete, updateMany, deleteMany 등도 동일 패턴
},
},
});
tenantCache.set(orgId, extended as typeof prisma);
return extended as typeof prisma;
}
이 확장의 보증은 두 가지입니다:
- 자동 격리: 다른 조직의 row가 쿼리 결과에 섞이지 않음
- 강제 태깅: create 시 organizationId를 명시하지 않아도 자동 주입
$transaction 콜백 안에서 이 둘이 모두 사라집니다. tx.project.create가 organizationId 없이 INSERT되면 DB 제약(nullable 허용 상태)으로 그대로 들어갑니다.
2.2 왜 이게 위험한가
테넌트 격리를 잃는 건 단순 버그가 아니라 보안 사고 직전의 상태입니다:
- Organization A의 사용자가 Organization B의 데이터를 보는 리스크
- SaaS 모델에서 가장 중요한 invariant 중 하나
- "대부분 동작하는데 가끔 누출"은 가장 디버깅하기 어려운 종류의 버그
리뷰어의 지적을 받아들여 트랜잭션을 도입하는 그 순간, 오히려 더 큰 문제를 만들 뻔했습니다.
3. 해결 방법 — "수동 orgId 주입" 전략
3.1 핵심 결정
세 가지 선택지 중 하나를 골라야 했습니다:
| 선택지 | 문제 |
|---|---|
| A. 트랜잭션 포기 | 리뷰어 지적대로 로그 누락 위험 (원래 문제) |
| B. extension 포기 | 모든 Project 쿼리를 수동으로 organizationId 넣어야 함 |
| C. 트랜잭션 안에서만 수동 주입 | 트랜잭션 경계에서만 extension의 책임을 "수동 이양" |
C를 선택했습니다. 트랜잭션은 어차피 드문 호출이고, 그 안에서만 명시적으로 orgId를 주입하면 테넌트 격리 보증이 유지됩니다. 전체 쿼리를 수동으로 바꾸는 B는 유지보수 비용이 너무 컸습니다.
3.2 리팩토링
// ✅ After: 트랜잭션 경계에서만 수동 orgId 주입
import { prisma } from "@/lib/prisma"; // ← 원본 prisma
import { getCurrentOrgId } from "@/lib/auth/gate";
import { logProjectCreated } from "@/lib/project-status-log";
export async function POST(request: Request) {
try {
await requireAuth();
const body = await parseBody(request);
// ...validation...
const ctx = isFeatureEnabled("auth") ? await getAuthContext() : null;
const initialStatus = (body.status as string) ?? "PI";
// 트랜잭션 시작 직전에 orgId를 조회해 수동으로 주입한다.
// 테넌트 prisma($extends)는 트랜잭션 콜백 내에서 동작하지 않으므로
// 여기서는 원본 prisma + 수동 orgId를 쓴다.
const orgId = isFeatureEnabled("auth") ? await getCurrentOrgId() : null;
const project = await prisma.$transaction(async (tx) => {
const created = await tx.project.create({
data: {
name,
// ...다른 필드들...
organizationId: orgId, // ← 명시적 주입
labels: { create: labelsToCreate },
},
});
// 상태 변경 이력에 초기 상태 기록 (같은 트랜잭션)
await logProjectCreated(tx, created.id, initialStatus, ctx?.userId ?? null);
return created;
});
return NextResponse.json(project, { status: 201 });
} catch (error) {
return handleApiError(error, "POST /api/projects");
}
}
3.3 변화 3가지
db→prisma:getTenantPrisma()로 받은 확장 클라이언트 대신 원본prisma를 사용.getCurrentOrgId()를 트랜잭션 시작 전에 호출: 세션에서 orgId를 한 번 읽어 변수에 담습니다.organizationId: orgId를data에 명시적 포함: 자동 주입 대신 직접 타이핑.
3.4 왜 이 트레이드오프가 수용 가능한가
수동 주입은 겉보기엔 "뒤로 가는" 선택처럼 보입니다. 하지만 몇 가지 이유로 이번 경우에는 합리적입니다:
- POST /api/projects는 유일한 Project create 호출점. 호출 지점이 하나라서 수동 주입이 퍼지지 않음.
- 명시성 ↑:
organizationId: orgId가 코드에 보이는 것이 오히려 리뷰 시 격리 검증에 도움이 됨. extension은 "암묵적 마법"이어서 신뢰는 가지만 증명은 어려움. - 트랜잭션 범위가 좁음: 콜백 안에서 organizationId가 들어가는 create는 1개뿐. 나머지 쿼리(logProjectCreated)는 orgId 파라미터가 필요 없는 관계 기반(projectId FK)이라 안전.
4. 주의사항과 함정
4.1 트랜잭션 콜백 안에서 다른 프로젝트를 읽으면?
예를 들어 이런 코드를 추가한다고 상상해봅시다:
// ⚠️ 위험한 확장
await prisma.$transaction(async (tx) => {
const created = await tx.project.create({...});
// 다른 프로젝트를 조회하는 쿼리 추가
const sibling = await tx.project.findFirst({
where: { name: "참조할 프로젝트" }, // ← organizationId 필터 없음!
});
});
이 findFirst는 다른 조직의 프로젝트도 찾아낼 수 있습니다. 트랜잭션 안에서는 extension의 자동 필터가 없기 때문입니다. 이 위험은 수동 주입의 숨겨진 비용입니다.
방어책:
- 트랜잭션 콜백 안의 모든 Project 쿼리에
where: { organizationId: orgId }를 수동으로 달아야 함 - 트랜잭션 경계 안에서 "다른 조직의 데이터를 읽을 수 있다"는 사실을 주석으로 문서화
- 신규 멤버 온보딩에서 이 함정을 명시적으로 알림
4.2 isFeatureEnabled("auth") 가드
OSS 모드(인증 비활성)에서는 getCurrentOrgId를 호출할 필요가 없습니다. 세션 context가 없기 때문입니다.
const orgId = isFeatureEnabled("auth") ? await getCurrentOrgId() : null;
OSS에서는 orgId가 null이고, 스키마에서 Project.organizationId가 nullable이므로 null 저장도 유효합니다. 기존 OSS 사용자의 데이터 호환성을 위해서도 이 가드는 필요합니다.
4.3 getCurrentOrgId의 위치
트랜잭션 시작 전에 호출해야 합니다:
// ✅ 올바름
const orgId = await getCurrentOrgId();
await prisma.$transaction(async (tx) => {
await tx.project.create({ data: { ..., organizationId: orgId } });
});
// ❌ 트랜잭션 안에서 세션 조회
await prisma.$transaction(async (tx) => {
const orgId = await getCurrentOrgId(); // ← 트랜잭션을 오래 잡음
// ...
});
세션 조회는 내부적으로 Better Auth → DB 읽기를 포함할 수 있어, 트랜잭션 안에서 수행하면 불필요하게 락을 오래 잡습니다. React.cache로 중복 호출은 방지되어 있지만, 트랜잭션 시작 전에 한 번이 원칙입니다.
4.4 테스트에서 mocking
getCurrentOrgId를 mock해야 하는 테스트라면 SaaS 가드도 같이 고려해야 합니다:
import { vi } from "vitest";
vi.mock("@/lib/auth/gate", () => ({
requireAuth: vi.fn(),
getAuthContext: vi.fn(() => ({ userId: "u1", email: "a@b", ... })),
getCurrentOrgId: vi.fn(() => "org-test"), // ← 추가
}));
실제로 테스트 추가 커밋(4a23f2b)에서 projects.test.ts의 mock에 getCurrentOrgId를 추가해야 빨간 줄이 사라졌습니다.
5. 핵심 개념 정리
5.1 Prisma $extends의 경계
| 범위 | extension 적용 |
|---|---|
prisma.project.findMany() |
✅ |
prisma.$transaction([prisma.project.findMany(), ...]) (sequential) |
✅ |
prisma.$transaction(async (tx) => tx.project.findMany()) (interactive) |
❌ |
prisma.$queryRaw |
❌ |
middlewares $use |
❌ (deprecated) |
정리: interactive 트랜잭션 콜백 안에서는 extension이 벗겨집니다. 이는 문서화된 제약이며, 현재 Prisma 6까지 유효합니다.
5.2 세 가지 테넌트 격리 레이어
SaaS에서 테넌트 격리는 보통 여러 층으로 구현합니다:
- DB 레벨 — Row-Level Security(RLS), PostgreSQL
CREATE POLICY - ORM 레벨 — Prisma
$extends로 자동 필터 - 애플리케이션 레벨 — 라우트 핸들러에서 명시적
where추가
이번 프로젝트는 2번 위주로 구성되어 있었고, 트랜잭션 예외로 인해 3번을 부분적으로 도입한 셈입니다. 가장 견고한 건 1번(RLS)이지만 PostgreSQL에 묶이고 OSS의 SQLite 경로와 충돌하여 도입을 보류한 상태입니다.
5.3 언제 extension, 언제 수동 주입?
일반 CRUD 경로 → extension (자동, 위반 시 컴파일/런타임 방어)
트랜잭션 필수 경로 → 수동 주입 (명시적, 리뷰에서 눈으로 확인)
raw SQL → 수동 WHERE 필수 (extension 불가)
6. 베스트 프랙티스
6.1 체크리스트
- [ ]
$transaction(async (tx) => ...)안에서 Project 쿼리가 있으면 모두 수동으로 orgId 필터/주입 - [ ] 트랜잭션 시작 전에
getCurrentOrgId를 변수로 캐싱 - [ ] 수동 주입하는 파일에 주석으로 이유 남기기 (나중에 누군가 extension으로 되돌리려 할 때 경고)
- [ ] OSS/SaaS 분기가 있는 필드는
isFeatureEnabled가드 - [ ] 테스트 mock에
getCurrentOrgId추가 (mock 파일 일관성)
6.2 주석으로 불변성 문서화
extension이 보장하던 invariant를 수동 주입으로 바꿨다면, 왜 수동인지를 주석에 명시하세요.
// 테넌트 prisma($extends)는 트랜잭션 콜백 내에서 동작하지 않으므로
// organizationId를 수동으로 주입한다 (OSS 모드에서는 null).
const orgId = isFeatureEnabled("auth") ? await getCurrentOrgId() : null;
미래의 당신(혹은 팀원)이 "왜 굳이 수동이지? extension이 있는데"라며 리팩토링할 때, 이 주석 한 줄이 사고를 막습니다.
6.3 트랜잭션 범위 최소화
트랜잭션 콜백이 커질수록 수동 주입이 퍼지고 실수가 늘어납니다. 가능하면 트랜잭션은 꼭 원자성이 필요한 2~3개 작업으로 좁게 유지하세요.
// ✅ 좁은 트랜잭션
const project = await prisma.$transaction(async (tx) => {
const created = await tx.project.create({...});
await logProjectCreated(tx, created.id, ...);
return created;
});
// 트랜잭션 바깥에서 나머지 작업
await sendWelcomeEmail(project.id); // 실패해도 DB 불변성에 영향 없음
7. FAQ
Q. $extends 대신 클라이언트 middleware $use를 쓰면?
A. $use middleware는 Prisma 5부터 deprecated입니다. 더 나아가 $use도 Interactive transaction 안에서는 동일한 제약이 있습니다. 공식 권장은 extension이지만 트랜잭션 호환성은 동일.
Q. $transaction을 배열 형태(sequential)로 쓰면 어떤가요?
A. prisma.$transaction([prisma.project.create(...), logProjectCreated(prisma, ...)]) 형태는 interactive가 아니라 sequential입니다. 이 경우 각 호출은 top-level 클라이언트의 메서드라서 extension이 적용됩니다. 하지만 로직 분기(예: if (created.id))가 필요하면 interactive를 써야 하고, 이번 사례는 logProjectCreated(tx, created.id, ...)가 created.id에 의존해서 sequential 배열 형태는 불가했습니다.
Q. RLS로 가면 이 문제가 사라지나요?
A. 네. PostgreSQL의 RLS는 DB 레벨에서 세션 변수(SET LOCAL app.current_org)로 정책을 적용하므로, 트랜잭션 여부와 무관하게 격리가 유지됩니다. 단:
- OSS/SQLite 경로와의 호환성 고려 필요
- 모든 테이블에 정책 작성 부담
- 테스트 데이터 시드 시
BYPASSRLS롤 필요
이런 이유로 이번 프로젝트는 Prisma extension 전략을 택했습니다.
Q. 수동 주입을 잊어버릴까 봐 걱정돼요.
A. 안전장치 몇 가지:
- ESLint 커스텀 룰로 "
$transaction콜백 안의project.create는organizationId필수"를 강제 - 타입 시스템으로 감싸기:
type ProjectCreateInTx = Prisma.ProjectCreateInput & { organizationId: string | null }처럼data를 강타입으로 요구 - 리뷰 체크리스트에 명시
- DB 레벨
NOT NULL제약 (현재는 nullable이지만 OSS 마이그레이션 완료 후 강화 가능)
Q. getTenantPrisma()를 트랜잭션 안에서 호출하면?
A. 호출은 가능하지만 반환되는 extension이 트랜잭션의 tx가 아닌 원본 prisma 기반이라 트랜잭션에 참여하지 못합니다. 트랜잭션 안에서는 tx만 써야 합니다.
8. 참고 자료
- Prisma - Client extensions
- Prisma - Transactions and batch queries
- PostgreSQL - Row Security Policies
- 관련 글: Prisma 일괄 업데이트에서 동시성 이슈 해결하기
- 관련 글: findUnique → create의 함정: Prisma get-or-create 패턴의 P2002 레이스 컨디션
9. 다음 단계
테넌트 경계가 트랜잭션 안에서 명시적으로 지켜졌다면, 다음 고민은 트랜잭션 안에서 일어나는 후속 부수 효과입니다. 프로젝트가 생성되면 슬랙 알림, 이메일, 외부 시스템 동기화 같은 것이 붙기 마련인데, 이들은 트랜잭션 바깥에서 실행되어야 롤백과 충돌하지 않습니다. 트랜잭션 경계를 어디서 끊을지는 설계의 핵심 감각입니다.