JAVA [Spring Boot] KaKao 소셜로그인 + 토큰발급

2024. 12. 11. 13:40·Project/STOOCK

폼 로그인 구현 이후 소셜로그인 구현을 위해서 여기저기 검색도 해보고 챗GPT와도 힘겨루기를 했다.

 

이전 학원에서 프로젝트를 했을때도 카카오 소셜로그인을 구현한적이 있는데 이번에는 구조가 조금 다르기 떄문에 조금 난관이었다.

 

이전 프로젝트에서 했을때는 카카오 파싱이후 그냥 세션에 저장을 했다.

 

하지만 이번에는 React_native와 통신을 하며 토큰을 주고받아야 하기 떄문에 백엔드/프론트 통신이 필요했다.

 

아직 실제 프로젝트를 들어간것은 아니나 서로 공부기간을 가졌기때문에 아직 수정 및 보안을 해야할 점들이 많다.

 

위와 같이 yaml파일에 카카오 관련로직들을 처리해줬다.

 

client-id : Rest API 키

Client Secret : KaKao developers 에서 제공해주는 Secret Code

scope : 

위와 같이 개인정보를 동의하고 소셜로그인을 하였을때 해당 유저의 정보를 받아 올 수 있다.

 

KaKao Config

우선 KaKao Config에 구성 자료들을 만들어 주었다.

 

KaKao_Redirect_Uri는 .env에 환경변수로 따로 빼두었다.

 

User Dto

유저의 정보가 들어오는 dto

 

SocialUser Dto

폼 로그인과 소셜로그인을 따로 구성했기 때문에 Provider_Type이 들어오는 SocialUser Dto를 따로 구성했다.

왜냐하면 폼로그인은 provider_type이 필요가 없기때문에 정규화를 위해서 따로 구성했다.

 

KaKao Dto

UserDto, SocialUserDto를 담을 KaKaoDto다. 유저의 정보를 KaKaoDto에 넣어준다 생각하면 된다.

 

 

KaKao Service

package com.example.user.service;

import com.example.user.config.KaKaoConfig;
import com.example.user.dto.KaKaoDto;
import com.example.user.dto.SocialUserDto;
import com.example.user.dto.UserDto;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

@Service
@RequiredArgsConstructor
public class KaKaoService {

    private final KaKaoConfig kakaoConfig;
    private final WebClient.Builder webClientBuilder;

    private boolean tokenRequestInProgress = false;

