OAuth 2.0을 활용한 로그인 구현을 하면서 (OAuth 2.0 동작, 인증/인가, Redis blackList...)

2025. 10. 3. 05:49·Project
728x90

https://keepgoingforever.tistory.com/121

 

좋아요 기능을 개발하면서 고민한 내용 (Redis, eventual consistency ...)

다양한 기능들을 개발하고 싶어서 인스타그램과 같은 소셜미디어 플랫폼을 개발하게 되었다.정말 빠른 시간 안에 개발을 하게 되어서 지금 다시 코드를 보면 또 다른 생각이 떠오르는 상태이다

keepgoingforever.tistory.com

개발을 시작하면서...

이 글에 이어서 소셜 미디어 플랫폼을 개발하면서 겪은 경험을 공유하고자 한다.

먼저 사용자가 있어야 하고, 사용자별로 인증을 하고 플랫폼을 원활하게 이용하기 위해서는 로그인이 필요하다.

예전에는 id/password 기반의 로그인이 먼저 떠올랐지만, 요즘에는 소셜 로그인이 거의 모든 쇼핑몰이나 다양한 웹사이트에서 사용되고 있다.

일단 왜 소셜 로그인이 많이 사용될까를 생각해 보면,

사용자는 우리 플랫폼에 가입하기 위해 새로운 아이디와 비밀번호를 만들어야 하고 이메일 인증을 거쳐야 하며 또 하나의 계정을 기억해야 한다.

하지만, 소셜로그인을 이용한다면, 사용자는 이미 사용하던 구글, 네이버 등의 계정으로 바로 로그인할 수 있다. 이때, 복잡한 회원가입 절차 생략되고, 비밀번호 관리 부담이 덜어지게 된다.

매우 편리한 기능이다!


OAuth를 이해하고 넘어가자.

 

이처럼 편한 소셜 로그인을 구현하기 위해서는 OAuth를 이해해야 한다.

OAuth는 Open Authorization이다. 명칭을 보면 Authorization이 들어있다.

로그인이라고 하면 Authentication이 먼저 떠오르지만, OAuth는 Authorization이 맞다.

즉, "누가 로그인했는지 확인"하는 게 아니라, “특정 사용자가 특정 자원(Resource)에 접근할 수 있도록 권한을 위임”하는 프로토콜이다.

OAuth 플로우에는 다음 네 가지 주체가 있다.

  • Resource Owner (사용자)
    실제 계정을 가진 사용자 (예: 구글/네이버/카카오 계정 보유자)
  • Client (우리 애플리케이션)
    소셜 로그인을 붙이는 플랫폼, 사용자 대신 자원 접근을 요청
  • Authorization Server (인가 서버)
    사용자의 동의를 받고 인가 코드를 발급하는 서버 (예: 카카오 인증 서버)
  • Resource Server (자원 서버)
    실제 사용자 정보를 가진 서버 (예: 카카오 사용자 정보 API 서버)

즉, 사용자가 카카오 로그인을 시도하면, 우리 앱(Client)은 인가 서버에서 권한을 받아, 자원 서버에서 사용자 정보를 가져온다.

결국에 우리는 카카오에서 사용자 정보를 접근할 수 있는 권한을 얻고 정보를 가져올 수 있게 되는 것이다.

이후에 해당 정보를 가지고 jwt 기반의 인증 로직을 구현하는 등 우리는 소셜 로그인을 구현할 수 있게 되는 것이다. 

실제 OAuth 플로우: 카카오 로그인 예시를 살펴보겠다.

카카오 개발자 페이지에 있는 서비스 로그인 과정 이미지이다.

1단계: 인증 요청

사용자가 "카카오로 로그인" 클릭 
→ 카카오 로그인 페이지 (kauth.kakao.com)로 리다이렉트
→ URL: https://kauth.kakao.com/oauth/authorize?client_id=YOUR_APP_KEY&redirect_uri=YOUR_CALLBACK_URL&response_type=code

2단계: 사용자 인증 및 동의

사용자가 카카오 계정으로 로그인 
→ "카카오계정(이메일 또는 전화번호), 카카오계정(닉네임, 프로필 사진)에 대한 접근을 허용하시겠습니까?" 
→ 동의 버튼 클릭

3단계: 인증 코드 발급

카카오가 임시 인증 코드 생성
→ 우리 플랫폼 콜백 URL로 코드와 함께 리다이렉트
→ https://yoursite.com/auth/kakao/callback?code=AUTHORIZATION_CODE

4단계: 액세스 토큰 교환

우리 서버가 인증 코드를 카카오에 POST 요청
→ POST https://kauth.kakao.com/oauth/token
→ 카카오가 액세스 토큰 발급

5단계: 사용자 정보 조회

액세스 토큰으로 카카오 API 호출 
→ GET https://kapi.kakao.com/v2/user/me
→ 사용자의 기본 정보 (카카오 고유 ID, 닉네임, 이메일) 획득

내 프로젝트에 어떻게 소셜 로그인을 적용했는가?

이제 내 소셜 플랫폼에서 어떻게 활용했는지에 대해서 말해보겠다.

