[ 롤모임 운영일지 ] - 25. 승인된 회원이 화면에서 사라진다 — 격리가 과했던 쪽의 사고
10편에서 크로스테넌트 누출을 다뤘다. 다른 모임 데이터가 새어 들어온 사고였고, 멀티테넌시를 하면 가장 먼저 걱정하는 종류의 문제다.
9월 1일과 2일에 고친 건 그 정확한 반대편이다. 데이터가 새는 게 아니라, 있어야 할 사람이 조용히 사라지는 쪽. 이쪽이 더 오래 안 잡힌다. 누출은 보이면 바로 사고지만, 누락은 아무도 신고하지 않으면 그냥 “원래 그런 화면”이 되기 때문이다.
이틀 동안 제보 두 건을 쫓다가 서로 다른 원인 두 개를 찾았고, 같은 원인을 네 번 고친 뒤에야 전수 조사를 했다.
TL;DR
- 제보 1: “가입 승인 상태인데 유저 목록·평가·유저페이지 어디에도 안 보인다.” 승인 멤버 98명 중 3명이 그랬고, 원인은
User.isGuest가true로 남아 있던 것이었다. 멤버 화면들이 쓰는 승인 필터에isGuest = false가 들어 있다. - 제보 2: “이 사람이 평가 대상에서만 빠진다.” 원인은 테넌트 스코프였다. 멤버는 다른 모임에서 만들어진 User 프로필을 재사용할 수 있어서,
Membership은 이 모임인데User.groupId는 원적 모임일 수 있다.prisma.user로 읽으면 확장이groupId를 주입해 이런 멤버를 통째로 걸러낸다. - 같은 원인을 네 번 고쳤다 — 평가 대상 → 라이엇 갱신 크론 → 매치 수집 → 유저 상세 404. 매번 신고된 화면만 고치고 나머지를 훑지 않아서 반복됐다.
- 네 번째에 전수로 처리했다.
getSharedUser/getGroupMemberUser두 갈래로 정리해 28곳을 전환하고, 소스를 훑는 회귀 테스트를 붙였다. 그 테스트가 내가 놓친 4곳을 잡았다.
1. 제보 1 — 승인받았는데 없는 사람
“가입 승인 상태인데 어디에도 안 보인다”는 제보가 먼저였다. 어드민 화면에서는 멀쩡히 ‘승인’으로 보이니 더 헷갈렸다.
승인 멤버 98명을 훑어보니 같은 증상이 3명이었고, 원인은 하나였다. User.isGuest가 true로 남아 있었다.
멤버를 보여주는 화면들은 공통 승인 필터를 쓰는데 거기에 u."isGuest" = false가 들어 있다. 그래서 승인을 받아도 게스트 딱지가 붙어 있으면 목록·랭킹·평가 대상에서 전부 빠진다. Membership은 멀쩡하니 관리 화면에서는 아무 이상이 없어 보인다.
어떻게 그 상태가 되는지는 경로를 따라가니 분명했다.
- 운영진이 내전에 게스트로 참가시킨다 →
User(isGuest=true)생성 - 그 사람이 나중에 정식 가입하고 프로필을 설정한다
- 인증 코드가 같은 닉네임의 기존 User를 찾아 연결한다 →
isGuest는true그대로
실제로 3명 모두 내전 참가 기록(각각 5판·9판·3판)이 있었다. 게스트로 뛰다가 정식 가입한 사람들이다.
게스트→정식 전환 경로는 관리 화면의 수동 승격 하나뿐이었는데, 본인이 계정을 만들어 붙인 시점이면 이미 정식 멤버로 봐야 한다. 그래서 계정이 붙는 모든 경로에서 isGuest를 내리도록 바꿨다(인증 1곳 + 관리 4곳). 기존 3명은 같은 조건으로 복구했다.
승인 여부는 계속 Membership이 관리하므로, 미승인 상태면 여전히 안 보인다 — 게스트 딱지를 내리는 것이 승인을 대신하지는 않는다.
2. 제보 2 — 유저 목록엔 있는데 평가 대상에만 없다
같은 날 들어온 다른 제보는 증상이 더 좁았다. 한 멤버가 유저 목록과 티어 랭킹에는 나오는데 평가 대상에서만 빠진다는 것이었다.
원인은 이 구조의 전제 하나에 있었다. 멤버는 다른 모임에서 만들어진 User 프로필을 재사용할 수 있다. 그래서 이런 상태가 정상적으로 존재한다.
Membership.groupId = 1 ← 이 모임의 승인 멤버가 맞다
User.groupId = 4 ← 프로필이 처음 만들어진 원적 모임
그런데 User는 테넌트 모델 목록에 들어 있어서, prisma.user로 읽으면 Prisma 확장이 현재 모임의 groupId를 자동으로 주입한다. 평가 대상 조회가 그 일반 prisma를 쓰고 있었으니, 원적이 다른 멤버는 조회 결과에서 통째로 빠졌다.
앞뒤가 안 맞는 구조이기도 했다. 바로 윗줄의 승인 멤버 id 조회는 raw SQL이라 이미 확장을 우회하고 있었는데, 그렇게 얻은 id를 다시 테넌트가 걸린 쿼리에 넣고 있었다. 유저 목록은 같은 이유로 진작 basePrisma를 쓰고 있었고.
당시 group 1의 승인 멤버 98명 중 원적이 다른 사람은 1명이라, 딱 그 사람만 빠져 있었다. 평가에서 빠지면 ‘받은 평가 0건’이 되므로 랭킹과 프로필까지 계속 영향이 갔다.
3. 같은 원인, 두 번째 — 크론에서는 21명이 빠졌다
평가를 고치고 나서 “이 사람 전적이 안 나온다”를 쫓다가, 같은 원인이 훨씬 넓게 퍼져 있다는 걸 알았다.
크론(refresh-ranks / refresh-matches / refresh-missions)과 관리자 수동 갱신이 전부 prisma.user.findMany()를 쓰고 있었다. 크론에는 요청이 없으니 모임 컨텍스트도 없고, 그러면 확장이 기본값인 groupId=1을 주입한다. 원적이 다른 모임인 유저는 갱신 대상 목록에 아예 못 들어간다.
실측(2026-09-01)이 그대로 드러났다.
| 원적 모임 | 인원 | 최근 3시간 내 갱신 | 마지막 갱신 |
|---|---|---|---|
| 기본 모임 | 110명 | 110명 | 정상 |
| 다른 모임 A | 20명 | 0명 | 39일 전 |
| 다른 모임 B | 1명 | 0명 | 53일 전 |
21명의 전적이 한 달 넘게 멈춰 있었는데 아무도 신고하지 않았다. 신고한 사람은 그중 한 명뿐이었다.
판단 기준은 이 사건에서 하나로 정리됐다. Riot 프로필(puuid·티어·전적)은 모임과 무관한 공유 데이터다. 크론은 전역 작업이므로 basePrisma로 전 모임을 훑어야 한다. 반대로 모임 데이터(내전 티어·점수·판수)는 모임별로 분리된 테이블에 있으니 테넌트 스코프가 걸린 prisma가 맞다.
4곳을 교체하니 갱신 대상이 110명 → 131명으로 늘었다(+19%). Riot 호출 간격은 기존 로직이 관리하므로 호출 폭증은 없었다.
4. 세 번째, 네 번째 — 그리고 전수 조사
이후에도 같은 원인이 두 번 더 나왔다. 매치 수집에서 한 번, 그리고 유저 상세 페이지가 404가 나는 것으로 한 번.
GET /api/g/default/users/349 → 404
유저 349는 group 1의 승인 멤버인데 User.groupId=4라, prisma.user.findUnique에 확장이 groupId=1을 주입해 조회가 비었다. 상세 페이지 본문, 방문 수 조회·기록, 상세 페이지의 평가 탭까지 네 경로가 같이 404였다.
여기서 멈추고 인정할 게 있었다. 같은 원인을 네 번째 고치고 있었다. 평가 대상 → 라이엇 갱신 크론 → 매치 수집 → 유저 상세. 매번 신고된 화면만 고치고 나머지를 훑지 않아서 반복된 것이다.
그래서 이번엔 전수로 정리했다. 헷갈리는 이유는 “언제 테넌트를 걸어야 하는가”가 상황마다 다르기 때문이라, 두 갈래로 이름을 나눴다.
// packages/api/src/lib/members.ts
getSharedUser(id) // id 가 이미 이 모임 데이터에서 나온 경우 — 공유 프로필 읽기
getGroupMemberUser(id) // 클라이언트가 준 id — 이 모임 멤버인지 확인해야 한다
둘 다 테넌트 확장을 우회하지만 의미가 다르다. 전자는 “이 id는 이미 검증된 자리에서 왔다”는 뜻이고, 후자는 “외부에서 들어온 id이므로 멤버십으로 확인한다”는 뜻이다. 후자 덕분에 다른 모임 유저가 새어 들어오지는 않는다 — 확인해 보니 group 4 전용 멤버 21명은 group 1 조회에 잡히지 않았다.
총 28곳을 전환했다. 닉네임이나 puuid처럼 id가 아닌 키로 찾는 곳은 이 문제와 무관해서 그대로 뒀다.
데이터는 고치지 않았다. User.groupId와 멤버십이 다른 건 이 구조에서 정상 상태이고(전 모임에 5명 있다), 고쳐야 할 건 조회하는 쪽이다.
5. 소스를 훑는 테스트 — 내가 놓친 4곳을 잡았다
전수 조사를 사람이 했다는 건, 다음에 또 사람이 해야 한다는 뜻이다. 그래서 테스트로 막았다. 이건 동작을 검증하는 테스트가 아니라 소스 코드를 정규식으로 훑는 테스트다.
// packages/api/src/lib/tenant-user-lookup.test.ts
/**
* 회귀 방지 — User 를 id 로 찾을 때 테넌트 스코프를 타면 안 된다.
*
* 배경: `User` 는 `tenantModels` 에 있어 `prisma.user` 로 읽으면 groupId 가 주입되는데,
* 실제로는 **모임 사이에 공유되는 행**이다(멤버가 다른 모임에서 만들어진 User 를 재사용).
* 그래서 `prisma.user.findUnique({ where: { id } })` 는 원적이 다른 멤버를 조용히 빠뜨린다.
*
* 이 버그로 2026-09-01~02 에 같은 원인을 네 번 고쳤다:
* 평가 대상 → 라이엇 갱신 크론 → 매치 수집 → 유저 상세 페이지(404).
* 매번 "신고된 화면"만 고치고 나머지를 훑지 않아서 반복됐다. 그래서 테스트로 막는다.
*/
const PATTERN = /prisma\.user\.(findUnique|findFirst)\(\s*\{\s*where:\s*\{\s*id:/g;
/**
* 예외 — 테넌트 스코프가 **의도**인 곳.
* ⚠️ 여기 추가하기 전에 "이 조회가 다른 모임 원적 멤버를 빠뜨려도 괜찮은가"를 먼저 답할 것.
*/
const ALLOW = new Set<string>([
// 어드민이 계정을 강제 연동할 때. 현재 모임 User 로 한정하는 게 맞다.
"routes/admin.ts",
]);
이 테스트를 돌리자 내가 손으로 훑고도 놓친 4곳(통계·인증·평가·연승 배팅)이 잡혔다. 28곳을 눈으로 확인했는데도 4곳이 남아 있었다는 게 이 방식의 근거다.
예외 목록을 ALLOW 하나로 두고 주석에 판단 기준을 적어 둔 것도 의도적이다. 예외를 추가하려면 “이 조회가 다른 모임 원적 멤버를 빠뜨려도 괜찮은가”에 먼저 답해야 한다.
6. 이중 모임 — 승인 상태는 모임마다 다르다
전수 정리를 하다 보니 한 가지를 더 반영해야 했다. 한 사람이 여러 모임에 속할 수 있고, 승인 상태는 모임마다 다르다. 실제로 A모임 대기 + B모임 승인 + C모임 승인 같은 조합이 있다.
그래서 getGroupMemberUser의 기본값을 승인 멤버만으로 두고, 대기 멤버는 어드민에게만 보이게 했다(유저 목록의 ?includeAll과 같은 규칙이다). 모임별 데이터인 티어·점수·판수는 이미 groupId로 분리된 테이블에 있어서 현재 모임 값이 그대로 나온다 — 여기는 건드릴 게 없었다.
7. 지금 상태 / 배운 것
- 9월 1~2일에 걸쳐 원인 두 개(게스트 딱지 잔류 · 테넌트 스코프)를 고쳤고, 후자는 28곳 전수 전환 + 회귀 테스트로 닫았다.
User.groupId ≠ Membership.groupId는 버그가 아니라 이 구조의 정상 상태라는 걸 코드 주석과 테스트에 명시했다.
배운 건 이거다. 누출과 누락은 같은 경계에서 생기는 반대 방향의 사고인데, 발견되는 속도가 전혀 다르다. 10편의 누출은 보자마자 사고인 걸 알았고 당일에 닫혔다. 이번 누락은 21명의 전적이 39일 동안 멈춰 있어도 아무도 모르고 있었다. 사용자는 화면에 없는 것을 신고하지 못한다.
그리고 하나 더. 같은 원인을 두 번 고쳤다면 그때 멈추고 전수 조사를 해야 한다. 나는 네 번째에야 멈췄고, 그 판단을 미룬 대가가 이틀이었다. 네 번째에 짠 테스트는 30분이 걸렸는데, 그걸 두 번째에 짰다면 나머지 두 번은 없었을 것이다.
댓글
아직 댓글이 없어요. 첫 댓글을 남겨보세요.