본문 바로가기

백엔드 스터디

스프링 OAuth2 로그인 구현하기 2

💻 코드 구현

저번 application.properties 설정에 이어서 이번에는 본격적으로 코드를 구현해보도록 하겠습니다.

이제 전체적인 코드 구조를 살펴보겠습니다. 각 파일의 역할을 이해하면서 전체 흐름을 파악하는 것이 좋습니다.

 1. KakaoProperties: 설정 정보 관리

application.yml에 작성한 카카오 관련 설정들을 자바 객체로 안전하게 가져오기 위한 클래스입니다. @ConfigurationProperties 어노테이션이 이 역할을 수행합니다.

 
package com.duck.moodflix.test.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "kakao") // yml 파일에서 "kakao" prefix를 가진 값들을 매핑
public class KakaoProperties {
    private String clientId;
    private String redirectUri;
}

 2. SecurityConfig: 보안 설정

스프링 시큐리티 설정을 담당합니다. 이번 예제에서는 카카오 로그인만 사용하므로 모든 요청을 허용하고 기본적인 form 로그인은 비활성화하는 간단한 설정을 사용합니다.

 
package com.duck.moodflix.test.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    // 비밀번호 암호화 (로컬 회원가입 구현 시 재사용 가능)
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화 (세션 방식에서는 권장되지 않지만, 테스트 편의를 위해)
                .authorizeHttpRequests(auth -> auth
                        .anyRequest().permitAll()  // 모든 경로에 대해 인증 없이 접근 허용
                )
                .formLogin(form -> form.disable()) // 기본 폼 로그인 비활성화
                .logout(logout -> logout
                        .logoutUrl("/auth/logout")
                        .logoutSuccessUrl("/")
                        .permitAll()
                );
        return http.build();
    }
}

💡 Tip: .anyRequest().permitAll()은 개발 초기 단계에서 편리하지만 실제 서비스에서는 각 API의 성격에 맞게 접근 권한을 설정해야 합니다.

 3. KakaoDto: 카카오 API 응답 데이터 처리

카카오 API는 JSON 형태로 응답을 보내줍니다. 이 데이터를 자바 객체로 쉽게 변환하기 위해 DTO(Data Transfer Object)를 정의합니다.

 
package com.duck.moodflix.test.dto;

import lombok.Getter;

public class KakaoDto {

    // 1. 토큰 받기 응답 DTO
    @Getter
    public static class OAuthToken {
        private String access_token;
        private String token_type;
        private String refresh_token;
        private int expires_in;
        private String scope;
        private int refresh_token_expires_in;
    }

    // 2. 사용자 정보 받기 응답 DTO
    @Getter
    public static class KakaoProfile {
        private Long id;
        private String connected_at;
        private Properties properties;
        private KakaoAccount kakao_account;

        @Getter
        public class Properties {
            private String nickname;
        }

        @Getter
        public class KakaoAccount {
            private String email;
            private Profile profile;

            @Getter
            public class Profile {
                private String nickname;
            }
        }
    }
}

KakaoProfile 내부 클래스 구조는 카카오가 보내주는 JSON 응답의 구조와 정확히 일치해야 데이터 매핑이 정상적으로 이루어집니다.

 4. KakaoUtil: 카카오 API 통신 담당

실제로 카카오 서버와 HTTP 통신을 하는 로직을 담은 유틸리티 클래스입니다. WebClient를 사용해 비동기적으로 API를 호출합니다.

 
package com.duck.moodflix.test.util;

import com.duck.moodflix.test.config.KakaoProperties;
import com.duck.moodflix.test.dto.KakaoDto;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;

@Component
@RequiredArgsConstructor
public class KakaoUtil {
    private final KakaoProperties kakaoProperties;

    // 1. 인가 코드로 Access Token 요청
    public KakaoDto.OAuthToken requestToken(String code) {
        WebClient webClient = WebClient.builder().baseUrl("https://kauth.kakao.com").build();

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", kakaoProperties.getClientId());
        params.add("redirect_uri", kakaoProperties.getRedirectUri());
        params.add("code", code);
        
        // ★★★ 디버깅 포인트: 요청 파라미터 확인 ★★★
        System.out.println("KAKAO TOKEN REQUEST PARAMS");
        System.out.println("client_id: " + kakaoProperties.getClientId());
        System.out.println("redirect_uri: " + kakaoProperties.getRedirectUri());


        return webClient.post()
                .uri("/oauth/token")
                .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded")
                .body(BodyInserters.fromFormData(params))
                .retrieve() // 응답을 받기 시작
                .bodyToMono(KakaoDto.OAuthToken.class) // 응답 본문을 OAuthToken 객체로 변환
                .block(); // 비동기 처리를 동기적으로 대기
    }

    // 2. Access Token으로 사용자 프로필 정보 요청
    public KakaoDto.KakaoProfile requestProfile(KakaoDto.OAuthToken oAuthToken) {
        WebClient webClient = WebClient.builder().baseUrl("https://kapi.kakao.com").build();

        return webClient.get()
                .uri("/v2/user/me")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + oAuthToken.getAccess_token())
                .retrieve()
                .bodyToMono(KakaoDto.KakaoProfile.class)
                .block();
    }
}

 5. KaKaoService: 로그인 비즈니스 로직

