Files
joungmin 80b553ec19 docs: 현행화 17개 설계서 Approved + 후속 이슈 백로그 등록
Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical).
- 17개 설계서를 Draft → Approved로 갱신
- #267(backend-user)은 critical 결함으로 06-Reviewer 유지
- 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영
  (critical 3 / major 46 / minor 75)
- docs/README.md에 18개 설계서 인덱스 추가
- CHANGELOG.md 2026-06-15 섹션 추가

Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
2026-06-15 11:08:18 +09:00
..

설계서: 백엔드 - 인증/로그인 (#266)

상태: Approved 작성: [AI] Architect · 최종수정: 2026-06-15 추적성 — Redmine: #266 · 관련 ADR: 없음 · 구현 파일: backend-java/src/main/java/com/tasteby/service/AuthService.java, backend-java/src/main/java/com/tasteby/controller/AuthController.java · 테스트: TBD (현재 없음)

1. 목적 (Why)

Tasteby 사용자가 Google 계정으로 1탭 로그인하여 즐겨찾기/리뷰/메모 등 개인화 기능을 사용할 수 있도록 한다. 자체 가입/비밀번호 운영 부담을 제거하고 검증된 ID 토큰 기반으로 안전한 세션 토큰(JWT)을 발급한다.

2. 범위 (Scope)

  • 포함
    • Google OAuth ID Token 검증 후 사용자 조회/생성(Upsert) → 자체 JWT 발급 (POST /api/auth/google).
    • 현재 로그인 사용자 정보 반환 (GET /api/auth/me).
    • Google 검증 실패/사용자 미존재 시 표준 HTTP 에러 매핑.
  • 제외 (out of scope)
    • 자체 ID/PW 회원가입·비밀번호 재설정.
    • Apple/Kakao/Naver 등 추가 소셜 로그인.
    • 리프레시 토큰, 토큰 회수(blacklist).
    • 로그아웃 처리(클라이언트 토큰 삭제로 처리).
    • 권한 부여(role) 변경 — 사용자 관리(#267) 책임.

3. 인수조건 (Acceptance Criteria)

  • 유효한 Google ID Token으로 POST /api/auth/google 호출 시 access_token(JWT)과 user 객체를 반환한다.
  • 신규 Google 계정 첫 로그인 시 tasteby_users 행이 생성되고, 재로그인 시 last_login_at이 갱신된다.
  • Google ID Token이 위조/만료/오디언스 불일치인 경우 HTTP 401을 반환한다.
  • 발급된 JWT를 Authorization: Bearer ...GET /api/auth/me 호출 시 본인 정보를 반환한다.
  • JWT의 sub가 존재하지 않는 사용자 ID인 경우 GET /api/auth/me는 HTTP 404를 반환한다.

4. 컨텍스트 & 제약

  • 의존성
    • google-api-clientGoogleIdTokenVerifier(NetHttpTransport + GsonFactory).
    • UserServiceUserMapper(MyBatis) → Oracle 23ai (tasteby_users).
    • 자체 JwtTokenProvider (HMAC 서명 가정), AuthUtil(SecurityContext에서 userId 추출).
  • 제약
    • Google Client ID는 app.google.client-id 프로퍼티로 단일 audience로 고정. 모바일/웹 다중 클라이언트 ID는 현 시점 미지원.
    • JWT 만료/서명 정책은 JwtTokenProvider에서 관리(본 설계서 범위 외).
    • CORS는 WebConfig에서 POST/GET 허용 필요(이미 적용).
    • 모든 외부 호출은 동기 HTTP, 실패 시 401로 합쳐서 반환.
  • 가정
    • Google ID Token의 sub는 영구 고유 식별자이며, 동일 사용자가 이메일을 바꾸어도 (provider='google', providerId=sub)로 식별 가능.
    • 사용자 객체의 nickname/avatarUrl은 최초 생성 시 Google payload 값을 그대로 저장(이후 사용자 편집은 본 기능 범위 외).

5. 아키텍처 개요

  • 모듈/파일
    • controller/AuthController.java — HTTP 진입점 (thin).
    • service/AuthService.java — Google 검증 + JWT 발급 오케스트레이션.
    • service/UserService.java#findOrCreate/findById — DB 조회/upsert.
    • security/JwtTokenProvider, security/AuthUtil — 토큰 생성 / SecurityContext 추출.
  • 데이터 흐름
[Client]
  │ POST /api/auth/google { id_token }
  ▼
AuthController.loginGoogle
  │
  ▼
AuthService.loginGoogle
  ├─ GoogleIdTokenVerifier.verify(idToken)  ── (외부 I/O: Google 공개키 검증)
  ├─ UserService.findOrCreate(provider, sub, email, name, picture)
  │     └─ UserMapper.findByProviderAndProviderId / insert / updateLastLogin
  └─ JwtTokenProvider.createToken(userMap)
  │
  ▼
{ access_token, user }


[Client] GET /api/auth/me  (Authorization: Bearer <jwt>)
  ▼
AuthController.me → AuthUtil.getUserId() → AuthService.getCurrentUser
  ▼ UserService.findById → UserMapper.findById
  ▼
UserInfo
  • I/O ↔ 순수 로직 경계
    • I/O: Google 토큰 검증, DB 조회/저장.
    • 순수: payload → UserInfo 매핑, Map<String,Object> 클레임 빌드.

6. 데이터 모델

  • 입력
    • POST /api/auth/google body: { "id_token": string(JWT, Google issued) }.
    • GET /api/auth/me: 헤더 Authorization: Bearer <accessToken>.
  • 출력
    • loginGoogle: { access_token: string, user: UserInfo }.
    • me: UserInfo.
  • UserInfo(domain/UserInfo.java)
    • id: String(32 hex), email: String, nickname: String, avatarUrl: String, admin: boolean (@JsonProperty("is_admin")), provider: String, providerId: String, createdAt: String, favoriteCount/reviewCount/memoCount: int.
  • 저장(tasteby_users)
    • PK: id (IdGenerator.newId(), 32-char uppercase hex), 유니크 가정: (provider, provider_id).
    • is_admin NUMBER(0/1), last_login_at TIMESTAMP.
  • 경계 검증
    • id_token 비어있거나 null → Google verifier가 null 리턴 → 401.
    • JWT 클레임 내 email/nickname이 null이면 빈 문자열로 정규화.

7. 함수 명세 (Function Specs)

함수 책임(1줄) 시그니처 입력 출력 에러/실패 복잡?
AuthService(UserService, JwtTokenProvider, String) 의존성 주입 및 GoogleIdTokenVerifier 초기화 AuthService(UserService userService, JwtTokenProvider jwtProvider, @Value("${app.google.client-id}") String googleClientId) DI 빈, client-id 인스턴스 프로퍼티 누락 시 빈 생성 실패 단순
AuthService.loginGoogle Google ID Token 검증 → 사용자 upsert → JWT 발급 Map<String,Object> loginGoogle(String idTokenString) Google id_token 문자열 { access_token, user } 검증 실패/예외 → 401 ResponseStatusException 복잡 (외부 I/O + DB upsert + 토큰 발급)
AuthService.getCurrentUser JWT sub로 사용자 조회 UserInfo getCurrentUser(String userId) userId UserInfo 미존재 → 404 단순
AuthController(AuthService) DI 생성자 DI 빈 인스턴스 없음 단순
AuthController.loginGoogle /api/auth/google 엔드포인트 Map<String,Object> loginGoogle(@RequestBody Map<String,String> body) body.id_token { access_token, user } AuthService 예외 위임 단순
AuthController.me /api/auth/me 엔드포인트 UserInfo me() (헤더에서 userId 자동 추출) UserInfo 인증 실패 → 401, 미존재 → 404 단순

복잡 표시 함수(loginGoogle)는 흐름이 8장에 상세 기술되어 있어 별도 fn-loginGoogle.md는 생략 가능.

8. 흐름 / 알고리즘

시나리오 A — Google 로그인

  1. 클라이언트가 Google Identity Services로 ID Token을 발급받아 POST /api/auth/google {id_token} 호출.
  2. AuthServiceGoogleIdTokenVerifier.verify로 서명/만료/aud 검증. null이면 401.
  3. payload에서 sub, email, name, picture 추출.
  4. UserService.findOrCreate("google", sub, email, name, picture) 호출.
    • 기존 유저: updateLastLogin 후 최신 사용자 반환.
    • 신규 유저: IdGenerator.newId()로 PK 발급 → insert → 재조회 반환.
  5. UserInfo의 핵심 필드를 Map으로 패키징하여 JwtTokenProvider.createToken 호출.
  6. { access_token, user } 응답.

시나리오 B — 현재 사용자 조회 (/api/auth/me)

  1. Spring Security 필터가 Bearer 토큰을 검증해 SecurityContext에 principal(userId) 설정.
  2. AuthUtil.getUserId()로 sub 추출.
  3. AuthService.getCurrentUserUserService.findByIdUserMapper.findById.
  4. 없으면 404, 있으면 UserInfo 반환.

9. 엣지케이스 & 에러 처리

  • id_token이 null/공백: Verifier가 null 또는 예외 발생 → 401 "Invalid Google token".
  • Google 공개키 조회 실패(네트워크/타임아웃): catch-all로 401에 메시지 포함. 재시도/백오프 없음(클라이언트가 재시도).
  • audience 불일치: Verifier가 null 반환 → 401.
  • 신규 사용자 insert 중 충돌: 트랜잭션(@Transactional)으로 묶여 있으며, (provider, provider_id) 유니크 위반 시 예외 발생 → 상위에서 500 변환(전역 예외 처리에 의존). 동시 첫 로그인은 드물어 별도 재시도 없음.
  • findById race: insert 직후 즉시 재조회 — 동일 트랜잭션 가시성 가정.
  • JWT 클레임 내 email/nickname null: 빈 문자열로 정규화 후 토큰에 포함.
  • /api/auth/me에서 sub가 존재하지 않는 ID(사용자 삭제 등): 404.
  • 안전한 기본값: 어떤 실패든 401/404로 매핑, 500은 예외적.

10. 테스트 계획

  • 현 상태: 자동화 테스트 없음 (TBD).
  • 추가 권장 단위 테스트 (Mockito 기반)
    • AuthService.loginGoogle
      • 유효 토큰 → UserService.findOrCreate 호출 + JwtTokenProvider.createToken 결과 포함.
      • Verifier null 반환 → 401.
      • Verifier 예외 → 401.
      • email/nickname null payload → 토큰 클레임에 빈 문자열.
    • AuthService.getCurrentUser
      • 존재 → UserInfo 반환.
      • 미존재 → 404.
  • 통합 테스트 (@SpringBootTest + MockMvc)
    • POST /api/auth/google happy path (Google verifier 모킹).
    • GET /api/auth/me 인증 헤더 유효/무효.
  • 모킹 전략: GoogleIdTokenVerifier@MockBean으로 교체. JWT는 실제 JwtTokenProvider 사용해 round-trip 검증.

11. 리스크 & 대안 검토

  • 선택: Google 단일 IdP + 자체 단기 JWT.
    • 장점: 구현 단순, 비밀번호 미관리, 즉시 사용 가능.
    • 단점: 리프레시 토큰 없음 → 만료 시 재로그인 필요.
  • 대안 1: Spring Security OAuth2 Client + 세션 쿠키.
    • 트레이드오프: 백엔드 세션 저장소 추가, SPA-친화 낮음. 현재 거부.
  • 대안 2: 리프레시 토큰 + 회수 리스트(Redis).
    • 트레이드오프: 복잡도 ↑. 향후 필요 시 도입.
  • 되돌리기 어려운 결정: (provider, provider_id) 식별 스키마. → 변경 시 ADR 필요.
  • 보안 리스크
    • JWT 시크릿 유출 시 위조 가능. 시크릿은 k8s/secrets.yaml로 관리.
    • audience 단일 — 모바일/웹 client_id 분리 시 verifier 다중 audience 지원 필요.

12. 미해결 질문 (Open Questions)

  • 리프레시 토큰을 도입할 것인가, 단기 만료 + 재로그인을 유지할 것인가?
  • 사용자 닉네임/프로필 사진을 매 로그인마다 Google 값으로 덮어쓸지(현 코드는 첫 생성만 반영) 여부.
  • 어드민 권한 부여 정책(최초 가입자 admin, 환경변수 화이트리스트 등)을 어디서 결정할지.
  • 멀티 클라이언트(웹/iOS/Android)별 Google Client ID 분리 시점.
  • 토큰 만료/서명 알고리즘(HS256 → RS256 전환) 시점.