멀티테넌트 누수 — tenantId 3계층 강제

NestJS 멀티테넌트 도입 다음 날 아침, 다른 고객사 토큰으로 회원 상세를 열었더니 200 OK가 떨어졌다. JWT payload·@CurrentUser·Repository 시그니처 3계층에 tenantId를 강제하고, ts-morph 정적 스캔과 cross-tenant e2e로 회귀까지 차단한 6시간 트러블슈팅.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • A 토큰으로 B 회원 상세를 호출했더니 200 OKfindUnique({ where: { id } })tenantId 없이 돌고 있었다
  • 해결: JWT payload + @CurrentUser + Repository 시그니처 3계층에 tenantId 강제 주입
  • findUniquefindFirst 전환 — unique 키 단독 매칭은 멀티테넌트에서 무조건 금지
  • 페이지네이션 total 누수는 where 변수 추출 + $transaction으로 차단
  • Repository 메서드명에 ByTenant 접미사 강제 → 호출부 누락이 컴파일 단계에서 멈춤
  • 회귀 차단: ts-morph 정적 스캔 + cross-tenant e2e + CI 게이트 3종

🔥 증상 — 다른 테넌트 토큰으로 회원 상세가 200 OK로 떨어졌다

전날 저녁에 멀티테넌트 도메인 1차 시나리오 15건을 다 구현하고, 운영자 로그인·회원 목록·그룹 상세까지 직접 클릭해 본 다음 잤다. 다음 날 아침 9시쯤 수동 보안 점검 한 번만 돌리고 다음 작업으로 넘어갈 생각이었는데, 9시 15분에 멈췄다.

테넌트 A의 운영자로 로그인해서 토큰을 받아 두고, 테넌트 B의 회원 ID를 직접 지정해서 회원 상세 API를 호출했다.

# A 운영자 토큰으로 B 테넌트의 회원 ID(=42) 조회
$ curl -s http://localhost:3000/api/v1/tenant/members/42 \
    -H "Authorization: Bearer $TENANT_A_TOKEN" | jq

{
  "success": true,
  "data": {
    "id": 42,
    "tenantId": 7,           # ← B 테넌트
    "name": "박지수",
    "email": "[email protected]"
  }
}

200 OK. 다른 고객사의 회원 정보가 그대로 응답으로 떨어졌다. SaaS에서 단 한 명이라도 cross-tenant로 새면 전체 신뢰가 깨진다.

같은 패턴을 다른 엔드포인트에도 돌려 봤다.

# 회원 목록 — 페이지네이션 total 확인
$ curl -s "http://localhost:3000/api/v1/tenant/members?page=1&limit=20" \
    -H "Authorization: Bearer $TENANT_A_TOKEN" | jq '.meta'

{
  "page": 1,
  "limit": 20,
  "total": 1247              # ← A 테넌트만이면 80명대여야 함
}

data 배열의 회원은 80명이 맞았다 — A 테넌트만. 그런데 total1,247. DB 전체 회원 수와 일치했다. findMany는 막혔는데 count는 안 막혔다는 뜻이다.

# 그룹 상세 — B 테넌트 그룹 ID(=18)에 접근
$ curl -s http://localhost:3000/api/v1/tenant/groups/18 \
    -H "Authorization: Bearer $TENANT_A_TOKEN" \
    -o /dev/null -w '%{http_code}\n'
200

또 200. 회원 상세·회원 목록 total·그룹 상세 — 세 엔드포인트가 모두 다른 테넌트 데이터를 그대로 내놓고 있었다. 1차 e2e 스모크가 멀쩡히 초록이었던 이유는, 그 테스트들이 한 테넌트 안에서만 검증하고 있었기 때문이다.

📌 핵심: 멀티테넌트 e2e는 반드시 cross-tenant 시나리오를 따로 만들어야 한다. 한 테넌트로 정상 동작만 검증하면 cross-tenant 누수는 통째로 침묵 회귀한다. 다른 테넌트 토큰으로 상대방 ID에 접근하는 테스트 한 줄이 없으면 없는 것과 같다.


🔍 탐색 — Repository 8개 파일에 tenantId가 0건이었다