먼저 아이디/비밀번호 로그인과 카카오 OAuth 로그인을 모두 제공한다.

로그인을 성공하면 JWT 토큰 기반 인증을 적용하고 있다.

 

  • 사용자가 카카오 로그인을 클릭 → 카카오 인가 서버로 이동
  • 사용자가 동의하면 → 인가 코드 발급
  • 서버가 인가 코드를 받아 → Access Token 발급 요청
  • Access Token으로 사용자 정보를 조회
  • 신규 사용자라면 회원가입 페이지로 리다이렉트, 기존 사용자라면 JWT 토큰 발급 후 로그인 처리

이렇게 로직을 마무리하였지만, id/pw + oauth를 통합하는 과정에서 다양한 고민이 생겼다.

고민 1. id/pw + oauth를 통합하려면 어떻게 해야할까?

  • ERD 설계에서 User vs SocialUser 분리 여부
  • 유저를 식별할 고유값(Unique Identifier) 결정

 

먼저 User와 Social User를 엔티티로 구분지어야 하는가?라는 질문이었다.

 

  • User 엔티티: 기본 회원 (id/password 기반 가입자)
  • SocialUser 엔티티: 소셜 로그인 회원 (카카오, 구글 등)

이렇게 두 개로 구분지어서 ERD를 그렸다. 

User와 SocialUser를 1:1 매핑해서 관리하면 어떨까?

  • id/password 로그인 시 → User 테이블에서 검증
  • 카카오 로그인 시 → SocialUser에서 providerId로 확인 후 없으면 새 등록

 

이는 구분 짓지 않고 하나로 합치기로 하였다. 왜냐하면

  • 이미 ID/PW로 가입한 사용자가 소셜 로그인 시도 → 계정 통합 문제 발생
  • 전화번호/이름으로는 중복이나 제약 때문에 식별 불가능
  • 결국 어떤 칼럼을 유저의 고유 식별자로 삼을지에 대한 결정이 필요해짐

이러한 이유 때문이다. 하지만 테이블을 합친다고 해도 어떤 컬럼을 유저의 고유 식별자로 삼아야 하는가에 대한 고민은 계속 이어졌다.

 

그 이유는, 이때 처음에 기반으로 한 화면 설계서에서는 email을 받는 곳이 없었다. 그래서 생각한 대안으로는

  • USERNAME 기반 (ID/PW 로그인에서 쓰던 값)
  • SOCIAL_ID 기반 (소셜에서 제공하는 providerId)

이 두 가지 값이다.

하지만 둘 다 한계가 있었다.

  • USERNAME: 소셜 로그인과 통합이 불가능
  • provider Id: 카카오/네이버/구글 등 플랫폼별로 다름 → 여러 소셜 계정을 같은 유저로 묶기 힘듦

email이 없기에 일단 providerId를 기준으로 신규 회원이라고 가정하고 진행하였다. 카카오로그인을 해서 카카오에서 정보 동의를 하고 인증을 하게 되면, 카카오에서 받은 정보를 Base64 인코딩해서, 정보를 URL 파라미터로 전달해 회원가입 페이지로 리다이렉트 하도록 개발해 두었다. 이 점이 매우 아쉽게 느껴진다.

전화번호는 따로 받고 있기에 해당 정보를 기준으로 해볼까도 생각해 보았지만, 정식으로 카카오 개발자 페이지에서 비즈앱으로 사업자 등록번호를 통해 만든 앱이 아니기에 전화번호는 사용할 수 없었다.

 

만약에 해당 화면 설계서가 아닌 내가 따로 기획서를 작성해서 개발을 한다면 EMAIL을 유니크 식별자로 사용하도록 할 것이다.

왜냐하면, 

  • ID/PW 로그인과 소셜 로그인을 한 계정으로 통합 가능
  • 소셜 로그인에서 대부분 이메일 제공 (카카오/구글/네이버 다 지원)
  • 이메일 검증(Email Verification)을 통해 신뢰도 확보 가능

 

그러면

소셜 로그인이 아닌 ID/PW 기반 로그인의 경우엔 EMAIL (유니크) + PASSWORD 만 있을 것이다.

email=xxx@gmail.com, password=암호화된 값, provider=null, provider_id=null

카카오톡 소셜 로그인을 회원가입한 계정은 EMAIL (유니크) + PROVIDER + PROVIDER_ID 가 있을 것이다.

email=xxx@kakao.com, password=null, provider='kakao', provider_id='1234567890'

2개를 모두 통합한다면 EMAIL (유니크) + PASSWORD + PROVIDER + PROVIDER_ID 다 가지게 된다.

email=xxx@gmail.com, password=암호화된값, provider='kakao', provider_id='1234567890'

이런 로직으로 진행한다면, 동일 이메일로 다양한 소셜 계정을 같이 묶는 건 불가능하다는 단점은 있다.

 

실제 구현 코드를 기반으로 동작 흐름을 살펴보자!

이제 다시 프로젝트에 돌아와서 구현한 로직을 설명해 보겠다.