    // 카카오 사용자 정보 가져오는 메소드
    public Mono<KaKaoDto> getKakaoUserInfo(String accessToken) {
        String url = "https://kapi.kakao.com/v2/user/me";

        return webClientBuilder.build()
                .get()
                .uri(url + "?access_token=" + accessToken)
                .retrieve()
                .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
                        clientResponse -> clientResponse.bodyToMono(String.class)
                                .flatMap(errorBody -> Mono.error(new RuntimeException("카카오 사용자 정보 조회 실패: " + errorBody))))
                .bodyToMono(String.class)
                .flatMap(response -> {
                    try {
                        ObjectMapper objectMapper = new ObjectMapper();
                        JsonNode jsonNode = objectMapper.readTree(response);

                        SocialUserDto socialUserDto = new SocialUserDto();
                        socialUserDto.setUser_id(jsonNode.get("id").asText());
                        socialUserDto.setProvider_type("KAKAO");

                        UserDto userDto = new UserDto();
                        userDto.setUser_id(jsonNode.get("id").asText());
                        userDto.setName(jsonNode.get("properties").get("nickname").asText());
                        userDto.setEmail(jsonNode.get("kakao_account").get("email").asText());
                        userDto.setAccess_token(accessToken);
                        userDto.setFile(jsonNode.get("properties").get("profile_image").asText());

                        KaKaoDto kaKaoDto = new KaKaoDto();
                        kaKaoDto.setSocialUserDto(socialUserDto);
                        kaKaoDto.setUserDto(userDto);

                        return Mono.just(kaKaoDto);
                    } catch (Exception e) {
                        return Mono.error(new RuntimeException("카카오 응답 파싱에 실패했습니다.", e));
                    }
                });
    }

    // 액세스 토큰을 가져오는 메소드
    public Mono<TokenResponse> getAccessToken(String code) {
        if (tokenRequestInProgress) {
            return Mono.error(new RuntimeException("액세스 토큰 요청이 이미 진행 중입니다."));
        }

        synchronized (this) {
            tokenRequestInProgress = true;
        }

        String url = "https://kauth.kakao.com/oauth/token";

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", kakaoConfig.getClientId());
        params.add("redirect_uri", kakaoConfig.getRedirectUri());
        params.add("code", code);

        return webClientBuilder.build()
                .post()
                .uri(url)
                .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded")
                .bodyValue(params)
                .retrieve()
                .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
                        clientResponse -> clientResponse.bodyToMono(String.class)
                                .flatMap(errorBody -> Mono.error(new RuntimeException("카카오 API 호출 실패: " + errorBody))))
                .bodyToMono(String.class)
                .flatMap(response -> {
                    try {
                        ObjectMapper objectMapper = new ObjectMapper();
                        JsonNode jsonNode = objectMapper.readTree(response);

                        String accessToken = jsonNode.get("access_token").asText();
                        String refreshToken = jsonNode.has("refresh_token") ? jsonNode.get("refresh_token").asText() : null;

                        synchronized (this) {
                            tokenRequestInProgress = false; // 요청 완료 후 토큰 상태를 reset
                        }

                        return Mono.just(new TokenResponse(accessToken, refreshToken));
                    } catch (Exception e) {
                        synchronized (this) {
                            tokenRequestInProgress = false; // 예외 발생시에도 상태를 reset
                        }
                        return Mono.error(new RuntimeException("카카오 액세스 토큰을 가져오는데 실패했습니다.", e));
                    }
                });
    }

    // 리프레시 토큰으로 새로운 액세스 토큰을 발급받는 메소드
    public Mono<TokenResponse> refreshAccessToken(String refreshToken) {
        String url = "https://kauth.kakao.com/oauth/token";

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "refresh_token");
        params.add("client_id", kakaoConfig.getClientId());
        params.add("refresh_token", refreshToken);

        return webClientBuilder.build()
                .post()
                .uri(url)
                .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded")
                .bodyValue(params)
                .retrieve()
                .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
                        clientResponse -> clientResponse.bodyToMono(String.class)
                                .flatMap(errorBody -> Mono.error(new RuntimeException("카카오 API 호출 실패: " + errorBody))))
                .bodyToMono(String.class)
                .flatMap(response -> {
                    try {
                        ObjectMapper objectMapper = new ObjectMapper();
                        JsonNode jsonNode = objectMapper.readTree(response);

                        String accessToken = jsonNode.get("access_token").asText();
                        String newRefreshToken = jsonNode.has("refresh_token") ? jsonNode.get("refresh_token").asText() : refreshToken;

                        return Mono.just(new TokenResponse(accessToken, newRefreshToken));
                    } catch (Exception e) {
                        return Mono.error(new RuntimeException("리프레시 토큰으로 액세스 토큰을 발급받는 데 실패했습니다.", e));
                    }
                });
    }

    // 토큰 응답을 처리하기 위한 내부 클래스
    public static class TokenResponse {
        private final String accessToken;
        private final String refreshToken;

        public TokenResponse(String accessToken, String refreshToken) {
            this.accessToken = accessToken;
            this.refreshToken = refreshToken;
        }

        public String getAccessToken() {
            return accessToken;
        }

        public String getRefreshToken() {
            return refreshToken;
        }
    }
}

카카오 유저가 로그인을 하면 엑세스 토큰을 발급하고 해당 유저의 정보들을 파싱한다.

 

이때 카카오 에서 제공하는 ID로 파싱을 제대로 해줘야 값이 제대로 들어간다.

예 :profile_nickname, profile_image, account_email

 

해당 값이 필요한 dto에 파싱을 해주고 프론트에서 엑세스 토큰을 서버측으로 보낸다.

 

서버에서는 해당 유저의 엑세스토큰이 맞는지 확인하고 가져온다.

 

엑세스 토큰이 만료 된 이후에는 리프레시 토큰으로 새로운 엑세스 토큰을 발급한다.

 

User Service

package com.example.user.service;

import com.example.user.dto.FormUserDto;
import com.example.user.dto.KaKaoDto;
import com.example.user.dto.SocialUserDto;
import com.example.user.dto.UserDto;
import com.example.user.mapper.FormUserMapper;
import com.example.user.mapper.SocialUserMapper;
import com.example.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;

