# 사용자 가시성 감사 보고서

작성 시각: 2026-06-08 16:55 KST

관련 이슈:
- #1035: adminfront 사용자 레지스트리 total이 Kratos 250건 제한으로 잘못 표시됨
- #1036: 사용자 가시성 영향 범위 검증 및 WORKS 비교 표 row count 표시

## 결론

`총 250명` 표시는 단순 UI 문제가 아니라, Kratos partial list를 full snapshot처럼 처리한 legacy sync 버그였습니다.

현재 로컬 DB와 API는 다음 상태입니다.

| 항목 | 건수 | 설명 |
| --- | ---: | --- |
| users 전체 | 2,114 | `deleted_at` 포함 전체 row |
| visible users | 1,917 | `deleted_at IS NULL`, adminfront/orgfront 사용자 목록 기준 |
| soft-deleted users | 197 | 사용자 삭제 또는 과거 legacy sync 문제로 숨겨진 row |
| CSV 원본 줄 수 | 1,887 | 헤더 포함 |
| CSV 실제 데이터 행 | 1,886 | 헤더 제외 |
| 이번 import 사용자 | 1,886 | 모두 DB 매칭, 모두 visible |
| import ID에 없는 기존 사용자 | 228 | visible 31, soft-deleted 197 |

따라서 “기존 221명 + 신규 1887명” 계산은 그대로 적용하면 안 됩니다.

- `1887`은 CSV 헤더 포함 줄 수이고 실제 신규 데이터 행은 `1886`입니다.
- 현재 DB에서 import ID에 없는 기존 사용자는 `228`명입니다.
- 그중 `197`명은 soft-delete 상태라 adminfront/orgfront visible total에는 포함되지 않습니다.
- 현재 화면 기준 총 사용자는 `신규 visible 1886 + 기존 visible 31 = 1917`입니다.

## 생성한 대조 파일

보고 파일 위치:

- `reports/user-visibility-audit-20260608-1645/existing_users_not_in_saman_import.csv`
- `reports/user-visibility-audit-20260608-1645/imported_users_missing_or_soft_deleted.csv`

파일 내용:

- `existing_users_not_in_saman_import.csv`: 이번 CSV import ID에 없는 기존 사용자 228명 전체 목록입니다.
- `imported_users_missing_or_soft_deleted.csv`: 이번 import 사용자 중 DB 누락 또는 soft-delete 상태인 사용자 목록입니다. 현재는 헤더만 있고 데이터 row는 0건입니다.

## 삭제 정책 확인

adminfront 사용자 삭제는 다음 순서로 동작합니다.

1. Kratos identity 삭제를 시도합니다.
2. WORKS 연동 범위 사용자이면 WORKS delete outbox를 enqueue합니다.
3. 로컬 `users` row는 GORM `Delete`로 soft-delete 합니다.

따라서 adminfront에서 사용자 삭제를 했다고 로컬 DB row가 hard-delete 되는 구조는 아닙니다. `users.deleted_at`에 값이 들어가고, 일반 조회에서는 제외됩니다.

예외적으로 사용자 생성/재생성 시 email unique 충돌을 풀기 위한 `Unscoped` hard-delete 경로가 일부 존재합니다. 이 경로는 일반 사용자 삭제 정책과 다릅니다.

## 영향 범위 검증

### adminfront 사용자 레지스트리

- `GET /api/v1/admin/users?limit=50&offset=0`
- 응답 total: `1917`
- adminfront `/users` 화면 문구: `총 1917명의 사용자가 등록되어 있습니다.`

### orgfront 사용자 소비 경로

현재 orgfront 조직도와 picker는 `fetchUsers(5000, 0)` 형태로 사용자 목록을 한 번 가져옵니다.

검증 결과:

- `GET /api/v1/admin/users?limit=5000&offset=0`
- items: `1917`
- total: `1917`
- soft-deleted 기존 사용자 197명 중 응답 포함: `0`

현재 visible 사용자가 5,000명 미만이라 orgfront의 현 방식은 전체 visible 사용자를 모두 받습니다. 다만 사용자가 5,000명을 넘으면 partial 조회가 될 수 있으므로 cursor 기반 전환이 필요합니다.

### WORKS comparison API와 화면

WORKS comparison은 Baron visible 사용자뿐 아니라 WORKS에만 존재하는 remote row도 함께 보여주는 비교 화면입니다. 따라서 사용자 목록 visible total과 1:1로 일치하지 않습니다.

`includeMatched=true` 기준 최신 API 결과:

| 구분 | 전체 | matched | missing_in_worksmobile | missing_external_key | missing_in_baron |
| --- | ---: | ---: | ---: | ---: | ---: |
| users | 2,110 | 1,874 | 41 | 6 | 189 |
| groups | 187 | 185 | 1 | - | 1 |

WORKS 화면 row count 표시:

- 구성원: `표시 232 / 전체 2110`
- 조직/그룹: `표시 2 / 전체 187`

구성원 `표시 232`는 기본 필터와 보호 계정 제거가 적용된 화면 노출 row 수입니다. `전체 2110`은 API comparison row 전체입니다.

soft-deleted 기존 사용자 197명과 WORKS comparison `baronId`를 대조한 결과:

- soft-deleted Baron row 포함: `0`

## 왜 업데이트가 많았는가

이번 문제는 단일 UI 카운트 문제가 아니라 다음 경계가 한꺼번에 얽힌 문제였습니다.

1. Kratos identity list는 partial list인데 legacy backend sync가 full snapshot으로 처리했습니다.
2. legacy sync가 Backend DB soft-delete까지 수행해 사용자 가시성 자체를 손상시켰습니다.
3. adminfront 사용자 목록, orgfront 조직도, WORKS comparison이 모두 사용자 데이터를 다른 방식으로 소비하고 있었습니다.
4. WORKS comparison은 사용자 목록이 아니라 Baron/WORKS 양쪽 차이를 보여주는 비교 화면이라 total 의미가 달랐습니다.
5. 운영자가 partial data인지 바로 볼 수 있도록 WORKS 표 row count가 필요했습니다.

## pagination 정리

현재 구조는 다음과 같습니다.

- Ory/Kratos -> Backend identity mirror warmup: `ListIdentities()` partial 조회를 전체 snapshot으로 취급하면 안 됩니다. 전체 수집은 pagination을 끝까지 따라가야 합니다.
- Backend -> adminfront 사용자 목록: `cursor`가 있으면 cursor pagination, 없으면 offset pagination을 받습니다. adminfront는 infinite query로 `nextCursor`를 사용합니다.
- Backend -> orgfront 조직도/picker: Redis orgchart snapshot 또는 Backend cursor API를 사용해야 하며 `limit=5000&offset=0` 단일 offset 조회는 금지합니다.
- WORKS comparison: backend가 비교 결과 배열을 만들어 내려주고, adminfront가 검색/필터 후 화면 row를 표시합니다.

## 재발 방지 조치

- 사용자 목록 API는 Backend가 Ory-warmed Redis cache와 허용된 read model을 조합해 cursor로 제공합니다.
- Kratos partial list에 없는 사용자를 legacy sync에서 삭제하지 않도록 수정했습니다.
- WORKS comparison에서 soft-deleted local user가 들어와도 comparison row로 노출되지 않도록 방어 테스트와 로직을 추가했습니다.
- WORKS comparison 표에 `표시 N / 전체 M` row count를 표시했습니다.