처음엔 JWT Guard가 깨진 게 아닌가 의심했다. @UseGuards(AuthGuard('tenant-jwt'))가 적용된 컨트롤러를 다른 테넌트 토큰이 통과했을 리는 없으니까. Passport 전략부터 다시 봤다.

// ❌ Before — apps/api/src/tenant/auth/jwt.strategy.ts
@Injectable()
export class TenantJwtStrategy extends PassportStrategy(Strategy, 'tenant-jwt') {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.getOrThrow('JWT_TENANT_SECRET'),
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, role: payload.role };  // ← tenantId 누락
  }
}

첫 번째 단서. validatetenantId를 반환하지 않고 있었다. JWT payload에는 tenantId가 들어 있었는데, 전략이 userIdrole만 추려서 request.user에 담고 있었다. 컨트롤러는 request.user.tenantIdundefined인 상태로 동작하고 있었다.

컨트롤러도 열어 봤다.

// ❌ Before — apps/api/src/tenant/members/members.controller.ts
@UseGuards(AuthGuard('tenant-jwt'))
@Controller('tenant/members')
export class MembersController {
  constructor(private readonly members: MembersService) {}

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.members.findOne(id);          // ← tenantId 안 넘김
  }

  @Get()
  list(@Query() q: ListMembersDto) {
    return this.members.list(q);               // ← tenantId 안 넘김
  }
}

토큰이 전달된다는 사실에 안심한 채, 토큰 안의 어떤 값을 어떻게 쓸지는 한 줄도 들어 있지 않았다. Repository까지 확인했다.

// ❌ Before — apps/api/src/tenant/members/members.repository.ts
async findOne(id: number) {
  return this.prisma.member.findUnique({
    where: { id },                             // ← tenantId 없음
  });
}

async list(q: ListMembersDto) {
  const items = await this.prisma.member.findMany({
    where: { deletedAt: null },                // ← tenantId 없음
    skip: (q.page - 1) * q.limit,
    take: q.limit,
  });
  const total = await this.prisma.member.count({
    where: { deletedAt: null },                // ← tenantId 없음
  });
  return { items, total };
}

Repository 메서드 전체가 tenantId를 모르는 채로 Prisma 쿼리를 던지고 있었다. 컴파일러가 한 번도 잡아 주지 않은 누락 — 시그니처에 tenantId가 옵셔널도 필수도 아니라 아예 없었기 때문이다.

# Repository 8개 파일 통째로 grep
$ rg -n 'tenantId' apps/api/src/tenant/**/*.repository.ts
# (출력 없음)

8개 Repository 파일에 tenantId 문자열이 0건이었다. 멀티테넌트 도메인을 도입했다고 했지만, 데이터 계층에는 멀티테넌트의 흔적이 한 줄도 없었다.

🔍 단서: 컴파일은 통과하지만 보안 검사가 빠지는 코드는 보통 옵셔널 타입이거나 시그니처에 없는 파라미터에서 나온다. TypeScript는 없는 파라미터에 대해서는 한 번도 묻지 않는다. 멀티테넌트는 Repository 메서드 시그니처에 tenantId를 필수 파라미터로 강제하는 단계가 첫 번째 안전장치다.


🔬 진짜 범인 — 단일 테넌트 코드를 그대로 복사했다

세 계층이 동시에 비어 있는 이유가 풀렸다. 1차 구현 단계에서 기존 단일 테넌트 코드를 그대로 복사해서 멀티테넌트 모듈을 만들었는데, 복사하면서 tenantId 컨텍스트를 더해야 한다는 사실을 빠뜨렸다. 단일 테넌트 시절에는 tenantId라는 개념 자체가 없었으니까, 복사본도 같은 모양으로 멀쩡히 동작했다 — 한 테넌트만 본다면.

세 계층이 동시에 비면 결과는 cross-tenant 누수다.

[Browser]   GET /tenant/members/42  (A 토큰)

[Guard]     AuthGuard('tenant-jwt') 통과 (서명 OK)

[Strategy]  validate(payload) → { userId, role }     ← tenantId 버림

[Ctrl]      findOne(@Param('id') 42)

[Service]   this.repo.findOne(42)

[Repo]      prisma.member.findUnique({ where: { id: 42 } })

[DB]        row { id: 42, tenantId: 7 (B) }          ← 그대로 반환

[Response]  200 OK { data: { id: 42, tenantId: 7 } }

어느 한 계층만 막혔어도 누수는 안 났다. 전략이 tenantId를 채워 줬으면 컨트롤러가 받았을 거고, 컨트롤러가 넘겼으면 서비스가 받았을 거고, 서비스가 넘겼으면 Repository가 받았을 거다. 모두가 다른 누군가를 가정한 채 비어 있었던 게 진짜 원인이다.

페이지네이션 total 누수는 별도 지점에서 한 번 더 일어났다. findManycount가 각자의 where 객체를 들고 있어서, 한 쿼리만 멀티테넌트로 고쳐도 다른 쿼리는 계속 새는 구조였다.

// 회귀 시나리오 — findMany만 고치고 count를 빠뜨린 경우
async list(tenantId: number, q: ListMembersDto) {
  const items = await this.prisma.member.findMany({
    where: { tenantId, deletedAt: null },       // ← 고침
    skip: (q.page - 1) * q.limit,
    take: q.limit,
  });
  const total = await this.prisma.member.count({
    where: { deletedAt: null },                  // ← 빼먹음
  });
  return { items, total };                       // data: 80건, total: 1247
}

UI에서 한 페이지에 표시되는 80건 자체는 A 테넌트만이라 데이터가 잘 분리된 것처럼 보인다. 그런데 페이지 네비게이션은 전체 1,247건 기준으로 그려져서, “다음 페이지”를 누르면 빈 페이지가 한참 이어진다. 수치 검증 e2e가 없으면 한참 침묵하는 종류의 버그다.

⚠️ 주의: findManycountwhere를 따로 적으면 한쪽만 고쳐지는 회귀가 거의 무조건 발생한다. where 변수를 한 번 추출해서 두 쿼리가 같은 객체를 공유하게 하고, $transaction으로 묶는 패턴이 가장 안전하다.


🛠️ 해결 — 3계층 시그니처 강제 + $transaction 묶음

멀티테넌트 NestJS Guard 패턴 — JWT / Controller / Repository 3계층의 tenantId 흐름과 tenant-isolation 점검 게이트

단계 1: JWT 전략에서 tenantId 끝까지 끌어내기

validate 반환값에 tenantId를 포함시키고, payload에 없으면 거부하는 한 줄을 추가했다.

// ✅ After — apps/api/src/tenant/auth/jwt.strategy.ts
type TenantJwtPayload = {
  sub: number;
  tenantId: number;
  role: 'TENANT_OWNER' | 'TENANT_OPERATOR';
  iat: number;
  exp: number;
};

async validate(payload: TenantJwtPayload): Promise<TenantUser> {
  if (!payload.tenantId) {
    throw new UnauthorizedException('Missing tenant in token');
  }
  return {
    userId: payload.sub,
    tenantId: payload.tenantId,
    role: payload.role,
  };
}

payload 타입을 any에서 명시 타입으로 바꾼 게 핵심. 반환 타입도 TenantUser로 고정해서, 컨트롤러 측 @CurrentUser() 데코레이터가 같은 타입을 받도록 강제한다.

단계 2: @CurrentUser 데코레이터로 토큰만 신뢰

@CurrentUser()request.user를 꺼내는 한 줄짜리 헬퍼다. @Body@QuerytenantId는 절대 안 본다.

// ✅ After — current-user.decorator.ts
export const CurrentUser = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): TenantUser => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  },
);
// ✅ After — members.controller.ts
@Get()
list(@CurrentUser() user: TenantUser, @Query() q: ListMembersDto) {
  return this.members.list(user.tenantId, q);
}

@Get(':id')
findOne(
  @CurrentUser() user: TenantUser,
  @Param('id', ParseIntPipe) id: number,
) {
  return this.members.findOne(user.tenantId, id);
}

⚠️ 주의: @Body() bodytenantId는 절대 사용 금지. UpdateMemberDtotenantId?: number 같은 필드가 끼어 있으면 클라이언트가 수평 권한 상승을 일으킬 수 있다. DTO 정의에서 tenantId를 완전히 제외하고, class-validatorwhitelist: true로 DTO에 없는 필드가 자동 제거되도록 강제한다.

