Post

[Spring Security] 카카오와 구글 소셜로그인 구현하기

[Spring Security] 카카오와 구글 소셜로그인 구현하기

image.png

도입 이유

기존 DevBid의 로그인 흐름은 세션 기반 인증이었다.

1
2
사용자 → ID/PW 입력 → 서버에서 DB 조회 → 비밀번호 비교 
→ 성공 시 세션 저장 및 JSESSIONID 쿠키 발급

이 구조는 단순하고 안정적이지만, 경매 플랫폼 특성상 몇 가지 한계가 있었다.

  • 회원가입 허들이 높다

    경매는 충동적으로 참여가 이루어지는 경우가 많다. 가입 과정에서 이메일 인증, 비밀번호 규칙 등의 절차가 길어지면 이탈률이 크게 증가한다. 소셜 로그인은 클릭 2~3번이면 즉시 가입이 가능해 사용자 경험이 크게 개선된다.

  • 비밀번호 관리 부담

    금전 거래가 발생하는 서비스 특성상 비밀번호 유출은 치명적이다. 단순 해싱만으로는 강력한 보안 체계를 제공하기 어렵고, 카카오와 구글이 제공하는 인증 인프라를 활용하는 것이 훨씬 안전하다.

  • 신뢰도 향상

    처음 방문한 사이트에 직접 정보를 입력하기 부담스러운 사용자도 많다. “카카오로 로그인”, “구글로 로그인” 버튼은 심리적 진입장벽을 낮추는 효과가 있어 서비스 신뢰도를 간접적으로 높여준다.

기존 ID/PW 방식은 유지하되,사용자 경험과 보안 측면에서 이점을 제공하는 OAuth2 기반 소셜 로그인을 주요 진입 방식으로 도입했다.


OAuth2 란?

소셜 로그인과 OAuth2를 혼동하기 쉬운데, 역할이 다르다. OAuth2는 ‘인가(Authorization)’를 위한 산업 표준 프로토콜 이다. 제3자 애플리케이션이 사용자의 리소스에 접근할 권한을 안전하게 위임받는 방식을 정의한다.

소셜 로그인은 이 OAuth2 위에서 ‘인증(Authentication)’ 을 수행하는 응용 사례다 사용자가 서비스에 직접 비밀번호를 제공하지 않고, 신뢰할 수 있는 외부 플랫폼의 인증을 빌려 접속한다.

소셜 로그인 플로우

1
2
3
4
5
6
7
1. 사용자가 "카카오 로그인" 버튼 클릭
2. 카카오 로그인 페이지로 리다이렉트
3. 사용자가 카카오에서 로그인
4. 카카오가 Authorization Code 발급
5. 서버가 그 Code로 카카오에 Access Token 요청
6. 카카오가 Access Token + 유저 정보 반환
7. 자체 DB에 회원 없으면 가입, 있으면 로그인
  • Provider - 카카오, 구글 같은 인증 제공자
  • Provider ID - 제공자가 해당 유저에서 부여한 고유 번호
  • OAuth2User - 제공자에서 받아온 유저 정보 객체

구현

도메인 모델 설계

provider와 providerId는 개념적으로 분리될 수 없다. “카카오의 12345번 유저”처럼 항상 함께 움직인다. 이걸 DDD 관점에서 Value Object로 묶었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Getter
@Embeddable
public class SocialAuthInfo implements Serializable {
    @Column(name = "provider")
    private String providerId;

    @Column(name = "provider_id")
    private String providerUserId;

    protected SocialAuthInfo() {}

    public SocialAuthInfo(String providerId, String providerUserId) {
        this.providerId = providerId;
        this.providerUserId = providerUserId;
    }
}

이렇게 구현하면 두 가지 이점이 있다.

  • 관련데이터가 하나로 관리되므로 응집도 높아짐
  • 생성 시점에 검증하고, provider만 바꾸는 것 같은 일관성 없는 변경을 방지 즉 불변성 보장

User 엔티티에는 소셜 회원가입용 팩토리 메서드를 추가했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//소셜 회원가입
public static User createFromSocialAuth(SocialAuthInfo socialAuthInfo,
                                        Email email,
                                        Nickname nickname
) {
    User user = new User();
    user.socialAuthInfo = socialAuthInfo;
    user.email = email;
    user.nickname = nickname;
    user.password = null; // 소셜 로그인은 비밀번호 불필요
    user.phone = null;
    user.address = null;
    user.username = null;
    user.status = UserStatus.ACTIVE;

    String generatedUsername = socialAuthInfo.getProviderId() + "-" + socialAuthInfo.getProviderUserId();
    user.username = Username.from(generatedUsername);

    return user;
}

Provider 별 속성 추출

카카오와 구글은 사용자 정보를 담는 JSON 구조가 다르므로 이걸 통일된 인터페이스로 추상화 했다.

1
2
3
4
5
public interface OAuth2Attribute {
    String getProviderUserId();
    String getEmail();
    String getNickname();
}
1
2
3
4
5
6
7
8
9
public class OAuth2AttributeFactory {
    public static OAuth2Attribute of(String providerId, Map<String, Object> attributes) {
        return switch (providerId) {
            case "kakao" -> new KakaoOAuth2Attribute(attributes);
            case "google" -> new GoogleOAuth2Attribute(attributes);
            default -> throw new IllegalArgumentException("Unsupported OAuth2 provider: " + providerId);
        };
    }
}
  • 팩토리 패턴으로 provider에 따라 조건에 맞는 구현체 반환
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RequiredArgsConstructor
public class KakaoOAuth2Attribute implements OAuth2Attribute{
    private final Map<String, Object> attributes;

