[Spring] 자체 서비스 회원과 소셜 로그인 회원 통합 관리 방법

로그인 페이지

우리 서비스는 쇼핑몰을 통한 회원 가입 및 로그인, 그리고 소셜 계정을 통한 회원 가입 및 로그인을 지원한다. (카카오, 네이버, 구글)

이 글을 포스팅하는 이유는 소셜 로그인을 뒤 늦게 적용하게 되었는데 소셜 로그인을 적용하고 난뒤에

기존 쇼핑몰 회원과 소셜 계정을 통해 가입한 회원을 관리하는데 문제가 발생하게 되어서 그 문제점을 해결한 방법에 대해 포스팅하고자 한다.


문제 발생 상황

⛔ 문제점
1. 소셜 로그인 구글 이메일이 a@, 나머지 이메일은 b@일 때 쇼핑몰을 통해 회원가입한 유저의 이메일이 b@ 일 경우 이메일 중복 문제가 발생해 이메일이 같은 경우 b@이메일 유저의 세션을 사용하는 문제.
2. 소셜 로그인은 보안상 비밀번호를 받아올 수 없기 때문에 소셜 로그인한 회원은 비밀번호 찾기가 불가.

이러한 문제점이 발생해 다른 서비스들은 자체 서비스 회원과 소셜 회원을 어떤식으로 관리하는 지 알아보았다.

다른 방법도 있을 수 있겠지만 알아본 방법은 총 2가지였다.

  1. 소셜 로그인시 해당 소셜 정보를 바탕으로 회원 가입 처리.
  2. 소셜 계정을 통해 본인인증을 하고 회원가입 페이지로 이동해 사용할 닉네임, 비밀번호 등을 입력 후 회원가입.(이메일, 프로필 등의 소셜 계정에서 받아올 수 있는 정보 제외)

 

2번의 경우에는 일명 타 서비스들에서 볼 수 있는 "소셜 계정으로 3초 간편가입" 같은 방식이다.

2번 방식을 사용한다면 추가적인 소셜 인증 회원의 회원가입 페이지를 만들어야 했기 때문에 1번 방식을 통해 구현하게 되었다. 


소셜 로그인시 해당 소셜 정보를 바탕으로 회원 가입 처리 방법

 

수정 전과 수정 후의 유저 테이블

먼저 수정 전 유저 테이블은 다음과 같다.

현재 이 테이블 구조를 가져가면 소셜 로그인 회원과 기존 회원의 이메일이 겹칠 경우 이메일이 중복된 회원들을 판별할 수 있는 방법이 없다.

수정 전

 

 

아래는 수정된 유저 테이블이다.

위 방식의 경우에는 다른 방식의 회원가입 회원들을 판별할 수 없는 문제가 있었는데

이 문제를 해결하기 위해, 로그인 방법을 담는 컬럼을 추가하여 각 회원들을 구별하도록 하였다.

LOGIN_METHOD라는 로그인 방법을 담는 컬럼을 추가하였고 기본값으로는 "일반"이라는 값이 담기도록했다.

해당 컬럼에 기본값을 설정한 이유자체 서비스 회원가입 유저들은 전부 "일반" 가입으로 처리할 것이기 때문에 insert문에 해당 컬럼의 값을 작성하지 않아도 되기 하기 위해서이다.

 

추가로 기존 USER_EMAIL 컬럼에는 유니크 제약 조건이 걸려있었는데 자체 서비스 회원가입 시에 이메일 중복 검사를 하는 로직이 있기 때문에 유니크 제약 조건을 없애는 방법으로 진행했다.

수정 후

이렇게 수정을 한 후에는 이메일이 중복되더라도 각 회원별로 로그인 방법을 통해 구별할 수 있게 되어 데이터의 무결성을 유지할  있게 되었다.

로그인 방법 별로 저장된 user 테이블

 

비밀번호 null?

해당 서비스에 첫 소셜 로그인을 한다면, 소셜 서비스에서 비밀번호는 받아올 수 없기 때문비밀번호는 설정하는 방법은 2가지가 있을 것이다.

1.비밀번호를 임의의 값으로 넣어주는 방법.

2. 비밀번호를 null로 설정하는 방법.

 

기존에는 USER_PWD 컬럼이 NOT NULL이었다.

하지만 해당 부분도 회원가입시 비밀번호 입력 여부를 체크하기 때문에 null 허용하고 소셜 로그인 회원은 비밀번호가 null로 담기게 하였다.

사실 소셜 로그인 유저의 비밀번호 DB에 어떤식으로 저장하든 상관이 없을 것이다.

어차피 소셜 회원 가입유저는 해당 소셜 서비스의 인증을 통해 로그인을 하기 때문이다.

 


소셜 로그인 회원 저장 로직 코드

 

소셜 로그인 유저 DTO

소셜 로그인은 총 3가지 카카오, 네이버, 구글을 지원하므로 각 소셜 서비스 별로 다르게 객체를 생성하도록 하였다.

