TIL_20250502_소셜로그인 구현

Kim jisu·2025년 5월 2일

TIL

목록 보기
40/43

기존 소셜 로그인의 문제점

기존에는 소셜 로그인 후 JWT토큰은 URL에 전달했다.

기존에 구현했던 소셜 로그인 방식은 다음과 같은 문제점이 있었다:

@GetMapping("/kakao/callback")
public void kakaoLogin(@RequestParam("code") String code, HttpServletResponse response) throws IOException {
    TokenResponse tokens = userService.kakaoLogin(code);
    String redirectUrl = String.format(
       "https://cheongyakplanet.vercel.app?accessToken=%s&refreshToken=%s",
       tokens.getAccessToken(),
       tokens.getRefreshToken()
    );
    response.sendRedirect(redirectUrl);
}

이 방식의 주요 문제점:

  • URL 길이 제한으로 인해 토큰이 잘릴 수 있음
  • URL 파라미터로 토큰을 전달하면 브라우저 히스토리에 노출됨
  • 서버 로그에 토큰이 기록될 가능성이 높음
  • 보안에 취약함

더 안전한 소셜 로그인 구현 방법

오늘 학습한 안전한 소셜 로그인 방법 중 가장 적합한 것은 임시 상태 코드를 사용하는 방식이다.

1. 상태 코드를 사용한 토큰 교환 방식

1.1 서버 측 구현

먼저 토큰을 임시 저장할 서비스 클래스를 구현한다:

@Service
public class TokenCacheService {
    // 토큰 정보와 만료 시간을 함께 저장
    private static class TokenInfo {
        private final TokenResponse tokens;
        private final LocalDateTime expiryTime;
        
        public TokenInfo(TokenResponse tokens, int expirySeconds) {
            this.tokens = tokens;
            this.expiryTime = LocalDateTime.now().plusSeconds(expirySeconds);
        }
        
        public boolean isExpired() {
            return LocalDateTime.now().isAfter(expiryTime);
        }
        
        public TokenResponse getTokens() {
            return tokens;
        }
    }
    
    // 상태 코드를 키로 사용하는 토큰 저장소
    private final Map<String, TokenInfo> tokenStore = new ConcurrentHashMap<>();
    
    // 토큰 저장
    public void storeTokens(String stateCode, TokenResponse tokens, int expirySeconds) {
        tokenStore.put(stateCode, new TokenInfo(tokens, expirySeconds));
    }
    
    // 토큰 조회 및 삭제
    public TokenResponse getAndRemoveTokens(String stateCode) {
        TokenInfo tokenInfo = tokenStore.remove(stateCode);
        
        if (tokenInfo == null || tokenInfo.isExpired()) {
            return null;
        }
        
        return tokenInfo.getTokens();
    }
    
    // 주기적으로 만료된 토큰 정리 (옵션)
    @Scheduled(fixedRate = 600000) // 10분마다
    public void cleanupExpiredTokens() {
        tokenStore.entrySet().removeIf(entry -> entry.getValue().isExpired());
    }
}

다음으로 컨트롤러에서 이 서비스를 활용한다:

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/member")
public class UserController {

    private final UserService userService;
    private final TokenCacheService tokenCacheService;
    
    @GetMapping("/kakao/callback")
    public void kakaoLogin(@RequestParam("code") String code, HttpServletResponse response) throws IOException {
        // 1. 인증 코드로 카카오 API 호출하여 토큰 획득
        TokenResponse tokens = userService.kakaoLogin(code);
        
        // 2. 짧은 임시 코드 생성
        String stateCode = UUID.randomUUID().toString().substring(0, 8);
        
        // 3. 인메모리 캐시에 임시 저장 (5분 만료)
        tokenCacheService.storeTokens(stateCode, tokens, 300);
        
        // 4. 상태 코드만 리다이렉트에 포함
        response.sendRedirect("https://cheongyakplanet.vercel.app/auth?state=" + stateCode);
    }
    
