프로젝트/Profee

[SpringBoot] 소셜 로그인 백엔드 구현 완전정복: 구글, 카카오, 네이버

승요나라 2024. 12. 21. 13:08

소셜 로그인은 사용자가 각 소셜 플랫폼(구글, 카카오, 네이버)에 저장된 계정을 통해 빠르고 안전하게 로그인할 수 있도록 지원한다. 이 글에서는 구글, 카카오, 네이버 소셜 로그인의 백엔드 구현 과정에서 각 파일들을 설명하고, 전체적인 흐름을 정리해 보겠다.

 

 

 

폴더 구조

Google, Kakao, Naver 소셜 로그인 구현을 마친 프로젝트 폴더 구조는 아래와 같다.

Profee 프로젝트 폴더 구조

 

 

 

주요 파일 설명

src
└── main
    ├── java
    │   └── com.example.Profee
    │       ├── config
    │       │   ├── SecurityConfig.java   # CORS 및 인증 정책 설정
    │       │   └── SwaggerConfig.java    # API 테스트를 위한 Swagger 설정
    │       ├── controller
    │       │   └── OauthController.java  # 소셜로그인 엔드포인트 처리
    │       ├── service
    │       │   └── OauthService.java     # 소셜로그인 비즈니스 로직 구현
    │       ├── oauth
    │       │   ├── GoogleOauth.java      # 구글 Oauth API 호출
    │       │   ├── KakaoOauth.java       # 카카오 Oauth API 호출
    │       │   └── NaverOauth.java       # 네이버 Oauth API 호출
    │       └── dto
    │           └── UserResponse.java     # 사용자 응답 데이터 정의
    └── resources
        └── application.properties

 

 

 

