블로그로 돌아가기
Engineering

JWT + OAuth2 인증 아키텍처 — Access Token, Refresh Token, 소셜 로그인 통합 설계

AES-256 암호화 JWT, httpOnly 쿠키 Refresh Token, 카카오·구글·애플 OAuth2 소셜 로그인을 하나의 인증 시스템으로 통합하기까지의 설계 과정과 Spring Security 구현

Team Somebling ·
#JWT #OAuth2 #Spring Security #인증 #소셜 로그인

“로그인 하나에 이렇게 많은 게 필요하다고?”

사용자 입장에서 로그인은 단순합니다. 버튼을 탭하거나 이메일과 비밀번호를 입력하면 앱에 들어옵니다. 하지만 그 뒤에서 일어나는 일은 전혀 단순하지 않습니다.

썸블링을 설계하면서 인증 시스템에 가장 많은 시간을 쏟았습니다. 표면적인 이유는 간단합니다. 인증 버그는 치명적입니다. 다른 사람의 계정에 접근할 수 있거나, 토큰이 탈취되거나, 세션이 예기치 않게 만료되면 사용자는 앱을 삭제합니다. 특히 데이팅 앱은 개인정보가 집약된 서비스이기 때문에 보안에 대한 신뢰가 핵심입니다.

더 복잡한 이유도 있습니다. 썸블링은 처음부터 다섯 가지 인증 수단을 지원해야 했습니다. 이메일+비밀번호, 전화번호(Firebase SMS), 카카오, 구글, 애플. 이 다섯 가지를 따로따로 구현하면 코드가 분산되고 유지보수가 어렵습니다. 반면 하나의 일관된 JWT 시스템으로 통합하면 인증 이후의 로직이 단순해집니다. 어떤 방법으로 로그인했든 서버는 동일한 JWT를 발급하고, 클라이언트는 동일한 방식으로 API를 호출합니다.

이 글은 그 통합 과정에서 겪은 고민과 결정들을 정리한 것입니다.


인증 수단이 5개인 이유

한국 시장을 타겟으로 하는 앱에서 카카오 로그인은 거의 필수입니다. 한국 인터넷 사용자의 절대다수가 카카오 계정을 보유하고 있고, 별도 회원가입 없이 원탭으로 진입할 수 있다는 점에서 전환율이 압도적으로 높습니다. 초기 베타 테스트에서도 카카오 로그인 사용 비율이 전체의 60%를 넘었습니다.

전화번호 인증은 그 다음입니다. 데이팅 앱 특성상 실명 또는 실제 연락처 기반의 신원 확인이 중요합니다. Firebase Phone Auth를 활용하면 SMS OTP 발송부터 검증까지 인프라를 직접 구축하지 않고도 안정적인 전화번호 인증을 제공할 수 있습니다.

구글 로그인은 Android 사용자를 위한 필수 수단입니다. 애플 로그인은 iOS 앱스토어 정책상 소셜 로그인을 지원하는 앱은 반드시 “Sign in with Apple”을 제공해야 하므로 선택이 아닌 의무입니다.

이메일+비밀번호 로그인은 소셜 로그인을 원하지 않는 사용자를 위해 남겨뒀습니다. 개인정보에 민감하거나 계정 분리를 원하는 사용자가 실제로 존재합니다.

Provider특성주요 사용자
LOCAL이메일+비밀번호, bcrypt 해시소셜 로그인 거부 사용자
KAKAOOAuth2, 카카오 계정 연동한국 사용자 (전환율 최고)
GOOGLEOAuth2, Google 계정 연동Android 사용자
APPLEOAuth2, Apple ID 연동iOS 사용자 (앱스토어 필수)
PHONEFirebase Phone Auth, SMS OTP실명 인증 선호 사용자

이 다섯 가지를 하나의 시스템으로 묶는 핵심은 member_auths 테이블입니다. 하나의 member가 여러 member_auth를 가질 수 있는 구조로, 동일 회원이 카카오로도, 이메일로도 로그인할 수 있습니다. 어떤 방법으로 로그인하든 인증 이후에는 동일한 JWT가 발급됩니다.


