<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->

# 설계서: 백엔드 - 사용자 관리 (#267)

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [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<UserInfo> 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<UserInfo> 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<String,Object> listUsers(int limit=50, int offset=0)` | 페이징 | `{users,total}` | 인증 실패 → 401/403 | 단순 |
| `userFavorites` | `GET …/{userId}/favorites` | `List<Restaurant> userFavorites(String userId)` | userId | 즐겨찾기 식당 | 인증/위임 | 단순 |
| `userReviews` | `GET …/{userId}/reviews` | `List<Review> userReviews(String userId)` | userId | 리뷰 100건 | 인증/위임 | 단순 |
| `userMemos` | `GET …/{userId}/memos` | `List<Memo> userMemos(String userId)` | userId | 메모 | 인증/위임 | 단순 |
| `updateAdmin` | `PATCH …/{userId}/admin` | `Map<String,Object> updateAdmin(String userId, Map<String,Boolean> 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 캐시 미적용).
- 회원 탈퇴/익명화 정책과 개인정보 보관기간.
