{baseUrl} 런타임 도메인 URL 런타임 환경에서 애플리케이션 URL이 바인딩됩니다.
# Redirect URI is where Naver will send the user after successful authentication.
# {baseUrl} is replaced by your application's base URL during runtime.
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/login/oauth2/code/naver
Provider URI 설정
Spring Security 에서 OAuth2 로그인을 위한 URI을 설정해줍니다. 이는 Naver에서 설정한 URI입니다.
# Grant Type configuration. We are using "authorization_code" which is a common OAuth2 grant type.
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
# Scope specifies what information your app is requesting. Here, it requests profile information.
spring.security.oauth2.client.registration.naver.scope=profile
# Provider (Naver Authentication Server Configuration)
# The URL for Naver's authorization endpoint where users will authenticate.
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
# The URL used to exchange the authorization code for an access token after the user authenticates.
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
# The URL to fetch the user's profile information using the access token.
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
# The attribute in the response that contains the user's name.
spring.security.oauth2.client.provider.naver.user-name-attribute=response
3) OAuth 응답 매핑 클래스 구현
OAuth2 제공별로 응답 형식이 다르므로, 제공자별로 필요한 정보를 파싱하는 OAuth2Response 인터페이스를 정의하고, NaverOAuthResponse 구현체에서 네이버 응답을 매핑합니다.
import com.ham.netnovel.member.OAuthProvider;
import com.ham.netnovel.member.data.Gender;
import java.util.Map;
public class NaverOAuthResponse implements OAuth2Response {
private final Map<String, Object> attribute;
public NaverOAuthResponse(Map<String, Object> attribute) {
this.attribute = (Map<String, Object>) attribute.get("response");
}
@Override
public OAuthProvider getProvider() {
return OAuthProvider.NAVER;
}
@Override
public String getProviderId() {
return attribute.get("id").toString();
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getNickName() {
return attribute.get("nickname").toString();
}
@Override
public Gender getGender() {
if (attribute.get("gender").equals("M")) {
return Gender.MALE;
} else {
return Gender.FEMALE;
}
}
}
4) CustomOAuth2User
네이버 로그인 API를 사용해 OAuth2 인증을 처리하기 위해, OAuth2User를 커스텀한 CustomOAuth2User 클래스를 구현하였습니다.
이는 Spring Security 의 OAuth2 로그인 방식에서 사용할 객체이며, Spring Security 에 의해 Authentication 객체에 포함되어 SecurityContext에 저장됩니다.(세션정보에 포함됩니다.)
프로젝트에서 providerId 를 OAuth2User 의 name 속성으로 대신 사용하며, 사용자 정보로서 닉네임, 성별, 권한 등의 다양한 속성도 추가로 다루고 있기 때문에 일부 메서드를 오버라이딩해 커스텀 클래스 형태로 설계했습니다.
Spring Session 라이브러리에서는 직렬화된 객체만 사용가능하므로 Serializable 추가하였습니다.
import com.ham.netnovel.member.data.Gender;
import com.ham.netnovel.member.data.MemberRole;
import com.ham.netnovel.member.dto.MemberOAuthDto;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;
public class CustomOAuth2User implements OAuth2User, Serializable {
private final MemberOAuthDto memberOAuthDto;
public CustomOAuth2User(MemberOAuthDto memberOAuthDto) {
this.memberOAuthDto = memberOAuthDto;
}
// 제공자별로 다른 속성 형식을 사용하기 때문에 불필요한 Attribute는 사용 금지
@Override
public Map<String, Object> getAttributes() {
return null;
}
// 사용자 권한 정보 가져오기 - OAuth2User 인터페이스 구현에 필수
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add((GrantedAuthority) () -> String.valueOf(memberOAuthDto.getRole()));
return collection;
}
// 프로젝트에서 사용자 고유 ID를 "name"으로 반환하도록 설정 (Spring Security 호환성)
@Override
public String getName() {
return memberOAuthDto.getProviderId();
}
// 닉네임 반환 메서드
public String getNickName(){
return memberOAuthDto.getNickName();
}
// 사용자 역할(Role) 반환 메서드
public MemberRole getRole(){
return memberOAuthDto.getRole();
}
// 사용자 성별(Gender) 반환 메서드
public Gender getGender(){
return memberOAuthDto.getGender();
}
}
5) OAuth2 로그인 비즈니스 로직
OAuth2 인증을 처리하기 위해 Spring Security에서 제공하는 DefaultOAuth2UserService를 확장하여 CustomOAuthUserService 구현하였습니다.
loadUser() 메서드 OAuth2UserRequest를 받아 OAuth2 인증 제공자로부터 사용자의 인증 정보를 로드합니다. 기본적인 인증 처리 후, 이를 기반으로 사용자 정보를 커스터마이징하여 DB와 비교하고 반환합니다.
제공자별 사용자 정보 분기 처리 registrationId를 통해 OAuth2 제공자를 식별합니다. 현재 네이버(Naver) 제공자에 대한 처리만 구현되어 있으며, 추가적으로 구글(Google) 등 다른 제공자에 대한 분기 처리가 가능하도록 설계했습니다.
OAuth2Response 객체 생성 제공자별로 사용자의 정보 형식이 다르기 때문에, 네이버로부터 받은 oAuth2User.getAttributes() 데이터를 기반으로 NaverOAuthResponse 객체를 생성합니다.
DB에서 사용자 조회 및 신규 사용자 처리
oAuth2Response.getProviderId()를 통해 providerId(네이버에서 제공하는 사용자 고유 ID)로 사용자가 DB에 존재하는지 조회합니다.
신규 사용자일 경우: MemberCreateDto를 생성하여 필요한 사용자 정보를 입력하고, 기본 역할로 MemberRole.READER를 설정하여 DB에 저장합니다.
기존 사용자일 경우: DB에서 가져온 정보(MemberLoginDto)를 이용해 MemberOAuthDto를 생성합니다. 이때, 닉네임과 역할 등의 변경 가능성을 고려하여 최신 정보를 반영할 수 있습니다.
CustomOAuth2User 반환 사용자 정보를 포함한 MemberOAuthDto 객체를 통해 CustomOAuth2User를 생성하고 반환합니다. 반환된 객체는 위의 설명과 같이 Spring Security 에 의해 Authentication 객체에 포함되어 SecurityContext에 저장됩니다.(세션정보에 포함됩니다.)
import com.ham.netnovel.common.OAuth.dto.NaverOAuthResponse;
import com.ham.netnovel.common.OAuth.dto.OAuth2Response;
import com.ham.netnovel.member.data.MemberRole;
import com.ham.netnovel.member.service.MemberService;
import com.ham.netnovel.member.dto.MemberCreateDto;
import com.ham.netnovel.member.dto.MemberLoginDto;
import com.ham.netnovel.member.dto.MemberOAuthDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class CustomOAuthUserService extends DefaultOAuth2UserService {
private final MemberService memberService;
public CustomOAuthUserService(MemberService memberService) {
this.memberService = memberService;
}
/**
* OAuth2 제공자에서 보낸 정보 핸들링
* @param userRequest
* @return
* @throws OAuth2AuthenticationException
*/
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
//oAuth2Response 객체 생성
OAuth2Response oAuth2Response = null;
switch (registrationId) {
case "naver"://제공자가 Naver일경우 처리
log.info("소셜로그인 : naver, 정보={}", oAuth2User.getAttributes());
oAuth2Response = new NaverOAuthResponse(oAuth2User.getAttributes());
break;
case "google":
log.info("구글");
break;
default:
throw new IllegalArgumentException("유효하지 않은 provider");
}
//DB에서 유저 정보 조회
MemberLoginDto memberLoginInfo = memberService.getMemberLoginInfo(oAuth2Response.getProviderId());
//반환을 위한 DTO
MemberOAuthDto memberOAuthDto;
//DB에 유저 정보가 없을경우, DB에 유저 정보를 저장
if (memberLoginInfo.getProviderId()==null){
//OAuth에서 받아온 유저 정보로, 유저 정보 저장을 위한 DTO 생성
MemberCreateDto memberCreateDto = MemberCreateDto.builder()
.email(oAuth2Response.getEmail())
.provider(oAuth2Response.getProvider())
.providerId(oAuth2Response.getProviderId())
.nickName(oAuth2Response.getNickName())
.role(MemberRole.READER)
.gender(oAuth2Response.getGender())
.build();
//유저 정보 생성
memberService.createNewMember(memberCreateDto);
//반환을 위한 DTO 정보 바인딩
memberOAuthDto = MemberOAuthDto.builder()
.providerId(oAuth2Response.getProviderId())
.nickName(oAuth2Response.getNickName())
.role(MemberRole.READER)
.gender(oAuth2Response.getGender())
.build();
}else {
//유저 정보가 DB에 있으면, DB값 바탕으로 DTO 반환(닉네임,role 등 변경 가능성 존재)
//반환을 위한 DTO 정보 바인딩
memberOAuthDto = MemberOAuthDto.builder()
.providerId(memberLoginInfo.getProviderId())
.nickName(memberLoginInfo.getNickName())
.role(memberLoginInfo.getRole())
.gender(memberLoginInfo.getGender())
.build();
}
return new CustomOAuth2User(memberOAuthDto);
}
}
6. Spring Security 설정
Spring Security 설정을 통해 OAuth2 로그인을 활성화하고, 기본 Form 로그인을 비활성화합니다.
import com.ham.netnovel.common.OAuth.CustomOAuth2SuccessHandler;
import com.ham.netnovel.common.OAuth.CustomOAuthUserService;
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.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomOAuthUserService customOAuthUserService;
private final CustomOAuth2SuccessHandler customOAuth2SuccessHandler;
public SecurityConfig(CustomOAuthUserService customOAuthUserService, CustomOAuth2SuccessHandler customOAuth2SuccessHandler) {
this.customOAuthUserService = customOAuthUserService;
this.customOAuth2SuccessHandler = customOAuth2SuccessHandler;
}
//서블릿 필터 무시 URL 정적 리소스 등록 필수
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers("/static/**", "/css/**", "/js/**", "/images/**", "/favicon**");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//CSRF disable, 테스트 상태에서만 disable 상태로 유지
http.csrf(AbstractHttpConfigurer::disable);
//From 로그인 방식 disable
http.formLogin((login) -> login.disable());
//oauth2 방식 로그인사용
http.oauth2Login(oauth2Login -> oauth2Login
.loginPage("/login")
.userInfoEndpoint(userInfoEndPoint ->
userInfoEndPoint.userService(customOAuthUserService))
.successHandler(customOAuth2SuccessHandler)
);
//사용자 인증 URL 설정
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/", "/login**", "/api/**", "/api/novels/**").permitAll()//인증 예외 URL
.anyRequest().authenticated()
);
return http.build();
}
}
3. 애플리케이션 테스트
1) 네이버 소셜로그인 진입
네이버 소셜 로그인 기능을 통해 로그인 페이지로 정상 진입하는 것을 확인했습니다. 로그인 화면은 아래와 같습니다.
2) DB 저장 여부 확인
로그인 후 사용자 정보가 성공적으로 파싱되어 DB에 저장되었음을 확인했습니다. 아래는 저장된 사용자 정보의 예시입니다.
3) 세션 데이터 확인
Redis Insight로 데이터를 확인했습니다. Spring Session 에 의해 정상적으로 세션이 확인되었음을 확인할 수 있습니다.
결론
이번 프로젝트에서는 Spring Security와 OAuth2를 결합하여 소셜 로그인 기능을 구현하였고, 이를 통해 로그인 편의성과 보안성을 강화할 수 있었습니다.
OAuth2 인증 방식을 적용함으로써 사용자 계정과 비밀번호를 직접적으로 관리할 필요가 없어졌으며, 네이버 등의 소셜 제공자와 연결된 사용자 정보를 안전하게 인증하고 처리할 수 있게 되었습니다.
이와 함께 Spring Session과 Redis를 활용하여 세션 관리와 데이터 저장을 더욱 효율적이고 안전하게 구성할 수 있었습니다. 이러한 구조는 확장성과 성능을 향상시키고, 인증 관련 데이터의 안정적인 관리와 빠른 접근을 가능하게 하여 사용자 경험을 크게 개선할 수 있습니다.