    @GetMapping("/auth/exchange")
    public ResponseEntity<TokenResponse> exchangeStateForTokens(@RequestParam("state") String stateCode) {
        // 5. 상태 코드로 실제 토큰 교환
        TokenResponse tokens = tokenCacheService.getAndRemoveTokens(stateCode);
        if (tokens == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        return ResponseEntity.ok(tokens);
    }
}

1.2 프론트엔드 구현

프론트엔드에서는 /auth 경로에 다음과 같은 컴포넌트를 구현한다:

// Auth.js
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';

function Auth() {
  const navigate = useNavigate();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    // URL에서 상태 코드 가져오기
    const urlParams = new URLSearchParams(window.location.search);
    const stateCode = urlParams.get('state');
    
    if (!stateCode) {
      setError('인증 코드가 없습니다');
      setLoading(false);
      return;
    }
    
    // 백엔드에 상태 코드를 제출하여 실제 토큰 받기
    fetch(`https://api.cheongyakplanet.com/api/member/auth/exchange?state=${stateCode}`)
      .then(response => {
        if (!response.ok) throw new Error('인증 실패');
        return response.json();
      })
      .then(data => {
        // 토큰을 안전하게 저장
        localStorage.setItem('accessToken', data.accessToken);
        localStorage.setItem('refreshToken', data.refreshToken);
        
        // 홈 페이지로 이동
        navigate('/');
      })
      .catch(error => {
        console.error('Authentication error:', error);
        setError(error.message);
      })
      .finally(() => {
        setLoading(false);
      });
  }, [navigate]);
  
  if (loading) {
    return <div className="loading">인증 처리 중입니다...</div>;
  }
  
  if (error) {
    return <div className="error">오류: {error}</div>;
  }
  
  return null;
}

export default Auth;

1.3 TokenResponse 클래스 구현

백엔드에서 토큰 응답을 위한 DTO 클래스:

@Getter
@AllArgsConstructor
public class TokenResponse {
    private String accessToken;
    private String refreshToken;
}

2. HTTP-only 쿠키 방식

또 다른 안전한 방법으로 HTTP-only 쿠키를 사용하는 방식도 배웠다. 이 방식은 XSS 공격에 더 안전하지만, 프론트엔드와 백엔드가 같은 도메인 또는 적절한 CORS 설정이 있어야 한다.

@GetMapping("/kakao/callback")
public void kakaoLogin(@RequestParam("code") String code, HttpServletResponse response) throws IOException {
    TokenResponse tokens = userService.kakaoLogin(code);
    
    // HTTP-only 쿠키에 토큰 저장
    Cookie accessTokenCookie = new Cookie("accessToken", tokens.getAccessToken());
    accessTokenCookie.setHttpOnly(true);
    accessTokenCookie.setSecure(true); // HTTPS에서만 전송
    accessTokenCookie.setPath("/");
    accessTokenCookie.setMaxAge(60 * 60); // 1시간
    
    Cookie refreshTokenCookie = new Cookie("refreshToken", tokens.getRefreshToken());
    refreshTokenCookie.setHttpOnly(true);
    refreshTokenCookie.setSecure(true);
    refreshTokenCookie.setPath("/");
    refreshTokenCookie.setMaxAge(24 * 60 * 60); // 1일
    
    response.addCookie(accessTokenCookie);
    response.addCookie(refreshTokenCookie);
    
    response.sendRedirect("https://cheongyakplanet.vercel.app");
}

각 방식의 장단점

상태 코드 방식

  • 장점:
    • 토큰이 URL에 노출되지 않음
    • 프론트엔드에서 토큰 관리가 용이함
    • 서버 재시작 시에만 토큰이 소실됨
  • 단점:
    • 서버 메모리를 사용함 (많은 동시 로그인이 있을 경우 주의)
    • 다중 서버 환경에서는 세션 어피니티나 Redis 같은 외부 저장소 필요

HTTP-only 쿠키 방식

  • 장점:
    • XSS 공격에 더 안전함
    • 자동으로 요청에 인증 정보가 포함됨
  • 단점:
    • CORS 설정이 복잡할 수 있음
    • 프론트엔드에서 토큰에 직접 접근 불가
    • 제3자 쿠키 제한으로 인한 문제 가능성

결론

우리 서비스에는 상태 코드를 사용한 토큰 교환 방식이 가장 적합하다고 판단하여 이 방식으로 구현하기로 결정했다. 이 방식은 구현이 비교적 간단하고, 토큰을 URL에 노출시키지 않으면서도 프론트엔드에서 토큰 관리가 용이하다는 장점이 있다.

향후 사용자가 많아지면 토큰 저장소를 Redis로 마이그레이션하는 것을 고려할 수 있다.

profile
Dreamer

0개의 댓글