멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
PATCH body에 끼워 넣은 다른 tenantId가 통과해 옆 고객사 회원이 옮겨 쓰였다. NestJS Guard + DTO에서 tenantId 제거 + whitelist:true 3중 차단으로 쓰기 가드를 만들고, count/sum/groupBy는 where 변수 추출 + $transaction으로 묶었다. 시간 슬라이스는 KST 자정 고정, 멱등성은 Redis EX 60 + 충돌 검사 + audit, 정책 위반은 bypassPolicy + reason + audit. ts-morph 정적 스캔까지 1일 트러블슈팅.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- PATCH body에 끼워 넣은 다른
tenantId가 통과했다 —UpdateMemberDto에tenantId?: number한 칸이 열려 있어 옆 고객사로 회원이 옮겨 쓰였다- 해결: NestJS Guard(body vs token 비교) + DTO에서
tenantId필드 제거 + ValidationPipewhitelist:true3중 차단count·sum·groupBy는where변수 추출 +$transaction— 집계 셋이 같은 모집단을 공유해야 부분 누락이 안 난다- 시간 슬라이스는 서버 KST 자정 고정 (
utcToZonedTime/zonedTimeToUtc) + 주 윈도우는weekStartsOn: 1명시- 수동 발행은 Redis 멱등성 키 EX 60 + 진행 중 잡 충돌 검사 + audit log 3종 동시
- 정책 위반 동사(레벨 점프 등)는
bypassPolicy플래그 +reason필수 + audit — ts-morph 정적 스캔으로 회귀 차단
🌱 배경 — 읽기 안전망 다음의 쓰기·집계 시나리오 14건
직전 단계에서 멀티테넌트 읽기 쪽은 tenantId 3계층 강제 패치로 닫아 뒀다. JWT payload·@CurrentUser·Repository 시그니처에 tenantId를 강제하고, findUnique → findFirst 전환으로 cross-tenant 직접 ID 조회를 404로 막은 상태.
다음 작업은 쓰기·집계 14건. PATCH/POST가 다수 들어오고, count·sum·groupBy가 통계·출석 카드에서 처음으로 등장하는 단계였다.
- 회원관리 4: 회원 정보 수정 / 레벨 수동 조정 / 비밀번호 초기화 / 배치고사 재실시
- 과제 3: 오늘 과제 현황 / 수동 발행 / 일정 변경
- 통계 3: 그룹별 / 회원별 / 교육과정 달성도
- 출석 2: 오늘 출석 / 주간 통계
- 알림·고객사 설정 2
읽기 단계의 안전망(컨트롤러 @CurrentUser → Repository 시그니처 tenantId 필수)을 그대로 갖고 시작했다. 하루 안에 14건을 적용하고 통합 테스트를 돌렸더니 읽기에서는 안 보였던 새 누수 셋이 동시에 드러났다.
🔥 증상 — body 끼워넣기 통과 / 집계 모집단 불일치 / 자정 직후 오늘 어긋남
증상 1: PATCH body에 다른 tenantId를 끼워 넣었더니 옆 고객사로 옮겨졌다
A 운영자 토큰으로 A 고객사 본인 회원을 수정하는 척하면서 body에 tenantId: 7(B 고객사)을 끼워 넣었다.
$ curl -s -X PATCH http://localhost:3000/api/v1/tenant/members/42 \
-H "Authorization: Bearer $TENANT_A_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "바뀐이름", "tenantId": 7}'
{ "success": true,
"data": { "id": 42, "tenantId": 7, "name": "바뀐이름" } } # ← B 고객사로 이동
DB를 직접 확인해 보니 행이 실제로 B 테넌트로 이동했다. A의 회원 42번은 사라지고 B 테넌트 안에 그 row가 들어가 있었다. 읽기 단계에서 막은 cross-tenant 직접 조회와 다른 종류의 누수. 쓰기는 토큰의 tenantId로 자동 필터되지 않는다.
# 원인 단서 — Repository는 토큰 tenantId를 받지만 update가 그걸 활용 안 함
$ rg -n 'update.*tenantId' apps/api/src/tenant/members/members.repository.ts
# (출력 없음)
update()는 where: { id }로만 갱신하고, data: { ...dto }가 클라이언트 body 그대로 흘러 들어갔다. UpdateMemberDto에 tenantId?: number 필드가 한 칸 열려 있던 게 원인.
증상 2: 그룹별 통계 — count·sum·groupBy가 같은 모집단을 안 본다
A 운영자가 그룹 통계를 조회했다. A 테넌트의 회원은 10명, B 테넌트의 같은 그룹 회원은 99명이라고 시드를 박아 두고 결과를 봤다.
$ curl -s "http://localhost:3000/api/v1/tenant/stats/group?groupId=1" \
-H "Authorization: Bearer $TENANT_A_TOKEN" | jq
{
"members": 109, # ← A만이면 10이어야 함
"completed": 10, # ← A만
"recent": 3 # ← A만
}
세 숫자가 각자 다른 모집단을 봤다. members.count는 tenantId 누락으로 109(=10+99)가 됐고, completed와 recent는 tenantId가 들어가서 정상. 화면에서는 그냥 숫자 셋이 다르게 보일 뿐이라 운영자가 누락 지점을 찾을 길이 없다.
증상 3: 자정 직후 KST와 UTC 사이의 6분 차이로 “오늘 과제”가 어제로 분류됐다
새벽 0시 0분~6시 사이에 등록된 과제가 어제 과제 목록에 잡혔다. 클라이언트(브라우저)가 보낸 today를 Date() 그대로 받아 쓴 게 원인.
// ❌ Before — apps/api/src/tenant/assignments/assignments.repository.ts
async findToday(tenantId: number, today: Date) {
return this.prisma.assignment.findMany({
where: {
tenantId,
scheduledAt: { gte: startOfDay(today), lte: endOfDay(today) }, // 서버 로컬 시각
},
});
}
서버 컨테이너의 TZ가 UTC였다. 브라우저에서 KST로 00:03에 호출하면 서버는 15:03 (전날 UTC)로 받아 startOfDay 결과가 전날이 됐다.
📌 핵심: 읽기 단계의
tenantId3계층 강제는 조회에 대한 안전망이었다. 쓰기·집계·시간 슬라이스는 각자 다른 누수를 만든다. body 끼워넣기, where 누락, TZ 불일치 — 세 누수는 서로 다른 위치에서 닫아야 한다.
🔍 탐색 — 가설 4건 검증
가설 1: 컨트롤러에서 body.tenantId만 검사하면 되나
if (dto.tenantId && dto.tenantId !== user.tenantId) throw new ForbiddenException() 한 줄을 모든 PATCH/POST에 추가하면 현재 누수는 막힌다. 하지만 PATCH가 7개, POST가 5개 — 매 컨트롤러에 같은 한 줄을 복붙하는 게 코드 리뷰 누락 한 번이면 새로운 라우트에서 빠진다. DTO 자체에 tenantId 필드가 살아 있는 한 클라이언트가 끼워 넣는 위치는 닫히지 않는다.
가설 2: DTO에서 tenantId만 빼면 되나
UpdateMemberDto에서 tenantId 필드를 삭제하면, 클라이언트가 body에 넣어도 class-validator가 무시할 것 같아 보였다. 그런데 기본 설정에서는 DTO에 정의되지 않은 필드도 그대로 통과해서 Prisma update까지 흘러간다. whitelist: true가 ValidationPipe에 명시적으로 켜져 있어야 DTO에 없는 필드가 자동 제거된다. NestJS 공식 Validation 문서에 그대로 적혀 있다.
가설 3: 집계 한 쿼리만 where를 고치면 되나
members.count에 tenantId를 추가하면 지금 새는 한 줄은 막힌다. 하지만 다음에 추가되는 새 집계 쿼리에서 동일한 누락이 반복될 가능성이 크다. 이전 단계의 페이지네이션 total 누수에서 학습한 패턴 — where를 변수로 추출해 findMany와 count가 같은 객체를 공유 — 를 모든 집계 쿼리에 강제하고, $transaction으로 한 호흡에 묶는 게 회귀 비용을 가장 크게 줄인다.
가설 4: 수동 발행 더블 클릭 — BullMQ jobId만 잘 두면 되나
수동 발행 버튼을 빠르게 두 번 누르면 같은 잡이 두 번 큐잉되는 별개 증상도 발견됐다. BullMQ jobId로 중복을 막을 수 있지만, 클라이언트 측에서 재요청을 거의 동시에 보내면 두 요청 모두 같은 시점의 inflight 검사를 통과한다. 서버 측 멱등성 키(Redis SETNX)와 진행 중 잡 충돌 검사가 같이 필요하다.
네 가설 모두 한 줄 패치로 끝나지 않는다는 결론. 위치마다 안전망이 2~3중으로 들어가야 한다.
🔬 진짜 범인 — 안전망 하나씩만 깔린 세 위치
세 증상의 공통 원인은 안전망의 두께였다.
[쓰기 누수]
body.tenantId → Validation 통과(필드 살아있음)
→ ValidationPipe whitelist 미설정
→ Controller에 비교 가드 없음
→ Repository update가 dto 그대로 흘려보냄
→ DB row가 다른 tenantId로 이동
[집계 누수]
count 쿼리 1개에 tenantId 누락
→ sum/groupBy는 따로 작성된 where
→ 화면에 숫자 셋이 다른 모집단 (디버깅 불가)
[시간 누수]
클라이언트가 보낸 Date 그대로 신뢰
→ 서버 TZ가 UTC
→ KST 자정 윈도우가 6~9시간 어긋남
세 누수 모두 한 곳만 막아도 통과되는 안전망 한 층이 깔려 있고, 그 한 층이 누락된 한 위치에서 통째로 샌다. 해결은 한 위치에 한 줄 추가가 아니라 모든 위치에 2~3중 안전망을 두는 패턴이다.
🛠️ 해결 — 쓰기 가드 3중 + 집계 가드 + 시간 슬라이스 + 멱등성 3중 + 정책 위반 동사 패턴