SecurityConfig부터 확인하겠다.

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final CorsConfigurationSource reactConfigurationSource;
    private final UserService customUserDetailsService;
    private final JwtAuthenticationProvider jwtAuthenticationProvider;
    private final PasswordEncoder passwordEncoder;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;


    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
        throws Exception {
        AuthenticationManager manager = configuration.getAuthenticationManager();
        if (manager instanceof ProviderManager pm) {
            pm.getProviders().add(jwtAuthenticationProvider);
            pm.getProviders().add(daoAuthenticationProvider());
        }
        return manager;
    }


    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(customUserDetailsService);
        provider.setPasswordEncoder(passwordEncoder);
        return provider;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> cors.configurationSource(reactConfigurationSource))
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authenticationProvider(jwtAuthenticationProvider)
            .authenticationProvider(daoAuthenticationProvider())
            .authorizeHttpRequests(authz -> authz
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                .requestMatchers(
                    "/swagger-ui.html",
                    "/swagger-ui/**",
                    "/api-docs/**",
                    "/v3/api-docs/**",
                    "/v3/api-docs.yaml"
                ).permitAll()
                .requestMatchers("/api/auth/logout").authenticated()
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/error").permitAll()

                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(customOAuth2UserService)
                )
                .successHandler(oAuth2AuthenticationSuccessHandler)
            );
        http.anonymous(withDefaults());
        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

 

AuthenticationManager는 인증 요청(Authentication)을 Provider에게 위임하는 핵심 객체이다.

ProviderManager는 여러 AuthenticationProvider를 등록할 수 있다.

 

  • 여기서는 두 가지 Provider를 등록하였다.
    • jwtAuthenticationProvider → JWT 기반 인증 처리
    • daoAuthenticationProvider → 일반적인 ID/PW 로그인 처리

 

DaoAuthenticationProvider 일반 로그인을 처리하는 Provider로  ID/PW 로그인 담당하여 UserService(UserDetailsService)에서 DB 사용자를 조회한다.

이때, PasswordEncoder로 암호화된 비밀번호를 비교한다.

 

SecurityFilterChain을 보겠다.

세션 관리는 JWT를 사용하기 때문에 STATELESS로 설정했다. 이 코드는 서버가 세션을 저장하지 않고 매 요청마다 JWT를 검증하겠다는 뜻이다.

.sessionManagement(session ->
    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

인증/인가 규칙은 거의 인증하지만, swagger, login, signup 등 인증 없이도 동작해야 하는 것은 permitAll()로 처리하였다.

OPTIONS, Swagger, Public API 등은 permitAll()
/api/auth/logout 은 인증 필요
나머지는 전부 authenticated()

 

OAuth2 로그인

 

.oauth2Login(oauth2 -> oauth2
    .userInfoEndpoint(userInfo -> userInfo
        .userService(customOAuth2UserService)
    )
    .successHandler(oAuth2AuthenticationSuccessHandler)
)

 

  • http.oauth2Login() 을 쓰면, Spring Security는 자동으로 OAuth2LoginAuthenticationFilter를 SecurityFilterChain에 추가한다. 이 필터는 OAuth2 로그인 요청(인가 코드 교환, 토큰 발급 등)을 처리하는 전용 필터이다.
  • 카카오 OAuth2 로그인 사용하기 때문에, 사용자 정보를 가져올 때 CustomOAuth2UserService 실행 → DB 매핑/신규 유저 생성
  • 로그인 성공 후 OAuth2AuthenticationSuccessHandler 실행하도록 해서 JWT 발급 및 리다이렉트 되도록 설정하였다.

JWT 필터 등록

http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

 

 

  • 이 필터는 요청 헤더에서 JWT 추출 → AuthenticationManager에 위임
  • JWT가 유효하면 SecurityContext에 Authentication 저장한다.

OAuth2LoginAuthenticationFilter는 카카오톡으로부터 전달받은 인가 코드를 받아서 AccessToken으로 교환한다.

이때 카카오 UserInfo Endpoint를 호출해서 사용자 정보를 조회한다.

 

CustomOAuth2UserService가 호출되어 기존에 있는 회원이라면 DefaultOAuthUser 객체를 생성한다.

그러나 기존 회원이 아닌 신규 사용자는 커스텀 attribute를 세팅해서 전달한다. 왜냐하면 신규 사용자는 사용자 정보를 더 받도록 화면 설계서에 기획되어 있어서 바로 회원으로 등록하지 않기 때문이다. 이후 로직에 따라서 신규 사용자면 사용자 정보를 입력할 수 있는 페이지로 리다이렉트 하려고 한다.

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

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

        Map<String, Object> attributes = oAuth2User.getAttributes();
        log.info("OAuth2User attributes: {}", attributes);

        String providerId = String.valueOf(attributes.get("id"));

        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");

        String nickname = (String) profile.get("nickname");
        String profileImageUrl = (String) profile.get("profile_image_url");

        log.info("nickname: {}, profileImageUrl: {}", nickname, profileImageUrl);

        // 기존 사용자 확인
        Optional<User> existingUser = userRepository.findByProviderId(providerId);

        if (existingUser.isPresent()) {
            // 기존 사용자 - 바로 OAuth2User 반환
            log.info("기존 사용자 발견: {}", existingUser.get().getUsername());
            User user = existingUser.get();

            return new DefaultOAuth2User(
                List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())),
                attributes,
                "id"
            );
        } else {
            // 신규 사용자 - DB에 저장하지 않고 attributes에 신규 표시 추가
            log.info("신규 사용자 - 회원가입 페이지로 이동 예정");

            Map<String, Object> modifiedAttributes = new HashMap<>(attributes);
            modifiedAttributes.put("isNewUser", true);
            modifiedAttributes.put("extractedNickname", nickname);
            modifiedAttributes.put("extractedProfileImage", profileImageUrl);

            return new DefaultOAuth2User(
                List.of(new SimpleGrantedAuthority("ROLE_TEMP_USER")),
                modifiedAttributes,
                "id"
            );
        }
    }
}

 

