02편에서 “설계 문서는 Postgres RLS를 원했지만 실제로는 Prisma Client Extension으로 테넌트 격리를 구현했고, 이후 크로스그룹 누출이 몇 차례 났다”고 짧게 언급했었다. 이번 편은 그 사고의 실제 내용이다. 같은 날 아침 20분 사이에 연달아 발견되고 고쳐진 세 커밋 이야기다.

Tier 1 — raw SQL은 자동 격리를 안 받는다

멀티테넌시용 Prisma Extension은 findMany, findFirst 같은 Prisma Client의 메서드를 가로채서 groupId를 자동으로 끼워 넣는 방식이다. 그런데 prisma.$queryRaw로 직접 짠 SQL은 이 가로채기 대상이 아니다 — Extension이 붙는 지점 자체가 다르기 때문에, raw SQL을 쓰는 순간 격리는 개발자가 직접 챙겨야 하는 일이 된다.

이 원칙 하나를 놓친 곳이 두 군데 있었다.

  • 확성기(megaphone) GET /active: raw SQL로 활성 확성기를 조회하면서 groupId 조건이 빠져 있었다. 어느 모임에 들어가든 group 1(원조 모임)의 확성기 배너가 그대로 보였다.
  • 경매 fetchEvalsByUser: 역시 raw SQL로 유저 평가 점수/코멘트를 조회하는데, 경매 화면에서 다른 모임의 평가 데이터가 섞여 나왔다.

수정은 각 쿼리에 AND "groupId" = ${getGroupId() ?? 1} 한 줄을 추가하는 것으로 끝났다. 문제는 이런 raw SQL이 코드베이스 어디에 몇 개나 더 있는지 매번 찾아야 한다는 점이다.


Tier 2 — 권한도 모임별로 스코프해야 한다

세부 권한(AccountPermission: 평가자, 배팅 관리자, 경매 진행자, 멘토 등)도 groupId 없이 저장되고 있었다. 즉 한 모임에서 평가자 권한을 받은 사람이 다른 모임에서도 그 권한을 그대로 갖게 되는 구조였다. groupId 컬럼을 추가하고 (accountId, permission, groupId) 복합 유니크로 바꿨다.


Tier 3-5 — 돈, 파이프라인, 알림

가장 큰 파장은 여기였다. BetCoin.userId가 시스템 전체에서 유니크였다 — 즉 유저 한 명당 지갑이 모임과 무관하게 딱 하나만 존재했다. 경매·상점 구매·복권 등 BetCoin 잔액을 바꾸는 raw SQL이 전부 userId만으로 그 지갑을 찾고 있었으니, 다른 모임에서 발생한 배팅이나 구매가 같은 유저의 (모임 구분 없는) 지갑 잔액을 그대로 건드릴 수 있는 구조였다.

고치는 방법도 신경 썼다. 유니크 제약을 userId 단독에서 (groupId, userId) 복합으로 바꾸는 작업을, 배포 창에서도 안전하도록 2단계로 나눴다.

  1. 1단계(additive): 기존 유니크 인덱스는 그대로 둔 채, 새 복합 유니크 인덱스를 나란히 만든다.
  2. 2단계(drop old): 새 백엔드가 배포된 걸 확인한 뒤에야 기존 인덱스를 제거한다.

1단계와 2단계 사이에 일부러 간격을 둔 이유가 명확했다 — 이 순서를 지키지 않고 옛 인덱스를 먼저 지워버리면, 아직 재배포 전인 이전 코드가 그 인덱스를 참조하고 있어서 그 자체로 장애가 난다.

여기에 더해:

  • 파이프라인(cron): cron 작업은 요청 컨텍스트가 없어서 getGroupId()가 항상 null이다. 티어 재계산·미션 진행도·베팅 정산 같은 cron 로직이 전부 모임 구분 없이 시스템 전체를 한 번에 처리하고 있었다. forEachGroup() 헬퍼를 만들어 각 모임 컨텍스트(runWithGroup(groupId, fn))로 감싸 모임별로 따로 돌게 고쳤다.
  • 알림: MVP 확정 cron이 보너스 지급과 푸시 알림을 보내는 로직도 같은 이유로 모임 컨텍스트 없이 실행되고 있어서 같은 방식으로 감쌌다.


하루 뒤 — 랭킹 페이지에서도 새어 나왔다

다음 날, 랭킹 페이지의 승/패 집계 쿼리에서도 같은 클래스의 버그가 발견됐다 — groupId 필터 없이 전체 모임의 경기 수를 합산하고 있었다. 유저 화면에 직접 보이는 버그였다는 점에서 앞의 것들보다 더 무거웠다.

같은 커밋에서 구조적인 문제도 하나 더 발견됐다: 새 모임을 만들 때 초기 시즌(Season) row 자체를 생성하지 않고 있었다. 그래서 새로 생긴 모임 2~5번은 랭킹·시즌 리포트·시즌 보상이 아예 없는 상태였고, 뒤늦게 수동으로 백필해야 했다.


정리

  • 02편에서 언급했던 우려가 정확히 이 형태로 실현됐다. Postgres RLS였다면 DB가 강제로 막아줬을 실수를, Prisma Extension 구조에서는 “raw SQL을 쓰는 모든 곳에 groupId를 빠짐없이 챙기는” 사람의 주의력에 의존해야 했다.
  • 유니크 제약을 바꾸는 마이그레이션은 순서가 생명이다. additive(새 인덱스 추가) → 배포 확인 → destructive(옛 인덱스 제거) 순서를 지킨 덕에, 이미 무거운 사고였던 이 작업이 배포 중단 사고로까지 번지진 않았다.
  • cron처럼 요청 컨텍스트가 없는 코드는 테넌시 격리에서 잊히기 쉽다. getGroupId()null을 반환하는 게 “격리 안 해도 됨”이 아니라 “직접 반복문으로 처리해야 함”이라는 신호라는 걸, 이 사고를 겪고 나서야 코드 전체에 일관되게 적용했다.
  • 버그를 고치다 보면 구조적 결함(새 모임에 초기 시즌이 없던 문제)도 같이 드러난다. 이런 건 발견 즉시 백필하고, 근본 원인(생성 로직 누락)도 같이 고쳐야 다음 모임에서 반복되지 않는다.