단계 1: 쓰기 가드 — Guard + DTO 필드 제거 + whitelist: true
3중으로 막는다. 어느 한 층이 빠져도 다른 층이 잡는다.
// ✅ apps/api/src/tenant/auth/tenant-body.guard.ts — 1층: NestJS Guard
@Injectable()
export class TenantBodyGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest();
const tokenTenantId = req.user?.tenantId;
const bodyTenantId = req.body?.tenantId;
if (bodyTenantId !== undefined && bodyTenantId !== tokenTenantId) {
throw new ForbiddenException('tenantId mismatch in body');
}
return true;
}
}
// ✅ apps/api/src/tenant/members/dto/update-member.dto.ts — 2층: DTO에서 제거
export class UpdateMemberDto {
@IsOptional() @IsString() @MaxLength(50)
name?: string;
@IsOptional() @IsEmail()
email?: string;
// tenantId, id, role 등 *컨텍스트성 필드*는 DTO에 정의하지 않는다
}
// ✅ apps/api/src/main.ts — 3층: ValidationPipe whitelist
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO에 없는 필드 자동 제거
forbidNonWhitelisted: true, // DTO에 없는 필드가 들어오면 400 (선택)
transform: true,
}),
);
// ✅ apps/api/src/tenant/members/members.controller.ts — 컨트롤러 적용
@UseGuards(AuthGuard('tenant-jwt'), TenantBodyGuard) // ← Guard 등록
@Controller('tenant/members')
export class MembersController {
@Patch(':id')
async update(
@CurrentUser() user: TenantUser,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateMemberDto,
) {
return this.members.updateByTenant(user.tenantId, id, dto);
}
}
3층 효과:
| 클라이언트 시도 | 1층(Guard) | 2층(DTO) | 3층(whitelist) | 결과 |
|---|---|---|---|---|
{ tenantId: 7 } | 403 차단 | — | — | 403 |
Guard 빠뜨림 + { tenantId: 7 } | 통과 | DTO에 미정의 | 자동 제거 | 200 (tenantId 무시) |
셋 다 빠뜨림 + { tenantId: 7 } | 통과 | 통과 | 통과 | 이전 누수 재발 |
세 층 중 어느 하나만 살아 있으면 누수가 막힌다. 코드 리뷰 누락 한 번에 통째로 새지 않는 게 3중 안전망의 진짜 가치.
단계 2: 집계 가드 — where 변수 추출 + $transaction
// ✅ apps/api/src/tenant/stats/stats.repository.ts
@Injectable()
export class StatsRepository {
async groupSummaryByTenant(tenantId: number, groupId: number) {
const where: Prisma.MemberWhereInput = {
tenantId,
groupId,
deletedAt: null,
};
const [members, completed, recent] = await this.prisma.$transaction([
this.prisma.member.count({ where }),
this.prisma.activityLog.count({
where: { ...where, completedAt: { not: null } },
}),
this.prisma.activityLog.count({
where: {
...where,
createdAt: { gte: startOfWeekKst() },
},
}),
]);
return { members, completed, recent };
}
}
세 카운트가 같은 where 객체를 spread로 공유한다. tenantId를 한 줄 누락하려고 해도 변수 선언부를 고쳐야 가능하고, 그 변경은 PR 단계에서 잡힌다.
// ✅ groupBy / aggregate도 동일 패턴
async curriculumProgressByTenant(tenantId: number, curriculumId: number) {
const where: Prisma.MemberWhereInput = { tenantId, curriculumId, deletedAt: null };
const [stageDistribution, avg] = await this.prisma.$transaction([
this.prisma.member.groupBy({
by: ['currentStage'],
where,
_count: { _all: true },
orderBy: { currentStage: 'asc' },
}),
this.prisma.member.aggregate({ where, _avg: { progressPct: true } }),
]);
return { stageDistribution, avgProgress: avg._avg.progressPct ?? 0 };
}
Prisma 공식 Aggregate/groupBy 문서도 where에 동일한 필터를 두라고 권한다. $transaction으로 묶으면 세 쿼리가 한 트랜잭션 안에서 실행돼 집계 도중 다른 쓰기가 끼어들지 않는다.
단계 3: 시간 슬라이스 — KST 자정 고정 + weekStartsOn: 1
// ✅ apps/api/src/common/time/kst.ts — 한 모듈에 시간 헬퍼 모음
import { startOfDay, endOfDay, startOfWeek, endOfWeek } from 'date-fns';
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
const KST = 'Asia/Seoul';
export function todayKstRange(): { start: Date; end: Date } {
const nowKst = utcToZonedTime(new Date(), KST);
return {
start: zonedTimeToUtc(startOfDay(nowKst), KST),
end: zonedTimeToUtc(endOfDay(nowKst), KST),
};
}
export function thisWeekKstRange(): { start: Date; end: Date } {
const nowKst = utcToZonedTime(new Date(), KST);
return {
start: zonedTimeToUtc(startOfWeek(nowKst, { weekStartsOn: 1 }), KST), // 월요일
end: zonedTimeToUtc(endOfWeek(nowKst, { weekStartsOn: 1 }), KST),
};
}
// ✅ Repository 호출부 — 클라이언트 시각 의존 제거
async findTodayByTenant(tenantId: number) {
const { start, end } = todayKstRange();
return this.prisma.assignment.findMany({
where: { tenantId, scheduledAt: { gte: start, lte: end }, deletedAt: null },
include: { member: { select: { id: true, name: true } } },
orderBy: { scheduledAt: 'asc' },
});
}
weekStartsOn: 1(월요일)이 명시되지 않으면 date-fns 디폴트가 일요일 시작이라 KST 환경에서 주간 통계가 7일 어긋난다. 명시 한 줄이 정확성을 결정한다.
⚠️ 주의: 오늘·이번 주·이번 달이라는 단어가 들어가는 모든 라우트는 서버 KST로 고정한다. 클라이언트가 보낸
today파라미터를 그대로 신뢰하면 고객사마다 다른 결과가 나온다. 컨테이너 TZ도Asia/Seoul로 명시(docker-compose.yml의TZ: Asia/Seoul).
단계 4: 수동 발행 — Redis 멱등성 키 + 충돌 검사 + audit
// ✅ apps/api/src/tenant/assignments/assignments.service.ts
async manualPublish(
tenantId: number,
operatorId: number,
dto: ManualPublishDto,
) {
// 1. 멱등성 키 — 같은 운영자가 1분 안에 같은 요청을 두 번 보내면 첫 결과 반환
const idemKey = `manual-publish:${tenantId}:${operatorId}:${hashBundleIds(dto.bundleIds)}`;
const setOk = await this.redis.set(idemKey, 'inflight', 'EX', 60, 'NX');
if (!setOk) {
const cached = await this.redis.get(`${idemKey}:result`);
if (cached) return JSON.parse(cached);
throw new ConflictException('Duplicate request in progress');
}
// 2. 진행 중 잡 충돌 검사
const inflight = await this.batchQueue.findInflight(tenantId);
if (inflight) {
await this.redis.del(idemKey);
throw new ConflictException(`Already in progress: jobId=${inflight.id}`);
}
// 3. 잡 큐잉
const result = await this.batchQueue.enqueueAssignmentPublish({
tenantId,
triggeredBy: operatorId,
bundleIds: dto.bundleIds,
});
// 4. 결과 캐시 + audit log
await this.redis.set(`${idemKey}:result`, JSON.stringify(result), 'EX', 60);
await this.audit.log({
tenantId,
actor: operatorId,
action: 'ASSIGNMENT_MANUAL_PUBLISH',
payload: { bundleIds: dto.bundleIds, jobId: result.jobId },
});
return result;
}
Redis SET NX EX 60이 원자적 멱등성 키를 만든다. 두 요청이 거의 동시에 도착해도 NX가 한 요청만 통과시키고, 나머지는 409를 받는다. NX 옵션이 빠지면 두 요청이 동시에 read-then-write로 들어가 둘 다 통과하는 케이스가 생긴다.
단계 5: 정책 위반 동사 — bypassPolicy + reason 필수 + audit
운영자가 정책 임계값을 무시하고 레벨을 점프시키는 케이스. 막을 수도 있지만 명시적 우회를 허용하되 추적 가능하게 만든다.
// ✅ apps/api/src/tenant/members/dto/adjust-level.dto.ts
export class AdjustLevelDto {
@IsInt() @Min(1) @Max(99)
newLevel!: number;
@IsOptional() @IsBoolean()
bypassPolicy?: boolean;
@IsOptional() @IsString() @MaxLength(200)
reason?: string;
}
// ✅ apps/api/src/tenant/members/members.service.ts
async adjustLevelByTenant(
tenantId: number,
operatorId: number,
memberId: number,
dto: AdjustLevelDto,
) {
const member = await this.repo.findOneByTenant(tenantId, memberId);
const policy = await this.policy.getCached(); // SystemPolicy 캐시
const policyOk = withinPolicyThreshold(member, dto.newLevel, policy);
if (!policyOk && !dto.bypassPolicy) {
throw new BadRequestException('Level jump violates policy threshold');
}
if (!policyOk && dto.bypassPolicy && !dto.reason) {
throw new BadRequestException('reason required when bypassing policy');
}
const updated = await this.repo.updateLevelByTenant(tenantId, memberId, {
newLevel: dto.newLevel,
adjustedBy: operatorId,
reason: dto.reason ?? null,
bypassedPolicy: !policyOk,
});
await this.audit.log({
tenantId,
actor: operatorId,
action: 'MEMBER_LEVEL_ADJUST',
payload: {
memberId,
from: member.currentLevel,
to: dto.newLevel,
bypassedPolicy: !policyOk,
reason: dto.reason,
},
});
return updated;
}
정책 안의 조정은 그대로 통과, 정책 밖의 조정은 reason이 없으면 400, 있으면 통과하지만 *bypassedPolicy: true*가 row와 audit log에 같이 기록된다. 어긴 결정을 추적할 수 있는 게 핵심.
NestJS Queues 공식 가이드도 멱등성과 충돌 검사를 큐 호출 외부에 두라고 권한다. BullMQ 자체의 jobId 중복 검사는 큐 도달 이후라 동시 요청 두 개가 모두 큐에 들어간 뒤에야 작동한다.
✅ 검증 — e2e 4종
세 단계를 적용하고 BE를 재시작했다. 증상 셋과 더블 클릭 회귀를 e2e로 옮겼다.
// apps/api/test/tenant/write-guard.e2e-spec.ts
describe('쓰기 가드', () => {
it('PATCH body에 다른 tenantId — 403', async () => {
const a = await fixtures.createTenant();
const b = await fixtures.createTenant();
const memberA = await fixtures.createMember({ tenantId: a.id });
const tokenA = await fixtures.signToken({ tenantId: a.id });
const res = await request(app.getHttpServer())
.patch(`/api/v1/tenant/members/${memberA.id}`)
.set('Authorization', `Bearer ${tokenA}`)
.send({ name: '바뀐이름', tenantId: b.id });
expect(res.status).toBe(403);
const after = await prisma.member.findUnique({ where: { id: memberA.id } });
expect(after?.tenantId).toBe(a.id); // row 이동 없음
});
it('Guard 미적용 라우트 — DTO/whitelist가 tenantId를 제거', async () => {
// forbidNonWhitelisted: false로 임시 전환한 컨트롤러도 row 이동이 없어야
const res = await request(app.getHttpServer())
.patch(`/api/v1/tenant/members/${memberA.id}`)
.send({ name: '이름만', tenantId: 99999 });
expect(res.body.data.tenantId).toBe(memberA.tenantId);
});
});
// apps/api/test/tenant/aggregate-guard.e2e-spec.ts
describe('집계 가드', () => {
it('그룹 통계 — count 3종이 같은 모집단', async () => {
const a = await fixtures.createTenant();
const b = await fixtures.createTenant();
await fixtures.createMembersInGroup(a.id, /* groupId */ 1, 10);
await fixtures.createMembersInGroup(b.id, /* groupId */ 1, 99);
const tokenA = await fixtures.signToken({ tenantId: a.id });
const res = await request(app.getHttpServer())
.get('/api/v1/tenant/stats/group?groupId=1')
.set('Authorization', `Bearer ${tokenA}`);
expect(res.body.data.members).toBe(10); // 109면 즉시 실패
});
});
// apps/api/test/tenant/today-kst.e2e-spec.ts
describe('시간 슬라이스', () => {
it('자정 직후 KST — 오늘 과제로 잡힌다', async () => {
// KST 00:03에 등록된 과제 (= UTC 전날 15:03)
const scheduledAt = zonedTimeToUtc('2026-01-12 00:03', 'Asia/Seoul');
const a = await fixtures.createTenant();
await fixtures.createAssignment({ tenantId: a.id, scheduledAt });
// 같은 KST 날짜에 todayList 조회
jest.useFakeTimers().setSystemTime(zonedTimeToUtc('2026-01-12 09:00', 'Asia/Seoul'));
const tokenA = await fixtures.signToken({ tenantId: a.id });
const res = await request(app.getHttpServer())
.get('/api/v1/tenant/assignments/today')
.set('Authorization', `Bearer ${tokenA}`);
expect(res.body.data).toHaveLength(1);
});
});
// apps/api/test/tenant/manual-publish-idempotency.e2e-spec.ts
describe('멱등성', () => {
it('수동 발행 동시 두 번 — 한 번만 큐잉, 다른 한 번은 409', async () => {
const a = await fixtures.createTenant();
const tokenA = await fixtures.signToken({ tenantId: a.id, role: 'TENANT_OWNER' });
const payload = { bundleIds: [1, 2, 3] };
const [r1, r2] = await Promise.all([
request(app.getHttpServer()).post('/api/v1/tenant/assignments/manual-publish')
.set('Authorization', `Bearer ${tokenA}`).send(payload),
request(app.getHttpServer()).post('/api/v1/tenant/assignments/manual-publish')
.set('Authorization', `Bearer ${tokenA}`).send(payload),
]);
const statuses = [r1.status, r2.status].sort();
expect(statuses).toEqual([201, 409]);
const jobs = await batchQueue.findByTenant(a.id);
expect(jobs).toHaveLength(1);
});
});
$ pnpm --filter api test:e2e write-guard aggregate-guard today-kst manual-publish-idempotency
PASS test/tenant/write-guard.e2e-spec.ts (3.214 s)
PASS test/tenant/aggregate-guard.e2e-spec.ts (1.842 s)
PASS test/tenant/today-kst.e2e-spec.ts (1.476 s)
PASS test/tenant/manual-publish-idempotency.e2e-spec.ts (2.103 s)
네 e2e 모두 초록. 아침에 빨갰던 네 시나리오가 회귀 영역으로 들어왔다.
🛡️ 예방 — ts-morph 정적 스캔 + CI 게이트
같은 누수가 다시 들어오지 못하게, 코드 작성 시점에 집계 쿼리의 where 변수 추출과 DTO에 tenantId 필드 부재를 자동 검사한다.
ts-morph 스캔 — 집계 쿼리 변수 추출 강제
// scripts/check-aggregate-where.ts
import { Project, SyntaxKind } from 'ts-morph';
const AGGREGATE = /\.(count|aggregate|groupBy|sum|avg)\(/;
const project = new Project({ tsConfigFilePath: 'apps/api/tsconfig.json' });
const violations: string[] = [];
for (const sf of project.getSourceFiles('apps/api/src/tenant/**/*.repository.ts')) {
sf.forEachDescendant((node) => {
if (node.getKind() !== SyntaxKind.CallExpression) return;
const text = node.getText();
if (!AGGREGATE.test(text)) return;
// where 인라인 객체 리터럴 → 위반
const inlineWhere = /where:\s*\{[^}]*tenantId/.test(text);
const sharedWhere = /where:\s*\{?\s*\.\.\.where/.test(text) || /where[,}\s]/.test(text);
if (inlineWhere && !sharedWhere) {
const { line } = sf.getLineAndColumnAtPos(node.getStart());
violations.push(`${sf.getBaseName()}:${line} ${text.slice(0, 80)}...`);
}
});
}
if (violations.length > 0) {
console.error('[aggregate-guard] where 인라인 객체 위반:');
for (const v of violations) console.error(' - ' + v);
process.exit(1);
}
console.log('[aggregate-guard] OK');
// scripts/check-write-dto.ts — DTO에 tenantId/id/role 필드 부재 검사
import { Project, SyntaxKind } from 'ts-morph';
const FORBIDDEN_FIELDS = ['tenantId', 'id', 'role'];
const project = new Project({ tsConfigFilePath: 'apps/api/tsconfig.json' });
const violations: string[] = [];
for (const sf of project.getSourceFiles('apps/api/src/tenant/**/dto/*.ts')) {
for (const cls of sf.getClasses()) {
if (!/Dto$/.test(cls.getName() ?? '')) continue;
for (const prop of cls.getProperties()) {
const name = prop.getName();
if (FORBIDDEN_FIELDS.includes(name)) {
const { line } = sf.getLineAndColumnAtPos(prop.getStart());
violations.push(`${sf.getBaseName()}:${line} ${cls.getName()}.${name}`);
}
}
}
}
// ... (위반 시 process.exit(1))
CI 게이트
# .github/workflows/tenant-write-aggregate.yml
name: tenant-write-aggregate
on:
pull_request:
paths:
- 'apps/api/src/tenant/**'
- 'scripts/check-aggregate-where.ts'
- 'scripts/check-write-dto.ts'
jobs:
guards:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: 집계 가드 정적 스캔
run: pnpm tsx scripts/check-aggregate-where.ts
- name: 쓰기 가드 DTO 정적 스캔
run: pnpm tsx scripts/check-write-dto.ts
- name: e2e
run: pnpm --filter api test:e2e write-guard aggregate-guard today-kst manual-publish-idempotency
PR이 tenant 디렉토리를 건드릴 때마다 두 정적 스캔과 네 e2e가 자동으로 돈다. 누수의 코드 패턴 자체가 PR 단계에서 빨갛게 잡힌다.
🔍 단서: 정적 스캔은 완벽한 검사가 아니라 흔한 패턴만 잡는다.
@AllowCrossTenant같은 명시적 옵트아웃 주석으로 예외를 허용하고, 그 주석 자체가 PR 리뷰의 신호가 되도록 두는 게 ts-morph 스캔의 표준 패턴이다.
PR 체크리스트 (tenant 모듈)
- 새 PATCH/POST 컨트롤러에
TenantBodyGuard가 등록됐는가? - 새 DTO에
tenantId/id/role필드가 없는가? -
app.useGlobalPipes에whitelist: true가 켜져 있는가? - 새 집계 쿼리(
count/sum/aggregate/groupBy)가where변수 추출 +$transaction패턴인가? - 오늘·이번 주·이번 달이 들어가는 라우트가
todayKstRange()/thisWeekKstRange()를 쓰는가? - 큐 호출 라우트에
Redis SET NX EX 60멱등성 키가 있는가? - 정책 위반 동사에
bypassPolicy+reason+ audit log 3종이 있는가?
📋 정리 — 핵심 요약
| 위치 | ❌ 안티패턴 | ✅ 권장 패턴 |
|---|---|---|
body의 tenantId | DTO에 tenantId?: number 옵셔널 + Guard 없음 + whitelist 미설정 | Guard 비교 + DTO 제거 + whitelist: true 3중 |
집계 where | count/sum/groupBy가 각자 인라인 객체 | where 변수 추출 + $transaction으로 묶기 |
| 집계 회귀 | 수동 코드 리뷰만 의존 | ts-morph 정적 스캔(check-aggregate-where.ts) |
| 시간 슬라이스 | 클라이언트 today 파라미터 신뢰 | todayKstRange() — utcToZonedTime/zonedTimeToUtc |
| 주 윈도우 | date-fns 디폴트(일요일 시작) | weekStartsOn: 1 명시 |
| 수동 발행 멱등성 | BullMQ jobId 중복만 의존 | Redis SET NX EX 60 + 진행 중 잡 충돌 검사 + audit log |
| 정책 위반 동사 | 막거나 통과시키거나 둘 중 하나 | bypassPolicy + reason 필수 + audit log + row에 플래그 박기 |
| 컨테이너 TZ | 디폴트 UTC | TZ: Asia/Seoul 명시 |
| Repository 메서드명 | update(id, dto) | updateByTenant(tenantId, id, dto) — 호출부에서 tenantId 누락 시 컴파일 에러 |
숫자로 보는 삽질
- 세 누수 발견까지: 약 1시간 (통합 테스트 직후)
- 가설 4건 검증: 약 2시간
- 3중 쓰기 가드 + 집계 가드 + 시간 슬라이스 + 멱등성 + 정책 위반 동사: 약 5시간
- e2e 4종 + ts-morph 스캔 2종 + CI 게이트: 약 3시간
- 이후 cross-tenant 쓰기/집계/시간 회귀: 0건 (3개월간)
쓰기·집계는 읽기에서 잘 막아 둔 안전망과 별개로 새는 위치다. 한 줄 패치 대신 2~3중 안전망을 모든 라우트에 강제하고, 코드 패턴 자체를 정적 스캔으로 검사하는 게 회귀 비용을 가장 크게 줄인다. NestJS Guard, DTO whitelist, Prisma $transaction, date-fns-tz, Redis SETNX — 다섯 도구가 각자의 역할로 맞물려야 멀티테넌트 SaaS의 쓰기·집계 회귀가 PR 단계에서 닫힌다.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
- 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
- 25. 멀티테넌트 누수 — tenantId 3계층 강제
- 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
- 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
- 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
- 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
- 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
- 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
- 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
- 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
- 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
- 35. Unity ↔ 웹 PostMessage 브릿지 설계기
- 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
- 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
- 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
- 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
- 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
- 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
- 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
- 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
- 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
- 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
- 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날