단계 3: Repository 시그니처에 tenantId 필수 파라미터

가장 중요한 단계. 메서드 이름 자체findOneByTenant / findManyByTenant로 바꿔서, 호출부가 tenantId를 빼고 부르기 어렵게 만들었다.

// ✅ After — members.repository.ts
@Injectable()
export class MembersRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findOneByTenant(tenantId: number, id: number) {
    const m = await this.prisma.member.findFirst({
      where: { tenantId, id, deletedAt: null },     // ← AND 조건
    });
    if (!m) throw new NotFoundException('Member not found');
    return m;
  }

  async findManyByTenant(tenantId: number, q: ListMembersDto) {
    const where: Prisma.MemberWhereInput = {
      tenantId,
      deletedAt: null,
      ...(q.search ? { name: { contains: q.search } } : {}),
    };

    const [items, total] = await this.prisma.$transaction([
      this.prisma.member.findMany({
        where,
        orderBy: { createdAt: 'desc' },
        skip: (q.page - 1) * q.limit,
        take: q.limit,
      }),
      this.prisma.member.count({ where }),          // ← 같은 where 공유
    ]);

    return { items, total };
  }
}

세 가지가 바뀌었다.

항목변경이유
메서드 이름findOnefindOneByTenanttenantId 누락 시 호출부 컴파일 에러
findUniquefindFirstunique 키 단독 매칭 폐기where: { tenantId, id } AND 조건
where 변수 추출 + $transactionfindMany/count가 같은 객체 공유total 누수 회귀 차단

findUnique는 unique 키(여기서는 id) 하나만으로 조회하는 메서드라, wheretenantId를 같이 넣을 수 없다. unique 매칭 결과 + 추가 필터가 아니라 복합 조건 매칭이 멀티테넌트의 표준이라 findFirst로 갈아끼웠다.

📌 핵심: Repository 메서드 이름에 ByTenant 접미사를 강제하면, 호출부에서 findOne(42)로 쓰는 순간 컴파일러가 *“이 메서드는 존재하지 않습니다”*라며 즉시 막아 준다. 의도하지 않은 cross-tenant 호출이 컴파일 단계에서 차단된다.

단계 4: PrismaService 직접 주입 차단

Repository가 아닌 곳(서비스·컨트롤러)에서 PrismaService를 직접 받아 쓰는 우회로를 막았다. ESLint 규칙 한 줄.

// .eslintrc.json (발췌)
{
  "overrides": [
    {
      "files": ["apps/api/src/tenant/**/!(*.repository).ts"],
      "rules": {
        "no-restricted-imports": ["error", {
          "paths": [{
            "name": "@/database/prisma.service",
            "message": "tenant 모듈에서는 Repository를 통해서만 Prisma에 접근하세요."
          }]
        }]
      }
    }
  ]
}

Repository를 tenantId 필수로 강제했으니, 그 외 경로를 닫아 두면 우회로가 거의 0이 된다.


✅ 검증 — curl 재시도와 cross-tenant e2e

세 단계를 모두 적용하고 BE를 재시작했다. 아침에 200 OK가 떨어졌던 세 호출을 다시 돌렸다.

# A 토큰으로 B 테넌트의 회원 ID 조회
$ curl -s http://localhost:3000/api/v1/tenant/members/42 \
    -H "Authorization: Bearer $TENANT_A_TOKEN" \
    -w '%{http_code}\n' -o /dev/null
404

# A 토큰으로 회원 목록 — total 확인
$ curl -s "http://localhost:3000/api/v1/tenant/members?page=1&limit=20" \
    -H "Authorization: Bearer $TENANT_A_TOKEN" | jq '.meta'
{
  "page": 1,
  "limit": 20,
  "total": 82          # ← A 테넌트 회원 수와 일치
}

# A 토큰으로 B 테넌트 그룹 ID 조회
$ curl -s http://localhost:3000/api/v1/tenant/groups/18 \
    -H "Authorization: Bearer $TENANT_A_TOKEN" \
    -w '%{http_code}\n' -o /dev/null
404

