Skip to content

카카오·구글·애플 OAuth 기반 인증/인가 기능 구현 - #15

Merged
hej090224 merged 13 commits into
developfrom
feature/14-oauth-login
Jul 31, 2026
Merged

카카오·구글·애플 OAuth 기반 인증/인가 기능 구현#15
hej090224 merged 13 commits into
developfrom
feature/14-oauth-login

Conversation

@hej090224

@hej090224 hej090224 commented Jul 28, 2026

Copy link
Copy Markdown
Member

✨ 작업 내용

Kakao/Google/Apple OAuth 인가코드 방식 기반의 인증/인가 도메인을 신규 구현하였습니다.

  • POST /api/v1/auth/oauth/{provider}: 인가코드를 provider 토큰 엔드포인트와 교환하여 로그인 처리, 신규/기존 회원을 판별하여 응답
  • POST /api/v1/auth/signup: 최초 로그인 시 생성된 미완성 회원 정보(이름/닉네임/성별/나이)를 완성
  • PATCH /api/v1/auth/reissue: Refresh Token 검증 및 Rotation 방식으로 재발급
  • DELETE /api/v1/auth/signout: Redis 기반 Access Token 블랙리스트 등록 및 Refresh Token 폐기
  • DELETE /api/v1/auth/withdraw: 회원 Soft Delete 및 개인정보 익명화 처리
  • OAuthStrategy 전략 패턴으로 Kakao/Google/Apple 인가코드 교환 로직을 분리하였고, Apple의 client_secret은 ES256 서명 JWT로 즉석 생성하도록 구현하였습니다.
  • Member 엔티티에 oauthProvider, providerId, gender, age, withdrawnAt 필드를 추가하고 Flyway 마이그레이션(V3)을 작성하였습니다.

🔍 리뷰 시 참고사항

  • 회원가입 흐름: 최초 OAuth 로그인 시점에 미완성 Member row를 즉시 생성하고 실제 Access/Refresh Token을 발급합니다. /signup은 이미 발급된 토큰으로 인증된 상태에서 프로필을 완성하는 구조라, 응답 바디 없이 201만 반환해도 클라이언트가 들고 있는 토큰이 그대로 유효합니다.
  • /oauth/{provider} 응답에 isNewMember 필드를 추가하였습니다. 신규/기존 회원 판별 없이는 클라이언트가 회원가입 화면으로의 분기를 할 수 없어 추가한 필드입니다.
  • /reissueAuthorization이 아닌 refreshToken이라는 커스텀 헤더를 사용합니다 (refreshToken: Bearer ey...).
  • Access Token 무효화는 Redis 블랙리스트(jti 단위, 남은 유효시간만큼 TTL)로 구현하였고, 이를 위해 JwtTokenProvider에 jti 클레임과 getRemainingValidity()를 추가하였습니다.
  • 회원 탈퇴는 Soft Delete로 처리하였습니다. 타임캡슐이 최대 10년까지 보존되는 서비스 특성상 회원을 물리 삭제하면 관련 타임캡슐의 FK가 깨지기 때문입니다. 탈퇴 시 이름/닉네임/이메일 등 개인정보는 익명화하고, 동일 소셜 계정으로의 재로그인은 차단됩니다.
  • Google/Kakao/Apple 모두 서버가 인가코드를 provider 토큰 엔드포인트와 직접 교환하는 구조라, 별도 JWKS 서명 검증 없이 응답을 그대로 신뢰합니다 (TLS + client_secret으로 이미 인증된 서버 간 통신이기 때문).
  • Apple 개발자 계정 발급 전이라 OAUTH_APPLE_* 환경변수는 아직 실제 값으로 검증되지 않았습니다. 나머지는 로컬에서 Kakao/Google 실제 콘솔 값으로 기동 및 API 응답까지 확인하였습니다.

✅ 체크리스트

  • 문서(README, .env.example 등) 변경이 필요한 경우 작성 또는 수정했나요?
  • 작업한 코드가 정상적으로 동작하는 것을 직접 확인했나요?
  • 필요한 경우 테스트 코드를 작성하거나 수정했나요?
  • Merge 대상 브랜치를 올바르게 설정했나요?
  • PR에 관련 없는 작업이 포함되지 않았나요?
  • 적절한 라벨과 리뷰어를 설정했나요?

📎 관련 이슈(선택)

@hej090224 hej090224 added the ✨ Feature 신규 기능 label Jul 28, 2026
@hej090224 hej090224 self-assigned this Jul 28, 2026
@hej090224
hej090224 requested a review from cfcromn July 28, 2026 06:33
@cfcromn

cfcromn commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

