ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 해커톤에서 OAuth2 로그인이 안 됐던 이유
    프로젝트, 트러블슈팅 2026. 3. 1. 22:59

     

    시작하면서

    해커톤에서 카카오/네이버/구글 소셜 로그인을 구현했는데 로그인이 제대로 동작하지 않았다. 원인을 찾아보니 내가 만든 구조와 프론트에서 원하는 구조가 달랐던 게 문제였다. 해커톤이 끝나고 이걸 제대로 이해하고 다시 만들면서 마주쳤던 문제들을 정리해봤다.


    핵심 문제: 백엔드 중심 방식의 한계

    당시엔 Spring Security의 oauth2Login()을 그대로 사용했다. 이 방식은 redirect_uri가 백엔드 URL로 고정된다.

    사용자 → http://localhost:8080/oauth2/authorization/kakao
           → 카카오 로그인
           → 카카오가 백엔드로 직접 리다이렉트 (redirect_uri = 백엔드 URL)
           → 백엔드가 JWT 발급 후 프론트로 리다이렉트
             http://localhost:3000/?accessToken=eyJ...&refreshToken=eyJ...
    

    문제는 redirect_uri가 백엔드 URL이라는 점이다. 카카오가 인가코드를 프론트가 아닌 백엔드로 보내버리니, 프론트는 로그인 흐름에서 완전히 배제된다. 프론트 입장에서는 카카오 로그인 버튼을 누르면 뭔가 일어나긴 하는데 자기가 제어할 수 있는 게 없는 구조다. 거기다 최종적으로 JWT가 URL 파라미터에 박혀서 날아오니 브라우저 히스토리에 토큰이 그대로 남는 보안 문제도 생겼다.

    프론트에서 원했던 건 자기가 인가코드를 직접 받아서, 원하는 타이밍에 백엔드에 넘기는 구조였다.


    해결: SPA 방식으로 전환

    redirect_uri를 프론트 URL로 바꾸는 게 핵심이다. 그러면 카카오가 인가코드를 프론트로 보내고, 프론트가 그걸 백엔드에 넘기는 구조가 된다.

    ① 프론트가 카카오 로그인 URL을 직접 만들어서 이동
       https://kauth.kakao.com/oauth/authorize
       ?client_id=xxx&redirect_uri=http://localhost:3000/callback&response_type=code
    
    ② 카카오 → 프론트 콜백으로 인가코드 전달
       http://localhost:3000/callback?code=abc123
    
    ③ 프론트 → 인가코드를 백엔드에 POST
       POST /api/auth/kakao/callback
       { "code": "abc123", "redirectUri": "http://localhost:3000/callback" }
    
    ④ 백엔드 → JWT를 Response Body로 반환
       { "accessToken": "...", "refreshToken": "..." }
    

    백엔드는 프론트에서 받은 인가코드로 카카오에 액세스 토큰을 요청하고, 사용자 정보를 가져와서 JWT를 발급해 돌려주기만 하면 된다. Spring Security의 자동화된 흐름 대신 직접 컨트롤러를 만들었다.

    @PostMapping("/kakao/callback")
    public ResponseEntity<DataResponse<TokenResponse>> kakaoCallback(
            @RequestBody KakaoCallbackRequest request) {
    
        // 인가코드 → 카카오 액세스토큰
        KakaoTokenResponse kakaoToken = kakaoOAuthService.getAccessToken(
            request.getCode(), request.getRedirectUri()
        );
    
        // 카카오 액세스토큰 → 사용자 정보
        KakaoUserInfoResponse kakaoUserInfo =
            kakaoOAuthService.getUserInfo(kakaoToken.getAccessToken());
    
        // 카카오 응답 파싱 → 공통 OAuth2Profile 형식으로 변환
        Map<String, Object> attributes = new HashMap<>();
        attributes.put("kakao_account", kakaoUserInfo.getKakaoAccount());
        OAuth2Profile profile = OAuth2Extractor.extract(OAuth2Provider.KAKAO, attributes);
    
        // DB 유저 조회/생성 + JWT 발급 + Redis 저장
        return ResponseEntity.ok(DataResponse.from(oAuthService.processLogin(profile)));
    }
    

    카카오, 네이버, 구글은 사용자 정보 응답 구조가 다 달라서 파싱 로직을 Strategy Pattern으로 분리했다.

    구글:  { "name": "...", "email": "..." }
    네이버: { "response": { "name": "...", "email": "..." } }
    카카오: { "kakao_account": { "email": "...", "profile": { "nickname": "..." } } }
    
    public enum OAuth2Extractor {
        GOOGLE(OAuth2Provider.GOOGLE, OAuth2Extractor::extractGoogleProfile),
        NAVER(OAuth2Provider.NAVER, OAuth2Extractor::extractNaverProfile),
        KAKAO(OAuth2Provider.KAKAO, OAuth2Extractor::extractKakaoProfile);
    
        public static OAuth2Profile extract(OAuth2Provider provider, Map<String, Object> attributes) {
            return Arrays.stream(values())
                    .filter(e -> e.provider == provider)
                    .findFirst()
                    .orElseThrow()
                    .extractor.apply(attributes);
        }
    
        @SuppressWarnings("unchecked")
        private static OAuth2Profile extractKakaoProfile(Map<String, Object> attributes) {
            Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
            Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
            return OAuth2Profile.builder()
                    .name((String) kakaoProfile.get("nickname"))
                    .email((String) kakaoAccount.get("email"))
                    .build();
        }
        // 네이버, 구글도 동일한 방식으로 구현
    }
    

    로그인 공통 처리는 OAuthService.processLogin()으로 분리했다. DB에서 이메일 + provider 조합으로 유저를 조회하고, 없으면 자동 회원가입 처리한다. 같은 이메일이라도 provider가 다르면 다른 계정으로 취급한다.

    public TokenResponse processLogin(OAuth2Profile profile) {
        User user = userRepository
            .findByEmailAndProvider(profile.getEmail(), profile.getProvider())
            .orElseGet(() -> userRepository.save(profile.toUser()));
    
        String accessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getEmail());
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getId());
    
        refreshTokenService.save(user.getId(), refreshToken);
    
        return TokenResponse.of(accessToken, refreshToken);
    }
    

    진짜 로그아웃 만들기

    구조 문제를 해결하고 나니 로그아웃이 가짜라는 게 보였다. JWT는 Stateless라서 서버가 발급한 토큰을 어디에도 저장하지 않는다. 로그아웃해도 토큰은 만료 전까지 그대로 유효하다.

    Redis로 블랙리스트를 만들어서 해결했다. 로그아웃할 때 Access Token을 블랙리스트에 등록하고, 모든 요청에서 블랙리스트를 먼저 확인한다. TTL을 토큰 남은 만료시간으로 설정하면 만료 후엔 Redis에서도 자동 삭제된다.

    // 로그아웃 시 블랙리스트 등록
    public void addToBlacklist(String token, long expirationTimeInMs) {
        redisTemplate.opsForValue().set(
            "blacklist:" + token, "logout", expirationTimeInMs, TimeUnit.MILLISECONDS
        );
    }
    
    // 모든 요청마다 체크
    public boolean isBlacklisted(String token) {
        return Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:" + token));
    }
    

    Refresh Token은 화이트리스트로 관리했다. 로그인할 때 Redis에 저장하고, 갱신 요청이 오면 저장된 토큰과 일치하는지 검증한다. refresh:{userId}를 키로 쓰기 때문에 유저당 하나만 유지되고, 새로 로그인하면 이전 토큰이 자동으로 덮어씌워진다.

    public void save(Long userId, String refreshToken) {
        redisTemplate.opsForValue().set(
            "refresh:" + userId, refreshToken, refreshTokenValidity, TimeUnit.MILLISECONDS
        );
    }
    
    public boolean validate(Long userId, String refreshToken) {
        String storedToken = redisTemplate.opsForValue().get("refresh:" + userId);
        return storedToken != null && storedToken.equals(refreshToken);
    }
    

    갱신할 때는 Token Rotation을 적용했다. 새 토큰을 발급하면서 기존 Refresh Token을 즉시 무효화한다. 해커가 Refresh Token을 탈취해도 정상 사용자가 먼저 갱신하면 탈취된 토큰은 Redis 검증에서 바로 걸린다.

    JWT 검증은 모든 요청마다 실행되기 때문에 Redis를 선택했다. MySQL이면 매 요청마다 DB 조회가 발생하는데 Redis는 메모리 기반이라 1ms 이하로 처리된다.


    마무리

    해커톤에서 로그인이 안 됐던 근본 원인은 redirect_uri가 어디로 가느냐에 따라 인가코드를 누가 받는지, 로그인 흐름을 누가 주도하는지가 완전히 달라진다는 걸 몰랐던 것이었다. Spring Security가 자동으로 처리해주는 게 편해 보였지만, SPA 환경에서는 오히려 프론트의 제어권을 빼앗는 구조였다.

    JWT + Redis 조합도 처음엔 과하다 싶었는데, Redis 없이 JWT만 쓰면 로그아웃이 사실상 무의미하다는 걸 이해하고 나서는 필수라는 생각이 들었다.

Designed by Tistory.