카카오 소셜 로그인 - OAuth 2.0 / Spring Boot 3.x 구현해보기

StrayCat·2026년 3월 11일

1. 소셜 로그인이 등장한 이유

웹 서비스가 많아질수록 사용자 입장에서는 매번 회원가입을 해야 하고, 사이트마다 다른 비밀번호를 기억해야 한다. 서비스 운영자 입장에서도 회원 개인정보를 안전하게 보호해야 한다는 부담이 크다.

이 문제를 해결하기 위해 등장한 것이 OAuth 2.0 기반 소셜 로그인이다.

OAuth 2.0 : 사용자가 비밀번호를 직접 제공하지 않고, 신뢰하는 서비스(카카오, 구글 등)에게 제한된 권한을 위임하는 HTTP 기반 표준 프로토콜


2. 카카오 로그인의 전체 흐름

사용자  →  우리 서버  →  카카오 인증 서버
  ↑                          ↓
  ←  JWT 발급  ←  사용자 정보  ←

단계별로 풀어보면:

  1. 사용자가 "카카오 로그인" 버튼 클릭
  2. 브라우저가 카카오 인증 페이지로 리다이렉트
  3. 사용자가 카카오 계정 로그인 + 동의 완료
  4. 카카오가 우리 서버의 Redirect URI인가 코드(Authorization Code) 전달
  5. 우리 서버가 인가 코드로 카카오에게 액세스 토큰 요청
  6. 액세스 토큰으로 카카오에게 사용자 정보 요청
  7. 사용자 정보로 우리 DB에 회원가입(또는 로그인) 처리
  8. 우리 서버 JWT 토큰 발급 후 클라이언트에 응답

이 흐름이 바로 OAuth 2.0의 Authorization Code Grant 방식이다.


3. 강의 기반 구현 방식 (RestTemplate — 레거시 참고용)

예제에서는 RestTemplate을 직접 사용해서 카카오 API를 호출하는 방식으로 구현한다.

3-1. 전체 구조

Controller  →  KakaoService  →  카카오 API (HTTP 직접 호출)

3-2. 인가 코드 받기

카카오 로그인 버튼 클릭 시 아래 URL로 이동한다.

https://kauth.kakao.com/oauth/authorize
  ?client_id={REST_API_KEY}
  &redirect_uri={REDIRECT_URI}
  &response_type=code

사용자가 동의하면 카카오는 설정한 Redirect URI로 인가 코드를 쿼리 파라미터로 전달한다.

http://localhost:8080/api/user/kakao/callback?code=zAGhy36K0...

3-3. Controller — 인가 코드 수신

// GET /api/user/kakao/callback 으로 카카오가 인가 코드를 전달
@GetMapping("/user/kakao/callback")
public String kakaoLogin(
    @RequestParam String code,   // 카카오가 전달한 인가 코드
    HttpServletResponse response
) throws JsonProcessingException {

    // KakaoService에서 전체 처리 후 JWT 반환
    String token = kakaoService.kakaoLogin(code);

    // JWT를 쿠키에 담아 브라우저로 전달
    Cookie cookie = new Cookie(JwtUtil.AUTHORIZATION_HEADER, token.substring(7));
    cookie.setPath("/");
    response.addCookie(cookie);

    // 메인 페이지로 리다이렉트
    return "redirect:/";
}

3-4. KakaoService — 핵심 로직

// 카카오 로그인 전체 처리 흐름
public String kakaoLogin(String code) throws JsonProcessingException {
    // Step 1. 인가 코드 → 액세스 토큰 교환
    String accessToken = getToken(code);

    // Step 2. 액세스 토큰 → 카카오 사용자 정보 조회
    KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);

    // Step 3. 신규 사용자라면 회원가입 처리
    User kakaoUser = registerKakaoUserIfNeeded(kakaoUserInfo);

    // Step 4. 우리 서버 JWT 발급 및 반환
    return jwtUtil.createToken(kakaoUser.getUsername(), kakaoUser.getRole());
}

Step 1 — 액세스 토큰 요청

private String getToken(String code) throws JsonProcessingException {
    // 카카오 토큰 발급 엔드포인트 URI 구성
    URI uri = UriComponentsBuilder
        .fromUriString("https://kauth.kakao.com")
        .path("/oauth/token")
        .encode()
        .build()
        .toUri();

    // 요청 헤더 설정
    HttpHeaders headers = new HttpHeaders();
    headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

    // 요청 바디 — 카카오 문서 기준 필수 파라미터
    MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
    body.add("grant_type", "authorization_code"); // 고정값
    body.add("client_id", "본인의 REST API키");
    body.add("redirect_uri", "http://localhost:8080/api/user/kakao/callback");
    body.add("code", code);                       // 방금 받은 인가 코드

    RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity
        .post(uri)
        .headers(headers)
        .body(body);

    // HTTP 요청 실행
    ResponseEntity<String> response = restTemplate.exchange(requestEntity, String.class);

    // JSON 응답에서 access_token 파싱
    JsonNode jsonNode = new ObjectMapper().readTree(response.getBody());
    return jsonNode.get("access_token").asText();
}