SecurityConfig.java

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))  // CORS 설정
                .authorizeHttpRequests(authorizeRequests ->
                        authorizeRequests
                                .requestMatchers("/auth/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()  // 경로 허용
                                .anyRequest().authenticated()  // 나머지 요청은 인증 필요
                )
                .csrf(csrf -> csrf.disable());  // CSRF 비활성화

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:8080"));  // Swagger UI URL 허용
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

먼저 SecurityConfig 파일에서 Spring Security를 이용해 보안 정책을 정의한다. 소셜 로그인은 /auth/** 경로에서 시작되므로, 이 경로를 인증 없이 허용한다. 인증이 필요한 다른 경로와는 다르게 /auth/** 경로는 누구나 접근할 수 있어야 하기 때문이다. 또한, 프론트엔드(React Native)에서 서버로 요청을 보낼 수 있도록 CORS 정책을 설정한다. CORS 설정은 어떤 도메인에서 요청을 허용할지, 어떤 HTTP 메서드를 허용할지 등을 결정한다. 이 설정이 없다면 브라우저에서 발생하는 CORS 에러로 인해 프론트엔드에서 API를 호출할 수 없다.

 

SwaggerConfig.java

@Configuration
public class SwaggerConfig implements WebMvcConfigurer {
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Social Login API")
                        .version("1.0")
                        .description("API for Social Login"));
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/swagger-ui/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
}

SwaggerConfig 파일에서는 API를 시각적으로 테스트할 수 있는 Swagger UI를 설정한다. 해당 파일을 통해 API 명세를 작성하고, 클라이언트나 개발자가 쉽게 API를 이해하고 테스트할 수 있다. 특히, 소셜 로그인과 같이 외부와 통신하는 API는 어떤 요청을 보내고 어떤 응답을 받는지 명확히 보여주는 것이 중요하다. Swagger를 통해 각 소셜 로그인 API를 설명하고, 예상되는 입력값과 반환값을 확인할 수 있다.

 

OauthService.java

@Service
@RequiredArgsConstructor
public class OauthService {
    private final List<SocialOauth> socialOauthList; // 다양한 소셜 로그인 OAuth 처리 객체
    private final UserRepository userRepository; // 사용자 정보를 저장하는 레포지토리
    private static final Logger logger = LoggerFactory.getLogger(OauthService.class);

    // 소셜 로그인 요청 URL을 반환하는 메서드
    public String request(SocialLoginType socialLoginType) {
        SocialOauth socialOauth = this.findSocialOauthByType(socialLoginType); // 주어진 소셜 로그인 타입에 맞는 OAuth 객체 찾기
        return socialOauth.getOauthRedirectURL(); // 해당 OAuth 객체의 리디렉션 URL 반환
    }

    // 인증 코드로 액세스 토큰을 요청하는 메서드
    public String requestAccessToken(SocialLoginType socialLoginType, String code) {
        SocialOauth socialOauth = this.findSocialOauthByType(socialLoginType); // 주어진 소셜 로그인 타입에 맞는 OAuth 객체 찾기
        return socialOauth.requestAccessToken(code); // 액세스 토큰 요청
    }


    // JSON에서 액세스 토큰만 추출하는 메서드
    private String extractAccessTokenFromJson(String accessTokenJson) {
        // JSON을 파싱하여 액세스 토큰만 추출
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            JsonNode jsonNode = objectMapper.readTree(accessTokenJson);
            // 여기서 필요한 토큰만 반환
            return jsonNode.get("access_token") != null ? jsonNode.get("access_token").asText() : null;
        } catch (JsonProcessingException e) {
            e.printStackTrace();
            return null; // 실패할 경우 null 반환
        }
    }

    // 액세스 토큰을 사용하여 사용자 정보를 가져오고, 사용자 정보가 있으면 저장하는 메서드
    public User requestAccessTokenAndSaveUser(SocialLoginType socialLoginType, String code) {
        System.out.println("socialLoginType & code : "+ socialLoginType + " \n & \n" + code +"\n********");

        // 1. 액세스 토큰을 포함한 JSON 응답을 요청
        String accessTokenJson = this.requestAccessToken(socialLoginType, code);

        System.out.println("\n accesTokenJson : "+ accessTokenJson +"\n********");
        // 2. JSON에서 액세스 토큰만 추출
        String accessToken = extractAccessTokenFromJson(accessTokenJson);

        if (accessToken == null) {
            // 액세스 토큰이 없으면 예외 처리
            throw new RuntimeException("Failed to extract access token.");
        }

        // 3. 액세스 토큰을 사용해 사용자 정보 요청
        String userInfo = getUserInfo(socialLoginType, accessToken);

        System.out.println("userinfo" + userInfo);

        // 4. 사용자 정보를 파싱하여 User 객체 생성
        User user = parseUserInfo(userInfo, socialLoginType, accessToken);

        // 5. 기존 사용자 확인 후 처리
        Optional<User> existingUser = userRepository.findBySocialId(user.getSocialId());
        if (existingUser.isPresent()) {
            // 이미 존재하는 사용자라면 로그인 처리 (액세스 토큰 갱신 등)
            User existing = existingUser.get();
            existing.setAccessToken(user.getAccessToken()); // 토큰 갱신
            existing.setUpdatedAt(new Timestamp(System.currentTimeMillis())); // 수정 시간 갱신
            return userRepository.save(existing); // 수정된 사용자 정보 저장
        } else {
            // 새 사용자라면 저장
            user.setCreatedAt(new Timestamp(System.currentTimeMillis())); // 생성 시간 설정
            return userRepository.save(user); // 새 사용자 정보 저장
        }
    }

    // 실제 소셜 로그인 API에서 사용자 정보를 받아오는 메서드 (Google, Kakao, Naver 등)
    private String getUserInfo(SocialLoginType socialLoginType, String accessToken) {
        switch (socialLoginType) {
            case GOOGLE:
                return googleApiCall(accessToken);  // Google API 호출 메서드
            case KAKAO:
                return kakaoApiCall(accessToken);  // Kakao API 호출 메서드
            case NAVER:
                return naverApiCall(accessToken);  // Naver API 호출 메서드
            default:
                throw new IllegalArgumentException("지원되지 않는 소셜 로그인 타입입니다."); // 지원되지 않는 로그인 타입 오류
        }
    }

    // 각 소셜 로그인 제공자별 API 호출 (실제 API 호출 방식은 각 로그인 서비스의 문서를 참조해야 함)
    // 구글 API 호출 시 응답 상태 코드와 메시지 출력
    public String googleApiCall(String accessToken) {
        try {
            // accessToken을 URL 인코딩
            logger.info("Starting Google API call with access token: {}", accessToken);
            String encodedAccessToken = URLEncoder.encode(accessToken, "UTF-8");
            logger.debug("Encoded access token: {}", encodedAccessToken);

            String url = "https://www.googleapis.com/oauth2/v3/userinfo?access_token=" + encodedAccessToken;
            logger.debug("Google API URL: {}", url);

            URL obj = new URL(url);
            HttpURLConnection con = (HttpURLConnection) obj.openConnection();
            con.setRequestMethod("GET");
            con.setRequestProperty("Content-Type", "application/json");

            int responseCode = con.getResponseCode();
            logger.info("Google API response code: {}", responseCode);

            if (responseCode == 200) {
                BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
                String inputLine;
                StringBuffer response = new StringBuffer();
                while ((inputLine = in.readLine()) != null) {
                    response.append(inputLine);
                }
                in.close();
                logger.info("Successfully received response from Google API.");
                return response.toString();
            } else {
                // 실패 시 에러 메시지와 상태 코드 출력
                BufferedReader in = new BufferedReader(new InputStreamReader(con.getErrorStream()));
                String inputLine;
                StringBuffer errorResponse = new StringBuffer();
                while ((inputLine = in.readLine()) != null) {
                    errorResponse.append(inputLine);
                }
                in.close();
                logger.error("Google API call failed with response code: {}, error: {}", responseCode, errorResponse.toString());
                throw new RuntimeException("Google API에서 사용자 정보를 가져오는 데 실패했습니다. 응답 코드: " + responseCode + ", 에러 메시지: " + errorResponse.toString());
            }
        } catch (IOException e) {
            logger.error("Google API 호출 중 오류 발생: {}", e.getMessage(), e);
            throw new RuntimeException("Google API 호출 중 오류 발생", e);
        }
    }


    private String kakaoApiCall(String accessToken) {
        try {
            String url = "https://kapi.kakao.com/v2/user/me";
            URL obj = new URL(url);
            HttpURLConnection con = (HttpURLConnection) obj.openConnection();
            con.setRequestMethod("GET");
            // Kakao의 경우 Authorization 헤더에 "Bearer" 토큰을 설정해야 합니다.
            con.setRequestProperty("Authorization", "Bearer " + accessToken);

            int responseCode = con.getResponseCode();
            if (responseCode == 200) {
                BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
                String inputLine;
                StringBuffer response = new StringBuffer();
                while ((inputLine = in.readLine()) != null) {
                    response.append(inputLine);
                }
                in.close();
                return response.toString();
            } else {
                throw new RuntimeException("Kakao API에서 사용자 정보를 가져오는 데 실패했습니다. 응답 코드: " + responseCode); // 오류 메시지 한국어로 수정
            }
        } catch (IOException e) {
            throw new RuntimeException("Kakao API 호출 중 오류 발생", e); // 오류 메시지 한국어로 수정
        }
    }

    private String naverApiCall(String accessToken) {
        try {
            String url = "https://openapi.naver.com/v1/nid/me";
            URL obj = new URL(url);
            HttpURLConnection con = (HttpURLConnection) obj.openConnection();
            con.setRequestMethod("GET");
            // Naver의 경우 Authorization 헤더에 "Bearer" 토큰을 설정해야 합니다.
            con.setRequestProperty("Authorization", "Bearer " + accessToken);

            int responseCode = con.getResponseCode();
            if (responseCode == 200) {
                BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
                String inputLine;
                StringBuffer response = new StringBuffer();
                while ((inputLine = in.readLine()) != null) {
                    response.append(inputLine);
                }
                in.close();
                return response.toString();
            } else {
                throw new RuntimeException("Naver API에서 사용자 정보를 가져오는 데 실패했습니다. 응답 코드: " + responseCode); // 오류 메시지 한국어로 수정
            }
        } catch (IOException e) {
            throw new RuntimeException("Naver API 호출 중 오류 발생", e); // 오류 메시지 한국어로 수정
        }
    }


    // 사용자 정보를 파싱하여 User 객체 생성
    private User parseUserInfo(String userInfo, SocialLoginType socialLoginType, String accessToken) {
        JsonObject jsonObject = JsonParser.parseString(userInfo).getAsJsonObject();

        // socialId와 name을 소셜 로그인 타입별로 분리
        String socialId = "";
        String name = "";

        if (socialLoginType == SocialLoginType.GOOGLE) {
            socialId = jsonObject.get("sub").getAsString(); // Google은 "sub"를 ID로 사용
            name = jsonObject.get("name").getAsString();    // Google에서 제공하는 이름
        } else if (socialLoginType == SocialLoginType.KAKAO) {
            socialId = jsonObject.get("id").getAsString(); // Kakao는 "id"를 사용자 ID로 사용
            name = jsonObject.getAsJsonObject("properties").get("nickname").getAsString(); // Kakao에서 제공하는 nickname
        } else if (socialLoginType == SocialLoginType.NAVER) {
            JsonObject response = jsonObject.getAsJsonObject("response"); // Naver의 데이터는 response 필드 안에 존재
            socialId = response.get("id").getAsString();   // Naver는 "id"를 사용자 ID로 사용
            name = response.get("name").getAsString();     // Naver에서 제공하는 이름
        }

        // User 객체에 정보 세팅
        User user = new User();
        user.setSocialId(socialId);             // 소셜 ID 설정
        user.setName(name);                     // 사용자의 이름 설정
        user.setProvider(socialLoginType.name()); // 로그인 제공자 설정
        user.setAccessToken(accessToken);       // 액세스 토큰 설정
        return user;
    }


    // 주어진 소셜 로그인 타입에 맞는 OAuth 객체를 찾는 메서드
    private SocialOauth findSocialOauthByType(SocialLoginType socialLoginType) {
        return socialOauthList.stream()
                .filter(x -> x.type() == socialLoginType)
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("알 수 없는 SocialLoginType 입니다.")); // 지원되지 않는 소셜 로그인 타입 오류
    }
}

OauthService는 각 소셜 플랫폼의 로그인 요청을 처리하는 서비스 계층이다. 사용자가 로그인 요청을 보내면, 이 요청을 GoogleOauth, KakaoOauth, NaverOauth 중 적절한 구현체에 위임한다.
이 계층은 비즈니스 로직을 담당하며, 컨트롤러와 각 소셜 API 로직 간의 중간 다리 역할을 한다.

 

OauthController.java

@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/auth")
@Slf4j
@Tag(name = "OAuth", description = "소셜 로그인 인증을 시작하고 콜백을 처리하는 API를 제공합니다.")
public class OauthController {
    private final OauthService oauthService;

    @Operation(
            summary = "소셜 로그인 프로세스 시작",
            description = "이 API는 사용자를 소셜 로그인 페이지로 리다이렉트하여 인증 절차를 시작합니다.",
            operationId = "startSocialLogin",
            parameters = {
                    @Parameter(name = "socialLoginType", description = "소셜 로그인 유형 (예: Kakao, Google 등).", required = true)
            })
    @GetMapping(value = "/{socialLoginType}")
    public ResponseEntity<String> socialLoginType(
            @PathVariable(name = "socialLoginType") SocialLoginType socialLoginType) {
        log.info(">> 사용자로부터 SNS 로그인 요청을 받음 :: {} Social Login", socialLoginType);
        String redirectURL = oauthService.request(socialLoginType);
        return ResponseEntity.ok(redirectURL);  // 리다이렉션 URL을 응답으로 반환
    }

    @Operation(
            summary = "소셜 로그인 콜백 처리 (백엔드에서 사용 X)",
            description = "사용자가 소셜 로그인 후 콜백 URL로 받은 코드를 통해 액세스 토큰을 요청합니다.",
            operationId = "handleSocialLoginCallback",
            parameters = {
                    @Parameter(name = "socialLoginType", description = "소셜 로그인 유형 (예: Kakao, Google 등).", required = true),
                    @Parameter(name = "code", description = "소셜 로그인 API 서버로부터 받은 인증 코드.", required = true)
            })
    @GetMapping(value = "/{socialLoginType}/callback")
    public ResponseEntity<String> callback(
            @PathVariable(name = "socialLoginType") SocialLoginType socialLoginType,
            @RequestParam(name = "code") String code) {
        log.info(">> 소셜 로그인 API 서버로부터 받은 code :: {}", code);

        // 액세스 토큰을 받아온 후, 사용자 정보를 DB에 저장
        User user = oauthService.requestAccessTokenAndSaveUser(socialLoginType, code);  // 서비스에서 사용자 정보 받아오기

        if (user != null) {
            log.info(">> 사용자 정보 DB 저장 완료 :: {}", user.getName());
            return ResponseEntity.ok("로그인 성공, 사용자 정보 저장 완료");
        } else {
            log.error(">> 사용자 정보 저장 실패");
            return ResponseEntity.status(500).body("사용자 정보 저장 실패");
        }
    }
}

OauthController는 클라이언트 요청을 처리하는 엔드포인트이다. 사용자가 /auth/{socialLoginType} 경로로 로그인 요청을 보내면, 적절한 socialLoginType(구글, 카카오, 네이버)을 OauthService로 전달한다.

 

GoogleOauth.java, KakaoOauth.java, NaverOauth.java

// 공통 interface를 구현할 소셜 로그인 각 타입별 Class 생성 (Google)
@Component
@RequiredArgsConstructor
public class GoogleOauth implements SocialOauth {
    @Value("${sns.google.url}")
    private String GOOGLE_SNS_BASE_URL;
    @Value("${sns.google.client.id}")
    private String GOOGLE_SNS_CLIENT_ID;
    @Value("${sns.google.callback.url}")
    private String GOOGLE_SNS_CALLBACK_URL;
    @Value("${sns.google.client.secret}")
    private String GOOGLE_SNS_CLIENT_SECRET;
    @Value("${sns.google.token.url}")
    private String GOOGLE_SNS_TOKEN_BASE_URL;

    @Override
    public String getOauthRedirectURL() {
        Map<String, Object> params = new HashMap<>();
        params.put("scope", "profile");
        params.put("response_type", "code");
        params.put("client_id", GOOGLE_SNS_CLIENT_ID);
        params.put("redirect_uri", GOOGLE_SNS_CALLBACK_URL);

        String parameterString = params.entrySet().stream()
                .map(x -> x.getKey() + "=" + x.getValue())
                .collect(Collectors.joining("&"));

        return GOOGLE_SNS_BASE_URL + "?" + parameterString;
    }

    @Override
    public String requestAccessToken(String code) {
        RestTemplate restTemplate = new RestTemplate();

        Map<String, Object> params = new HashMap<>();
        params.put("code", code);
        params.put("client_id", GOOGLE_SNS_CLIENT_ID);
        params.put("client_secret", GOOGLE_SNS_CLIENT_SECRET);
        params.put("redirect_uri", GOOGLE_SNS_CALLBACK_URL);
        params.put("grant_type", "authorization_code");

        ResponseEntity<String> responseEntity =
                restTemplate.postForEntity(GOOGLE_SNS_TOKEN_BASE_URL, params, String.class);

        if (responseEntity.getStatusCode() == HttpStatus.OK) {
            return responseEntity.getBody();
        }
        return "구글 로그인 요청 처리 실패";
    }

    public String requestAccessTokenUsingURL(String code) {
        try {
            URL url = new URL(GOOGLE_SNS_TOKEN_BASE_URL);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
            conn.setDoOutput(true);

            Map<String, Object> params = new HashMap<>();
            params.put("code", code);
            params.put("client_id", GOOGLE_SNS_CLIENT_ID);
            params.put("client_secret", GOOGLE_SNS_CLIENT_SECRET);
            params.put("redirect_uri", GOOGLE_SNS_CALLBACK_URL);
            params.put("grant_type", "authorization_code");

            String parameterString = params.entrySet().stream()
                    .map(x -> x.getKey() + "=" + x.getValue())
                    .collect(Collectors.joining("&"));

            BufferedOutputStream bous = new BufferedOutputStream(conn.getOutputStream());
            bous.write(parameterString.getBytes());
            bous.flush();
            bous.close();

            BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));

            StringBuilder sb = new StringBuilder();
            String line;

            while ((line = br.readLine()) != null) {
                sb.append(line);
            }

            if (conn.getResponseCode() == 200) {
                return sb.toString();
            }
            return "구글 로그인 요청 처리 실패";
        } catch (IOException e) {
            throw new IllegalArgumentException("알 수 없는 구글 로그인 Access Token 요청 URL 입니다 :: " + GOOGLE_SNS_TOKEN_BASE_URL);
        }
    }
}
// 공통 interface를 구현할 소셜 로그인 각 타입별 Class 생성 (Kakao)
@Slf4j
@Component
@RequiredArgsConstructor
public class KakaoOauth implements SocialOauth {

    @Value("${sns.kakao.url}")
    private String KAKAO_SNS_BASE_URL;
    @Value("${sns.kakao.client.id}")
    private String KAKAO_SNS_CLIENT_ID;
    @Value("${sns.kakao.callback.url}")
    private String KAKAO_SNS_CALLBACK_URL;
    @Value("${sns.kakao.client.secret}")
    private String KAKAO_SNS_CLIENT_SECRET;
    @Value("${sns.kakao.token.url}")
    private String KAKAO_SNS_TOKEN_BASE_URL;

    @Override
    public String getOauthRedirectURL() {
        Map<String, Object> params = new HashMap<>();
        params.put("response_type", "code");
        params.put("client_id", KAKAO_SNS_CLIENT_ID);
        params.put("redirect_uri", KAKAO_SNS_CALLBACK_URL);

        String parameterString = params.entrySet().stream()
                .map(x -> x.getKey() + "=" + x.getValue())
                .collect(Collectors.joining("&"));

        return KAKAO_SNS_BASE_URL + "?" + parameterString;
    }

    @Override
    public String requestAccessToken(String code) {
        RestTemplate restTemplate = new RestTemplate();

        // 1. HTTP 헤더 설정 (Content-Type을 application/x-www-form-urlencoded로 설정)
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        // 2. 요청 파라미터를 문자열로 인코딩
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("code", code);
        params.add("client_id", KAKAO_SNS_CLIENT_ID);
        params.add("client_secret", KAKAO_SNS_CLIENT_SECRET);
        params.add("redirect_uri", KAKAO_SNS_CALLBACK_URL);
        params.add("grant_type", "authorization_code");

        // 3. HttpEntity 생성 (헤더와 파라미터 포함)
        HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);

        // 4. POST 요청 보내기
        ResponseEntity<String> responseEntity =
                restTemplate.postForEntity(KAKAO_SNS_TOKEN_BASE_URL, requestEntity, String.class);

        // 5. 응답 확인 및 반환
        if (responseEntity.getStatusCode() == HttpStatus.OK) {
            return responseEntity.getBody();
        }
        return "카카오 로그인 요청 처리 실패";
    }


    public String requestAccessTokenUsingURL(String code) {
        try {
            URL url = new URL(KAKAO_SNS_TOKEN_BASE_URL);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
            conn.setDoOutput(true);

            Map<String, Object> params = new HashMap<>();
            params.put("code", code);
            params.put("client_id", KAKAO_SNS_CLIENT_ID);
            params.put("client_secret", KAKAO_SNS_CLIENT_SECRET);
            params.put("redirect_uri", KAKAO_SNS_CALLBACK_URL);
            params.put("grant_type", "authorization_code");

            String parameterString = params.entrySet().stream()
                    .map(x -> x.getKey() + "=" + x.getValue())
                    .collect(Collectors.joining("&"));

            BufferedOutputStream bous = new BufferedOutputStream(conn.getOutputStream());
            bous.write(parameterString.getBytes());
            bous.flush();
            bous.close();

            BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));

            StringBuilder sb = new StringBuilder();
            String line;

            while ((line = br.readLine()) != null) {
                sb.append(line);
            }

            if (conn.getResponseCode() == 200) {
                return sb.toString();
            }
            return "카카오 로그인 요청 처리 실패";
        } catch (IOException e) {
            throw new IllegalArgumentException("알 수 없는 카카오 로그인 Access Token 요청 URL 입니다 :: " + KAKAO_SNS_TOKEN_BASE_URL);
        }
    }
}
// 공통 interface를 구현할 소셜 로그인 각 타입별 Class 생성 (Naver)
@Component
@RequiredArgsConstructor
public class NaverOauth implements SocialOauth {
    @Value("${sns.naver.url}")
    private String NAVER_SNS_BASE_URL;
    @Value("${sns.naver.client.id}")
    private String NAVER_SNS_CLIENT_ID;
    @Value("${sns.naver.callback.url}")
    private String NAVER_SNS_CALLBACK_URL;
    @Value("${sns.naver.client.secret}")
    private String NAVER_SNS_CLIENT_SECRET;
    @Value("${sns.naver.token.url}")
    private String NAVER_SNS_TOKEN_BASE_URL;

    @Override
    public String getOauthRedirectURL() {
        Map<String, Object> params = new HashMap<>();
        params.put("response_type", "code");
        params.put("client_id", NAVER_SNS_CLIENT_ID);
        params.put("redirect_uri", NAVER_SNS_CALLBACK_URL);
        params.put("state", "random_state_value"); // CSRF 방지를 위한 state 파라미터 추가

        String parameterString = params.entrySet().stream()
                .map(x -> x.getKey() + "=" + x.getValue())
                .collect(Collectors.joining("&"));

        return NAVER_SNS_BASE_URL + "?" + parameterString;
    }

    @Override
    public String requestAccessToken(String code) {
        RestTemplate restTemplate = new RestTemplate();

        // Header 설정
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        // 파라미터 설정 (application/x-www-form-urlencoded)
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("code", code);
        params.add("client_id", NAVER_SNS_CLIENT_ID);
        params.add("client_secret", NAVER_SNS_CLIENT_SECRET);
        params.add("redirect_uri", NAVER_SNS_CALLBACK_URL);
        params.add("grant_type", "authorization_code");
        params.add("state", "random_state_value");

        // HTTP Entity 생성
        HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);

        // 요청 전송
        ResponseEntity<String> responseEntity =
                restTemplate.postForEntity(NAVER_SNS_TOKEN_BASE_URL, requestEntity, String.class);

        if (responseEntity.getStatusCode() == HttpStatus.OK) {
            return responseEntity.getBody();
        }
        return "네이버 로그인 요청 처리 실패";
    }
}

이 세 파일은 각각의 소셜 플랫폼의 API와 통신하는 구현체이다.

  • GoogleOauth: 구글 OAuth 2.0 인증을 처리한다. Access Token을 발급받고, 이를 통해 사용자 정보를 가져온다.
  • KakaoOauth: 카카오 로그인 API와 통신한다. 카카오에서 제공하는 Access Token을 기반으로 사용자 정보를 요청한다.
  • NaverOauth: 네이버 로그인 API를 사용해 사용자 정보를 가져온다. 네이버는 사용자 인증 후, 별도로 추가 요청을 통해 사용자 정보를 반환한다.

 

UserResponse.java

public class UserResponse {
    private String name;
    private String accessToken;
    private String provider;

    public UserResponse(String name, String accessToken, String provider) {
        this.name = name;
        this.accessToken = accessToken;
        this.provider = provider;
    }

    // Getter, Setter 추가
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getAccessToken() {
        return accessToken;
    }

    public void setAccessToken(String accessToken) {
        this.accessToken = accessToken;
    }

    public String getProvider() {
        return provider;
    }

    public void setProvider(String provider) {
        this.provider = provider;
    }
}

UserResponse는 사용자 정보를 담아 클라이언트에 전달하는 DTO(Data Transfer Object)이다. 이 파일에서 서버가 클라이언트에 반환할 데이터를 정의한다. 사용자가 소셜 로그인을 완료하면, 서버는 사용자 이름(name), 액세스 토큰(accessToken), 그리고 로그인 제공자(provider, 예: 구글, 카카오, 네이버)를 반환한다. 이 데이터는 프론트엔드에서 사용자 상태를 관리하거나 이후 요청에 인증 정보를 포함하는 데 사용된다.

 

 

 

전체 흐름

  1. 클라이언트 요청: 사용자가 프론트엔드에서 소셜 로그인 버튼을 클릭하면, /auth/{socialLoginType} 경로로 로그인 요청이 전달된다.
    예: /auth/google, /auth/kakao, /auth/naver.
  2. OauthController: 요청이 들어오면 적절한 socialLoginType(구글, 카카오, 네이버)을 OauthService로 전달한다.
  3. OauthService: socialLoginType에 따라 GoogleOauth, KakaoOauth, NaverOauth 중 적합한 로직을 호출한다.
  4. GoogleOauth, KakaoOauth, NaverOauth: 각 플랫폼의 API와 통신하여 Authorization Code로 Access Token을 발급 받고, Access Token으로 사용자 정보를 받아온다.
  5. UserResponse: 사용자 정보를 UserResponse 형식으로 변환해 클라이언트에 반환한다.

 

 

 

테스트 결과

Swagger를 이용해 구글, 카카오, 네이버 순서로 소셜로그인 API 테스트를 진행했다.

user 데이터가 저장된 화면

 

 

 API가 로직대로 작동하여 MySQL에 토큰 정보와 유저 데이터가 잘 저장된 것을 확인할 수 있었다. 😀