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 (백로그)
11 KiB
11 KiB
설계서: 백엔드 - 인증/로그인 (#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 에러 매핑.
- Google OAuth ID Token 검증 후 사용자 조회/생성(Upsert) → 자체 JWT 발급 (
- 제외 (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 Client ID는
- 가정
- Google ID Token의
sub는 영구 고유 식별자이며, 동일 사용자가 이메일을 바꾸어도(provider='google', providerId=sub)로 식별 가능. - 사용자 객체의
nickname/avatarUrl은 최초 생성 시 Google payload 값을 그대로 저장(이후 사용자 편집은 본 기능 범위 외).
- Google ID Token의
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/googlebody:{ "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.
- PK:
- 경계 검증
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 로그인
- 클라이언트가 Google Identity Services로 ID Token을 발급받아
POST /api/auth/google {id_token}호출. AuthService가GoogleIdTokenVerifier.verify로 서명/만료/aud 검증. null이면 401.- payload에서
sub,email,name,picture추출. UserService.findOrCreate("google", sub, email, name, picture)호출.- 기존 유저:
updateLastLogin후 최신 사용자 반환. - 신규 유저:
IdGenerator.newId()로 PK 발급 → insert → 재조회 반환.
- 기존 유저:
UserInfo의 핵심 필드를Map으로 패키징하여JwtTokenProvider.createToken호출.{ access_token, user }응답.
시나리오 B — 현재 사용자 조회 (/api/auth/me)
- Spring Security 필터가 Bearer 토큰을 검증해
SecurityContext에 principal(userId) 설정. AuthUtil.getUserId()로 sub 추출.AuthService.getCurrentUser→UserService.findById→UserMapper.findById.- 없으면 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 변환(전역 예외 처리에 의존). 동시 첫 로그인은 드물어 별도 재시도 없음. findByIdrace: 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/googlehappy 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 지원 필요.
- JWT 시크릿 유출 시 위조 가능. 시크릿은
12. 미해결 질문 (Open Questions)
- 리프레시 토큰을 도입할 것인가, 단기 만료 + 재로그인을 유지할 것인가?
- 사용자 닉네임/프로필 사진을 매 로그인마다 Google 값으로 덮어쓸지(현 코드는 첫 생성만 반영) 여부.
- 어드민 권한 부여 정책(최초 가입자 admin, 환경변수 화이트리스트 등)을 어디서 결정할지.
- 멀티 클라이언트(웹/iOS/Android)별 Google Client ID 분리 시점.
- 토큰 만료/서명 알고리즘(HS256 → RS256 전환) 시점.