@Getter
@ToString
public class OAuthAttributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String name;
    private String email;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email) {
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
        this.name = name;
        this.email = email;
    }

    public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
        if (registrationId.equals("naver")) {
            return ofNaver("id", attributes);
        }
        if (registrationId.equals("kakao")) {
            return ofKakao("id", attributes);
        }
        return ofGoogle(userNameAttributeName, attributes);
    }

    // OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야한다.
    private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .name((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        return OAuthAttributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) account.get("profile");

        return OAuthAttributes.builder()
                .name((String) profile.get("nickname"))
                .email((String) account.get("email"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    /**
     * DTO -> User 엔티티 변환 메서드 (생성 시점은 처음 가입할 때)
     */
    public User toEntity(String registrationId) {
        return User.builder()
                .user_name(name)
                .user_email(email)
                .loginMethod(registrationId)
                .user_status(Role.USER)
                .build();
    }
}

 

 

CustomOAuth2UserService - 소셜 로그인 유저 조회 및 저장 로직
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final UserRepository userRepository;
    private final HttpSession httpSession;


    /**
     * OAuth 2 프로바이더로 부터 사용자 정보를 가져오는 메서드.
     */
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        String registrationId = userRequest.getClientRegistration().getRegistrationId(); // 현재 로그인 진행 중인 서비스를 구분하는 코드 (구글, 네이버 등)

        // 소셜 로그인 사용자를 구분하는 key값을 가져옴 (구글은 sub, naver kakao는 id)
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        User user = saveOrUpdate(attributes, registrationId);
        httpSession.setAttribute(LOGIN_USER, new SessionUser(user));

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(Role.USER.name())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey());
    }

    /**
     * 이미 가입된 유저일 경우와 새로운 유저일 때 분기처리
     */
    private User saveOrUpdate(OAuthAttributes attributes, String registrationId) {
        User user = userRepository.selectUserByUserEmail(attributes.getEmail(), registrationId);
        if (user == null) {
            user = attributes.toEntity(registrationId);
            userRepository.insertOAuthUser(user);
            return user;
        }

        String newName = attributes.getName();
        String curName = user.getUser_name();
        if (!curName.equals(newName)) {
            UsernameUpdateDto dto = UsernameUpdateDto.builder()
                    .newName(newName)
                    .curName(curName)
                    .userEmail(user.getUser_email())
                    .loginMethod(user.getLoginMethod())
                    .role(user.getUser_status())
                    .build();

            userRepository.updateUserName(dto);
            return dto.toEntity(dto);
        }
        return user;
    }
}

위 서비스에서 saveOrUpdate() 메서드소셜 로그인 유저의 DTO 객체 OAuthAttribues소셜 서비스를 구분하는 registrationId를 받는다.

 

이 메서드를 이렇게 구성한 이유에 대해 설명하면 다음과 같다.

  1. 소셜 로그인 유저의 이메일과 registrtionId( = "로그인 방법")을 DB에서 조회해서 만약 가입된 회원이면 DB에 저장없이 User 객체를 반환.
  2. 이미 가입된 회원이지만 소셜 서비스에서 닉네임이 변경되었을 수 있기 때문에 닉네임이 변경되었을 경우에 변경된 닉네임으로 update하여 변경된 해당 User 객체 반환.
  3. 1, 2번에 해당하지 않으면 기존에 저장되어 있는 User객체 반환.
  4. 소셜 로그인 유저의 이메일과 registrtionId( = "로그인 방법")으로 DB에 조회된 회원이 없으면 전달 받은 파라미터 값으로 User 객체를 생성해 DB에 저장 후 반환.

Spring Security 설정

해당 OAuth2 소셜 로그인을 적용하기 위해서는 Spring Security 설정이 필요 하다.

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
    private final CustomOAuth2UserService customOAuth2UserService;

	/** 비밀번호 암호화를 위한 클래스 
    * 소셜 로그인은 비밀번호를 저장하지 않기때문에 해당 x
    * 일반 회원가입 회원에게만 적용했다.
    */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    protected SecurityFilterChain configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable().headers().frameOptions().disable()
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/user/searchfilter", "/user/css/**", "/user/img/**", "/user/js/**", "/login", "/user/logout", "/user/cart/**", "/local/**" ,"/user/update-cart" , "/user/detail/**", "/user/review/**", "/findPw", "/changePw", "/changeNewPw", "/user/like/**", "/join/**", "/user/login").permitAll()
                    .antMatchers("/user/mypage", "/confirm/payment", "/user/order/**", "/payment/**").hasAuthority(Role.USER.name()) // 해당 주소는 USER 권한을 가진 사람만 가능
                    .anyRequest().authenticated()
                .and()
                    .logout()
                    .invalidateHttpSession(true) // 로그아웃시 세션 제거
                    .logoutSuccessUrl("/")
                .and()
                    .oauth2Login() // OAuth2 로그인 설정
                    .defaultSuccessUrl("/", true)
                    .loginPage("/login")
                    .userInfoEndpoint() // 이 체인 다음에 로그인 성공 이후 사용자 정보를 가져오기 위한 설정을 담당하는 객체 등록
                    .userService(customOAuth2UserService); // OAuth2UserService 구현체를 등록

        return http.build();
    }
}

 

위 설정은 소셜 로그인만 적용한다면 시큐리티는 저렇게만 작성해도 충분하다.

하지만 일반 로그인과 소셜 로그인 2가지 방식을 지원한다면 문제점이 발생할 것이다.

그 이유는 아래의 포스팅에서 다루고자 한다.

 

 

[Spring Security] 일반 로그인 & 소셜 로그인 분리 및 Security 로그인 비동기 처리 방법

 

hstory0208.tistory.com