# 설계서: 백엔드 - 인증/로그인 (#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-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 ) ▼ AuthController.me → AuthUtil.getUserId() → AuthService.getCurrentUser ▼ UserService.findById → UserMapper.findById ▼ UserInfo ``` - **I/O ↔ 순수 로직 경계** - I/O: Google 토큰 검증, DB 조회/저장. - 순수: payload → `UserInfo` 매핑, `Map` 클레임 빌드. ## 6. 데이터 모델 - **입력** - `POST /api/auth/google` body: `{ "id_token": string(JWT, Google issued) }`. - `GET /api/auth/me`: 헤더 `Authorization: Bearer `. - **출력** - `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 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 loginGoogle(@RequestBody Map 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 전환) 시점.