Files
tasteby/docs/design/267-backend-user/README.md
joungmin 4638f605aa fix(security): [Developer] #267 AdminUserController GET 4종에 requireAdmin() 추가
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
2026-06-15 11:17:48 +09:00

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.
  • 제외 (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 클레임 검사.
  • 제약
    • 모든 /api/admin/** 엔드포인트는 관리자만 호출 가능.
    • 페이징 기본 limit=50, offset=0. 상한 강제 없음(향후 캡 고려).
    • is_admin은 Oracle NUMBER(0/1) → Java boolean (@JsonProperty("is_admin")).
    • 트랜잭션: upsert 및 권한 변경은 @Transactional.
  • 가정
    • UserInfo.idIdGenerator.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 빌드 → insertfindById(newId) 반환.
  4. 전체 트랜잭션(@Transactional)으로 묶여 부분 실패 시 롤백.

B. 관리자 목록 (listUsers)

  1. 컨트롤러에서 limit/offset 기본값 적용.
  2. findAllWithCountsLEFT 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 캐시 미적용).
  • 회원 탈퇴/익명화 정책과 개인정보 보관기간.