Spring Boot에서 소셜 로그인을 구현하기 위해서는 spring-boot-starter-oauth2-client
를 추가해야 합니다.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
이 의존성을 추가하면 기본적인 OAuth2 클라이언트 기능을 사용할 수 있습니다.
DefalutOAuth2User
를 상속한 User 클래스 - CustomOAuth2User
OAuth2 로그인 사용자를 표현하는 기본 클래스인 DefaultOAuth2User
를 확장하여 CustomOAuth2User
클래스를 구현합니다. 이 클래스에는 이메일과 사용자 역할(UserRole)을 추가로 저장하도록 설계했습니다.
CustomOAuth2User
전체 코드/**
* DefaultOAuth2User를 상속하고, email과 role 필드를 추가로 가진다.
*/
@Getter
public class CustomOAuth2User extends DefaultOAuth2User {
private String email;
private UserRole role;
public CustomOAuth2User(Collection<? extends GrantedAuthority> authorities,
Map<String, Object> attributes, String nameAttributeKey,
String email, UserRole role) {
super(authorities, attributes, nameAttributeKey);
this.email = email;
this.role = role;
}
}
DefaultOAuth2User
를 생성하고, email과 role 파라미터를 추가로 받아서, 주입하여 CustomOAuth2User
를 생성합니다.OAuthAttributes
다양한 소셜 로그인 API에서 제공하는 사용자 정보가 다를 수 있습니다. 이 데이터를 통일된 형태로 다룰 수 있도록 OAuthAttributes
라는 DTO 클래스를 만들어, 각 소셜 서비스별로 데이터를 분기 처리합니다.
OAuthAttributes
전체 코드/**
* 각 소셜에서 받아오는 데이터가 다르므로
* 소셜별로 데이터를 받는 데이터를 분기 처리하는 DTO 클래스
*/
@Getter
public class OAuthAttributes {
private String nameAttributeKey; // OAuth2 로그인 진행 시 키가 되는 필드 값, PK와 같은 의미
private OAuth2UserInfo oauth2UserInfo; // 소셜 타입별 로그인 유저 정보(닉네임, 이메일, 프로필 사진 등등)
@Builder
public OAuthAttributes(String nameAttributeKey, OAuth2UserInfo oauth2UserInfo) {
this.nameAttributeKey = nameAttributeKey;
this.oauth2UserInfo = oauth2UserInfo;
}
/**
* SocialType에 맞는 메소드 호출하여 OAuthAttributes 객체 반환
* 파라미터 : userNameAttributeName -> OAuth2 로그인 시 키(PK)가 되는 값 / attributes : OAuth 서비스의 유저 정보들
*/
public static OAuthAttributes of(SocialType socialType,
String userNameAttributeName, Map<String, Object> attributes) {
switch (socialType) {
case NAVER:
return ofNaver(userNameAttributeName, attributes);
case KAKAO:
return ofKakao(userNameAttributeName, attributes);
default:
throw new AppException(ErrorCode.INVALID_SOCIAL_TYPE,ErrorCode.INVALID_SOCIAL_TYPE.getMessage());
}
}
private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.nameAttributeKey(userNameAttributeName)
.oauth2UserInfo(new KakaoOAuth2UserInfo(attributes))
.build();
}
public static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.nameAttributeKey(userNameAttributeName)
.oauth2UserInfo(new NaverOAuth2UserInfo(attributes))
.build();
}
/**
* of메소드로 OAuthAttributes 객체가 생성되어, 유저 정보들이 담긴 OAuth2UserInfo가 소셜 타입별로 주입된 상태
* OAuth2UserInfo에서 socialId(식별값), nickname, imageUrl을 가져와서 build
* email에는 UUID로 중복 없는 랜덤 값 생성
* role은 GUEST로 설정
*/
public User toEntity(SocialType socialType, OAuth2UserInfo oauth2UserInfo) {
return User.builder()
.socialType(socialType)
.socialId(oauth2UserInfo.getId())
.email(UUID.randomUUID() + "@socialUser.com")
.userName(oauth2UserInfo.getNickname())
.imageUrl(oauth2UserInfo.getImageUrl())
.userRole(USER)
.build();
}
}
OAuth2UserInfo
추상 클래스 및 자식 클래스OAuth2 소셜 서비스별로 다른 유저 정보를 처리하기 위해, OAuth2UserInfo
라는 추상 클래스를 만들고 이를 상속한 KakaoOAuth2UserInfo
와 NaverOAuth2UserInfo
클래스를 구현합니다.
OAuth2UserInfo
상세 코드public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public abstract String getId(); // 소셜 식별 값 : 카카오 - "id", 네이버 - "id"
public abstract String getNickname();
public abstract String getImageUrl();
}
NaverOAuth2UserInfo
상세 코드네이버 로그인 API에서 제공하는 사용자 정보를 처리하는 클래스입니다. 네이버는 사용자 정보를 response라는 키 아래에서 제공합니다. 이 구조에 맞춰 getId
, getNickname
, getImageUrl
메서드를 구현하여, 각각 사용자 ID, 닉네임, 프로필 이미지를 반환하도록 합니다.
@Slf4j
public class NaverOAuth2UserInfo extends OAuth2UserInfo {
public NaverOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return response != null ? (String) response.get("id") : null;
}
@Override
public String getNickname() {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
log.info("response:{}", response);
return response != null ? (String) response.get("name") : null;
}
@Override
public String getImageUrl() {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return response != null ? (String) response.get("profile_image") : null;
}
}
KakaoOAuth2UserInfo
전체 코드카카오 로그인 API에서 제공하는 사용자 정보를 처리하는 클래스입니다. 카카오는 사용자 정보를 kakao_account와 profile이라는 구조로 제공합니다. 이를 이용해 getId
, getNickname
, getImageUrl
메서드를 구현하여, 카카오 사용자의 정보를 추출합니다.
@Slf4j
public class KakaoOAuth2UserInfo extends OAuth2UserInfo {
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return String.valueOf(attributes.get("id"));
}
@Override
public String getNickname() {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) account.get("profile");
return profile != null ? (String) profile.get("nickname") : null;
}
@Override
public String getImageUrl() {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) account.get("profile");
return profile != null ? (String) profile.get("thumbnail_image_url") : null;
}
}
OAuth2UserService
를 커스텀한 CustomOAuth2UserService
기본적으로 Spring Security는 DefaultOAuth2UserService
를 사용하여 OAuth2 로그인을 처리합니다. 우리는 이 기본 동작을 커스터마이징하여, 소셜 서비스에서 제공하는 사용자 정보를 커스터마이징된 OAuth2UserInfo
를 통해 처리하고, 이를 User 엔티티와 연결시키기 위해 CustomOAuth2UserService
를 구현합니다.
CustomOAuth2UserService
전체 코드@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private static final String NAVER = "naver";
private static final String KAKAO = "kakao";
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
log.info("CustomOAuth2UserService
.loadUser() 실행 - OAuth2 로그인 요청 진입");
/**
* DefaultOAuth2UserService 객체를 생성하여, loadUser(userRequest)를 통해 DefaultOAuth2User 객체를 생성 후 반환
*/
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
/**
* userRequest에서 registrationId 추출 후 SocialType 저장
*/
String registrationId = userRequest.getClientRegistration().getRegistrationId();
SocialType socialType = getSocialType(registrationId);
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
Map<String, Object> attributes = oAuth2User.getAttributes();
// socialType에 따라 유저 정보를 통해 OAuthAttributes 객체 생성
OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, userNameAttributeName, attributes);
User createdUser = getUser(extractAttributes, socialType);
// DefaultOAuth2User를 구현한 CustomOAuth2User 객체를 생성해서 반환
return new CustomOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(createdUser.getUserRole().name())),
attributes,
extractAttributes.getNameAttributeKey(),
createdUser.getEmail(),
createdUser.getUserRole()
);
}
private SocialType getSocialType(String registrationId) {
switch (registrationId) {
case NAVER:
return SocialType.NAVER;
case KAKAO:
return SocialType.KAKAO;
default:
throw new AppException(ErrorCode.REGISTRATION_ID_NOT_FOUND, "Unknown registrationId: " + registrationId);
}
}
/**
* 소셜 로그인의 식별값 id를 통해 회원을 찾아 반환하는 메소드
*/
private User getUser(OAuthAttributes attributes, SocialType socialType) {
return userRepository.findBySocialTypeAndSocialId(socialType, attributes.getOauth2UserInfo().getId())
.orElseGet(() -> saveUser(attributes, socialType));
}
/**
* OAuthAttributes의 toEntity() 메소드를 통해 빌더로 User 객체 생성 후 반환
*/
private User saveUser(OAuthAttributes attributes, SocialType socialType) {
User createdUser = attributes.toEntity(socialType, attributes.getOauth2UserInfo());
return userRepository.save(createdUser);
}
}
CustomOAuth2UserService
는 OAuth2UserService
인터페이스를 구현하여, OAuth2 로그인 요청이 들어올 때 사용자의 정보를 추출하고, 데이터베이스에 저장된 유저 정보를 확인합니다. 없으면 새로운 유저를 생성하고, 있으면 기존 유저 정보를 반환합니다.OAuth2LoginSuccessHandler
로그인에 성공했을 때 JWT 토큰을 발급하여 클라이언트에게 전달하는 로직을 OAuth2LoginSuccessHandler
에서 구현합니다. 이 핸들러는 SimpleUrlAuthenticationSuccessHandler
를 상속받아, 로그인 성공 시 커스텀한 동작을 정의합니다.
OAuth2LoginSuccessHandler
전체 코드@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtService jwtService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("OAuth2 Login 성공!");
try {
CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();
loginSuccess(request, response, oAuth2User); // 로그인에 성공한 경우 access, refresh 토큰 생성
} catch (Exception e) {
throw e;
}
}
private void loginSuccess(HttpServletRequest request, HttpServletResponse response, CustomOAuth2User oAuth2User) throws IOException {
log.info("getEmail:{}", oAuth2User.getEmail());
String accessToken = jwtService.createAccessToken(oAuth2User.getEmail());
String refreshToken = jwtService.createRefreshToken();
response.addHeader(jwtService.getAccessHeader(), "Bearer " + accessToken);
response.addHeader(jwtService.getRefreshHeader(), "Bearer " + refreshToken);
jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken);
jwtService.updateRefreshToken(oAuth2User.getEmail(), refreshToken);
String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/login")
.queryParam("accessToken", accessToken)
.queryParam("refreshToken", refreshToken)
.queryParam("email",oAuth2User.getEmail())
.build().toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
CustomOAuth2User
객체에서 사용자의 이메일을 추출하고, 이를 통해 JWT 토큰을 생성합니다.OAuth2LoginFailureHandler
소셜 로그인 실패 시 처리할 로직은 OAuth2LoginFailureHandler
에 구현됩니다. 이 핸들러는 SimpleUrlAuthenticationFailureHandler
를 상속받아, 로그인 실패 시 JSON 형식의 에러 응답을 전송합니다.
OAuth2LoginFailureHandler
전체 코드@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
// HTTP 응답 상태 코드를 401로 설정
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// 응답의 Content-Type을 JSON 형식으로 설정
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
// 로그인 실패 시 사용할 ErrorResponse 객체 생성 (ErrorCode와 예외 메시지를 담음)
ErrorResponse errorResponse = new ErrorResponse(ErrorCode.LOGIN_FAILED, exception.getMessage());
// ErrorResponse 객체를 Response<ErrorResponse>로 감싸서 통일된 응답 형식으로 변환
Response<ErrorResponse> responseBody = Response.error("ERROR", errorResponse);
// ObjectMapper를 사용하여 Response 객체를 JSON으로 직렬화하고, 클라이언트에 응답으로 보냄
objectMapper.writeValue(response.getWriter(), responseBody);
// 로그인 실패 로그 기록
log.info("소셜 로그인에 실패했습니다. 에러 메시지 : {}", exception.getMessage());
}
}
401 Unauthorized
로 설정하고, 에러 메시지를 담은 JSON 응답을 전송합니다.