Filter에서 인증을 성공하게 되면 SuccessHandler가 호출된다.

@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final JwtProvider jwtProvider;
    private final RefreshTokenService refreshTokenService;
    private final UserRepository userRepository;
    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
    private final UserService userService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
        Authentication authentication) throws IOException {

        DefaultOAuth2User oAuth2User = (DefaultOAuth2User) authentication.getPrincipal();

        try {
            // loadUser()에서 설정한 isNewUser 플래그 확인
            Boolean isNewUser = (Boolean) oAuth2User.getAttribute("isNewUser");

            if (isNewUser != null && isNewUser) {
                log.info("신규 사용자 - 회원가입 페이지로 리다이렉트");
                handleNewUser(oAuth2User, request, response);
            } else {
                log.info("기존 사용자 - 로그인 처리");
                handleExistingUser(oAuth2User, request, response);
            }
        } catch (Exception e) {
            log.error("OAuth2 authentication success handling failed", e);
            redirectStrategy.sendRedirect(request, response,
                "http://localhost:3000/auth/error?message=" + e.getMessage());
        }
    }

    private void handleExistingUser(DefaultOAuth2User oAuth2User, HttpServletRequest request,
        HttpServletResponse response)
        throws IOException {

        Object idValue = oAuth2User.getAttribute("id");
        String providerId = String.valueOf(idValue);

        System.out.println(Optional.ofNullable(oAuth2User.getAttribute("id")));
        // DB에서 사용자 조회
        User user = userService.findByProviderId(providerId);
        log.info("CustomOAuth2UserService - kakaoId: {}", providerId);

        // 마지막 로그인 시간 업데이트
        user.updateLastLoginAt();
        userRepository.save(user);

        // JWT 토큰 생성
        Authentication auth = new UsernamePasswordAuthenticationToken(
            user, null, user.getAuthorities()
        );
        String accessToken = jwtProvider.generateAccessToken(auth);
        String refreshToken = jwtProvider.generateRefreshToken(user.getUsername());
        log.info("accessToken : {}", accessToken);
        log.info("refreshToken : {}", refreshToken);

        // RefreshToken 저장 (Redis 연동시 사용)
        refreshTokenService.store(user.getId(), refreshToken);

        String targetUrl = "http://localhost:3000/auth/callback" +
            "?accessToken=" + accessToken
            + "&refreshToken=" + refreshToken;

        log.info("기존 사용자 로그인 완료 - 리다이렉트: {}", targetUrl);
        redirectStrategy.sendRedirect(request, response, targetUrl);
    }

    private void handleNewUser(DefaultOAuth2User oAuth2User, HttpServletRequest request,
        HttpServletResponse response)
        throws IOException {

        Object idValue = oAuth2User.getAttribute("id");
        String providerId = String.valueOf(idValue);
        String nickname = oAuth2User.getAttribute("extractedNickname");

        log.info("provider Id : " + providerId);
        // Base64 인코딩하여 URL 파라미터로 안전하게 전달
        String kakaoInfo = Base64.getEncoder().encodeToString(
            String.format("%s|%s|%s|%s",
                providerId,
                nickname,
                nickname,
                ""
            ).getBytes(StandardCharsets.UTF_8)
        );

        String targetUrl =
            "http://localhost:3000/auth/signup/oauth?provider=kakao&info=" + kakaoInfo;

        log.info("신규 사용자 회원가입 페이지로 리다이렉트: {}", targetUrl);
        redirectStrategy.sendRedirect(request, response, targetUrl);
    }
}

이 코드를 보면 신규 사용자의 경우, handleNewUser로 분기하게 되고, 카카오에서 제공한 id, nickname을 추출한다. 그리고 해당 정보를 Base64 인코딩한 후, 프런트엔드 회원가입 페이지로 리다이렉트 시킨다.

기존 사용자라면, DB에서 providerId기준으로 User를 조회하고, 마지막 로그인 시간을 갱신하여 저장한다. 그리고 jwtProvider의 generateAccessToken, generateRefreshToken을 통해서 토큰들을 발급하여  프런트엔드 콜백 URL로 리다이렉트 시킨다.