Step 2 — 사용자 정보 요청

private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
    // 카카오 사용자 정보 API 엔드포인트
    URI uri = UriComponentsBuilder
        .fromUriString("https://kapi.kakao.com")
        .path("/v2/user/me")
        .encode()
        .build()
        .toUri();

    // Authorization 헤더에 액세스 토큰 포함
    HttpHeaders headers = new HttpHeaders();
    headers.add("Authorization", "Bearer " + accessToken);
    headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

    RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity
        .post(uri)
        .headers(headers)
        .body(new LinkedMultiValueMap<>());

    ResponseEntity<String> response = restTemplate.exchange(requestEntity, String.class);

    // JSON 응답 파싱 — 카카오 응답 구조에 맞게 접근
    JsonNode jsonNode = new ObjectMapper().readTree(response.getBody());
    Long id = jsonNode.get("id").asLong();
    String nickname = jsonNode.get("properties").get("nickname").asText();
    String email = jsonNode.get("kakao_account").get("email").asText();

    return new KakaoUserInfoDto(id, nickname, email);
}

Step 3 — 조건부 회원가입

private User registerKakaoUserIfNeeded(KakaoUserInfoDto kakaoUserInfo) {
    Long kakaoId = kakaoUserInfo.getId();

    // kakaoId로 기존 회원 조회
    User kakaoUser = userRepository.findByKakaoId(kakaoId).orElse(null);

    if (kakaoUser == null) {
        String kakaoEmail = kakaoUserInfo.getEmail();
        // 동일 이메일로 일반 회원가입된 계정이 있는지 확인
        User sameEmailUser = userRepository.findByEmail(kakaoEmail).orElse(null);

        if (sameEmailUser != null) {
            // 기존 계정에 kakaoId만 업데이트 (계정 연동)
            kakaoUser = sameEmailUser.kakaoIdUpdate(kakaoId);
        } else {
            // 완전 신규 회원가입
            String password = passwordEncoder.encode(UUID.randomUUID().toString());
            kakaoUser = new User(
                kakaoUserInfo.getNickname(),
                password,
                kakaoEmail,
                UserRoleEnum.USER,
                kakaoId
            );
        }
        userRepository.save(kakaoUser);
    }
    return kakaoUser;
}

3-5. 카카오 사용자 정보 JSON 구조 (참고)

{
  "id": 1632335751,
  "properties": {
    "nickname": "홍길동"
  },
  "kakao_account": {
    "email": "hong@example.com",
    "is_email_verified": true
  }
}

4. 2026년 기준 — 권장 방식으로 업그레이드하기

예제의 방식은 동작하는 데 문제는 없다. 다만, 현재 개발 생태계는 꽤 많이 바뀌었다.

4-1. RestTemplate의 현재 상태

Spring Framework는 공식적으로 RestTemplate의 deprecation 계획을 발표했다. Spring Framework 7.0(2025년 11월)에서 deprecation 의도를 선언하고, 7.1에서 @Deprecated 어노테이션을 적용하며, 8.0에서는 완전히 제거될 예정이다.

즉, 기존 레거시 프로젝트에서는 RestTemplate이 여전히 동작하지만, 새 프로젝트에서는 RestClient를 사용하는 것이 옳다.

마이그레이션 경로는 간단하다. RestClient.create(restTemplate) 형태로 기존 인스턴스를 감싸는 것이 가능하며, 새 코드에서는 RestTemplate을 건너뛰고 바로 RestClient를 사용하면 된다.

4-2. 방식 1 — RestClient로 교체 (수동 구현 유지)

강의 코드의 구조(Controller → Service → 카카오 API 직접 호출)를 그대로 유지하면서 RestTemplateRestClient로 교체하는 방법이다. 구조 변경이 최소화되어 레거시 코드를 점진적으로 마이그레이션할 때 적합하다.

// RestTemplateConfig 대신 RestClientConfig로 교체
@Configuration
public class RestClientConfig {

    @Bean
    public RestClient restClient() {
        return RestClient.builder()
            // 5초 안에 연결 안 되면 예외 발생
            .requestFactory(clientHttpRequestFactory())
            .build();
    }

    private ClientHttpRequestFactory clientHttpRequestFactory() {
        // Spring Framework 6.1+ 기본 제공 팩토리
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(5000);
        factory.setReadTimeout(5000);
        return factory;
    }
}

KakaoService에서 RestTemplate 대신 RestClient 사용:

// 기존: restTemplate.exchange(requestEntity, String.class)
// 변경: RestClient의 플루언트(Fluent) API 스타일

// 액세스 토큰 요청 예시
String responseBody = restClient
    .post()
    .uri("https://kauth.kakao.com/oauth/token")
    .contentType(MediaType.APPLICATION_FORM_URLENCODED)
    .body(body)  // MultiValueMap
    .retrieve()
    .body(String.class);

