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

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

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [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-client`의 `GoogleIdTokenVerifier`(`NetHttpTransport` + `GsonFactory`).
  - `UserService` → `UserMapper`(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. `AuthService`가 `GoogleIdTokenVerifier.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.getCurrentUser` → `UserService.findById` → `UserMapper.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 전환) 시점.