이때, refreshToken은 Redis에 저장해서 관리하도록 설계하고, accessToken은 클라이언트 측에서 관리하도록 하였다.


고민 2. Access Token이 탈취 당할 수 있다?!

accessToken이 나왔으니까 accessToken과 관련된 구현 부분도 짚고 넘어가겠다.

지금 AccessToken은 클라이언트에서 관리되고 stateless 한 상황이다. 그렇기 때문에 로그아웃이 되더라도 토큰 만료 기간에 따라서 요청이 될 수 있다.

해당 현상을 방지하기 위해서, Redis에서 BlackList를 통해서 AccessToken을 관리하였다.

https://keepgoingforever.tistory.com/89

 

Refresh Token 사용 이유와 Redis를 활용한 관리 방법: Blacklist, TTL, 보안 강화

토큰 기반의 인증 방식토큰 기반 인증(Token-based Authentication)은 사용자 인증 후, 사용자에게 특정한 토큰을 발급하여 이후 요청 시 이 토큰을 통해 인증을 확인하는 방식대표적으로 JWT(JSON Web Token)

keepgoingforever.tistory.com

예전에 해당 내용에 대해서만 정리해 두었지만 실제로 적용해 보았다!

사용자가 로그아웃하고 클라이언트에서 토큰을 삭제하겠지만, 삭제 되기 전에 악의적인 의도로 액세스 토큰이 도난된다면 인증되지 않은 유저가 요청을 보낼 수 있다.

Blacklist를 사용하면 accessToken을 redis에 만료 시간 전까지 관리해서 해당 accessToken으로 인증을 요청할 시 차단할 수 있다.

@Service
@RequiredArgsConstructor
@Slf4j
public class TokenBlacklistService {

    private static final String BLACKLIST_ACCESS_TOKEN_PREFIX = "blacklist:access:";
    private static final String BLACKLIST_USER_PREFIX = "blacklist:user:";

    private final RedisTemplate<String, Object> redisTemplate;
    private final JwtProvider jwtProvider;

    public void blacklistAccessToken(String token, long expirationTime) {
        String key = BLACKLIST_ACCESS_TOKEN_PREFIX + token;
        redisTemplate.opsForValue().set(key, "blacklisted", expirationTime, TimeUnit.SECONDS);
        log.info("Access token added to blacklist with expiration: {} seconds", expirationTime);
    }

    public void blacklistUserTokens(Long userId, int durationHours) {
        String key = BLACKLIST_USER_PREFIX + userId;
        long expirationTime = Duration.ofHours(durationHours).getSeconds();
        redisTemplate.opsForValue().set(key, "user_blacklisted", expirationTime, TimeUnit.SECONDS);
        log.warn("All tokens for user {} blacklisted for {} hours", userId, durationHours);
    }

    public boolean isAccessTokenBlacklisted(String token) {
        if (token == null || token.isEmpty()) {
            return false;
        }

        String key = BLACKLIST_ACCESS_TOKEN_PREFIX + token;
        Boolean exists = redisTemplate.hasKey(key);
        return Boolean.TRUE.equals(exists);
    }

    public boolean isUserTokensBlacklisted(Long userId) {
        String key = BLACKLIST_USER_PREFIX + userId;
        Boolean exists = redisTemplate.hasKey(key);
        return Boolean.TRUE.equals(exists);
    }

    public void blacklistTokenWithRemainingTime(String token) {
        try {
            if (!jwtProvider.validateToken(token)) {
                log.warn(
                    "Invalid token cannot be blacklisted: token is already expired or malformed");
                return;
            }

            long remainingTimeSeconds = getRemainingTokenTime(token);

            if (remainingTimeSeconds > 0) {
                blacklistAccessToken(token, remainingTimeSeconds);
            } else {
                log.info("Token already expired, no need to blacklist");
            }
        } catch (Exception e) {
            log.error("Error while blacklisting token: {}", e.getMessage());
        }
    }

    private long getRemainingTokenTime(String token) {
        try {
            Date expiration = jwtProvider.getExpiration(token);
            long expirationTime = expiration.getTime();
            long currentTime = System.currentTimeMillis();
            long remainingMillis = expirationTime - currentTime;
            if (remainingMillis <= 0) {
                return 0;
            }
            return remainingMillis / 1000;
        } catch (Exception e) {
            log.debug("Error calculating remaining token time: {}", e.getMessage());
            return 0;
        }
    }

    public void removeUserBlacklist(Long userId) {
        String key = BLACKLIST_USER_PREFIX + userId;
        redisTemplate.delete(key);
        log.info("User {} removed from blacklist", userId);
    }
}

최대한 redis에 효율적으로 데이터를 관리하고자, 현재 accessToken의 남은 만료시간을 계산해서 해당 시간만큼만 redis에서 보관하도록 하였다.

로직에 따라 로그아웃 시에, Redis에 블랙리스트로 추가된다.

이렇게 Redis에서 추가된 블랙리스트를 검증하는 곳은 JwtAuthenticationFilter이다.

