[Spring] OAuth2 기반 회원 인증 시스템 도입 (2) - Naver 로그인 구현,Spring코드 구현

Spring Boot에서 네이버 로그인 API를 통한 OAuth2 인증 구현하기

  • 이번 포스트에서는 Spring Boot에서 네이버 로그인 API를 사용해 OAuth2 인증을 구현하는 방법을 설명합니다.
  • 상세한 설정 사항은 네이버 개발자 센터에서 확인하실 수 있습니다.

1. 네이버 로그인 API 신청

1) 애플리케이션 등록

  1. 애플리케이션 이름 등록: 식별할 수 있는 이름을 설정합니다.
  2. 사용 API 선택: 로그인 기능을 사용할 것이므로 ‘로그인’을 선택합니다
  3. 제공 정보 선택: 필요한 정보를 선택합니다. 단, 네이버 정책상 이메일은 네이버 외 다른 도메인일 수 있습니다(@google.com 등).

2) 서비스 환경 설정

  1. 서비스 URL: 현재 도메인(테스트 환경에서는 localhost:8080)을 입력합니다.
  2. Callback URL: 인증 후 리다이렉트할 URL을 설정합니다. (예: http://localhost:8080/login/oauth2/code/naver)

3) id, key 확인

  1. 설정이 완료되면 발급된 Client ID와 Secret Key를 확인합니다. 이 정보는 Spring 설정에 사용됩니다.

2. Spring 코드 구현

1) build. gradle

  • spring-boot-starter-oauth2-client: WAS가 인증 제공자(Naver.Google등) 을 통해 인증을 수행할수 있도록 도와주는 라이브러리입니다.
  • 인증 제공자 서버에서 토큰을 받아 유저 정보를 요청합니다.그외 의존성
  • Spring Security 의 OAuth2 로그인 기능을 사용하고, Spring Session 으로 외부 저장소에 Session을 저장하기 위해 의존성을 추가해주었습니다(Redis를 Session Storage로 사용)
  • 편의상 다른 의존성은 생략했습니다.
implementation 'org.springframework.boot:spring-boot-starter-web'
//Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

//OAuth2 
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

//Redis 
implementation 'org.springframework.session:spring-session-data-redis'

//Spring Session For Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

2) apllication.properteis

  • OAuth2 구동을 위한 설정값들을 입력해줍니다.

Naver에서 발급받은 ID, Secret Key 값 설정

  • 앞서 Login API 설정시 발급받은 ID, Secret 값을 설정해줍니다.
  • spring.security.oauth2.client.registration.naver.client-id, spring.security.oauth2.client.registration.naver.client-secret
# Registration (OAuth2 Client Configuration)
spring.security.oauth2.client.registration.naver.client-name=naver
spring.security.oauth2.client.registration.naver.client-id=${NAVER_CLIENT_ID}
spring.security.oauth2.client.registration.naver.client-secret=${NAVER_CLIENT_SECRET}

Redicrect URI 설정

  • API 발급시 설정했던 CallBack URL을 입력해줍니다.
  • {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 구현체에서 네이버 응답을 매핑합니다.
  • Naver의 경우 아래와 같은 JSON 형태로 유저정보가 전달됩니다.
{
resultcode=00, 
message=success, 
response={id=<Id>, 
nickname=<NickName>, 
gender=M, 
email=<mail>@naver.com
}

Interface 정의


import com.ham.netnovel.member.OAuthProvider;
import com.ham.netnovel.member.data.Gender;

public interface OAuth2Response {


    //콘텐츠 제공자입니다(ENUM Class) 예: google, naver
    OAuthProvider getProvider();


    // 콘텐츠 제공자가 전달한 Id 값입니다.
    String getProviderId();

    //콘텐츠 제공자가 전달한 유저 이메일 입니다.
    String getEmail();

    /**
     * 콘텐츠 제공자가 전달한 유저 이름입니다.
     */
    String getNickName();

    //유저의 성별입니다.(ENUM Class)
    Gender getGender();

}

구현 Class

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에 저장됩니다.(세션정보에 포함됩니다.)
  • 프로젝트에서 providerIdOAuth2Username 속성으로 대신 사용하며, 사용자 정보로서 닉네임, 성별, 권한 등의 다양한 속성도 추가로 다루고 있기 때문에 일부 메서드를 오버라이딩해 커스텀 클래스 형태로 설계했습니다.
  • 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 구현하였습니다.
  1. loadUser() 메서드 OAuth2UserRequest를 받아 OAuth2 인증 제공자로부터 사용자의 인증 정보를 로드합니다. 기본적인 인증 처리 후, 이를 기반으로 사용자 정보를 커스터마이징하여 DB와 비교하고 반환합니다.
  2. 제공자별 사용자 정보 분기 처리 registrationId를 통해 OAuth2 제공자를 식별합니다. 현재 네이버(Naver) 제공자에 대한 처리만 구현되어 있으며, 추가적으로 구글(Google) 등 다른 제공자에 대한 분기 처리가 가능하도록 설계했습니다.
  3. OAuth2Response 객체 생성 제공자별로 사용자의 정보 형식이 다르기 때문에, 네이버로부터 받은 oAuth2User.getAttributes() 데이터를 기반으로 NaverOAuthResponse 객체를 생성합니다.
  4. DB에서 사용자 조회 및 신규 사용자 처리
  • oAuth2Response.getProviderId()를 통해 providerId(네이버에서 제공하는 사용자 고유 ID)로 사용자가 DB에 존재하는지 조회합니다.
  • 신규 사용자일 경우: MemberCreateDto를 생성하여 필요한 사용자 정보를 입력하고, 기본 역할로 MemberRole.READER를 설정하여 DB에 저장합니다.
  • 기존 사용자일 경우: DB에서 가져온 정보(MemberLoginDto)를 이용해 MemberOAuthDto를 생성합니다. 이때, 닉네임과 역할 등의 변경 가능성을 고려하여 최신 정보를 반영할 수 있습니다.
  1. 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 SecurityOAuth2를 결합하여 소셜 로그인 기능을 구현하였고, 이를 통해 로그인 편의성과 보안성을 강화할 수 있었습니다.
  • OAuth2 인증 방식을 적용함으로써 사용자 계정과 비밀번호를 직접적으로 관리할 필요가 없어졌으며, 네이버 등의 소셜 제공자와 연결된 사용자 정보를 안전하게 인증하고 처리할 수 있게 되었습니다.
  • 이와 함께 Spring Session과 Redis를 활용하여 세션 관리와 데이터 저장을 더욱 효율적이고 안전하게 구성할 수 있었습니다. 이러한 구조는 확장성과 성능을 향상시키고, 인증 관련 데이터의 안정적인 관리와 빠른 접근을 가능하게 하여 사용자 경험을 크게 개선할 수 있습니다.