import java.util.List;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserMapper userMapper; // 생성자 주입
    private final FormUserMapper formUserMapper; // 생성자 주입
    private final BCryptPasswordEncoder bCryptPasswordEncoder; // 생성자 주입
    private final EmailService emailService;
    private final SocialUserMapper socialUserMapper;


    @Transactional
    public String saveFormUser(FormUserDto formUserDto, UserDto userDto) {
        try {
            //비밀번호 암호화
            String encodedPassword = bCryptPasswordEncoder.encode(formUserDto.getPasswd());
            formUserDto.setPasswd(encodedPassword);

            //인증 코드 생성 및 이메일 전송
            String authCode = emailService.generateAuthCode();
            emailService.sendEmail(userDto.getEmail(), authCode);


            userMapper.save(userDto); // user테이블에 삽입
            formUserMapper.save(formUserDto); //formuser테이블에 삽입

            return authCode;
        } catch (Exception e) {
            throw new RuntimeException("회원가입 처리 중 오류가 발생하였습니다.", e);
        }
    }

    @Transactional
    public Mono<String> saveSocialUser(SocialUserDto socialUserDto, UserDto userDto) {
        try {
            // UserDto와 SocialUserDto를 각각 저장
            userMapper.socialSave(userDto); // UserDto 테이블에 저장
            socialUserMapper.save(socialUserDto); // SocialUserDto 테이블에 저장

            return Mono.just("success");
        } catch (Exception e) {
            e.printStackTrace();
            return Mono.error(new RuntimeException("회원가입 처리 중 오류가 발생하였습니다.", e));
        }
    }


    public List<UserDto> select() {
        return userMapper.select();
    }
}

이후에 UserService에서 @Transactional 어노테이션을 통해서 트랜잭션 처리를 같이 이루어준다.

 

 

UserController

package com.example.user.controller;

import com.example.user.dto.*;
import com.example.user.service.KaKaoService;
import com.example.user.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequiredArgsConstructor
@CrossOrigin(origins = "http://localhost:3000")
public class UserController {

    private final UserService userService;
    private final KaKaoService kaKaoService;

    @PostMapping("/formuser")
    public ResponseEntity<Map<String, String>> saveFormUser(@RequestBody FormInfoDto formInfoDto) {
        FormUserDto formUserDto = formInfoDto.getFormUserDto();
        UserDto userDto = formInfoDto.getUserDto();

        userService.saveFormUser(formUserDto, userDto);

        // 응답 메세지 생성
        Map<String, String> response = new HashMap<>();
        response.put("message", "회원가입이 성공적으로 완료되었습니다.");
        response.put("userId", userDto.getUser_id());

        return ResponseEntity.ok(response);
    }

    // 카카오 토큰을 받아오는 API - 'access_token'을 직접 받음
    @PostMapping("/api/kakao-token")
    public Mono<ResponseEntity<Map<String, String>>> getAccessToken(@RequestBody Map<String, String> tokenData) {
        String accessToken = tokenData.get("access_token");  // access_token을 받음
        String refreshToken = tokenData.get("refresh_token");  // refresh_token을 받음
        System.out.println("Access Token: " + accessToken);
        System.out.println("Refresh Token: " + refreshToken);

        if (accessToken == null || accessToken.isEmpty()) {
            Map<String, String> errorResponse = new HashMap<>();
            errorResponse.put("message", "Access Token이 없습니다.");
            return Mono.just(ResponseEntity.badRequest().body(errorResponse));
        }

        // Access Token을 사용하여 사용자 정보를 받아오는 서비스 호출
        return kaKaoService.getKakaoUserInfo(accessToken)
                .flatMap(kaKaoDto -> {
                    Map<String, String> response = new HashMap<>();
                    response.put("access_token", accessToken);  // 서버에서 받은 access_token 그대로 전달
                    response.put("refresh_token", kaKaoDto.getRefreshToken());  // refresh_token도 받아서 전송
                    return Mono.just(ResponseEntity.ok(response));
                })
                .onErrorResume(e -> {
                    // 만약 access_token이 만료되었거나 문제가 생겼다면 refresh_token을 사용하여 새 access_token을 발급
                    if (refreshToken != null && !refreshToken.isEmpty()) {
                        return kaKaoService.refreshAccessToken(refreshToken) // 리프레시 토큰을 사용해 새로운 access_token 요청
                                .flatMap(tokenResponse -> {
                                    String newAccessToken = tokenResponse.getAccessToken(); // 새로 발급된 access_token
                                    Map<String, String> response = new HashMap<>();
                                    response.put("access_token", newAccessToken);  // 새로 발급된 access_token
                                    response.put("refresh_token", refreshToken);  // 기존 refresh_token 그대로 전송
                                    return Mono.just(ResponseEntity.ok(response));
                                });
                    } else {
                        Map<String, String> errorResponse = new HashMap<>();
                        errorResponse.put("message", "카카오 사용자 정보 요청에 실패했습니다.");
                        errorResponse.put("error", e.getMessage());
                        return Mono.just(ResponseEntity.status(500).body(errorResponse));
                    }
                });
    }

    // 소셜 사용자 정보와 함께 카카오 사용자 정보 저장
    @PostMapping("/socialuser")
    public Mono<ResponseEntity<Map<String, Object>>> saveSocialUser(@RequestBody Map<String, Object> requestData) {
        // 전달받은 socialUserDto와 userDto 추출
        Map<String, Object> socialUserDtoMap = (Map<String, Object>) requestData.get("socialUserDto");
        Map<String, Object> userDtoMap = (Map<String, Object>) requestData.get("userDto");

        SocialUserDto socialUserDto = new SocialUserDto();
        socialUserDto.setUser_id((String) socialUserDtoMap.get("user_id"));
        socialUserDto.setProvider_type((String) socialUserDtoMap.get("provider_type"));

        UserDto userDto = new UserDto();
        userDto.setUser_id((String) userDtoMap.get("user_id"));
        userDto.setName((String) userDtoMap.get("name"));
        userDto.setEmail((String) userDtoMap.get("email"));
        userDto.setAccess_token((String) userDtoMap.get("access_token"));
        userDto.setRefresh_token((String) userDtoMap.get("refresh_token"));
        userDto.setFile((String) userDtoMap.get("file"));

        // 클라이언트에서 받은 Access Token으로 카카오 사용자 정보를 가져오는 방식
        return kaKaoService.getKakaoUserInfo(userDto.getAccess_token())
                .flatMap(kaKaoDto -> {
                    // 사용자 정보 저장
                    return userService.saveSocialUser(socialUserDto, userDto)
                            .map(result -> {
                                // 응답 데이터 준비
                                Map<String, Object> response = new HashMap<>();
                                response.put("socialUserDto_user_id", socialUserDto.getUser_id());
                                response.put("socialUserDto_provider_type", socialUserDto.getProvider_type());

                                response.put("userDto_user_id", userDto.getUser_id());
                                response.put("userDto_name", userDto.getName());
                                response.put("userDto_email", userDto.getEmail());
                                response.put("userDto_file", userDto.getFile());
                                response.put("userDto_access_token", userDto.getAccess_token());
                                response.put("userDto_refresh_token", userDto.getRefresh_token());

                                // 성공 메시지
                                response.put("message", "소셜 로그인 회원가입이 성공적으로 완료되었습니다.");
                                response.put("result", result);

                                return ResponseEntity.ok(response);
                            });
                })
                .onErrorResume(e -> {
                    // 오류 처리
                    Map<String, Object> errorResponse = new HashMap<>();
                    errorResponse.put("message", "소셜 로그인 회원가입에 실패했습니다.");
                    errorResponse.put("error", e.getMessage());
                    return Mono.just(ResponseEntity.status(500).body(errorResponse));
                });
    }
}

클라이언트에서 받은 Access Token으로 카카오 사용자 정보를 가져온다. 

 

Access Token으로 해당 사용자의 정보를 가져와서 내가 원하는 즉 해당 dto에 값을 저장시켜준다.

 

유저의 Access Token이 만료되면 Refresh Token으로 새로운 토큰을 필히 발급 해줘야한다.

 

 

 

Kakao handle

import axios from 'axios';

const handleKakaoLogin = () => {
    // 카카오 로그인 후 Authorization Code를 받기 위한 과정
    window.Kakao.Auth.login({
      success: function (authObj) {
        //console.log("카카오 로그인 성공:", authObj.access_token);
        // Authorization Code는 authObj에 포함되지 않음
        // 대신 access_token을 바로 사용하면 된다.
        
       
  
        // 백엔드로 Authorization Code 대신 access_token을 전송하여 필요한 작업을 진행
        if (authObj && authObj.access_token) {
          axios.post('http://localhost:8080/api/kakao-token', { access_token: authObj.access_token }, {
            headers: {
              'Content-Type': 'application/json',
            },
          })
          .then(response => {
            console.log("서버에서 받은 Access Token:", response.data.access_token);
            if (response.data.access_token) {
              fetchUserInfo(response.data.access_token);
            } else {
              console.error("Access Token이 없습니다.");
            }
          })
          .catch(err => {
            console.error("Authorization Code 서버 요청 실패:", err);
          });
        } else {
          console.error("카카오 로그인 실패, access_token 없음");
        }
      },
      fail: function (err) {
        console.error("카카오 로그인 실패:", err);
      }
    });
  };
  

const fetchUserInfo = (accessToken) => {
  console.log("Access Token을 통해 사용자 정보 요청 중:", accessToken);

  // Kakao SDK를 사용해 사용자 정보 요청
  window.Kakao.API.request({
    url: "/v2/user/me",
    success: function (res) {
      console.log("사용자 정보:", res);

      // 사용자 정보를 서버로 전송
      sendUserInfoToServer(res, accessToken); // 서버로 정보 전달
    },
    fail: function (error) {
      console.error("사용자 정보 요청 실패:", error);
    }
  });
};

const sendUserInfoToServer = (userInfo, accessToken, refreshToken) => {
  const requestData = {
    socialUserDto: {
      user_id: userInfo.id.toString(),
      provider_type: "KAKAO",
    },
    userDto: {
      user_id: userInfo.id.toString(),
      name: userInfo.properties.nickname,
      email: userInfo.kakao_account.email,
      access_token: accessToken,
      refresh_token: refreshToken,
      file: userInfo.properties.profile_image ? userInfo.properties.profile_image : "",
    },
  };

  console.log("전송 데이터 확인:", requestData);

  axios.post('http://localhost:8080/socialuser', requestData, {
    headers: {
      'Content-Type': 'application/json',
    },
  })
  .then(response => {
    console.log("서버 응답:", response.data);
  })
  .catch(error => {
    console.error("서버로 전송 실패:", error);
  });
};

 

프론트단 내용인데 우선 토큰이 잘 주고받고 디비에 값이 잘 저장 되는지만 확인하기 위하여 작성된 코드다.

 

정리 : 클라이언트에서 로그인을 하면 카카오 측에 엑세스토큰을 발급 받아서 서버측으로 전달한다.

서버측에서 해당 유저의 토큰을 검증하고 유저의 정보는 DB에 저장되고 토큰이 만료가 되면 Refresh Token을 통해서

새로운 Access Token을 발급해서 클라이언트 측으로 전달한다.

 

 

DB

위에 정보는 카카오 고유의 ID 이다. 즉 윗줄은 카카오 유저 아래는 폼로그인 유저다.

 

해당 유저의 값들이 잘 전달된다.

 

소셜DB에는 해당 유저의 고유 ID값과 소셜로그인 타입이 제대로 기입이 된다.

 

소셜로그인을 여기까지 진행해오면서 너무 어려웠다. 백엔드와 프론트가 통신을 하며 토큰을 주고받고 모르는 부분도 너무 많았고

 

앞으로 더 많이 공부하고 정진해 나가야 할 것 같다..

 

오늘은 여기까지,,,,

'Project > STOOCK' 카테고리의 다른 글

JAVA Mybatis Logout(로그아웃)(1)  (1) 2024.12.20
JAVA Mybatis FormLogin Password변경(2)  (0) 2024.12.19
JAVA Mybatis FormLogin Password변경(1)  (1) 2024.12.18
JAVA [Spring Boot] SMTP 구현  (1) 2024.12.03
Java-RuntimeException  (1) 2024.11.29
'Project/STOOCK' 카테고리의 다른 글
  • JAVA Mybatis FormLogin Password변경(2)
  • JAVA Mybatis FormLogin Password변경(1)
  • JAVA [Spring Boot] SMTP 구현
  • Java-RuntimeException
hankyungtory
hankyungtory
기록이 정답이다.
  • hankyungtory
    한경이의 개발 일지
    hankyungtory
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Study
        • Java
        • DataBase
        • Error
        • Linux
      • Project
        • STOOCK
        • BackOffice
  • 블로그 메뉴

    • 링크

    • 공지사항

    • 인기 글

    • 태그

      java
      WAS
      서블릿
      mybatis
      servlet
      개발
      컨테이너
      springboot
      jwt
      war
      tomcat
      서블릿컨테이너
      wrapper
      SecurityConfig
      build
      내장톰캣
      스프링부트
      자바
      톰캣
      server
      spring boot
      container
      폼로그인
      react
      소셜로그인
      서버
      spring
      SMTP
      에러
      error
    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.3
    hankyungtory
    JAVA [Spring Boot] KaKao 소셜로그인 + 토큰발급
    상단으로

    티스토리툴바