인증 아키텍처 전체 흐름

sequenceDiagram
    participant C as Client (Next.js)
    participant S as Server (Spring Boot)
    participant DB as PostgreSQL
    participant R as Redis
    participant K as Kakao/Google/Apple

    Note over C,S: 이메일 로그인 흐름
    C->>S: POST /api/v1/auth/login (email, password)
    S->>DB: member_auths WHERE provider='LOCAL' AND email=?
    S->>S: bcrypt 비밀번호 검증
    S->>S: AES-256 암호화 JWT Access Token 생성
    S->>R: SET member:{id}:refresh {refreshToken} EX 1209600
    S-->>C: Access Token (body) + Refresh Token (httpOnly Cookie)

    Note over C,S: 소셜 로그인 흐름
    C->>K: OAuth2 인증 요청 (client_id, redirect_uri)
    K-->>C: Authorization Code
    C->>S: POST /api/v1/auth/oauth2/callback?code=...&provider=KAKAO
    S->>K: POST token endpoint (code 교환)
    K-->>S: OAuth2 Access Token
    S->>K: GET userinfo endpoint
    K-->>S: 사용자 프로필 (이메일, 닉네임, provider_id)
    S->>DB: member_auths WHERE provider=KAKAO AND provider_id=?
    alt 신규 사용자
        S->>DB: members INSERT + member_auths INSERT
    else 기존 사용자
        S->>DB: member_auths 조회
    end
    S->>S: JWT Access Token 생성
    S->>R: Refresh Token 저장
    S-->>C: Token Response

    Note over C,S: 전화번호 로그인 흐름
    C->>K: Firebase signInWithPhoneNumber (SMS OTP)
    K-->>C: Firebase ID Token
    C->>S: POST /api/v1/auth/phone-login (firebaseIdToken)
    S->>K: Firebase Admin SDK 토큰 검증
    K-->>S: 전화번호 정보
    S->>DB: member_auths WHERE provider=PHONE AND email=phoneNumber
    S->>S: JWT 발급
    S-->>C: Token Response

세 가지 인증 흐름이 Provider마다 다르지만, JWT 발급 이후는 완전히 동일합니다. 서버는 토큰에서 memberId를 추출하고 DB에서 회원 정보를 조회합니다. 클라이언트는 Authorization: Bearer {token} 헤더만 붙이면 됩니다.


JWT 설계: 왜 AES-256 암호화인가

JWT의 payload는 기본적으로 Base64로 인코딩되어 있어 누구나 디코딩할 수 있습니다. 토큰을 가진 사람은 memberId, role, email 같은 정보를 평문으로 읽을 수 있습니다. 서명(Signature)이 있으니 위변조는 불가능하지만, 정보 노출 자체는 막지 못합니다.

썸블링은 JWT payload를 AES-256-GCM으로 암호화합니다. 토큰을 탈취하더라도 payload 내용을 알 수 없습니다.

/**
 * JWT 토큰 생성 (AES-256 암호화 적용)
 *
 * @author Rojae
 */
@Component
public class JwtTokenProvider {

    private final String secretKey;
    private final long accessTokenExpiration;
    private final AesEncryptor aesEncryptor;

    public JwtTokenProvider(
            @Value("${aro.jwt.secret}") String secretKey,
            @Value("${aro.jwt.access-expiration:1800000}") long accessTokenExpiration,
            AesEncryptor aesEncryptor) {
        // 기본값 폴백 없음 — 환경변수 미설정 시 즉시 실패
        this.secretKey = secretKey;
        this.accessTokenExpiration = accessTokenExpiration;
        this.aesEncryptor = aesEncryptor;
    }