@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        String token = resolveTokenFromHeader(request);
        log.info("Token: {}", token);

        if (StringUtils.hasText(token)) {
            try {
                if (tokenBlacklistService.isAccessTokenBlacklisted(token)) {
                    log.warn("Access token is blacklisted, rejecting request");
                    SecurityContextHolder.clearContext();
                    filterChain.doFilter(request, response);
                    return;
                }
                JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(token);

                AuthenticationManager authenticationManager =
                    authenticationConfiguration.getAuthenticationManager();
                Authentication authentication = authenticationManager.authenticate(
                    jwtAuthenticationToken);

                SecurityContextHolder.getContext().setAuthentication(authentication);

                log.info("JWT authentication successful for user: {}", authentication.getName());

            } catch (AuthenticationException e) {
                log.info("JWT authentication failed: {}", e.getMessage());
                SecurityContextHolder.clearContext();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }

        filterChain.doFilter(request, response);
    }

 

JwtAuthenticationFilter에서 doFilterInternal()에서 블랙리스트에 속하는지 확인하고 없다면 넘어가고 있으면 바로 차단한다.


JWT 인증 동작 순서를 알아보자!

JwtAuthenticationFilter가 나왔으니 JWT 인증 동작 순서도 살펴보겠다.

먼저 클라이언트가 API 호출 시, HTTP Authorization 헤더에 JWT Access Token을 담아 보낸다.

Authorization: Bearer <access_token>

이때 Bearer + accessToken 형식이다.

참고 Bearer 란?

더보기

Bearer = "소지자, 지참자"라는 의미
HTTP 요청 시, 인증 정보를 담을 수 있는 표준 방식이 Authorization 헤더인데, 이 헤더에 토큰을 담을 때 사용하는 scheme이 여러 가지가 있는데, 그중 하나가 Bearer이다.
Bearer Token
 인증은 "토큰 소지자(Bearer)가 권한을 가진다"는 의미이다. 즉, “이 토큰을 가진 사람은 누구든 해당 자원(Resource)에 접근할 수 있다”라는 방식이다.

 

JwtAuthenticationFilter는 Spring Security FilterChain에서 아까 설정해 둔 것처럼 UsernamePasswordAuthenticationFilter 이전에 실행된다.

이때, 

 

  • 요청 헤더에서 Authorization 값을 추출한다.
  • "Bearer " 접두사 제거 후 JWT 토큰을 획득한다.
  • JwtAuthenticationToken(token) 객체 생성하여 AuthenticationManager에 위임하게 된다.

토큰을 꺼내서 인증 객체로 감싸 준다.

그리고 AuthenticationManager가 전달받은 JwtAuthenticationToken을 처리 가능한 AuthenticationProvider를 탐색한다.

supports() 메서드로 해당 토큰이 JwtAuthenticationProvider에서 지원되는지 확인한다.

 

    @Override
    public boolean supports(Class<?> authentication) {
        log.info("supports check: {}, input={}",
            JwtAuthenticationToken.class.isAssignableFrom(authentication),
            authentication);

        return JwtAuthenticationToken.class.isAssignableFrom(authentication);
    }

매칭 성공 시 JwtAuthenticationProvider.authenticate() 호출한다.

이제 핵심 인증 로직이 실행된다.

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationProvider implements AuthenticationProvider {

    private final JwtProvider jwtProvider;
    private final UserService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication)
        throws AuthenticationException {
        JwtAuthenticationToken jwtToken = (JwtAuthenticationToken) authentication;
        String token = jwtToken.getToken();

        if (token == null || token.trim().isEmpty()) {
            throw new BadCredentialsException("JWT token is missing");
        }

        try {
            if (!jwtProvider.validateToken(token)) {
                throw new BadCredentialsException("Invalid JWT token");
            }

            String username = jwtProvider.getUsernameFromToken(token);
            if (username == null || username.trim().isEmpty()) {
                throw new BadCredentialsException("JWT subject is missing");
            }

            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            if (userDetails instanceof User user) {
                if (user.getStatus() != UserStatus.ACTIVE) {
                    throw new CustomException(ErrorCode.USER_NOT_ACTIVE);
                }
            }
            return new JwtAuthenticationToken(username, userDetails.getAuthorities());

        } catch (Exception e) {
            log.debug("JWT authentication failed: {}", e.getMessage());
            throw new BadCredentialsException("JWT authentication failed", e);
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        log.info("supports check: {}, input={}",
            JwtAuthenticationToken.class.isAssignableFrom(authentication),
            authentication);

        return JwtAuthenticationToken.class.isAssignableFrom(authentication);
    }
}
  1. 토큰 유효성 확인 (jwtProvider.validateToken)
    • 만료 여부
    • 시그니처 위조 여부
    • 파싱 가능 여부
  2. 토큰에서 subject(username) 추출 (jwtProvider.getUsernameFromToken)
  3. DB에서 사용자 로드 (UserService.loadUserByUsername)
  4. 사용자 상태 확인 (ACTIVE 인지 체크)
  5. 최종적으로 JwtAuthenticationToken(username, authorities) 반환

이 단계에서 성공하면 인증된 Authentication 객체가 생성된다.

