feat(auth): 구글 로그인과 JWT 인증 기반 - #81
Merged
Merged
Conversation
프론트가 구글 로그인으로 받은 ID 토큰을 검증해 우리 서비스의 액세스·리프레시 토큰을 발급한다. 인가 적용은 이 변경에 포함하지 않는다. 구글 ID 토큰 검증 서명(JWKS), iss, aud, exp, email_verified 를 모두 확인한다. aud 를 빼면 다른 서비스용으로 발급된 유효한 구글 토큰으로 우리 서비스에 로그인할 수 있다. 식별자는 sub 다. 이메일은 바뀌고 재사용되므로 계정을 잇는 기준으로 쓰지 않는다. JWKS 는 캐시하고 조회 횟수를 제한한다. 조작된 kid 로 외부 호출을 유발하는 것도 막는다. 토큰 액세스 30분, 무상태. role 을 담아 매 요청 DB 조회를 피한다. 리프레시 14일, Redis 에 SHA-256 해시로 저장한다. 원문을 두면 Redis 를 읽을 수 있는 사람이 남의 세션을 그대로 이어받는다. 갱신할 때마다 회전시킨다. 소비된 토큰이 다시 오면 탈취로 보고 그 사용자의 모든 기기를 끊는다. 다만 만료·로그아웃된 토큰은 구분해 이 요청만 거절한다. 구분하지 않으면 로그아웃 직후 클라이언트가 한 번 재시도하는 것만으로 다른 기기가 전부 끊긴다. JWT_SECRET 이 없거나 32바이트 미만이면 기동을 멈춘다. 서명 키 없이 뜨면 누구나 원하는 사용자와 권한으로 토큰을 만들 수 있어, 조용히 뚫린 채 도는 것보다 낫다. 인가 사용자 API 는 그대로 공개다. 비로그인 요청이 401 을 받기 시작하는 파괴적 변경이라 프론트 배포와 맞춰야 한다. /api/v1/admin/** 만 지금부터 막는다. permitAll 인 채로 관리자 컨트롤러를 두면 누구나 사용자를 정지시키고 게시물을 지울 수 있다. 인증 필터는 토큰을 읽되 요구하지 않는다. 잘못된 토큰에 401 을 내면 비로그인도 볼 수 있어야 하는 화면이 깨진다. 401·403 도 code·fieldErrors·traceId 를 갖춘 ErrorResponse 로 낸다. Security 예외는 필터에서 나 @ControllerAdvice 가 잡지 못한다. 권한 관리자 지정은 DB 에서 한다(UPDATE users SET role='ADMIN'). 로그인은 권한을 건드리지 않는다. 로그인이 권한을 다시 계산하면 DB 로 준 권한이 다음 로그인에 사라진다. 스키마 V9 는 users 에 email, provider, provider_id, role, status, suspended_until, suspended_reason 을 더한다. 기존 행이 있어 provider 계열은 nullable 이다. V10 은 schedules 에 user_id 를 더한다. FastAPI 가 채우므로 컬럼이 먼저 있어야 그쪽 배포가 가능하다. 인증 전에 만들어진 일정은 NULL 로 남는다. 두 갈래 작업이 동시에 migration 을 추가하므로 out-of-order 를 허용한다. 커뮤니티(V8)와 인증(V9)이 다른 순서로 머지돼도 기동이 깨지지 않는다. 함께 고친 것 CORS 허용 헤더에 Authorization 이 없었다. 없으면 브라우저에서 로그인 자체가 안 된다. 같은 이유로 커뮤니티가 쓰는 X-User-Id 도 넣는다. 하드코딩이라 설정으로 못 바꾸던 것을 CorsProperties 로 옮긴다. TraceIdFilter 가 Security 뒤에 실행돼, 인가로 걸러진 요청에는 traceId 도 X-Trace-Id 헤더도 로그 MDC 도 남지 않았다. 추적이 가장 필요한 실패였다. 순서를 앞으로 당긴다. 응답 헤더 X-Trace-Id 를 노출 헤더로 지정한다. 지정하지 않으면 다른 오리진의 스크립트가 읽을 수 없어, 오류 응답의 traceId 와 같은 값이라는 문서가 브라우저에서 성립하지 않았다. /api/v1/auth 의 본문 검증 실패가 INVALID_SCHEDULE_CONDITION 으로 나가던 것을 고친다. 검증 ./gradlew test 169건 통과. 로컬에서 실제 구글 로그인으로 계정 생성·토큰 발급·재로그인 시 권한 유지를 확인했다. FastAPI 를 로컬에 띄워 일정 생성 시 schedules.user_id 저장과, 수정 후에도 소유자가 유지되는 것을 확인했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
갱신에서 사용자를 먼저 읽고 토큰을 나중에 소비한다 소비를 먼저 하면 DB 가 잠시 실패했을 때 Redis 삭제와 회전 흔적만 남는다. 그 뒤 클라이언트가 재시도하면 소비된 토큰으로 보여 탈취로 판정되고 revokeAll 이 돌아 모든 기기가 끊긴다. DB 가 몇 초 불안정했을 뿐인데 전체 재로그인이 된다. 순서를 바꾸면 사라진다. 사용자를 못 찾을 때의 코드를 USER_NOT_FOUND 에서 INVALID_TOKEN 으로 바꾼다. 이제 토큰 검증보다 DB 조회가 먼저라, 두 코드를 구분해 주면 아무 토큰이나 보내 사용자 ID 가 존재하는지 확인할 수 있다. 로그인에서 트랜잭션을 걷어낸다 구글 JWKS 로 나가는 HTTP 호출이 트랜잭션 안에 있었다. 캐시가 만료된 직후 로그인이 몰리면 구글 응답을 기다리는 동안 DB 커넥션을 쥔 채 묶인다. CLAUDE.md 가 금지한 패턴이다. 사용자 저장만 OAuthUserRegistrar 가 자기 트랜잭션으로 처리한다. revokeAll 이 KEYS 대신 SCAN 을 쓴다 KEYS 는 전체 키공간을 한 번에 훑으며 그동안 Redis 싱글 스레드를 점유해 다른 모든 명령이 대기한다. 이 Redis 는 모든 로그인 갱신이 쓰므로, 정지나 탈취 탐지 한 번에 갱신 전체가 멈춘다. 닉네임이 겹치면 다른 이름으로 다시 시도한다 확인 후 저장이라 흔한 이름이 동시에 가입하면 고유 인덱스에 걸려 한쪽이 500 이 된다. 사용자에게는 구글 로그인이 그냥 실패한 것으로 보인다. 회귀 테스트를 추가한다. 순서를 되돌리면 실패하는 것을 확인했다. 전체 170건 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🤖 Gemini PR Review
1. [HIGH]
|
SD-gif
added a commit
that referenced
this pull request
Aug 25, 2026
인증 기반(#81)이 먼저 머지되면서 생긴 충돌을 해소한다. 자동 병합이 조용히 깨뜨리는 곳이 있어 하나씩 확인했다. ErrorCode 양쪽이 USER_NOT_FOUND 를 각자 추가해 자동 병합 결과에 같은 상수가 두 번 들어갔다. 그대로 두면 컴파일이 되지 않는다. 하나만 남긴다. UserRepository 양쪽이 findByIdAndDeletedAtIsNull 을 추가했다. 나머지 조회 메서드는 서로 다르므로 둘 다 남긴다. GlobalExceptionHandler 커뮤니티(posts·users·comments)와 인증(auth) 분기를 함께 둔다. 댓글 경로가 /api/v1/posts/{postId}/comments 라 게시물보다 먼저 봐야 하는 순서를 그대로 지킨다. User updateProfile 이 changeNickname·changeProfileImage 로 나뉘어, 이를 쓰던 OAuthUserRegistrarTest 를 새 메서드로 바꾼다. ERD 같은 14절에 양쪽이 다른 users 표를 썼다. 인증 컬럼이 반영된 쪽을 쓰고 커뮤니티 15~26절을 이어 붙인다. migration 번호 양쪽이 V9 를 썼다. 두 파일이 같은 버전이면 Flyway 가 기동 자체를 거부한다. 인증 쪽 V9·V10 은 이미 dev 에 적용돼 번호를 바꿀 수 없으므로 신고 고유 제약을 V11 로 옮긴다. 전체 218건 통과. 실제 PostgreSQL 에 전체 migration 적용과 JPA 스키마 검증을 확인했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
프론트가 구글 로그인으로 받은 ID 토큰을 검증해 우리 서비스의 토큰을 발급한다.
인가 적용은 이 PR 에 없다. 사용자 API 는 그대로 공개다.
왜 인가를 넣지 않았나
비로그인 요청이 401 을 받기 시작하는 것이 유일한 파괴적 변경이라 프론트 배포와 맞춰야 한다.
이 PR 은 토큰을 주되 요구하지 않는다. 토큰을 보내면 소유자가 기록되고, 안 보내도 지금처럼
동작한다. 프론트가 자기 속도로 붙이면 되고, 다 붙은 뒤 별도 PR 에서 한 번에 전환한다.
/api/v1/admin/**만 예외다.permitAll인 채로 관리자 컨트롤러를 두면 누구나 사용자를정지시키고 게시물을 지울 수 있다. 만들기 전에 막아 둔다.
엔드포인트
POST/api/v1/auth/googlePOST/api/v1/auth/refreshPOST/api/v1/auth/logout설계 판단
구글
sub를 식별자로 쓴다. 이메일은 바뀌고 재사용된다.aud를 반드시 검증한다. 빼면 다른 서비스용으로 발급된 유효한 구글 토큰으로 우리 서비스에로그인할 수 있다. 클라이언트 ID 는 쉼표로 여러 개를 넣을 수 있다. 웹·iOS·안드로이드가 각각 다른
ID 를 쓰기 때문이다.
리프레시는 원문을 저장하지 않는다. SHA-256 해시만 Redis 에 넣는다. 공유 링크 토큰과 같은 방식이다.
리프레시를 회전시키되, 재사용과 단순 무효를 구분한다. 소비된 토큰이 다시 오면 탈취로 보고 전
기기를 끊지만, 만료·로그아웃된 토큰은 이 요청만 거절한다. 구분하지 않으면 로그아웃 직후 클라이언트가
한 번 재시도하는 것만으로 다른 기기가 전부 끊긴다. 테스트를 짜다 발견해 고쳤다.
JWT_SECRET이 없거나 32바이트 미만이면 기동을 멈춘다. 서명 키 없이 뜨면 누구나role=ADMIN토큰을 만들 수 있다. 조용히 뚫린 채 도는 것보다 안 뜨는 편이 낫다. 배포 워크플로에도 사전 확인을 넣었다.
관리자 지정은 DB 에서 한다. 로그인은 권한을 건드리지 않는다.
로그인이 권한을 다시 계산하면 DB 로 준 권한이 다음 로그인에 사라진다. 대상은 한 번은 로그인했어야 한다.
스키마
users에email,provider,provider_id,role,status,suspended_until,suspended_reason. 기존 행이 있어 provider 계열은 nullable.schedules.user_id(nullable). FastAPI 가 채우므로 이 PR 이 먼저 배포돼야 한다.인증 전에 만들어진 일정은 NULL 로 남는다.
out-of-order허용. 커뮤니티(V8)와 인증(V9)이 다른 순서로 머지돼도 기동이 깨지지 않는다.함께 고친 것
CORS 에
Authorization이 없었다. 없으면 브라우저에서 로그인 자체가 안 된다. 같은 이유로PR #78 의
X-User-Id도 preflight 에서 막히고 있었다. 하드코딩이던 것을CorsProperties로 옮겼다.TraceIdFilter가 Security 뒤에서 실행됐다. 인가로 걸러진 요청에는traceId도X-Trace-Id헤더도 로그 MDC 도 남지 않는다. 추적이 가장 필요한 실패다. 순서를 앞으로 당겼다.
X-Trace-Id가 노출 헤더가 아니었다. 다른 오리진의 스크립트가 읽을 수 없어, "오류 응답의traceId 와 같은 값" 이라는 문서가 브라우저에서 성립하지 않았다.
/api/v1/auth의 본문 검증 실패가INVALID_SCHEDULE_CONDITION으로 나갔다. PR #78 에지적한 것과 같은 누락이다.
검증
./gradlew test— 169건 통과 (기존 114 + 신규 55)로컬에서 실제로 확인한 것:
provider_id가 구글sub, 닉네임이 구글 이름, 신규는USERADMIN유지, 새 행 안 생김, 기기별 리프레시가 따로 쌓임kid로 키 조회 후 서명 검증까지 도달schedules.user_id저장과 수정 후 소유자 유지 확인배포 전 필요한 것
GitHub Secrets 2개. 없으면 워크플로가 배포 전에 멈춘다.
JWT_SECRETGOOGLE_CLIENT_IDGOOGLE_CLIENT_ID는 비밀값이 아니다. 프론트 JS 에 그대로 들어간다.순서
이 PR 을 먼저 배포하고, 그다음 data 저장소 PR 을 배포한다. 반대로 하면 없는 컬럼에 INSERT 해서
일정 생성이 전부 실패한다.
영향
/api/v1/auth/**)🤖 Generated with Claude Code