세 호출 모두 말이 되는 응답을 돌려줬다. 다른 테넌트 리소스에는 404, total 숫자는 A 테넌트 회원 수와 일치.

같은 시나리오를 e2e 테스트로 옮겼다.

// apps/api/test/tenant/tenant-isolation.e2e-spec.ts
describe('tenant-isolation', () => {
  it('회원 상세 — 다른 테넌트 토큰으로 조회 시 404', async () => {
    const memberB = await fixtures.createMember({ tenantId: fixtures.tenantB.id });
    const tokenA = tenantATokenSigner(1);

    const res = await request(app.getHttpServer())
      .get(`/api/v1/tenant/members/${memberB.id}`)
      .set('Authorization', `Bearer ${tokenA}`);

    expect(res.status).toBe(404);  // 403도 OK, 200은 절대 금지
  });

  it('회원 목록 total — A 테넌트만 카운트', async () => {
    await fixtures.createMembers(80, { tenantId: fixtures.tenantA.id });
    await fixtures.createMembers(1167, { tenantId: fixtures.tenantB.id });
    const tokenA = tenantATokenSigner(1);

    const res = await request(app.getHttpServer())
      .get('/api/v1/tenant/members?page=1&limit=20')
      .set('Authorization', `Bearer ${tokenA}`);

    expect(res.body.meta.total).toBe(80);   // 1247이면 회귀
  });

  it('JWT에 tenantId 없으면 401', async () => {
    const tokenWithoutTenant = jwt.sign(
      { sub: 1, role: 'TENANT_OWNER' },     // ← tenantId 누락
      process.env.JWT_TENANT_SECRET!,
    );

    const res = await request(app.getHttpServer())
      .get('/api/v1/tenant/members?page=1&limit=20')
      .set('Authorization', `Bearer ${tokenWithoutTenant}`);

    expect(res.status).toBe(401);
  });
});

세 테스트가 세 가지 위협을 각각 잠근다.

  • cross-tenant 직접 ID 조회 → 404
  • cross-tenant total 누수 → 80
  • JWT payload tenantId 누락 → 401
$ pnpm --filter api test:e2e tenant-isolation
 PASS  test/tenant/tenant-isolation.e2e-spec.ts (4.821 s)
  tenant-isolation
 회원 상세 다른 테넌트 토큰으로 조회 404 (312 ms)
 회원 목록 total A 테넌트만 카운트 (218 ms)
 JWT에 tenantId 없으면 401 (94 ms)

세 줄 초록. 6시간 전엔 빨갛게 200을 토했던 세 호출이 이제 e2e 회귀 영역으로 들어왔다.


🛡️ 예방 — ts-morph 정적 스캔 + CI 게이트

같은 누수를 다시 일으키지 않으려고 코드 작성 시점코드 실행 시점 양쪽에 검사를 추가했다.

정적 스캔 — ts-morph로 Repository AST 검사

가장 싼 검사는 코드 자체다. Repository 파일에서 Prisma 쿼리 메서드 호출에 tenantId 문자열이 함께 나오는지만 검사해도 주요 누락의 90%가 잡힌다.

// scripts/check-tenant-filter.ts
import { Project, SyntaxKind } from 'ts-morph';

