-
해커톤에서 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만 쓰면 로그아웃이 사실상 무의미하다는 걸 이해하고 나서는 필수라는 생각이 들었다.
'프로젝트, 트러블슈팅' 카테고리의 다른 글
복합 유니크 제약조건으로 중복 데이터 막기 (0) 2026.03.01 JPA N+1 문제와 @EntityGraph로 해결하기 (0) 2026.03.01 JWT 인증에서 Access Token, Refresh Token, Redis가 필요한 이유 (0) 2026.03.01 SMTP 이메일 인증 시스템 (0) 2025.07.11 JPA 일대일 양방향 매핑에서 발생한 무한 순환 참조 문제와 해결 (0) 2024.12.23