멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성

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가 통과했다UpdateMemberDtotenantId?: number 한 칸이 열려 있어 옆 고객사로 회원이 옮겨 쓰였다
  • 해결: NestJS Guard(body vs token 비교) + DTO에서 tenantId 필드 제거 + ValidationPipe whitelist:true 3중 차단
  • count·sum·groupBywhere 변수 추출 + $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를 강제하고, findUniquefindFirst 전환으로 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 그대로 흘러 들어갔다. UpdateMemberDtotenantId?: 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.counttenantId 누락으로 109(=10+99)가 됐고, completedrecenttenantId가 들어가서 정상. 화면에서는 그냥 숫자 셋이 다르게 보일 뿐이라 운영자가 누락 지점을 찾을 길이 없다.

증상 3: 자정 직후 KST와 UTC 사이의 6분 차이로 “오늘 과제”가 어제로 분류됐다

새벽 0시 0분~6시 사이에 등록된 과제가 어제 과제 목록에 잡혔다. 클라이언트(브라우저)가 보낸 todayDate() 그대로 받아 쓴 게 원인.

// ❌ 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 결과가 전날이 됐다.

📌 핵심: 읽기 단계의 tenantId 3계층 강제는 조회에 대한 안전망이었다. 쓰기·집계·시간 슬라이스는 각자 다른 누수를 만든다. 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.counttenantId를 추가하면 지금 새는 한 줄은 막힌다. 하지만 다음에 추가되는 새 집계 쿼리에서 동일한 누락이 반복될 가능성이 크다. 이전 단계의 페이지네이션 total 누수에서 학습한 패턴 — where를 변수로 추출해 findManycount가 같은 객체를 공유 — 를 모든 집계 쿼리에 강제하고, $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중 + 정책 위반 동사 패턴

멀티테넌트 동사 14건의 카테고리 5종과 점검 가드 8종 교집합 — 쓰기 가드·집계 가드가 어떤 시나리오에 적용되는지 정리한 구조도

단계 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.ymlTZ: 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에 같이 기록된다. 어긴 결정을 추적할 수 있는 게 핵심.

docs.nestjs.com

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.useGlobalPipeswhitelist: true가 켜져 있는가?
  • 새 집계 쿼리(count/sum/aggregate/groupBy)가 where 변수 추출 + $transaction 패턴인가?
  • 오늘·이번 주·이번 달이 들어가는 라우트가 todayKstRange()/thisWeekKstRange()를 쓰는가?
  • 큐 호출 라우트에 Redis SET NX EX 60 멱등성 키가 있는가?
  • 정책 위반 동사에 bypassPolicy + reason + audit log 3종이 있는가?

📋 정리 — 핵심 요약

위치❌ 안티패턴✅ 권장 패턴
body의 tenantIdDTO에 tenantId?: number 옵셔널 + Guard 없음 + whitelist 미설정Guard 비교 + DTO 제거 + whitelist: true 3중
집계 wherecount/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디폴트 UTCTZ: 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. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날