4-3. 방식 2 — Spring Security OAuth2 Client 활용 (현대적 방식)

Spring Security 6.4에서는 RestClient를 기반으로 한 새로운 OAuth2AccessTokenResponseClient 구현체들이 도입되어, OAuth 2.0 토큰 요청 커스터마이징이 훨씬 일관성 있게 처리된다.

spring-boot-starter-oauth2-client 의존성만 추가하면, 토큰 교환과 사용자 정보 요청을 Spring Security가 자동으로 처리해준다.

의존성 추가 (build.gradle)

// OAuth2 클라이언트 자동 처리를 위한 스타터
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

application.yml 설정

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: ${KAKAO_CLIENT_ID}         # 카카오 REST API 키
            client-secret: ${KAKAO_CLIENT_SECRET}  # 카카오 Client Secret (설정한 경우)
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            authorization-grant-type: authorization_code
            scope:
              - profile_nickname  # 카카오 동의항목 scope명 (2021년 이후 변경됨)
              - account_email
            client-name: Kakao
            client-authentication-method: client_secret_post  # 카카오는 POST 방식 사용
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id  # 카카오 응답 JSON의 최상위 id 필드

주의 : 카카오 scope 이름이 변경되었다. 예전 profile, account_email 대신 현재는 profile_nickname, account_email 등으로 분리되어 있다. 카카오 디벨로퍼스 콘솔에서 동의항목 설정 시 scope 이름을 반드시 확인해야 한다.

SecurityConfig 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    public SecurityConfig(CustomOAuth2UserService customOAuth2UserService) {
        this.customOAuth2UserService = customOAuth2UserService;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 세션 대신 JWT를 사용하는 경우 STATELESS 설정
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            // 접근 권한 설정
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
                .anyRequest().authenticated()
            )
            // OAuth2 로그인 설정
            .oauth2Login(oauth2 -> oauth2
                // 사용자 정보를 커스텀 서비스로 처리
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(customOAuth2UserService)
                )
                // 로그인 성공 후 JWT 발급 등 후처리 핸들러
                .successHandler(oAuth2AuthenticationSuccessHandler())
            );

        return http.build();
    }
}

CustomOAuth2UserService

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // Spring Security가 토큰 교환, 사용자 정보 요청을 이미 처리한 상태
        OAuth2User oAuth2User = super.loadUser(userRequest);

        // 어떤 OAuth2 제공자인지 식별 (ex: "kakao", "google")
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        // 카카오 응답 JSON에서 필요한 정보 추출
        Map<String, Object> attributes = oAuth2User.getAttributes();
        Long kakaoId = (Long) 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 email = (String) kakaoAccount.get("email");

        // DB에 없으면 자동 회원가입
        User user = registerIfNeeded(kakaoId, nickname, email);

        // Spring Security가 인식할 수 있는 OAuth2User 반환
        return new DefaultOAuth2User(
            Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())),
            attributes,
            "id"  // user-name-attribute와 일치해야 함
        );
    }

    private User registerIfNeeded(Long kakaoId, String nickname, String email) {
        return userRepository.findByKakaoId(kakaoId).orElseGet(() ->
            userRepository.save(new User(
                nickname,
                passwordEncoder.encode(UUID.randomUUID().toString()),
                email,
                UserRoleEnum.USER,
                kakaoId
            ))
        );
    }
}

5. 두 방식 비교 요약

구분강의 방식 (RestTemplate 수동)현대적 방식 (oauth2-client)
토큰 교환직접 HTTP 요청 코드 작성Spring Security 자동 처리
사용자 정보 요청직접 HTTP 요청 코드 작성Spring Security 자동 처리
코드량많다적다
커스터마이징자유도 높음정해진 확장 포인트 활용
유지보수RestTemplate deprecation 영향 받음Spring Security 업데이트 따라감
적합한 상황레거시 코드 유지, 흐름 학습 목적신규 프로젝트

6. 핵심 포인트

  • OAuth 2.0 Authorization Code 흐름: 인가 코드 → 액세스 토큰 → 사용자 정보 순서를 잊지 말 것
  • 카카오 Redirect URI는 카카오 디벨로퍼스 콘솔의 "플랫폼 > Redirect URI" 에 등록되어야 한다
  • kakaoId를 DB에 저장해 두면, 같은 카카오 계정이 재로그인할 때 신규 회원가입 없이 기존 계정과 연결할 수 있다
  • 이미 동일 이메일로 가입된 일반 계정이 있다면, 카카오 로그인 시 해당 계정에 kakaoId만 업데이트하는 방식으로 계정을 연동할 수 있다
  • RestTemplate은 동작은 하지만 Spring Framework 7.x 이후로 공식 deprecated 예정이므로, 신규 코드에서는 RestClient 사용을 권장한다

참고 자료

profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글