const PRISMA_QUERY = /\.(findMany|findFirst|findUnique|count|update|updateMany|delete|deleteMany)\(/;
const project = new Project({ tsConfigFilePath: 'apps/api/tsconfig.json' });
const violations: string[] = [];

const files = project.getSourceFiles('apps/api/src/tenant/**/*.repository.ts');
for (const sf of files) {
  sf.forEachDescendant((node) => {
    if (node.getKind() !== SyntaxKind.CallExpression) return;
    const text = node.getText();
    if (!PRISMA_QUERY.test(text)) return;

    const hasTenantId = /tenantId/.test(text);
    const hasOptOut   = /@AllowCrossTenant/.test(node.getFullText());
    if (!hasTenantId && !hasOptOut) {
      const { line } = sf.getLineAndColumnAtPos(node.getStart());
      violations.push(`${sf.getBaseName()}:${line}  ${text.slice(0, 80)}...`);
    }
  });
}

if (violations.length > 0) {
  console.error('[tenant-isolation] tenantId 누락:');
  for (const v of violations) console.error('  - ' + v);
  process.exit(1);
}
console.log('[tenant-isolation] OK');

@AllowCrossTenant 주석으로 명시적 옵트아웃을 허용한다. 시스템 콘텐츠처럼 cross-tenant 조회가 진짜로 필요한 경우는 한 줄 주석을 추가하면 통과되고, 그 주석 자체가 코드 리뷰의 신호가 된다.

CI 게이트 — 정적 스캔과 e2e를 한 워크플로로 묶기

# .github/workflows/tenant-isolation.yml
name: tenant-isolation
on:
  pull_request:
    paths:
      - 'apps/api/src/tenant/**'
      - 'apps/api/src/database/**'
      - 'scripts/check-tenant-filter.ts'

jobs:
  isolation:
    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-tenant-filter.ts
      - name: cross-tenant e2e
        run: pnpm --filter api test:e2e tenant-isolation

PR이 tenant 디렉토리를 건드릴 때마다 두 검사가 자동으로 돈다. 한 줄짜리 누락도 PR 단계에서 빨갛게 잡힌다.

코드 리뷰 체크리스트

  • 새 Repository 메서드 이름에 ByTenant가 들어가 있는가?
  • Prisma 호출의 wheretenantId가 명시되어 있는가?
  • findUnique 대신 findFirst인가?
  • findMany + count가 같은 where를 공유하고 $transaction으로 묶였는가?
  • DTO에 tenantId 필드가 없는가? (body로 받지 않음)
  • 컨트롤러가 @CurrentUser()tenantId를 꺼내고 있는가?
  • cross-tenant e2e 테스트가 추가되어 있는가?

💡 인사이트: 멀티테넌트는 Repository 시그니처가 끝까지 강제하는 구조에서 닫힌다. 컨트롤러는 @CurrentUser()tenantId를 꺼내고, 서비스는 그대로 전달하고, Repository가 반드시 받아서 Prisma where에 넣도록 시그니처가 막아 주면, 멀티테넌트라는 사실은 서비스 계층에서 거의 안 보인다. 책임을 가운데에 분산하지 않고 데이터 계층에 모으는 게 회귀 비용을 가장 크게 줄인다.


📋 정리 — 핵심 요약

계층❌ 안티패턴✅ 권장 패턴
JWT validatereturn { userId, role } — tenantId 버림tenantId 포함 반환 + 누락 시 UnauthorizedException
tenantId 출처@Body() body.tenantId / @Query() q.tenantId@CurrentUser() user.tenantId — 토큰만 신뢰
Repository 메서드명findOne(id) / findMany(q)findOneByTenant(tenantId, id) / findManyByTenant(tenantId, q)
Prisma 조회findUnique({ where: { id } })findFirst({ where: { tenantId, id, deletedAt: null } })
페이지네이션 totalfindMany/count가 별도 wherewhere 변수 추출 + $transaction
DTOUpdateMemberDtotenantId?: numberDTO에서 완전 제외 + whitelist: true
Prisma 주입서비스/컨트롤러에 PrismaService 직접 injectESLint no-restricted-imports로 Repository 외 차단
회귀 차단한 테넌트 e2e만cross-tenant e2e + ts-morph 정적 스캔 + CI 게이트

숫자로 보는 삽질

  • 최초 누수 발견까지: 15분 (수동 cross-tenant curl)
  • 원인 파악까지: 약 1시간 (3계층 코드 정독)
  • 3계층 패치 + 검증: 약 4시간
  • CI 게이트 추가까지: 약 1시간
  • 이후 cross-tenant 회귀: 0건

멀티테넌트는 컨트롤러가 막아 주는 게 아니라 Repository 시그니처가 막아 준다. JWT payload에 tenantId를 넣고 @CurrentUser()로 끌어다 Repository 필수 파라미터로 강제하면, 가운데 서비스 계층은 멀티테넌트라는 사실을 거의 모르고도 안전하다. 멀티테넌트 SaaS의 첫 6시간은 이 시그니처 강제에 다 쓸 가치가 있다.

refine.dev

📚 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 권한 가드 — 목록은 막고 상세는 뚫린 날