    @Override
    public String getProviderUserId() {
        return String.valueOf(attributes.get("id"));
    }

    @Override
    public String getEmail() {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
        return (String) profile.get("email");
    }

    @Override
    public String getNickname() {
        Map<String, Object> profile = (Map<String, Object>) attributes.get("profile");
        return (String) profile.get("nickname");
    }
}
  • 카카오는 중첩된 구조에서 정보를 꺼내야 한다.
  • 새로운 provider를 추가할 땐 팩토리에 case 하나만 추가하면 된다.

CustomOAuth2UserService 구현

OAuth2 로그인 성공 후 사용자 정보를 처리하는 서비스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest); // 부모 클래스로 유저 정보 가져오기
        String providerId = getProviderId(userRequest); //provider 구분 (kakao/google)
        log.info("OAuth2User 로그인 시도: {}, 플랫폼: {}" + oAuth2User.getAttributes() + providerId);

        //provider별 데이터 추출
        OAuth2Attribute oAuth2Attribute = OAuth2AttributeFactory.of(providerId, oAuth2User.getAttributes());

        //DB 조회 또는 신규 생성
        return getOrCreateOAuth2User(
                providerId,
                oAuth2Attribute.getProviderUserId(),
                oAuth2Attribute.getEmail(),
                oAuth2Attribute.getNickname(),
                oAuth2User
        );
    }

    private String getProviderId(OAuth2UserRequest userRequest) {
        return userRequest.getClientRegistration().getRegistrationId();
    }

    private CustomOAuth2User getOrCreateOAuth2User(
            String providerId,
            String providerUserId,
            String email,
            String nickname,
            OAuth2User oAuth2User
    ) {
        //DB에서 유저 조회 또는 생성
        //providerId(kakao, google)와 providerUserId를 조합해야 고유하게 식별 가능 (복합키)
        User user = userRepository.findBySocialAuthInfo_ProviderIdAndSocialAuthInfo_ProviderUserId(providerId,providerUserId)
                .orElseGet(() -> {
                    //회원가입
                    log.info("새로운 소셜 회원 가입: provider={}, email={}", providerId, email);

                    SocialAuthInfo socialAuthInfo = new SocialAuthInfo(
                            providerId,
                            providerUserId
                    );
                    User newUser = User.createFromSocialAuth(
                            socialAuthInfo,
                            new Email(email),
                            new Nickname(nickname)
                    );
                    return userRepository.save(newUser);
                });
        return new CustomOAuth2User(user, oAuth2User.getAttributes());
    }
}

super.loadUser()가 실제로 provider에 HTTP 요청을 보내 사용자 정보를 가져온다. 나머지는 그 정보를 가공해서 DB와 동기화

CustomOAuth2User 구현

OAuth2User와 UserDetails를 동시해 구현해 소셜 로그인과 일반 로그인을 통합

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User, UserDetails, Serializable {
    @Getter
    private final User user;
    
    // Redis 직렬화에서 제외 (매 요청마다 새로 받아올 수 있음)
    private transient final Map<String, Object> attributes;
    private static final long serialVersionUID = 1L;

    @Override
    public String getPassword() {
        if (user.getPassword() == null) {
            return "";
        }
        return "{bcrypt}" + user.getPassword().getEncryptedValue();
    }

    @Override
    public String getUsername() {
        return user.getUsername() != null
            ? user.getUsername().getValue()
            : user.getEmail().getValue();
    }

    @Override
    public boolean isEnabled() {
        return user.getStatus() == UserStatus.ACTIVE;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singletonList(
            new SimpleGrantedAuthority("ROLE_USER"));
    }
    
    // ... 나머지 메서드
}
  • transient 키워드로 attributtes를 직렬화에서 제외 (Redis 세션에 불필요한 데이터가 저장되는 걸 방지)
  • 소셜 로그인 사용자는 password가 null이므로 빈 문자열 반환
  • username 도 null 일 수 있어서 email로 폴백

설정

build.gradle.kts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_CLIENT_SECRET}
            redirect-uri: http://localhost:3000/login/oauth2/code/kakao
            authorization-grant-type: authorization_code
            scope: profile_nickname, account_email
            client-authentication-method: client_secret_post
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            redirect-uri: http://localhost:3000/login/oauth2/code/google
            scope: profile, email
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
  • redirect-uri 패턴 http://localhost:3000/login/oauth2/code/{provider}는 Spring OAuth2가 자동으로 처리 즉 별도 컨트롤러가 필요 없다.

SecurityConfig

1
2
3
4
5
6
7
8
9
10
11
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ... 기타 설정
        .oauth2Login(oauth2 -> oauth2
            .loginPage("/login")
            .defaultSuccessUrl("/", false)
            .userInfoEndpoint(userInfo -> 
                userInfo.userService(customOAuth2UserService)));
    return http.build();
}

마무리

이번 구현을 통해 OAuth2의 Authorization Code Flow를 직접 다뤄보면서, “인증”과 “인가”의 차이가 왜 중요한지 체감할 수 있었다. Spring Security가 많은 부분을 추상화해주지만, 내부 동작을 이해하고 있어야 디버깅이나 커스터마이징이 수월해질 것 같다. 그리고 응답 구조가 달라 파싱 로직을 통일하는게 생각보다 손이 많이 갔다.

다음으로는 Refresh Token 관리와 토큰 만료 시 재인증 흐름을 좀 더 다듬어볼 예정이다.

This post is licensed under CC BY 4.0 by the author.