이때, JwtProvider는 JWT 유틸리티로 발급, 검증, 조회를 진행한다. 

@Component
@Slf4j
public class JwtProvider implements InitializingBean {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";
    public static final String AUTHORITIES_KEY = "authorities";
    public static final String USER_ID_KEY = "userId";

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.token.expiration}")
    private long accessTokenExpiration;

    @Value("${jwt.refresh-token.expiration}")
    private long refreshTokenExpiration;

    private SecretKey key;

    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public String generateAccessToken(Authentication authentication) {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();

        String authorities = userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));

        Date now = new Date();
        Date expiry = new Date(now.getTime() + (accessTokenExpiration * 1000));

        return Jwts.builder()
            .subject(userDetails.getUsername())
            .claim(AUTHORITIES_KEY, authorities)
            .issuedAt(now)
            .expiration(expiry)
            .signWith(key)
            .compact();
    }

    public String generateRefreshToken(String username) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + (refreshTokenExpiration * 1000));

        return Jwts.builder()
            .subject(username)
            .issuedAt(now)
            .expiration(expiry)
            .signWith(key)
            .compact();
    }

    public String getUsernameFromToken(String token) {
        return getClaimsFromToken(token).getSubject();
    }

    public Date getExpiration(String token) {
        return getClaimsFromToken(token).getExpiration();
    }

    public boolean validateToken(String token) {
        try {
            getClaimsFromToken(token);
            return true;
        } catch (ExpiredJwtException e) {
            log.debug("JWT token expired: {}", e.getMessage());
        } catch (UnsupportedJwtException e) {
            log.debug("JWT token unsupported: {}", e.getMessage());
        } catch (MalformedJwtException e) {
            log.debug("JWT token malformed: {}", e.getMessage());
        } catch (io.jsonwebtoken.security.SignatureException e) {
            log.debug("JWT signature validation failed: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
            log.debug("JWT token illegal argument: {}", e.getMessage());
        }
        return false;
    }

    private Claims getClaimsFromToken(String token) {
        return Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
}

 

  • 발급
    • AccessToken: subject(username) + authorities + 만료시간
    • RefreshToken: subject(username) + 만료시간
  • 검증
    • 토큰을 파싱 하여 Claims 추출
    • 만료, 위조, 지원 여부를 체크
  • 조회
    • getUsernameFromToken → JWT subject 반환 (username/email)
    • getExpiration → 만료시간 반환

 

 

그리고, JwtAuthenticationProvider에서 인증 성공하면 반환된 Authentication 객체는 SecurityContextHolder에 저장된다.

이후에, 서비스에서 User 정보가 필요하면  AuthService라는 서비스에 정의해 둔 getCurrentUser()라는 메서드를 통해서 Authentication 객체에서 username을 꺼내서 User 가 사용자가 맞는지 확인도 하고, User 객체를 반환해서 다른 로직에서 사용했다.

-> 사실 이 부분은 매번 서비스에서 메서드를 호출해서 사용해서 신경이 쓰였다. 멘토님께 피드백을 받았는데, @Secured를 통해서 User Role로 검증을 한다면 현재 사용자를 간접적으로 검증하기에 편리하다고 했다. 만약 사용자 검증만 필요하다면 @Secured만으로도 가능할 것 같다. 하지만 User 객체가 필요할 때는 현재 메서드로 검증 및 반환까지 하게 되어서 getCurrentUser()를 변경하지 못하고 사용하고 있는 상태이다...

요약하면 Jwt 관련된 로직은 아래와 같다!

  1. JwtAuthenticationFilter: 요청 헤더에서 JWT 추출 → AuthenticationManager로 전달
  2. AuthenticationManager: 적절한 Provider 선택 (JwtAuthenticationProvider)
  3. JwtAuthenticationProvider: JWT 검증 + 사용자 조회 + 상태 확인
  4. JwtProvider: 발급/검증/파싱 전담
  5. SecurityContext: 인증 정보 저장 → 컨트롤러에서 인증된 사용자 사용 가능

 

 


정리

마지막으로 내 프로젝트에서 사용한 인증 로직을 정리하자면,

  1. 일반 로그인 (ID/PW)
    • 클라이언트 → /api/auth/login 요청 (id, pw)
    • DaoAuthenticationProvider 실행
    • UserService에서 사용자 조회 + PasswordEncoder 비교
    • 성공 시 JWT 발급
  2. 소셜 로그인 (카카오)
    • 클라이언트 → 카카오 로그인 시도
    • Spring Security OAuth2 필터 → 카카오에서 사용자 정보를 받아옴
    • CustomOAuth2UserService → DB 사용자 확인/생성
    • OAuth2AuthenticationSuccessHandler → JWT 발급 & 클라이언트 리다이렉트
  3. 이후 모든 요청 (JWT 기반 인증)
    • JwtAuthenticationFilter 실행 → 헤더에서 토큰 추출
    • AuthenticationManager → JwtAuthenticationProvider에게 위임
    • 토큰 검증 후 SecurityContextHolder에 Authentication 저장
    • 컨트롤러 진입 시 @AuthenticationPrincipal로 인증된 유저 확인 가능

 

 

 

회고

이번에 카카오 소셜 로그인과 ID/PW 로그인, 그리고 JWT 기반 인증, Blacklist 기능까지 모두 붙여보면서 깊게 공부할 수 있었다.

처음에는 소셜 로그인 = 단순한 인증(Authentication)으로만 생각이 들었지만 직접 구현해 보니 OAuth는 “사용자가 가진 자원에 접근할 수 있는 권한을 위임받는 프로토콜”이라는 점을 체감할 수 있었다.

특히 기억에 남는 부분은 소셜 로그인 유저와 일반 로그인 유저를 통합하는 과정이었다. 현재는 email을 관리하지 않고 있어서 providerId(카카오에서 주는 고유 id)를 기준으로 유저를 매핑했지만, 결국은 email 기반 통합이 가장 합리적이라는 결론에 도달했다. 추후에는 email을 기준으로 다시 구현해볼 계획이다.

그리고, Spring Security에서 Filter → AuthenticationManager → Provider → SuccessHandler의 흐름을 이해하게 되어서 뿌듯하다. 한 단계씩 디버깅하고, 직접 글을 쓰는 과정을 거치며, “Filter는 요청 추출, Provider는 검증, SuccessHandler는 후처리”라는 구분을 명확히 할 수 있었다.

Jwt를 기반으로 작업하면서 Stateless 환경에서 생기는 장단점(서버 부담 ↓, 하지만 토큰 만료/재발급 로직 필요 ↑)에 대해서 더 이해할 수 있었다. 완전한 stateless는 보안을 위해서는 좋지 않다는 생각도 들었다. 그래서 refreshToken도 서버에서 관리하고, blacklist로 accessToken으로 관리하게 되면서 보안을 더 고려할 수 있었다. 

무엇보다도 예전에 블로그로만 정리했던 개념들을 직접 코드로 구현해 본 경험이 가장 뿌듯했다. 역시 개발은 직접 해봐야 더 깊이 이해할 수 있다는 걸 다시 한번 느꼈다.

앞으로도 더 많은 질문을 던지고, 더 나은 방식을 고민하며 성장하는 개발자가 되고 싶다. 🚀

 

 

참고 : https://developers.kakao.com/docs/latest/ko/kakaologin/common

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해 보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

728x90
반응형
저작자표시 비영리 변경금지 (새창열림)

'Project' 카테고리의 다른 글

좋아요 기능을 개발하면서 고민한 내용 v2 (Test와 대규모 트래픽 환경에서의 고민...)  (1) 2025.10.03
좋아요 기능을 개발하면서 고민한 내용 (Redis, eventual consistency ...)  (0) 2025.09.20
📌 ERD 설계 가이드 (+ 직접 겪은 ERD 설계 경험)  (0) 2025.01.29
[LMS] 10일 동안의 '학습 관리 시스템' 개발 그리고 다른 팀과의 협업 이야기(1)  (0) 2025.01.27
[inter-face]프로젝트 기획 마무리 회고록 (1)  (1) 2024.12.03
'Project' 카테고리의 다른 글
  • 좋아요 기능을 개발하면서 고민한 내용 v2 (Test와 대규모 트래픽 환경에서의 고민...)
  • 좋아요 기능을 개발하면서 고민한 내용 (Redis, eventual consistency ...)
  • 📌 ERD 설계 가이드 (+ 직접 겪은 ERD 설계 경험)
  • [LMS] 10일 동안의 '학습 관리 시스템' 개발 그리고 다른 팀과의 협업 이야기(1)
pink_salt
pink_salt
유익함을 주는 개발자가 되도록 keep going
  • pink_salt
    KeepGoingForever
    pink_salt
  • 전체
    오늘
    어제
    • 분류 전체보기 (117)
      • Project (7)
      • WEB study (3)
        • WEB(Springboot) (10)
        • Git, GitLab (13)
        • Clean code (1)
        • FrontEnd (3)
      • Study (21)
        • Algorithm (19)
        • 면접 준비 (2)
      • Cloud Computing (2)
        • AWS (2)
      • 프로그래밍 언어 (35)
        • Java (29)
        • Python (0)
        • javascript (6)
      • 운영체제 (0)
        • Linux (0)
      • Database (4)
        • MongoDB (8)
        • SQL (8)
      • 애플리케이션 개발 (1)
        • Android (1)
      • AI (1)
        • Deeplearning (1)
        • machinelearning (0)
      • Daily (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    git branch
    백준
    코드프레소
    codepresso
    자바
    대외활동
    무료IT교육
    빅오표기법
    코딩강의
    SWEA
    언어
    코딩이러닝
    gitlab
    spring boot
    객체지향
    SW
    티스토리챌린지
    개념
    Database
    Git
    IT교육
    python
    MongoDB
    오블완
    dp
    무료코딩교육
    BFS
    Java
    mysql
    Query
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
pink_salt
OAuth 2.0을 활용한 로그인 구현을 하면서 (OAuth 2.0 동작, 인증/인가, Redis blackList...)
상단으로

티스토리툴바