# 설계서: 백엔드 - 사용자 관리 (#267) > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-15 > **추적성** — Redmine: #267 · 관련 ADR: 없음 > · 구현 파일: `backend-java/src/main/java/com/tasteby/service/UserService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminUserController.java`, `backend-java/src/main/java/com/tasteby/mapper/UserMapper.java`, `backend-java/src/main/resources/mybatis/mapper/UserMapper.xml` · 테스트: TBD (현재 없음) ## 1. 목적 (Why) 관리자(`is_admin=1`)가 가입 사용자 목록과 각 사용자의 활동(즐겨찾기·리뷰·메모)을 조회하고, 관리자 권한을 부여/회수할 수 있도록 한다. 또한 인증(#266) 흐름에서 호출되는 사용자 upsert/조회의 단일 책임 지점을 제공한다. ## 2. 범위 (Scope) - **포함** - 사용자 upsert/조회 도메인 서비스 (`UserService.findOrCreate`, `findById`). - 관리자 전용 사용자 목록 조회(페이징, 활동 카운트 포함) — `GET /api/admin/users`. - 사용자별 즐겨찾기/리뷰/메모 조회 — `GET /api/admin/users/{userId}/{favorites|reviews|memos}`. - 관리자 권한 토글 — `PATCH /api/admin/users/{userId}/admin`. - **제외 (out of scope)** - 회원 탈퇴/익명화, 개인정보 수정 API. - 일반 사용자가 자기 프로필을 수정하는 API. - 사용자 자체 검색(이름/이메일). - 즐겨찾기/리뷰/메모의 CRUD (각 도메인 서비스 책임). - Google 토큰 검증 (#266 책임). ## 3. 인수조건 (Acceptance Criteria) - [x] 관리자 토큰으로 `GET /api/admin/users?limit=&offset=` 호출 시 `{ users:[…], total:n }` 구조와 각 사용자의 `favoriteCount/reviewCount/memoCount`가 포함된다. - [x] `/api/admin/users/**` 모든 엔드포인트(GET 4종 + PATCH)는 진입 시 `AuthUtil.requireAdmin()`을 호출하여 비관리자 토큰에 대해 403을 반환한다. (보안 핫픽스 2026-06-15) - [ ] `findOrCreate`는 `(provider, providerId)`로 기존 사용자가 있으면 `last_login_at`만 갱신하고, 없으면 신규 PK로 INSERT한다. - [ ] `PATCH /api/admin/users/{userId}/admin {admin: true|false}` 호출 시 자기 자신의 권한을 변경하면 400을 반환한다. - [ ] 존재하지 않는 사용자 ID로 `updateAdmin` 호출 시 404를 반환한다. - [ ] 관리자 권한 변경 후 응답에 `{ success:true, user_id, is_admin }`이 포함되고, 서버 로그에 변경자/대상/값이 기록된다. ## 4. 컨텍스트 & 제약 - **의존성** - Oracle 23ai 테이블: `tasteby_users`, `user_favorites`, `user_reviews`, `user_memos`. - MyBatis (`UserMapper.xml`) — resultMap으로 UPPERCASE 컬럼 → 도메인 매핑. - `ReviewService`, `MemoService` — 사용자별 활동 조회 위임. - `AuthUtil.requireAdmin()` — JWT의 admin 클레임 검사. - **제약** - 모든 `/api/admin/**` 엔드포인트는 관리자만 호출 가능. - 페이징 기본 `limit=50, offset=0`. 상한 강제 없음(향후 캡 고려). - `is_admin`은 Oracle NUMBER(0/1) → Java boolean (`@JsonProperty("is_admin")`). - 트랜잭션: upsert 및 권한 변경은 `@Transactional`. - **가정** - `UserInfo.id`는 `IdGenerator.newId()` 32-char hex로 사전 발급. - `findAllWithCounts`는 LEFT JOIN으로 활동 미존재 시 0 반환. ## 5. 아키텍처 개요 - **모듈/파일** - `controller/AdminUserController.java` — 관리자 전용 엔드포인트. - `service/UserService.java` — upsert·조회·권한 변경. - `mapper/UserMapper.java` + `resources/mybatis/mapper/UserMapper.xml` — SQL 매핑. - `domain/UserInfo.java` — Lombok DTO. - **데이터 흐름** ``` [Admin Client] │ GET /api/admin/users?limit&offset (Bearer JWT, is_admin=true) ▼ AdminUserController.listUsers ├─ UserService.findAllWithCounts(limit, offset) │ └─ UserMapper.xml: SELECT u.* + LEFT JOIN (fav/rev/memo COUNT) └─ UserService.countAll ▼ { users:[UserInfo], total } [Admin Client] │ PATCH /api/admin/users/{id}/admin {admin} ▼ AdminUserController.updateAdmin ├─ AuthUtil.requireAdmin() (자기 자신 변경 거부) └─ UserService.updateAdmin → UserMapper.updateAdmin ▼ { success, user_id, is_admin } [AuthService (#266)] ▼ UserService.findOrCreate ├─ findByProviderAndProviderId → updateLastLogin └─ insert + findById ``` - **I/O ↔ 순수 로직 경계** - I/O: MyBatis Mapper(DB). - 순수: 권한 변경 시 자기 자신 검사, boolean→int 변환. ## 6. 데이터 모델 - **`UserInfo`** (`domain/UserInfo.java`) - `id: String`, `email: String`, `nickname: String`, `avatarUrl: String`, `admin: boolean(@JsonProperty("is_admin"))`, `provider: String`, `providerId: String`, `createdAt: String`, `favoriteCount/reviewCount/memoCount: int`. - **저장(`tasteby_users`)** - `id PK(32)`, `provider VARCHAR`, `provider_id VARCHAR`, `email`, `nickname`, `avatar_url`, `is_admin NUMBER(1)`, `created_at TIMESTAMP`, `last_login_at TIMESTAMP`. - 유니크(가정): `(provider, provider_id)`. - **입력** - `GET /api/admin/users`: query `limit:int(=50)`, `offset:int(=0)`. - `PATCH /api/admin/users/{userId}/admin`: body `{ admin: boolean }`. - **출력** - 목록: `{ users: UserInfo[], total: int }`. - 사용자 즐겨찾기/리뷰/메모: `Restaurant[]`, `Review[]`, `Memo[]`. - 권한 변경: `{ success:true, user_id, is_admin }`. - **경계 검증** - `limit/offset` 정수, 음수 가드 없음 — 호출자 신뢰. Oracle FETCH NEXT가 0/음수 시 결과 0건. - body.admin이 null → `Boolean.TRUE.equals(null)` = false 처리. ## 7. 함수 명세 (Function Specs) ### UserService (public) | 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? | |------|-----------|----------|------|------|-----------|-------| | `UserService(UserMapper)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 | | `findOrCreate` | provider+providerId로 사용자 upsert + 마지막 로그인 갱신 | `UserInfo findOrCreate(String provider, String providerId, String email, String nickname, String avatarUrl)` | 5개 문자열 | `UserInfo` | DB 예외 → 500 | **복잡** (분기 + 트랜잭션) | | `findById` | PK로 단건 조회 | `UserInfo findById(String userId)` | userId | `UserInfo` or null | DB 예외 → 500 | 단순 | | `findAllWithCounts` | 활동 카운트 포함 페이징 목록 | `List findAllWithCounts(int limit, int offset)` | 페이징 | 사용자 리스트 | DB 예외 → 500 | 단순 | | `countAll` | 전체 사용자 수 | `int countAll()` | 없음 | int | DB 예외 → 500 | 단순 | | `updateAdmin` | 관리자 플래그 변경 | `void updateAdmin(String userId, boolean admin)` | userId, boolean | void | 미존재 → 404 | 단순 | ### UserMapper (public, MyBatis) | 함수 | 책임 | 시그니처 | 출력 | |------|------|----------|------| | `findByProviderAndProviderId` | provider+providerId 조회 | `UserInfo findByProviderAndProviderId(String provider, String providerId)` | `UserInfo`/null | | `updateLastLogin` | last_login_at = SYSTIMESTAMP | `void updateLastLogin(String id)` | void | | `insert` | 신규 사용자 INSERT | `void insert(UserInfo user)` | void | | `findById` | PK 조회 | `UserInfo findById(String id)` | `UserInfo`/null | | `findAllWithCounts` | 활동 COUNT 조인 페이징 | `List findAllWithCounts(int limit, int offset)` | 목록 | | `countAll` | 전체 카운트 | `int countAll()` | int | | `updateAdmin` | `is_admin` UPDATE | `int updateAdmin(String id, int admin)` | 영향 행 수 | ### AdminUserController (public) | 함수 | 책임 | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? | |------|------|----------|------|------|-----------|-------| | `AdminUserController(...)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 | | `listUsers` | `GET /api/admin/users` | `Map listUsers(int limit=50, int offset=0)` | 페이징 | `{users,total}` | 인증 실패 → 401/403 | 단순 | | `userFavorites` | `GET …/{userId}/favorites` | `List userFavorites(String userId)` | userId | 즐겨찾기 식당 | 인증/위임 | 단순 | | `userReviews` | `GET …/{userId}/reviews` | `List userReviews(String userId)` | userId | 리뷰 100건 | 인증/위임 | 단순 | | `userMemos` | `GET …/{userId}/memos` | `List userMemos(String userId)` | userId | 메모 | 인증/위임 | 단순 | | `updateAdmin` | `PATCH …/{userId}/admin` | `Map updateAdmin(String userId, Map body)` | userId, `{admin}` | `{success,user_id,is_admin}` | 자기 자신 → 400, 미존재 → 404 | **복잡** (정책 분기) | ## 8. 흐름 / 알고리즘 **A. Upsert (`findOrCreate`)** 1. `findByProviderAndProviderId(provider, providerId)` 호출. 2. 존재 → `updateLastLogin(id)` → `findById(id)` 반환. 3. 미존재 → `IdGenerator.newId()`로 PK 발급 → `UserInfo` 빌드 → `insert` → `findById(newId)` 반환. 4. 전체 트랜잭션(`@Transactional`)으로 묶여 부분 실패 시 롤백. **B. 관리자 목록 (`listUsers`)** 1. 컨트롤러에서 limit/offset 기본값 적용. 2. `findAllWithCounts`로 `LEFT JOIN ( user_favorites|user_reviews|user_memos GROUP BY user_id )` + `ORDER BY created_at DESC OFFSET ? FETCH NEXT ?`. 3. `countAll`로 전체 수 합산하여 `{ users, total }` 반환. **C. 권한 변경 (`updateAdmin`)** 1. `AuthUtil.requireAdmin()` — JWT의 admin 클레임 미보유 시 403. 2. `userId == currentUser.subject` → 400 ("자기 자신의 관리자 권한은 변경할 수 없습니다"). 3. body.admin → boolean (null=false). 4. `UserService.updateAdmin` → Mapper에서 `UPDATE … WHERE id = ?`. 5. 영향 행 0 → 404, 1 → 감사 로그 `[ADMIN] User {} set admin={} for user {}` 출력 후 성공 응답. ## 9. 엣지케이스 & 에러 처리 - **자기 자신 권한 변경**: 명시적으로 400으로 차단(마지막 관리자 사고 방지). - **존재하지 않는 사용자 권한 변경**: Mapper 영향 행 0 → 404. - **음수 limit/offset**: Oracle은 OFFSET 0 ROWS FETCH NEXT 음수 시 결과 0건. 클라이언트 신뢰. - **활동 카운트 0**: `NVL(…, 0)`으로 0으로 노출. - **`findById` race(인증 직후 삭제)**: null 반환 → 호출자(AuthService)에서 404. - **email/nickname null** (Google에서 일부 제공 안 함): 컬럼 NULL 허용 가정. UI에서 빈 값 처리. - **권한 변경 동시성**: 트랜잭션 + 단일 UPDATE이므로 마지막 쓰기 승리(last-write-wins). 감사 로그로 추적. - **안전한 기본값**: 권한 변경 실패 시 변경 없음. ## 10. 테스트 계획 - **현 상태**: 자동화 테스트 없음 (TBD). - **단위 테스트** (Mockito) - `UserService.findOrCreate` - 기존 사용자 → `updateLastLogin` + `findById` 호출 검증. - 신규 사용자 → `insert` + `findById(newId)` 호출 검증. - `UserService.updateAdmin` - 영향 행 1 → 정상. - 영향 행 0 → 404 예외. - **컨트롤러 통합 테스트** (MockMvc + `@MockBean`) - `GET /api/admin/users` 정상/페이징 파라미터. - `PATCH /admin` 자기 자신 → 400, 미존재 → 404, 정상 → 200 + 응답 구조. - 비-관리자 토큰 → 403. - **Mapper 통합** (`@MybatisTest` + Testcontainers Oracle) - `findByProviderAndProviderId` 일치/미일치. - `findAllWithCounts` 활동 카운트 정확성. - **모킹 전략**: `AuthUtil` 정적 메서드는 `Mockito.mockStatic`으로 stub. ## 11. 리스크 & 대안 검토 - **선택**: 관리자 전용 엔드포인트 분리 + 일반 사용자용 프로필 API 없음. - 장점: 권한 경계 단순. - 단점: 일반 사용자가 자기 프로필 수정 시 별도 API 신설 필요. - **대안 1**: `is_admin` 외 RBAC(role 테이블). - 트레이드오프: 복잡도 ↑. 현 규모에서는 과설계. - **대안 2**: 활동 카운트를 캐시/뷰로 분리. - 트레이드오프: 사용자 수 증가 시 JOIN 비용 ↑ — 그때 도입. - **되돌리기 어려운 결정**: `is_admin` boolean → 다중 역할로 확장 시 마이그레이션 필요. - **운영 리스크** - 마지막 관리자 권한 회수: 자기 자신 차단으로 부분 보호. 다른 관리자가 회수하면 무관리자 상태 가능 → 향후 "최소 1명 admin 유지" 가드 고려. - 사용자 페이징에 상한 없음 → 큰 limit으로 메모리 압박 가능. ## 12. 미해결 질문 (Open Questions) - 일반 사용자가 자기 프로필(닉네임/아바타)을 수정하는 API를 어디에 둘 것인지(`UserController` 신설 vs `/api/auth/me PATCH`). - 사용자 검색(이메일/닉네임 부분 일치)을 관리자 화면에 추가할 것인지. - "최소 1명 admin 유지" 가드를 도입할지, 운영 정책으로만 둘지. - 활동 카운트 페이지 캐싱 TTL을 도입할지(현재 Redis 캐시 미적용). - 회원 탈퇴/익명화 정책과 개인정보 보관기간.