    /**
     * Access Token 생성
     *
     * @param memberId 회원 ID
     * @param role     회원 권한
     * @return AES-256 암호화된 JWT
     */
    public String createAccessToken(Long memberId, MemberRole role) {
        // payload를 암호화하여 claim에 저장
        String encryptedPayload = aesEncryptor.encrypt(
            String.format("%d:%s", memberId, role.name())
        );

        return Jwts.builder()
                .claim("data", encryptedPayload)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
                .signWith(Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)))
                .compact();
    }

    /**
     * 토큰에서 회원 ID 추출
     *
     * @param token JWT 토큰
     * @return 회원 ID
     */
    public Long extractMemberId(String token) {
        String encryptedData = parseClaims(token).get("data", String.class);
        String decrypted = aesEncryptor.decrypt(encryptedData);
        return Long.parseLong(decrypted.split(":")[0]);
    }
}

기본값 폴백 제거는 프로덕션 업그레이드 과정에서 명시적으로 적용한 보안 정책입니다. 환경변수 ARO_JWT_SECRET이 설정되지 않으면 애플리케이션이 시작 시점에 실패합니다. “혹시 몰라” 설정된 기본 시크릿 키가 실수로 프로덕션에서 사용되는 사고를 원천 차단합니다.

Access Token vs Refresh Token

속성Access TokenRefresh Token
만료 시간30분14일
저장 위치클라이언트 localStoragehttpOnly Cookie
전달 방식Authorization: Bearer 헤더쿠키 자동 전송
탈취 위험XSS에 노출될 수 있음XSS로 접근 불가
용도API 인증Access Token 갱신

Access Token을 localStorage에 저장하는 것은 XSS 공격에 노출될 수 있다는 단점이 있습니다. 하지만 Refresh Token을 httpOnly + Secure + SameSite=Strict 쿠키로 분리함으로써 실질적인 피해를 제한합니다. Access Token이 탈취되더라도 30분 후 만료되고, Refresh Token 없이는 갱신이 불가능합니다.

# application-prod.yml
aro:
  jwt:
    secret: ${ARO_JWT_SECRET}           # 환경변수 필수 (기본값 없음)
    access-expiration: 1800000          # 30분
    refresh-expiration: 1209600000      # 14일
  cookie:
    secure: true                        # prod 환경에서만 Secure 플래그
    same-site: Strict

토큰 갱신 메커니즘

sequenceDiagram
    participant C as Client (Next.js)
    participant AX as Axios Interceptor
    participant S as Server
    participant R as Redis

    C->>AX: API 요청
    AX->>S: GET /api/v1/members/me (만료된 Access Token)
    S-->>AX: 401 Unauthorized

    Note over AX: 토큰 갱신 시도
    AX->>S: POST /api/v1/auth/refresh (Cookie: refreshToken 자동 전송)
    S->>R: GET member:{memberId}:refresh
    R-->>S: 저장된 Refresh Token
    S->>S: 쿠키 토큰과 Redis 토큰 일치 확인
    S->>S: 새 Access Token 생성
    S->>R: SET member:{memberId}:refresh {newRefreshToken} EX 1209600
    Note over S: Refresh Token Rotation
    S-->>AX: 새 Access Token (body) + 새 Refresh Token (Cookie)

    AX->>AX: localStorage에 새 Access Token 저장
    AX->>S: 원래 API 요청 재시도 (새 Access Token)
    S-->>C: 200 OK

    Note over C,S: 갱신 실패 시
    AX->>AX: handleAuthFailure()
    AX-->>C: /login 리다이렉트

Refresh Token Rotation이 핵심입니다. 토큰을 갱신할 때마다 기존 Refresh Token을 폐기하고 새로운 토큰을 발급합니다. Refresh Token이 탈취되더라도 한 번 사용되면 무효화됩니다. 탈취한 공격자가 먼저 갱신하면 정상 사용자의 갱신이 실패하고, 이를 통해 계정 탈취를 감지할 수 있습니다.

Redis에서의 저장 구조는 다음과 같습니다.

# Refresh Token 저장
SET member:123456:refresh "eyJhbGci..." EX 1209600

# 갱신 시: 기존 삭제 + 새 토큰 저장 (원자적 처리)
DEL member:123456:refresh
SET member:123456:refresh "eyJhbGci...new" EX 1209600

# 로그아웃 시: 즉시 무효화
DEL member:123456:refresh

