Skip to content

feat(auth): 구글 로그인과 JWT 인증 기반 - #81

Merged
SD-gif merged 2 commits into
mainfrom
feat/auth-foundation
Aug 25, 2026
Merged

feat(auth): 구글 로그인과 JWT 인증 기반#81
SD-gif merged 2 commits into
mainfrom
feat/auth-foundation

Conversation

@SD-gif

@SD-gif SD-gif commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

프론트가 구글 로그인으로 받은 ID 토큰을 검증해 우리 서비스의 토큰을 발급한다.
인가 적용은 이 PR 에 없다. 사용자 API 는 그대로 공개다.

왜 인가를 넣지 않았나

비로그인 요청이 401 을 받기 시작하는 것이 유일한 파괴적 변경이라 프론트 배포와 맞춰야 한다.
이 PR 은 토큰을 주되 요구하지 않는다. 토큰을 보내면 소유자가 기록되고, 안 보내도 지금처럼
동작한다. 프론트가 자기 속도로 붙이면 되고, 다 붙은 뒤 별도 PR 에서 한 번에 전환한다.

/api/v1/admin/** 만 예외다. permitAll 인 채로 관리자 컨트롤러를 두면 누구나 사용자를
정지시키고 게시물을 지울 수 있다. 만들기 전에 막아 둔다.

엔드포인트

Method Path 설명
POST /api/v1/auth/google 구글 ID 토큰 → 액세스·리프레시 발급
POST /api/v1/auth/refresh 회전 갱신
POST /api/v1/auth/logout 이 기기만 폐기

설계 판단

구글 sub 를 식별자로 쓴다. 이메일은 바뀌고 재사용된다.

aud 를 반드시 검증한다. 빼면 다른 서비스용으로 발급된 유효한 구글 토큰으로 우리 서비스에
로그인할 수 있다. 클라이언트 ID 는 쉼표로 여러 개를 넣을 수 있다. 웹·iOS·안드로이드가 각각 다른
ID 를 쓰기 때문이다.

리프레시는 원문을 저장하지 않는다. SHA-256 해시만 Redis 에 넣는다. 공유 링크 토큰과 같은 방식이다.

리프레시를 회전시키되, 재사용과 단순 무효를 구분한다. 소비된 토큰이 다시 오면 탈취로 보고 전
기기를 끊지만, 만료·로그아웃된 토큰은 이 요청만 거절한다. 구분하지 않으면 로그아웃 직후 클라이언트가
한 번 재시도하는 것만으로 다른 기기가 전부 끊긴다. 테스트를 짜다 발견해 고쳤다.

JWT_SECRET 이 없거나 32바이트 미만이면 기동을 멈춘다. 서명 키 없이 뜨면 누구나 role=ADMIN
토큰을 만들 수 있다. 조용히 뚫린 채 도는 것보다 안 뜨는 편이 낫다. 배포 워크플로에도 사전 확인을 넣었다.

관리자 지정은 DB 에서 한다. 로그인은 권한을 건드리지 않는다.

UPDATE users SET role = 'ADMIN' WHERE email = '...';

로그인이 권한을 다시 계산하면 DB 로 준 권한이 다음 로그인에 사라진다. 대상은 한 번은 로그인했어야 한다.

스키마

  • V9usersemail, provider, provider_id, role, status, suspended_until,
    suspended_reason. 기존 행이 있어 provider 계열은 nullable.
  • V10schedules.user_id (nullable). FastAPI 가 채우므로 이 PR 이 먼저 배포돼야 한다.
    인증 전에 만들어진 일정은 NULL 로 남는다.
  • out-of-order 허용. 커뮤니티(V8)와 인증(V9)이 다른 순서로 머지돼도 기동이 깨지지 않는다.

함께 고친 것

CORS 에 Authorization 이 없었다. 없으면 브라우저에서 로그인 자체가 안 된다. 같은 이유로
PR #78X-User-Id 도 preflight 에서 막히고 있었다. 하드코딩이던 것을 CorsProperties 로 옮겼다.

TraceIdFilter 가 Security 뒤에서 실행됐다. 인가로 걸러진 요청에는 traceIdX-Trace-Id
헤더도 로그 MDC 도 남지 않는다. 추적이 가장 필요한 실패다. 순서를 앞으로 당겼다.

X-Trace-Id 가 노출 헤더가 아니었다. 다른 오리진의 스크립트가 읽을 수 없어, "오류 응답의
traceId 와 같은 값" 이라는 문서가 브라우저에서 성립하지 않았다.

/api/v1/auth 의 본문 검증 실패가 INVALID_SCHEDULE_CONDITION 으로 나갔다. PR #78
지적한 것과 같은 누락이다.

검증

./gradlew test169건 통과 (기존 114 + 신규 55)

로컬에서 실제로 확인한 것:

  • 실제 구글 로그인 — 계정 생성, provider_id 가 구글 sub, 닉네임이 구글 이름, 신규는 USER
  • 재로그인 — DB 로 준 ADMIN 유지, 새 행 안 생김, 기기별 리프레시가 따로 쌓임
  • 리프레시 회전 — 재사용 시 전 기기 폐기, 로그아웃은 그 기기만
  • 구글 JWKS 실연결 — 실제 kid 로 키 조회 후 서명 검증까지 도달
  • 인가 — 토큰 없음 401 / 일반 사용자 403 / 관리자 통과, 위조·만료·발급자 불일치 401
  • 일정 소유권 — FastAPI 를 로컬에 띄워 schedules.user_id 저장과 수정 후 소유자 유지 확인

배포 전 필요한 것

GitHub Secrets 2개. 없으면 워크플로가 배포 전에 멈춘다.

이름 비고
JWT_SECRET 32바이트 이상
GOOGLE_CLIENT_ID 프론트가 쓰는 값과 같아야 한다. 다르면 모든 로그인이 401

GOOGLE_CLIENT_ID 는 비밀값이 아니다. 프론트 JS 에 그대로 들어간다.

순서

이 PR 을 먼저 배포하고, 그다음 data 저장소 PR 을 배포한다. 반대로 하면 없는 컬럼에 INSERT 해서
일정 생성이 전부 실패한다.

영향

  • API 추가 (/api/v1/auth/**)
  • DB·ERD 변경 (V9, V10)
  • 기존 API 계약 변경 없음
  • 파괴적 변경 없음

🤖 Generated with Claude Code

프론트가 구글 로그인으로 받은 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>
@github-actions

Copy link
Copy Markdown

🤖 Gemini PR Review

⚠️ PR 변경량이 커서 Diff 일부만 분석했습니다.
리뷰되지 않은 파일이나 코드가 존재할 수 있으니 사람이 추가로 확인해야 합니다.

1. [HIGH] OAuthUserRegistrar.createNew에서 DataIntegrityViolationException 발생 시 트랜잭션 롤백 오류

  • 파일: src/main/java/com/server/auth/service/OAuthUserRegistrar.java
  • 라인: 55-74
  • 문제점:
    register 메서드에 @Transactional이 선언되어 있어 하나의 트랜잭션 범위 내에서 createNew가 실행됩니다. 이 상태에서 userRepository.saveAndFlush 호출 시 닉네임 중복 등으로 인해 DataIntegrityViolationException이 발생하면, Spring의 트랜잭션 관리자는 현재 트랜잭션을 rollback-only 상태로 마킹합니다.
    이후 catch 블록에서 예외를 포착하여 for 루프를 통해 재시도를 하더라도, 이미 롤백 전용으로 마킹된 트랜잭션이기 때문에 최종적으로 메서드가 종료되는 시점에 UnexpectedRollbackException이 발생하며 전체 가입 프로세스가 실패합니다. 결과적으로 작성하신 재시도 로직이 정상적으로 동작하지 않습니다.
  • 개선 제안:
    닉네임 충돌 시 재시도하는 저장 로직을 상위 트랜잭션과 분리해야 합니다. 가입 처리를 담당하는 전용 컴포넌트를 분리하여 @Transactional(propagation = Propagation.REQUIRES_NEW)를 적용함으로써, 각 저장 시도가 독립된 트랜잭션에서 수행되도록 해야 합니다.
  • 개선 예시:
@Component
public class OAuthUserRegistrar {
    private final UserRepository userRepository;
    private final UserTransactionExecutor userTransactionExecutor;

    public OAuthUserRegistrar(UserRepository userRepository, UserTransactionExecutor userTransactionExecutor) {
        this.userRepository = userRepository;
        this.userTransactionExecutor = userTransactionExecutor;
    }

    @Transactional
    public User register(GoogleIdentity identity) {
        return userRepository
                .findByProviderAndProviderIdAndDeletedAtIsNull(AuthProvider.GOOGLE, identity.subject())
                .map(existing -> {
                    existing.syncFromProvider(identity.email(), identity.pictureUrl());
                    return existing;
                })
                .orElseGet(() -> createNew(identity));
    }

    private User createNew(GoogleIdentity identity) {
        for (int attempt = 0; attempt < NICKNAME_RETRY_LIMIT; attempt++) {
            try {
                String nickname = uniqueNickname(identity.name());
                return userTransactionExecutor.saveNewUser(identity, nickname);
            } catch (DataIntegrityViolationException exception) {
                log.info("Nickname taken while creating user. retrying. attempt={}", attempt + 1);
            }
        }
        throw new BusinessException(ErrorCode.INTERNAL_ERROR);
    }
}

@Component
public class UserTransactionExecutor {
    private final UserRepository userRepository;

    public UserTransactionExecutor(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public User saveNewUser(GoogleIdentity identity, String nickname) {
        return userRepository.saveAndFlush(User.ofOAuth(
                AuthProvider.GOOGLE,
                identity.subject(),
                identity.email(),
                nickname,
                identity.pictureUrl(),
                UserRole.USER));
    }
}

2. [MEDIUM] AuthService.refresh 메서드의 부적절한 @Transactional(readOnly = true) 사용

  • 파일: src/main/java/com/server/auth/service/AuthService.java
  • 라인: 56-80
  • 문제점:
    refresh 메서드는 @Transactional(readOnly = true)로 선언되어 있습니다. 하지만 내부적으로 refreshTokenStore.consumeissueFor를 호출하여 Redis의 데이터를 삭제하고 새로운 리프레시 토큰을 삽입하는 쓰기(State-changing) 작업을 수행하고 있습니다.
    Redis 작업은 Spring의 JPA 트랜잭션 매니저의 직접적인 제어를 받지 않아 런타임 에러가 발생하지는 않지만, "읽기 전용"으로 명시된 메서드 내부에서 상태 변경이 일어나는 구조는 코드의 의도를 오해하게 만들며 유지보수 시 혼란을 초래할 수 있습니다.
  • 개선 제안:
    refresh 메서드에서 @Transactional(readOnly = true) 어노테이션을 제거하고, 단순 DB 조회 작업만 필요한 경우에 한해 내부적으로 별도의 읽기 전용 메서드나 쿼리 서비스를 호출하도록 변경하는 것이 바람직합니다.
  • 개선 예시:
    // @Transactional(readOnly = true) 제거
    public AuthTokenResponse refresh(String refreshToken) {
        RefreshTokenStore.RefreshTokenRef ref = RefreshTokenStore.parse(refreshToken)
                .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_TOKEN));

        User user = userRepository.findByIdAndDeletedAtIsNull(ref.userId())
                .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_TOKEN));

        if (!refreshTokenStore.consume(ref.userId(), ref.token())) {
            // ... 후속 처리

3. [MEDIUM] 배포 스크립트 내 Redis 컨테이너 실행 시 도커 네트워크 존재 여부 미보장

  • 파일: .github/workflows/deploy-dev.yml
  • 라인: 296-310
  • 문제점:
    배포 스크립트에서 redis 컨테이너를 실행할 때 --network hackathon-network 옵션을 사용하고 있습니다. 만약 대상 호스트 서버에 hackathon-network라는 이름의 도커 네트워크가 미리 생성되어 있지 않다면, docker run 명령이 실패하여 전체 배포 워크플로우가 중단됩니다.
  • 개선 제안:
    docker run을 실행하기 전에 해당 네트워크가 존재하는지 확인하고, 없을 경우 자동으로 생성하는 스크립트를 추가하여 배포 안정성을 확보해야 합니다.
  • 개선 예시:
              echo "Ensure redis container"

              # 네트워크 존재 여부 확인 후 없으면 생성
              docker network inspect hackathon-network >/dev/null 2>&1 || \
                docker network create hackathon-network

              if [ -z "$(docker ps -q -f name=^redis$)" ]; then
                docker rm redis || true
                docker run -d \
                  --name redis \
                  --restart unless-stopped \
                  --network hackathon-network \
                  redis:7-alpine \
                  redis-server --save 60 1 --appendonly no
              fi

Model: `gemini-3.5-flash` · API key: `PRIMARY` · Commit: `5657cf0`

@SD-gif
SD-gif merged commit d01208a into main Aug 25, 2026
2 checks passed
@SD-gif
SD-gif deleted the feat/auth-foundation branch August 25, 2026 06:33
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant