요즘엔 필수적으로 들어가는 소셜로그인을 구현해보려다가
이해가 되지않았다.
그렇게 머리를 싸매고 로직을 이해하려고 며 칠을 자료를 찾아보고
이해한 로직이 맞는지 여쭤보다가 깨달음을 얻어
다른 분들도 도움이 되고 나도 정리를 하기 위해 작성했다.
우선 소셜 로그인에 대해 이해를 해보자.
https://blog.naver.com/mds_datasecurity/222182943542
OAuth 2.0 동작 방식의 이해
OAuth 2.0(Open Authorization 2.0, OAuth2)은 인증을 위한 개방형 표준 프로토콜입니다. 이 프로토...
blog.naver.com
Oauth 2.0프로토콜을 활용해서 하는 것이 소셜 로그인이다.
처음에 보면 이해하기 어려울 수 있다.
간단히 설명하자면 사용자에게 인증을
이미 정보를 가지고 있는 외부에 하도록 해서 그 인증을 활용해
내 사이트에 정보를 남기지 않은 채 사용할 수 있도록 하는 것이다.
(불필요한 정보가 DB에 저장되지 않는다.)
Oauth 2.0을 주입해서 1부터 10까지 자동으로 할수도있고
커스텀도 가능하고 이미 Oauth 2.0을 활용한 api를 활용해할 수 있따.
처음에 이 소셜로그인을 공부하면서 이 4가지 방식이 혼재되어서 너무 혼란스러웠다.
(나같은 사람이 없었으면 해서 남김)
우선 내가 활용한 방법은 코드를 통해 인증을 받아 액세스토큰을 얻고
거기서 정보를 뽑아내서 우리 DB에 저장해서 소셜로그인을 구현하는 방식을 사용했다.
(1번 방식)
현재 프로젝트에서 팩토리와 전략 패턴을 사용하고 있기떄문에 그것 또한 활용했다.(로그인)
우선 카카오 개발자 홈페이지에서 인증코드를 받는 문서가 있다.
그 부분을 참고해서 카카오 컨트롤러를 만들어서 인증코드 받는 로직을 진행한다.
그리고 그 인증코드안에 보통 원하는 정보들이 들어있기 때문에.
거기서 필요한 정보를 New user할 경우에 들어갈 수 있도록 만들어야 한다.
좀 더 자세한 내용은
https://deeplify.dev/back-end/spring/oauth2-social-login#oauth2userinfo
[Spring Boot] OAuth2 소셜 로그인 가이드 (구글, 페이스북, 네이버, 카카오)
스프링부트를 이용하여 구글, 페이스북, 네이버, 카카오 OAuth2 로그인 구현하는 방법에 대해서 소개합니다.
deeplify.dev
여기를 참고하였다.
(단 우리는 리프레쉬 토큰은 사용하지 않았다)
< OAuth2UserInfo > 부분
이렇게 유저정보를 받아내는 것 자체도 팩토리 매서드로 만들어서
원하는 정보를 얻을 수 없는(권한이 없기 때문에 - 카카오는 기본정보 외에는 신청이 필요함) 경우에는
null로 설정해서 각 소셜마다 다르게 만들 수 있다는 것을 알았다.
나 같은 경우는 카카오만 했기때문에 실패랑 성공 핸들러를 만들지는 않았다.
여러개를 한번에 구현해야한다면 OCP원칙을 지키면서 만들 수 있을 것 같다.
그래서 현재 프로젝트에서 카카오의 경우 카카오 컨트롤러와 클라이언트를 따로 만들어서
위에 블로그에 보면 Oauth 프로토콜 과정이 나오는데 그 중 3번 5번 6번과정을
진행하도록 했다.
(이 부분은 공통-소셜 컨트롤러와 클라이언트로 만들어서 팩토리패턴으로 관리하면 될 것 같다)
그리고 로그인 서비스를 진행하면 전략 패턴을 통해서 어떤 서비스가 진행되는지
체크를 해서 소셜로그인이면 로그인 전에 액세스토큰에서 받은 정보로 새로운 유저를 생성해서 DB에 담은 뒤에
그 정보로 토큰을 생성하고 로그인을 진행하게 된다.
동의항목에서 유저 정보를 얻기위한 DTO
package com.coach.chiselbot.domain.kakao.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 카카오 사용자 정보 API (/v2/user/me) 응답을 매핑하는 DTO
* https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info
*/
@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoUserInfoResponseDto {
// 회원번호
@JsonProperty("id")
private Long id;
// 카카오 계정 정보
@JsonProperty("kakao_account")
private KakaoAccount kakaoAccount;
@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class KakaoAccount {
// 프로필
@JsonProperty("profile")
private Profile profile;
// 이메일 제공 동의 여부
@JsonProperty("email_needs_agreement")
private Boolean isEmailAgree;
// 이메일
@JsonProperty("email")
private String email;
@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Profile {
@JsonProperty("nickname")
private String nickName;
@JsonProperty("profile_image_url")
private String profileImageUrl;
}
}
}
액세스 토큰을 얻고 정보를 추출하는 메서드
package com.coach.chiselbot.domain.kakao;
import com.coach.chiselbot.domain.kakao.dto.KakaoUserInfoResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
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;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class KakaoOAuthClient {
private final WebClient webClient;
@Value("${oauth.kakao.client-id}")
private String clientId;
@Value("${oauth.kakao.redirect-uri}")
private String redirectUri;
public String getAccessToken(String code) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("grant_type", "authorization_code");
formData.add("client_id", clientId);
formData.add("redirect_uri", redirectUri);
formData.add("code", code);
Map<String, Object> response = webClient.post()
.uri("https://kauth.kakao.com/oauth/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block();
System.out.println("카카오 토큰 응답: " + response);
return (String) response.get("access_token");
}
public KakaoUserInfoResponseDto getUserInfo(String accessToken) {
KakaoUserInfoResponseDto response = webClient.get()
.uri("https://kapi.kakao.com/v2/user/me")
.header("Authorization", "Bearer " + accessToken)
.retrieve()
.bodyToMono(KakaoUserInfoResponseDto.class)
.block();
return response;
}
}
코드받기 전 카카오 로그인을 진행하고 콜백하면서 코드를 받고(정보가 담긴 토큰포함)
유저 생성 후로그인까지 한번에 진행하는 콘트롤러
(이 부분은 사실 처음 만들어서 이해를 못해서 카카오 전용 콘트롤러가 따로 만들어져 버렸다
정확히는 Auth콘트롤러가 만들어져야한다.(각 소셜 별 인증코드를 처리하는)
package com.coach.chiselbot.domain.kakao;
import com.coach.chiselbot._global.config.jwt.JwtTokenProvider;
import com.coach.chiselbot._global.dto.CommonResponseDto;
import com.coach.chiselbot.domain.user.User;
import com.coach.chiselbot.domain.user.dto.UserRequestDTO;
import com.coach.chiselbot.domain.user.login.LoginStrategy;
import com.coach.chiselbot.domain.user.login.LoginStrategyFactory;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/oauth/kakao")
public class KakaoOAuthController {
@Value("${oauth.kakao.client-id}")
private String clientId;
@Value("${oauth.kakao.redirect-uri}")
private String redirectUri;
private final LoginStrategyFactory loginStrategyFactory;
private final JwtTokenProvider jwtTokenProvider;
@GetMapping("/login")
public void redirectToKakao(HttpServletResponse response) throws IOException {
String kakaoAuthUrl = UriComponentsBuilder.fromUriString("https://kauth.kakao.com/oauth/authorize")
.queryParam("response_type", "code")
.queryParam("client_id", clientId)
.queryParam("redirect_uri",redirectUri)
.build()
.toUriString();
response.sendRedirect(kakaoAuthUrl);
}
@GetMapping("/callback")
public void kakaoCallback(@RequestParam String code, HttpServletResponse response) throws IOException {
LoginStrategy strategy = loginStrategyFactory.findStrategy("kakao");
UserRequestDTO.Login dto = new UserRequestDTO.Login();
dto.setAuthCode(code);
User user = strategy.login(dto);
String token = jwtTokenProvider.createToken(user);
String encodedToken = URLEncoder.encode(token, StandardCharsets.UTF_8);
response.sendRedirect("myapp:login?token=" + encodedToken);
}
/**
* Flutter SDK를 통한 카카오 로그인
* POST /oauth/kakao/token
*/
@PostMapping("/token")
public ResponseEntity<CommonResponseDto<?>> loginWithAccessToken(
@RequestBody Map<String, String> request) {
String accessToken = request.get("accessToken");
if (accessToken == null || accessToken.isBlank()) {
return ResponseEntity.badRequest()
.body(CommonResponseDto.error("accessToken이 필요합니다."));
}
try {
LoginStrategy strategy = loginStrategyFactory.findStrategy("kakao");
UserRequestDTO.Login dto = new UserRequestDTO.Login();
dto.setAccessToken(accessToken);
User user = strategy.login(dto);
String token = jwtTokenProvider.createToken(user);
Map<String, Object> responseData = Map.of(
"userEmail", user.getEmail().toString(),
"name", user.getName(),
"token", token,
"profileImageUrl", user.getProfileImage()
);
return ResponseEntity.ok(CommonResponseDto.success(responseData, "로그인 성공"));
} catch (Exception e) {
return ResponseEntity.status(401)
.body(CommonResponseDto.error("카카오 로그인 실패: " + e.getMessage()));
}
}
}
팩토리 패턴으로 타입을 확인하여 알아서 매칭하고
전략 패턴을 통해서 카카오 전략은 코드를 받아온게 확인이 되면
그 정보를 카카오오쓰클라이언트에 있는 메서드로 정보를 뽑아낼 수 있도록 한다.
그 정보로 유저를 만들고 DB에 넣은 다음 JWT 토큰을 발급까지 한 후 로그인하도록 한다.
package com.coach.chiselbot.domain.user.login;
import com.coach.chiselbot.domain.kakao.KakaoOAuthClient;
import com.coach.chiselbot.domain.kakao.RedirectRequiredException;
import com.coach.chiselbot.domain.kakao.dto.KakaoUserInfoResponseDto;
import com.coach.chiselbot.domain.user.Provider;
import com.coach.chiselbot.domain.user.User;
import com.coach.chiselbot.domain.user.UserJpaRepository;
import com.coach.chiselbot.domain.user.dto.UserRequestDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.UUID;
@Component
@RequiredArgsConstructor
public class KakaoLoginStrategy implements LoginStrategy {
@Value("${oauth.kakao.client-id}")
private String clientId;
@Value("${oauth.kakao.redirect-uri}")
private String redirectUri;
private final KakaoOAuthClient kakaoOAuthClient;
private final UserJpaRepository userJpaRepository;
private final PasswordEncoder passwordEncoder;
@Override
public User login(UserRequestDTO.Login dto) {
String accessToken;
// 1. accessToken이 직접 넘어온 경우 (Flutter SDK 방식)
if (dto.getAccessToken() != null && !dto.getAccessToken().isBlank()) {
accessToken = dto.getAccessToken();
}
// 2. authCode가 넘어온 경우 (기존 웹 방식)
else if (dto.getAuthCode() != null && !dto.getAuthCode().isBlank()) {
accessToken = kakaoOAuthClient.getAccessToken(dto.getAuthCode());
}
// 3. 둘 다 없으면 리다이렉트
else {
String kakaoAuthUrl = UriComponentsBuilder
.fromUriString("https://kauth.kakao.com/oauth/authorize")
.queryParam("response_type", "code")
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectUri)
.build()
.toUriString();
throw new RedirectRequiredException(kakaoAuthUrl);
}
// 카카오 사용자 정보 조회
KakaoUserInfoResponseDto kakaoUser = kakaoOAuthClient.getUserInfo(accessToken);
String rawEmail = kakaoUser.getKakaoAccount().getEmail();
String nickname = kakaoUser.getKakaoAccount().getProfile().getNickName();
String profileImageUrl = kakaoUser.getKakaoAccount().getProfile().getProfileImageUrl();
String kakaoId = String.valueOf(kakaoUser.getId());
String safeEmail = (rawEmail == null || rawEmail.isBlank())
? "kakao_" + kakaoId + "@placeholder.kakao"
: rawEmail;
final String email = safeEmail;
String randomPassword = UUID.randomUUID().toString();
String encodedPassword = passwordEncoder.encode(randomPassword);
return userJpaRepository.findByEmail(email)
.orElseGet(() -> userJpaRepository.save(
User.builder()
.kakaoId(kakaoId)
.email(email)
.password(encodedPassword)
.name(nickname)
.profileImage(profileImageUrl)
.provider(Provider.KAKAO)
.build()
));
}
@Override
public boolean supports(String type) {
return "kakao".equalsIgnoreCase(type);
}
}
'JAVA' 카테고리의 다른 글
| 컴포넌트 스캔 (0) | 2025.11.14 |
|---|---|
| 리플렉션(+어노테이션) (0) | 2025.11.14 |
| 리플렉션(동적 분석 도구 API) (0) | 2025.10.27 |
| 리플렉션(경직된 설계) (0) | 2025.10.27 |
| 리플렉션 (어노테이션) (0) | 2025.10.27 |