TTL 14일 설정의 장점은 Redis가 알아서 만료된 토큰을 정리한다는 것입니다. 별도 배치 작업 없이 자연스럽게 만료됩니다.


member_auths 테이블: 다중 인증의 핵심

다섯 가지 인증 수단을 하나의 회원 계정으로 통합하는 열쇠는 member_auths 테이블 설계에 있습니다.

CREATE TABLE member_auths (
    id         BIGSERIAL PRIMARY KEY,
    member_id  BIGINT    NOT NULL REFERENCES members(id),
    provider   VARCHAR   NOT NULL,          -- LOCAL, KAKAO, GOOGLE, APPLE, PHONE
    email      VARCHAR   NOT NULL,
    password   VARCHAR,                     -- LOCAL 인증 시만 사용 (bcrypt)
    provider_id VARCHAR                     -- 소셜 로그인 제공자 ID
);

-- provider와 email 조합은 유일
CREATE UNIQUE INDEX idx_member_auths_provider_email
    ON member_auths (provider, email);
erDiagram
    members ||--o{ member_auths : "1:N"
    members {
        bigint id PK
        varchar nickname
        varchar email
        enum status
        enum role
    }
    member_auths {
        bigint id PK
        bigint member_id FK
        enum provider
        varchar email
        varchar password
        varchar provider_id
    }
    members ||--o| matching_profiles : "1:1"
    matching_profiles {
        bigint id PK
        bigint member_id FK
        jsonb matching_vector
        boolean is_active
    }

(provider, email) 조합이 UNIQUE인 이유는 동일한 카카오 계정으로 중복 가입을 막기 위해서입니다. 카카오 로그인으로 이미 가입한 사용자가 다시 카카오 로그인을 시도하면 기존 member_auth를 찾아 로그인 처리합니다.

/**
 * OAuth2 소셜 로그인 처리
 * 신규 사용자는 자동 회원가입, 기존 사용자는 로그인
 *
 * @author Rojae
 */
@Service
@RequiredArgsConstructor
public class AuthService {

    private final MemberRepository memberRepository;
    private final MemberAuthRepository memberAuthRepository;
    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshTokenStore refreshTokenStore;

    /**
     * 소셜 로그인 통합 처리
     *
     * @param provider     인증 제공자 (KAKAO, GOOGLE, APPLE)
     * @param email        소셜 계정 이메일
     * @param providerId   소셜 제공자 사용자 ID
     * @param nickname     소셜 프로필 닉네임 (신규 가입 시 사용)
     * @return 발급된 토큰 정보
     */
    @Transactional
    public TokenResponse socialLogin(Provider provider, String email,
                                      String providerId, String nickname) {
        // 기존 member_auth 조회
        Optional<MemberAuth> existingAuth = memberAuthRepository
                .findByProviderAndEmail(provider, email);

        Member member;
        if (existingAuth.isPresent()) {
            // 기존 사용자: member_auth → member 조회
            member = existingAuth.get().getMember();
        } else {
            // 신규 사용자: member + member_auth 생성
            member = Member.createSocialMember(nickname, email);
            memberRepository.save(member);

            MemberAuth auth = MemberAuth.createSocialAuth(
                member, provider, email, providerId
            );
            memberAuthRepository.save(auth);
        }

        return issueTokens(member);
    }

    /**
     * JWT 발급 및 Refresh Token Redis 저장
     *
     * @param member 인증된 회원
     * @return Access Token + Refresh Token 정보
     */
    private TokenResponse issueTokens(Member member) {
        String accessToken = jwtTokenProvider.createAccessToken(
            member.getId(), member.getRole()
        );
        String refreshToken = jwtTokenProvider.createRefreshToken(member.getId());

        // Redis에 Refresh Token 저장 (TTL 14일)
        refreshTokenStore.save(member.getId(), refreshToken);

        return TokenResponse.of(accessToken, refreshToken);
    }
}

소셜 로그인의 UPSERT 패턴은 회원가입과 로그인을 하나의 API로 처리합니다. 사용자 입장에서는 “카카오로 계속하기” 버튼 하나면 충분합니다.


Spring Security 설정: Provider별 분기

Spring Security의 OAuth2 클라이언트 기능을 활용하면 카카오, 구글, 애플 각각의 인증 서버와 통신하는 코드를 크게 줄일 수 있습니다. 하지만 각 Provider마다 사용자 정보 응답 형식이 다르기 때문에 커스텀 OAuth2UserService 구현이 필요합니다.

/**
 * OAuth2 사용자 정보 로드 서비스
 * 각 Provider별 응답 형식 차이를 추상화
 *
 * @author Rojae
 */
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final AuthService authService;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String providerName = userRequest.getClientRegistration()
                                          .getRegistrationId()
                                          .toUpperCase();
        Provider provider = Provider.valueOf(providerName);

        // Provider별 사용자 정보 추출 전략
        OAuth2UserInfo userInfo = switch (provider) {
            case KAKAO  -> KakaoUserInfo.from(oAuth2User.getAttributes());
            case GOOGLE -> GoogleUserInfo.from(oAuth2User.getAttributes());
            case APPLE  -> AppleUserInfo.from(oAuth2User.getAttributes());
            default     -> throw new IllegalArgumentException("지원하지 않는 Provider: " + provider);
        };

        TokenResponse tokens = authService.socialLogin(
            provider,
            userInfo.getEmail(),
            userInfo.getProviderId(),
            userInfo.getNickname()
        );

        return new AroOAuth2User(oAuth2User, tokens);
    }
}

카카오의 경우 사용자 정보가 kakao_account.email처럼 중첩된 구조로 오기 때문에 별도 파싱 로직이 필요합니다. 구글은 평탄한 구조라 비교적 간단합니다. 애플은 최초 로그인 시에만 이름 정보를 제공하고 이후에는 제공하지 않는 특이한 정책이 있어 별도 처리가 필요합니다.

# application.yml — OAuth2 클라이언트 설정
spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_CLIENT_SECRET}
            redirect-uri: "{baseUrl}/api/v1/auth/oauth2/callback/kakao"
            authorization-grant-type: authorization_code
            scope: profile_nickname, account_email
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            redirect-uri: "{baseUrl}/api/v1/auth/oauth2/callback/google"
            scope: email, profile
          apple:
            client-id: ${APPLE_CLIENT_ID}
            client-secret: ${APPLE_CLIENT_SECRET}   # JWT 형태 (P8 키로 생성)
            redirect-uri: "{baseUrl}/api/v1/auth/oauth2/callback/apple"

프론트엔드 인터셉터: 토큰 갱신 자동화

사용자가 앱을 사용하는 도중 Access Token이 만료되면 어떻게 될까요? 아무것도 모르는 사용자 입장에서는 갑자기 API가 실패하고 앱이 이상하게 동작하는 것처럼 보입니다. 이를 방지하기 위해 Axios 인터셉터에서 토큰 갱신을 완전히 자동화했습니다.

// src/lib/api.ts

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  timeout: 10000,
  headers: { 'Content-Type': 'application/json' },
  withCredentials: true, // httpOnly 쿠키 (Refresh Token) 자동 전송
});

// 요청 인터셉터: Access Token 헤더 주입
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 진행 중인 갱신 요청 추적 (동시 401 처리)
let isRefreshing = false;
let failedQueue: Array<{
  resolve: (token: string) => void;
  reject: (error: unknown) => void;
}> = [];

const processQueue = (error: unknown, token: string | null = null) => {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) reject(error);
    else resolve(token!);
  });
  failedQueue = [];
};

// 응답 인터셉터: 401 시 자동 토큰 갱신
api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as AxiosRequestConfig & {
      _retry?: boolean;
    };

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // 이미 갱신 중이면 큐에 대기
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then((token) => {
          originalRequest.headers!.Authorization = `Bearer ${token}`;
          return api(originalRequest);
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        // Refresh Token은 쿠키로 자동 전송됨
        const { data } = await api.post('/api/v1/auth/refresh');
        const newToken = data.data.accessToken;

        localStorage.setItem('accessToken', newToken);
        processQueue(null, newToken);

        originalRequest.headers!.Authorization = `Bearer ${newToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        handleAuthFailure(); // 로그인 페이지로 리다이렉트
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

isRefreshing 플래그와 failedQueue 처리가 중요합니다. 사용자가 여러 API를 동시에 호출하다가 토큰이 만료되면, 여러 개의 401이 동시에 발생합니다. 이때 각각이 독립적으로 갱신을 시도하면 충돌이 생깁니다. 큐 방식으로 처리하면 갱신 요청은 한 번만 보내고, 나머지 실패한 요청들은 갱신이 완료된 후 새 토큰으로 일괄 재시도합니다.

handleAuthFailure()는 Refresh Token도 만료되거나 유효하지 않을 때 호출됩니다. localStorage를 비우고 로그인 페이지로 리다이렉트합니다. 이때 사용자가 보던 페이지의 경로를 redirect 파라미터로 저장해 로그인 후 원래 위치로 돌아올 수 있게 합니다.


Next.js Middleware: 서버 사이드 인증 가드

클라이언트 인터셉터만으로는 충분하지 않습니다. 사용자가 브라우저 주소창에 /home을 직접 입력하거나 북마크로 접근하면, JavaScript가 로드되기 전에 서버에서 먼저 페이지를 렌더링합니다. 이때 인증되지 않은 사용자가 보호된 페이지의 HTML을 받아가는 문제가 생깁니다.

// src/middleware.ts

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const PUBLIC_PATHS = ['/', '/login', '/signup', '/quiz', '/privacy', '/terms'];
const ADMIN_PATHS = ['/admin'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 공개 경로는 통과
  if (PUBLIC_PATHS.some((path) => pathname.startsWith(path))) {
    return NextResponse.next();
  }

  // 토큰 확인 (쿠키에서)
  const token = request.cookies.get('accessToken')?.value;

  if (!token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('redirect', pathname);
    return NextResponse.redirect(loginUrl);
  }

  // 관리자 경로: role 확인
  if (ADMIN_PATHS.some((path) => pathname.startsWith(path))) {
    // JWT decode (검증 없이 payload만 파싱)
    // 실제 검증은 API 서버에서 수행
    const payload = decodeJwtPayload(token);
    if (payload?.role !== 'ADMIN') {
      return NextResponse.redirect(new URL('/home', request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

미들웨어에서는 JWT의 서명 검증 없이 payload만 파싱합니다. 서명 검증을 위해서는 시크릿 키가 필요한데, Next.js 미들웨어는 Edge Runtime에서 동작하기 때문에 Node.js 전용 암호화 라이브러리를 사용할 수 없습니다. 대신 실제 권한 검증은 Spring Security가 모든 API 요청에서 수행합니다. 미들웨어는 단순 UX 가드 역할이고, 보안의 마지막 방어선은 서버입니다.


보안 체크리스트

인증 시스템을 구현하면서 적용한 보안 사항들을 정리합니다.

항목적용 여부구현 방식
JWT 시크릿 기본값 폴백 제거환경변수 미설정 시 애플리케이션 시작 실패
AES-256 payload 암호화JWT claim 내 데이터 암호화
Refresh Token httpOnly CookieXSS로 JS 접근 불가
Refresh Token Rotation갱신 시마다 새 토큰 발급
Cookie Secure 플래그prod 프로파일에서만 활성화
Cookie SameSite=StrictCSRF 공격 방어
CORS 와일드카드 제거허용 Origin 환경변수로 명시
Rate Limiting (로그인)1분당 5회 제한
Rate Limiting (회원가입)1시간당 3회 제한
bcrypt 비밀번호 해시strength 12
Admin API 별도 포트 분리8080 (사용자) / 8081 (관리자)
Admin API 인증 강화hasRole('ADMIN') + IP 화이트리스트

CORS 설정은 처음에 와일드카드(*)로 편하게 뒀다가 프로덕션 업그레이드 과정에서 명시적으로 변경했습니다.

// 변경 전 (개발 편의 목적)
configuration.addAllowedOrigin("*");

// 변경 후 (환경변수 기반)
String allowedOrigins = environment.getProperty("aro.cors.allowed-origins");
Arrays.stream(allowedOrigins.split(","))
      .map(String::trim)
      .forEach(configuration::addAllowedOrigin);

인증 흐름 디버깅 경험

개발 과정에서 가장 골치 아팠던 문제는 카카오 로그인 후 무한 리다이렉트였습니다.

증상은 다음과 같았습니다. 카카오 로그인 버튼을 누르면 카카오 인증 화면으로 이동하고, 인증 후 콜백 URL로 돌아오지만 다시 카카오 인증 화면으로 이동하는 루프가 반복됩니다.

원인은 OAuth2 콜백 처리 흐름에 있었습니다. Spring Security의 OAuth2 성공 핸들러에서 JWT를 발급하고 프론트엔드로 리다이렉트하는 과정에서, 리다이렉트 URL에 Access Token을 쿼리 파라미터로 전달했는데 이것이 브라우저 히스토리에 남았습니다. Next.js 미들웨어가 히스토리의 URL을 처리하면서 쿼리 파라미터가 사라진 URL을 “토큰 없음”으로 판단해 다시 로그인으로 보내는 악순환이었습니다.

해결책은 두 가지를 조합했습니다. 첫째, 리다이렉트 시 Access Token을 쿼리 파라미터가 아닌 세션 스토리지에 임시 저장하도록 변경했습니다. 둘째, Next.js 미들웨어에서 OAuth2 콜백 경로를 PUBLIC_PATHS에 추가해 인증 가드를 우회하도록 했습니다.

또 다른 문제는 동시 API 호출 시 중복 갱신이었습니다. 홈 화면 진입 시 매칭 정보, 채팅 읽지 않은 수, 사용자 정보를 동시에 요청하는데, 모두 401이 돌아오면 세 번의 갱신 요청이 나갔습니다. 이것을 위에서 설명한 failedQueue 패턴으로 해결했습니다.


회고: 인증은 끝나지 않는다

인증 시스템은 한 번 만들고 끝나는 게 아닙니다. 썸블링의 인증 코드는 지금도 조금씩 바뀌고 있습니다.

초기에는 “일단 돌아가게 만들자”는 마음으로 기본값 폴백도 있었고, CORS 와일드카드도 있었습니다. 프로덕션 업그레이드를 준비하면서 그것들을 하나씩 걷어내는 과정이 있었습니다. 시크릿 키 기본값 하나를 제거하는 PR을 올리면서 “이게 무슨 의미가 있나” 싶었는데, 실제로 팀원 한 명이 개발 환경에서 export ARO_JWT_SECRET=... 없이 서버를 띄우다가 즉시 실패하는 것을 보고 환경변수 설정을 빠뜨렸다는 것을 바로 알아챘습니다. 그게 프로덕션이었다면 이야기가 달랐을 것입니다.

향후 계획이 있다면 두 가지입니다.

Passkey 지원. FIDO2 기반의 Passkey는 비밀번호 없는 인증의 표준이 되어가고 있습니다. 지문이나 Face ID로 로그인하는 경험은 이미 일부 사용자들이 기대하는 수준입니다. Spring Security 6.4부터 WebAuthn 지원이 들어왔기 때문에 통합이 어렵지 않을 것입니다.

토큰 블랙리스트 개선. 현재 로그아웃은 Redis에서 Refresh Token을 삭제하는 방식입니다. Access Token은 만료까지 기다려야 합니다. 30분이라는 시간이 짧기는 하지만, 계정 탈취가 의심되는 상황에서는 즉각적인 Access Token 무효화가 필요합니다. Redis Set에 블랙리스트를 관리하는 방식을 검토 중입니다.

로그인 버튼 하나의 뒤에는 이 모든 것이 있습니다. 사용자가 그것을 모를수록 잘 만든 것입니다.

댓글

GitHub 계정으로 로그인하여 댓글을 남길 수 있습니다. 허위사실, 욕설, 사칭 등의 댓글은 통보 없이 삭제될 수 있습니다.

블로그로 돌아가기