CRITICAL: listUsers, userFavorites, userReviews, userMemos 4개 GET이 인증만 요구하고 admin 검사가 없어, 일반 사용자 토큰으로 전체 사용자 목록 및 타인 활동 조회 가능했음. 각 메서드 첫 줄에 AuthUtil.requireAdmin() 호출 추가 → non-admin은 403. 함께 커밋(이전 미커밋 작업): - Logger 등록 (감사 로그용) - AuthUtil/Logger/HttpStatus/ResponseStatusException import 정리 - updateAdmin: 자기 자신 admin 변경 차단 + 감사 로그 (이미 동작 중이던 변경이나 git 미커밋 상태였음) 문서: - 설계서 §3 인수조건에 권한 강제 항목 추가, 상태 Draft → Approved - CHANGELOG.md 2026-06-15 핫픽스 항목 추가 검증: - Anonymous GET /api/admin/users → 403 ✓ - Bad-token GET /api/admin/users → 403 ✓ - 백엔드 빌드 성공, tasteby-api 재시작 완료 Refs: #267
13 KiB
13 KiB
설계서: 백엔드 - 사용자 관리 (#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.
- 사용자 upsert/조회 도메인 서비스 (
- 제외 (out of scope)
- 회원 탈퇴/익명화, 개인정보 수정 API.
- 일반 사용자가 자기 프로필을 수정하는 API.
- 사용자 자체 검색(이름/이메일).
- 즐겨찾기/리뷰/메모의 CRUD (각 도메인 서비스 책임).
- Google 토큰 검증 (#266 책임).
3. 인수조건 (Acceptance Criteria)
- 관리자 토큰으로
GET /api/admin/users?limit=&offset=호출 시{ users:[…], total:n }구조와 각 사용자의favoriteCount/reviewCount/memoCount가 포함된다. /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 클레임 검사.
- Oracle 23ai 테이블:
- 제약
- 모든
/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: querylimit: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)
findByProviderAndProviderId(provider, providerId)호출.- 존재 →
updateLastLogin(id)→findById(id)반환. - 미존재 →
IdGenerator.newId()로 PK 발급 →UserInfo빌드 →insert→findById(newId)반환. - 전체 트랜잭션(
@Transactional)으로 묶여 부분 실패 시 롤백.
B. 관리자 목록 (listUsers)
- 컨트롤러에서 limit/offset 기본값 적용.
findAllWithCounts로LEFT JOIN ( user_favorites|user_reviews|user_memos GROUP BY user_id )+ORDER BY created_at DESC OFFSET ? FETCH NEXT ?.countAll로 전체 수 합산하여{ users, total }반환.
C. 권한 변경 (updateAdmin)
AuthUtil.requireAdmin()— JWT의 admin 클레임 미보유 시 403.userId == currentUser.subject→ 400 ("자기 자신의 관리자 권한은 변경할 수 없습니다").- body.admin → boolean (null=false).
UserService.updateAdmin→ Mapper에서UPDATE … WHERE id = ?.- 영향 행 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으로 노출. findByIdrace(인증 직후 삭제): 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_adminboolean → 다중 역할로 확장 시 마이그레이션 필요. - 운영 리스크
- 마지막 관리자 권한 회수: 자기 자신 차단으로 부분 보호. 다른 관리자가 회수하면 무관리자 상태 가능 → 향후 "최소 1명 admin 유지" 가드 고려.
- 사용자 페이징에 상한 없음 → 큰 limit으로 메모리 압박 가능.
12. 미해결 질문 (Open Questions)
- 일반 사용자가 자기 프로필(닉네임/아바타)을 수정하는 API를 어디에 둘 것인지(
UserController신설 vs/api/auth/me PATCH). - 사용자 검색(이메일/닉네임 부분 일치)을 관리자 화면에 추가할 것인지.
- "최소 1명 admin 유지" 가드를 도입할지, 운영 정책으로만 둘지.
- 활동 카운트 페이지 캐싱 TTL을 도입할지(현재 Redis 캐시 미적용).
- 회원 탈퇴/익명화 정책과 개인정보 보관기간.