리뷰 코멘트

구조는 잘 잡혀 있습니다. 패키지 레이아웃, {Action}{Domain}Service + execute(), OAuthStrategy 전략 패턴, RefreshTokenStore/AccessTokenBlacklist 포트를 global/security에 두고 Redis 어댑터를 infrastructure에 둔 분리 모두 AGENTS.md 규약대로네요. SecurityConfig에서 /api/v1/auth/** permitAll을 oauth/** + reissue로 좁힌 것도 좋습니다. id_token 서명 검증을 생략한 근거(토큰 엔드포인트에서 TLS + client_secret으로 직접 수령)도 OIDC §3.1.3.7과 일치해서 맞는 판단으로 보입니다.

다만 토큰 설계 쪽에 머지 전에 봐야 할 부분이 몇 개 있어서 정리했습니다.


P0 — 머지 전 수정 제안

1. Refresh Token을 Access Token으로 쓸 수 있습니다

JwtTokenProvider.ktcreateAccessToken/createRefreshToken이 만료시간만 다른 동일 구조 토큰을 만들고, JwtAuthenticationFilter는 서명·만료·블랙리스트만 확인합니다. 즉 14일짜리 refresh token을 Authorization: Bearer <refresh>로 보내면 그대로 인증됩니다. Access Token 1시간 만료와 signout 블랙리스트가 동시에 무력화되는 지점입니다.

// createToken에 typ 클레임 추가 → 필터는 access만, ReissueAuthService는 refresh만 통과
.claim("typ", "access")

2. getJti가 플랫폼 타입을 반환 → 배포 직후 500

claims(token).idString!입니다. JWT_SECRET이 그대로라 jti 없이 발급된 기존 토큰(access 1h / refresh 14d)이 배포 후에도 유효한데, 그 토큰으로 들어온 요청은 필터의 getJti(it)에서 NPE → 401이 아니라 500이 나갑니다. CLAUDE.md의 "플랫폼 타입 노출 금지"에도 걸립니다.

fun getJti(token: String): String? = claims(token).id
// 필터: ?.takeUnless { t -> getJti(t)?.let(blacklist::isBlacklisted) ?: false }

3. 같은 이메일 + 다른 provider → 500

V2의 uq_member_email이 그대로 남아 있습니다. 카카오로 가입한 이메일로 구글 로그인하면 LoginAuthService가 신규 Member를 insert → unique 위반 → DataIntegrityViolationException → 500. 실사용에서 꽤 자주 나올 시나리오로 보입니다. 식별자가 이미 (oauth_provider, provider_id)니 email unique를 V4에서 제거하거나, 최소한 409 에러코드로 변환하는 쪽이 어떨까요?

4. 탈퇴해도 다른 기기의 Access Token이 최대 1시간 유효

WithdrawAuthService는 요청에 실려온 토큰 하나만 블랙리스트에 등록하고, 필터는 withdrawnAt을 보지 않습니다. 멀티 디바이스라면 나머지 세션은 살아있습니다. signout(기기 단위)과 구분되는 지점이라, 탈퇴는 회원 단위 무효화 키(auth:revoked-before:{memberId})가 필요해 보입니다.


P1 — 확인 부탁드립니다

5. V3 마이그레이션이 기존 row 있으면 실패합니다

ADD COLUMN oauth_provider VARCHAR(20) NOT NULL에 DEFAULT가 없습니다. AGENTS.md는 "breaking schema change는 add-then-backfill-then-drop으로 분리"를 명시하고 Flyway는 green 컨테이너 기동 시 자동 실행되니, staging tbl_member에 데이터가 있으면 배포가 깨집니다. 비어있는지 확인 부탁드려요. 아니라면 DEFAULT 'KAKAO' → backfill → DROP DEFAULT로 분리가 필요합니다.

6. Redis String 직렬화가 검증되지 않았습니다

RedisConfigRedisTemplate<String, Any>activateDefaultTyping(..., EVERYTHING, PROPERTY)이고 validator 허용 prefix가 team.cklob.mudda/java.util./java.time.뿐입니다. String 값에 java.lang.String 타입 ID가 붙어 저장되면 역직렬화에서 validator가 막을 가능성이 있고, Redis 통합 테스트가 없어 /reissue가 런타임에만 터질 수 있습니다.

RedisRefreshTokenStore/RedisAccessTokenBlacklistStringRedisTemplate을 주입받으면 이 질문 자체가 사라지고 as String? 캐스팅도 없어집니다. Testcontainers Redis로 save→find→delete 왕복 테스트 1개도 함께 있으면 좋겠습니다.

7. 필수 필드 누락 요청이 400이 아니라 500

gender: Gender(non-null)가 본문에서 빠지면 Jackson MissingKotlinParameterExceptionGlobalExceptionHandler.handleException → 500입니다. HttpMessageNotReadableExceptionErrorCode.INVALID_INPUT 핸들러 한 줄이면 해결됩니다. 그리고 age@field:Min / @field:Max — 현재 음수나 9999도 통과합니다.

8. provider 호출 실패 원인이 전부 소실됩니다

3개 Strategy 모두 runCatching { ... }.getOrNull() ?: throw AuthException(OAUTH_INVALID_CODE)라서 client_secret 오설정·redirect_uri 불일치·provider 장애가 전부 같은 400이 되고 로그도 남지 않습니다. 운영에서 원인 추적이 어려울 것 같아 onFailure { logger.warn(...) } 정도는 있으면 좋겠습니다(비밀값 제외).


P2 — 설계 판단이 필요한 부분

  • 탈퇴 = 영구 차단: uq_member_oauth_provider_provider_id가 탈퇴 row까지 포함해서 동일 소셜 계정 재가입이 영구 불가합니다. 본문에 의도라고 쓰셨지만 되돌릴 경로가 없다는 점은 짚어둡니다. 유예기간 후 providerId 파기로 재가입을 허용할지 결정이 필요해 보입니다.
  • email 없으면 로그인 불가: 카카오 이메일 동의는 선택 항목인데 OAUTH_EMAIL_REQUIRED로 하드 실패합니다. 실제 식별자는 providerId이니, 탈퇴 경로에서 이미 쓰는 {provider}-{providerId}@mudda.local 합성 패턴을 재사용하는 것도 방법입니다.
  • 응답 envelope 불일치: 성공은 raw DTO, 실패는 ApiResponse.failure라 클라이언트가 두 형태를 다뤄야 합니다. 이 PR이 첫 실사용 컨트롤러니 지금 통일하는 게 좋아 보입니다.
  • age: Int 스냅샷 저장: 캡슐이 최대 10년 보존되는 서비스에서 나이를 값으로 저장하면 반드시 낡습니다. birthYear 쪽이 맞지 않을까요?

P3 — 정리 제안 (선택)

  • LoginAuthService의 두 쿼리(existsBy...WithdrawnAtIsNotNull + findBy...WithdrawnAtIsNull)를 findByOauthProviderAndProviderId 하나로 합치고 withdrawnAt != null은 Kotlin에서 판정 → 레포지토리 메서드 1개 삭제 + 쿼리 절반
  • LoginAuthService.execute@Transactional 누락 (다른 3개 서비스에는 있음)
  • 요청 1건당 JWT 파싱 3회(validategetJtigetMemberId) → Claims 1회 파싱 후 재사용
  • OAuthConfig는 빈 클래스 — MuddaApplication@ConfigurationPropertiesScan 한 줄이면 JwtProperties까지 함께 대체됩니다
  • 테스트의 ReflectionTestUtils.setField(member, "id", 1L) (5곳) → 생성자가 이미 id를 받습니다: Member(..., id = 1L)
  • extractBearerTokenAuthControllerJwtAuthenticationFilter에 중복
  • LoginAuthRequest.providerUriredirectUri (실제 의미가 redirect_uri)

테스트는 서비스·전략·컨트롤러 레이어를 MockK + MockRestServiceServer + MockMvc로 꼼꼼히 덮으셨네요. 빠진 건 Redis 어댑터 2개(6번)뿐입니다. 고생하셨습니다 👍

@hej090224

Copy link
Copy Markdown
Member Author

리뷰 감사합니다. 전부 확인해서 4개 커밋으로 나눠 반영했습니다.

P0

  1. refresh token으로 access token 위장3ea1dfc에서 반영. JWT에 typ(access/refresh) 클레임을 추가하고, 필터는 isAccessToken이 아닌 토큰을 거부하도록 했습니다. /reissueisRefreshToken이 아니면 거부합니다.
  2. getJti 플랫폼 타입 노출3ea1dfc에서 반영. getJtiString?로 바꾸고, 필터/signout/withdraw 전부 null 분기를 명시적으로 처리하도록 했습니다. 겸사겸사 필터에서 validate+getJti+getMemberId 3회 파싱하던 것도 parseAccessToken 한 번으로 합쳤습니다.
  3. 같은 이메일 다른 provider → 5005d3f482에서 반영. uq_member_email 제약을 제거하고 Member.emailunique=true도 뺐습니다.
  4. 탈퇴 후 다른 기기 세션 유효3ea1dfc에서 반영. AccessTokenBlacklistauth:revoked-before:{memberId} 방식의 회원 단위 revocation을 추가해서, withdraw 시 발급 시각이 그 이전인 토큰은 어느 기기든 거부되도록 했습니다. signout(기기 단위)은 기존대로 jti 블랙리스트만 사용합니다.

P1

  1. V3 마이그레이션 NOT NULL without default → 확인 결과 조치 불필요로 판단했습니다. V3는 이번 PR(feat: #14)에서 처음 도입된 마이그레이션이라 develop에 배포된 적이 없고(git log로 확인), OAuth 가입 경로 자체가 이번 PR이 처음이라 tbl_member에 기존 row가 없습니다. AGENTS.md의 "배포된 마이그레이션 수정 금지" 규칙이 적용되지 않는 케이스라 V3를 직접 수정했습니다(3번 항목의 uq_member_email 제거 포함).
  2. Redis String 직렬화 미검증3ea1dfc에서 반영. RedisRefreshTokenStore/RedisAccessTokenBlacklistStringRedisTemplate으로 바꾸고 as String? 캐스팅을 제거했습니다. Testcontainers Redis로 save→find→delete, blacklist, member-wide revocation 왕복 테스트도 추가했습니다(RedisAuthAdaptersIntegrationTest).
  3. 필수 필드 누락 → 500fbd4ef5에서 반영. GlobalExceptionHandlerHttpMessageNotReadableException 핸들러를 추가해 INVALID_INPUT(400)으로 매핑했습니다. age(→birthYear) 검증은 @Min/@Max로 아래 P2에서 함께 반영했습니다.
  4. provider 실패 원인 소실cdfecba에서 반영. 카카오/구글/애플 3개 전략 모두 토큰 교환·유저 정보 조회 실패 시 onFailure { logger.warn(...) }으로 로그를 남기도록 했습니다.

P2 (하민과 논의 후 결정)

  • 탈퇴 = 영구 차단 → 유예기간(30일) 후 재가입을 허용하는 쪽으로 결정. 5d3f482에서 반영. 유예기간 내에는 기존과 동일하게 WITHDRAWN_MEMBER로 막고, 유예기간이 지나면 탈퇴 row의 providerIdwithdrawn-{id}-{providerId}로 파기해서 unique 슬롯을 비운 뒤 새 Member를 생성합니다.
  • 이메일 없으면 로그인 불가 → 합성 이메일 폴백으로 결정. cdfecba에서 반영. 세 provider 모두 이메일이 없으면 {provider}-{providerId}@mudda.local로 대체하고, OAUTH_EMAIL_REQUIRED는 더 이상 쓰이지 않아 제거했습니다.
  • 응답 envelope 불일치 → 지금 통일하는 쪽으로 결정. fbd4ef5에서 반영. /oauth/{provider}, /reissue가 이제 ApiResponse.success(data)로 감쌉니다.
  • age(Int) 스냅샷 → birthYear로 전환 결정. 5d3f482에서 반영. SignupAuthRequest.birthYear@Min(1900)/@Max(2100) 검증도 추가했습니다.

P3

  • LoginAuthService의 두 쿼리 → findByOauthProviderAndProviderId 하나로 통합 (5d3f482)
  • LoginAuthService.execute@Transactional 추가 (5d3f482)
  • JWT 3중 파싱 → parseAccessToken 1회로 축소 (3ea1dfc, 2번 항목 참고)
  • extractBearerToken 중복 → global/util/BearerToken으로 통합해 필터/컨트롤러가 공유 (3ea1dfc, fbd4ef5)
  • ReflectionTestUtils.setField(member, "id", ...) → 생성자 id 파라미터로 교체 (전체 커밋에 걸쳐 반영)
  • LoginAuthRequest.providerUriredirectUri (5d3f482)
  • OAuthConfig 빈 클래스 → 보류했습니다. @ConfigurationPropertiesScan으로 옮기면 AuthControllerTest/SecurityConfigTest@WebMvcTest 슬라이스가 @EnableConfigurationProperties(JwtProperties::class)에 의존하고 있어 컨텍스트가 깨집니다. 리스크 대비 이득이 작아 이번 PR에서는 그대로 두었습니다. 필요하시면 별도 이슈로 슬라이스 테스트 설정까지 같이 정리하겠습니다.

전체 테스트(./gradlew build, Testcontainers 포함) 통과 확인했습니다.

@hej090224
hej090224 merged commit 3199d3c into develop Jul 31, 2026
2 checks passed
@hej090224
hej090224 deleted the feature/14-oauth-login branch July 31, 2026 07:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 신규 기능

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants