💻 백엔드

🔐 Spring Boot로 소셜 로그인 완벽 구현하기 - OAuth2 + JWT 조합의 힘

twoweekhee 2025. 6. 10. 15:08

 

안녕하세요! 👋 최근에 Spring Boot 기반의 소셜 로그인 시스템을 구축하면서 정말 많은 것들을 배웠는데요, 오늘은 그 경험을 여러분과 공유해보려고 합니다.

요즘 웬만한 서비스에는 '구글로 로그인', '카카오로 로그인' 버튼이 기본이잖아요? 사용자 입장에서는 편리하고, 개발자 입장에서는... 음, 처음엔 좀 복잡하게 느껴질 수 있어요. 하지만 한 번 제대로 구현해보면 생각보다 재미있는 기능이에요! 😄

 

📋 목차

  1. 프로젝트 소개
  2. 기술 스택 선택 이유
  3. OAuth2 구조 설계
  4. Security 설정의 핵심
  5. 인증 플로우 완전 정복
  6. JWT 토큰 관리 전략
  7. 보안 강화 포인트
  8. 환경 설정 가이드
  9. 마무리 및 회고

🎯 프로젝트 소개

이번 프로젝트의 목표는 명확했어요. 사용자 경험은 편리하게, 보안은 탄탄하게! 🛡️

주요 기능들을 살펴보면:

  • 다중 소셜 로그인 지원: Google, Kakao, Naver를 모두 지원해서 사용자가 선택의 폭이 넓어요
  • JWT 기반 인증: Access Token과 Refresh Token을 활용한 안전한 토큰 관리
  • 쿠키 기반 저장: XSS 공격을 방지하는 HttpOnly 쿠키 사용
  • 자동 회원가입: 소셜 로그인 시 별도 가입 절차 없이 자동으로 계정 생성
  • 약관 동의 프로세스: 법적 요구사항을 충족하는 약관 동의 시스템

처음에는 "소셜 로그인이 그렇게 어려울까?"라고 생각했는데, 막상 해보니 고려할 것들이 정말 많더라고요. 특히 보안 부분에서는 더욱 신경써야 할 것들이 많았어요.

상세 코드 (꼭 보고.. star..부탁)

https://github.com/twoweekhee/oauth2-demo

🛠 기술 스택 선택 이유

기술 스택을 선택할 때는 각각 나름의 이유가 있었어요:

백엔드 기술

  • Java 21: 최신 LTS 버전으로 성능 개선과 새로운 기능들을 활용
  • Spring Boot 3.4.5: 최신 Spring Security와의 호환성, 향상된 OAuth2 지원
  • Spring Security + OAuth2 Client: 소셜 로그인의 표준이자 가장 안정적인 선택
  • JWT (jjwt 0.12.3): 무상태 인증을 위한 토큰 기반 시스템

데이터베이스 & 캐시

  • MySQL: 관계형 데이터 저장을 위한 신뢰할 수 있는 선택
  • Redis: Refresh Token 관리와 세션 캐싱을 위한 고성능 인메모리 DB

기타 도구들

  • QueryDSL: 타입 안전한 쿼리 작성
  • Lombok: 보일러플레이트 코드 제거

Spring Boot 3.x 버전을 선택한 가장 큰 이유는 OAuth2 관련 설정이 이전 버전보다 훨씬 직관적으로 개선되었기 때문이에요. 특히 OAuth2AuthorizedClientService와 관련된 설정들이 많이 간소화되었거든요! 🚀

🏗 OAuth2 구조 설계

OAuth2 구현에서 가장 중요한 건 각 컴포넌트의 역할을 명확히 하는 것이었어요.

핵심 컴포넌트들

  • CustomAuthorizationRequestResolver: OAuth2 인증 요청을 사용자 정의로 처리해요. 여기서 state 파라미터나 추가 스코프를 설정할 수 있거든요.
  • CustomOAuth2UserService: 각 소셜 플랫폼에서 받아온 사용자 정보를 우리 시스템에 맞게 변환하는 역할을 해요.
  • OAuth2AuthenticationSuccessHandler: 로그인 성공 후의 모든 처리 로직이 들어가요. JWT 토큰 생성, 쿠키 설정, 리다이렉트 등등.
  • OAuth2AuthenticationFailureHandler: 로그인 실패 시 사용자에게 적절한 피드백을 제공해요.

각 소셜 플랫폼마다 사용자 정보 형식이 달라서 이 부분에서 좀 고생했어요. Google은 sub, Kakao는 id, Naver는 response.id로 고유 식별자를 제공하거든요. 이런 차이점들을 추상화해서 처리하는 게 핵심이었어요.

// 예시: 소셜 플랫폼별 사용자 정보 추출
public String extractSocialId(String provider, OAuth2User oAuth2User) {
    return switch (provider.toLowerCase()) {
        case "google" -> oAuth2User.getAttribute("sub");
        case "kakao" -> String.valueOf(oAuth2User.getAttribute("id"));
        case "naver" -> {
            Map<String, Object> response = oAuth2User.getAttribute("response");
            yield response != null ? (String) response.get("id") : null;
        }
        default -> throw new IllegalArgumentException("지원하지 않는 소셜 로그인입니다.");
    };
}

🔧 Security 설정의 핵심

Spring Security 설정에서 가장 신경쓴 부분은 REST API에 최적화된 구조를 만드는 것이었어요.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            // CORS 설정
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            // CSRF 비활성화 (JWT 사용으로 불필요)
            .csrf(AbstractHttpConfigurer::disable)
            // 폼 로그인 비활성화
            .formLogin(AbstractHttpConfigurer::disable)
            // 세션을 사용하지 않음
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // 요청별 권한 설정
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/private/**").authenticated()
                .anyRequest().denyAll())
            // OAuth2 로그인 설정
            .oauth2Login(oauth2 -> oauth2
                .authorizationEndpoint(endpoint -> endpoint
                    .authorizationRequestResolver(customAuthorizationRequestResolver))
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(customOAuth2UserService))
                .successHandler(oAuth2AuthenticationSuccessHandler)
                .failureHandler(oAuth2AuthenticationFailureHandler))
            .build();
    }
}

여기서 중요한 포인트는 SessionCreationPolicy.STATELESS 설정이에요. 전통적인 세션 기반 인증이 아닌 JWT 토큰 기반으로 동작하기 때문에 서버가 세션 상태를 유지할 필요가 없거든요. 이렇게 하면 서버 확장성도 좋아지고 메모리 사용량도 줄일 수 있어요! 📈

🔄 인증 플로우 완전 정복

소셜 로그인 과정을 단계별로 살펴보면 생각보다 복잡한 과정을 거쳐요:

1단계: 로그인 시작 사용자가 프론트엔드에서 "Google로 로그인" 버튼을 클릭하면, /api/public/oauth2/authorization/google 엔드포인트로 요청이 전송돼요.

2단계: OAuth2 제공자로 리다이렉트 Spring Security가 자동으로 Google OAuth2 인증 페이지로 사용자를 리다이렉트시켜요. 이때 state 파라미터와 함께 콜백 URL도 전달됩니다.

3단계: 인증 및 콜백 사용자가 Google에서 로그인을 완료하면, Google이 인증 코드와 함께 우리 서비스의 콜백 URL로 리다이렉트해요.

4단계: 토큰 교환 및 사용자 정보 획득 서버에서는 받은 인증 코드를 Google Access Token으로 교환하고, 이를 사용해 사용자 정보를 가져와요.

5단계: JWT 토큰 생성 및 응답 사용자 정보를 바탕으로 우리 시스템의 JWT 토큰을 생성하고, HttpOnly 쿠키에 저장해서 응답해요.

신규 사용자 처리 만약 처음 로그인하는 사용자라면 자동으로 회원가입 프로세스가 진행돼요:

  1. 소셜 계정 정보로 새 사용자 계정 생성
  2. 기본 역할(ROLE_USER) 할당
  3. 약관 동의 페이지로 리다이렉트
  4. 동의 완료 후 메인 페이지로 이동

이 과정에서 사용자는 별도의 회원가입 폼을 작성할 필요가 없어서 정말 편리해요! 😊

🎫 JWT 토큰 관리 전략

JWT 토큰 관리에서는 Access Token과 Refresh Token의 이중 구조를 사용했어요.

토큰 설계 원칙

  • Access Token: 30분의 짧은 만료 시간으로 실제 API 접근에 사용
  • Refresh Token: 2일의 긴 만료 시간으로 Access Token 갱신에만 사용

토큰 저장 방식 모든 토큰은 HttpOnly 쿠키에 저장해서 XSS 공격으로부터 보호했어요. 또한 Refresh Token은 Redis에도 함께 저장해서 서버 측에서 토큰 유효성을 이중으로 검증할 수 있게 했습니다.

자동 토큰 갱신 프론트엔드에서는 API 요청 시 401 응답을 받으면 자동으로 토큰 갱신을 시도해요:

// 프론트엔드 토큰 갱신 로직 예시
async function refreshTokenIfNeeded(response) {
    if (response.status === 401) {
        const refreshResponse = await fetch('/api/auth/refresh', {
            method: 'POST',
            credentials: 'include'
        });
        
        if (refreshResponse.ok) {
            // 원래 요청 재시도
            return fetch(originalRequest);
        } else {
            // 로그인 페이지로 리다이렉트
            window.location.href = '/login';
        }
    }
    return response;
}

이렇게 하면 사용자는 토큰 만료를 전혀 신경 쓸 필요 없이 seamless한 경험을 할 수 있어요! ✨

🛡 보안 강화 포인트

보안은 타협할 수 없는 부분이라 여러 방면으로 신경썼어요:

1. HttpOnly 쿠키 사용 JavaScript에서 토큰에 접근할 수 없도록 해서 XSS 공격을 원천 차단했어요.

2. Redis 기반 토큰 검증 Refresh Token을 Redis에 저장해서 서버 측에서 토큰 유효성을 검증하고, 필요시 즉시 무효화할 수 있게 했어요.

3. 완벽한 로그아웃 처리 로그아웃 시에는 클라이언트 쿠키와 서버 Redis 저장소 모두에서 토큰을 제거해요:

@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletResponse response, 
                                  HttpServletRequest request) {
    // 쿠키 삭제
    Cookie deletedAccessCookie = cookieProvider.generateDeletedAccessTokenCookie();
    Cookie deletedRefreshCookie = cookieProvider.generateDeletedRefreshTokenCookie();
    response.addCookie(deletedAccessCookie);
    response.addCookie(deletedRefreshCookie);
    
    // Redis에서 토큰 삭제
    String refreshToken = cookieProvider.getRefreshTokenFromCookies(request);
    if (refreshToken != null) {
        redisService.deleteRefreshToken(refreshToken);
    }
    
    // SecurityContext 정리
    SecurityContextHolder.clearContext();
    
    return ResponseEntity.ok().build();
}

4. CORS 설정 허용된 도메인에서만 API 접근이 가능하도록 제한했어요.

5. 환경변수를 통한 민감 정보 관리 모든 시크릿 키와 설정 정보는 환경변수로 관리해서 코드에 노출되지 않도록 했어요.

⚙️ 환경 설정 가이드

이 프로젝트를 직접 실행해보고 싶으시다면 다음 단계를 따라주세요:

필수 환경변수 설정

# OAuth2 소셜 로그인 설정
GOOGLE_CLIENT_ID=${YOUR_GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET=${YOUR_GOOGLE_CLIENT_SECRET}
KAKAO_CLIENT_ID=${YOUR_KAKAO_CLIENT_ID}
KAKAO_CLIENT_SECRET=${YOUR_KAKAO_CLIENT_SECRET}
NAVER_CLIENT_ID=${YOUR_NAVER_CLIENT_ID}
NAVER_CLIENT_SECRET=${YOUR_NAVER_CLIENT_SECRET}

# JWT 서명 키 (충분히 복잡한 랜덤 문자열 사용)
ACCESS_TOKEN_SECRET=${YOUR_ACCESS_TOKEN_SECRET}
REFRESH_TOKEN_SECRET=${YOUR_REFRESH_TOKEN_SECRET}

# 데이터베이스 설정
DATABASE_URL=jdbc:mysql://localhost:3306/oauth_demo
DATABASE_USERNAME=${YOUR_DB_USERNAME}
DATABASE_PASSWORD=${YOUR_DB_PASSWORD}

# Redis 설정
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=${YOUR_REDIS_PASSWORD}

소셜 로그인 설정 각 플랫폼의 개발자 콘솔에서 OAuth2 애플리케이션을 등록해야 해요:

콜백 URL은 다음과 같이 설정해주세요:

  • Google: http://localhost:8080/login/oauth2/code/google
  • Kakao: http://localhost:8080/login/oauth2/code/kakao
  • Naver: http://localhost:8080/login/oauth2/code/naver

실행 방법

# 1. MySQL, Redis 서버 실행
docker-compose up -d

# 2. 애플리케이션 실행
./gradlew bootRun

# 3. 브라우저에서 테스트
open http://localhost:8080

🎯 마무리 및 회고

이번 소셜 로그인 프로젝트를 진행하면서 정말 많은 걸 배웠어요. 특히 사용자 경험과 보안 사이의 균형을 맞추는 것이 가장 도전적이었던 것 같아요.

잘했던 점들

  • OAuth2와 JWT를 조합한 현대적인 인증 시스템 구축
  • 다중 소셜 로그인 지원으로 사용자 선택권 확대
  • Redis를 활용한 효율적인 토큰 관리
  • HttpOnly 쿠키를 통한 XSS 방어

아쉬웠던 점들

  • 초기 설정이 복잡해서 러닝커브가 있었음
  • 각 소셜 플랫폼별 API 차이점 처리에 시간이 많이 소요됨
  • 에러 처리 부분에서 좀 더 세밀한 대응이 필요함

앞으로의 계획 다음 단계로는 이런 기능들을 추가해보려고 해요:

  • 소셜 계정 연동/해제 기능
  • 권한 기반 접근 제어 (RBAC)
  • 사용자 프로필 관리
  • 2FA (Two-Factor Authentication) 지원

이 글이 소셜 로그인 구현을 고민하고 계신 분들에게 도움이 되었으면 좋겠어요! 혹시 궁금한 점이나 개선할 부분이 있다면 언제든 댓글로 알려주세요. 함께 성장하는 개발자가 되어요! 🚀

참고: 이 글의 코드는 학습 목적의 데모 애플리케이션입니다. 실제 프로덕션 환경에서는 추가적인 보안 검토와 테스트가 필요해요.


Tags: #SpringBoot #OAuth2 #JWT #소셜로그인 #Spring Security #Google로그인 #카카오로그인 #네이버로그인 #인증 #보안 #REST API #Redis #MySQL #Java21 #백엔드개발 #웹개발 #토큰관리 #쿠키보안