가장 핵심적인 로직이 담긴 서비스 클래스입니다. 컨트롤러로부터 인가 코드를 받아 전체 로그인 과정을 처리합니다.

로그인 처리 순서:

  1. 받아온 인가 코드로 KakaoUtil을 통해 카카오 서버에 Access Token을 요청합니다.
  2. 발급받은 Access Token으로 KakaoUtil을 통해 사용자 프로필 정보를 요청합니다.
  3. 받아온 사용자 이메일로 우리 DB에서 회원을 조회합니다.
  4. 만약 회원이 없다면, 받아온 프로필 정보(이메일, 닉네임)로 신규 회원가입 처리를 합니다.
  5. 조회 또는 생성된 회원 정보를 세션(Session)에 저장하여 로그인 상태를 유지시킵니다.
 
package com.duck.moodflix.test.service;

import com.duck.moodflix.test.dto.KakaoDto;
import com.duck.moodflix.test.util.KakaoUtil;
import com.duck.moodflix.users.domain.entity.User;
import com.duck.moodflix.users.repository.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class KaKaoService {

    private final KakaoUtil kakaoUtil;
    private final UserRepository userRepository;

    public User oAuthLogin(String accessCode, HttpServletRequest request) {
        // 1. 카카오로부터 Access Token 발급받기
        KakaoDto.OAuthToken oAuthToken = kakaoUtil.requestToken(accessCode);

        // 2. Access Token으로 사용자 프로필 정보 가져오기
        KakaoDto.KakaoProfile profile = kakaoUtil.requestProfile(oAuthToken);

        String email = profile.getKakao_account().getEmail();
        String nickname = profile.getKakao_account().getProfile().getNickname();

        // 3. 우리 DB에 회원 정보가 있는지 확인 (없으면 신규 가입)
        User user = userRepository.findByEmail(email)
                .orElseGet(() -> userRepository.save(
                        User.builder()
                                .email(email)
                                .name(nickname)
                                .provider("kakao") // 소셜 로그인 제공자 정보
                                .build()
                ));

        // 4. 세션에 사용자 정보 저장 (로그인 처리)
        HttpSession session = request.getSession(true); // 세션이 없으면 새로 생성
        session.setAttribute("loginUser", user);

        return user;
    }
}

6. AuthController: API 엔드포인트

마지막으로, 클라이언트(브라우저)와 상호작용할 API 엔드포인트를 정의합니다.

package com.duck.moodflix.test.controller;

import com.duck.moodflix.test.service.KaKaoService;
import com.duck.moodflix.users.domain.entity.User;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {

    private final KaKaoService kakaoService;

    // 카카오 로그인 콜백 (Redirect URI)
    // GET /auth/login/kakao?code=...
    @GetMapping("/login/kakao")
    public ResponseEntity<?> kakaoLogin(@RequestParam("code") String code, HttpServletRequest request) {
        User user = kakaoService.oAuthLogin(code, request);
        
        // 로그인 성공 후 프론트엔드로 유저 정보를 반환하거나, 리다이렉트할 수 있음
        return ResponseEntity.ok(user);
    }

    // 로그인 상태 확인용 API (세션 기반)
    @GetMapping("/me")
    public ResponseEntity<?> getLoginUser(HttpServletRequest request) {
        User loginUser = (User) request.getSession().getAttribute("loginUser");
        if (loginUser == null) {
            return ResponseEntity.status(401).body("로그인이 필요합니다.");
        }
        return ResponseEntity.ok(loginUser);
    }

    // 로그아웃 (세션 무효화)
    @PostMapping("/logout")
    public ResponseEntity<?> logout(HttpServletRequest request) {
        request.getSession().invalidate();
        return ResponseEntity.ok("로그아웃 되었습니다.");
    }
}

✅ 최종 테스트

이제 애플리케이션을 실행하고 테스트하는 단계가 남았습니다. 저는 포트스맨으로 테스트 했습니다.

https://kauth.kakao.com/oauth/authorize?response_type=code&client_id={REST_API_키}&redirect_uri={Redirect_URI}

  • {여러분의_REST_API_키}: 카카오 디벨로퍼스에서 발급받은 REST API 키
  • {여러분의_Redirect_URI}: application.yml에 설정한 http://localhost:8080/auth/login/kakao

위 주소로 접속하면 카카오 로그인 및 동의 화면이 나타납니다. 동의하고 계속하기를 누르면, 설정한 Redirect URI로 이동하면서 code 파라미터가 함께 전달됩니다. 

서버의 AuthController가 이 요청을 받아 로그인 처리를 수행하고 성공적으로 사용자 정보가 반환되면 구현이 완료된 것입니다.

로그인 후 http://localhost:8080/auth/me로 접속하여 로그인 정보가 잘 유지되는지 확인하고 /auth/logout으로 로그아웃도 테스트해보세요.