[Spring Boot] Spring Security로 소셜 로그인 구현하기 (카카오, 구글)

왔다 정보리·2026년 4월 18일

이번에 진행한 프로젝트가 웹 기반이다 보니, 전부터 한 번 써보고 싶었던 Spring Security를 통해 소셜 로그인을 구현할 수 있는 기회가 생겼다. Spring Security로 해보는 건 처음이었는데, 구현할 당시에는 개발 자체보다 프론트 측에 어떤 형식으로 데이터를 넘겨줄지에 대한 이야기를 더 많이 나눴던 것 같다. 사용자에 따라 소셜 로그인 후에 분기 처리를 하는 과정이 가장 고민이 많이 됐다.


OAuth2 Authorization Code Flow


Spring Security OAuth2 Client는 표준 Authorization Code Flow를 따른다. 전체 흐름은 다음과 같다.

  1. 사용자가 /oauth2/authorization/{provider}로 접근하면 Spring이 해당 Provider의 인증 페이지로 리다이렉트한다
  2. 사용자가 동의하면 Provider는 redirect-uri인가 코드(authorization code)를 전달한다
  3. Spring이 이 코드를 다시 Provider에게 보내 Access Token으로 교환한다
  4. Access Token으로 Provider의 사용자 정보 API를 호출해 프로필 정보를 가져온다
  5. OAuth2UserService에서 이 정보를 기반으로 내부 회원 정보와 매핑한다
  6. 인증이 완료되면 SuccessHandler가 호출되어 후처리를 진행한다

이 중 대부분은 Spring이 자동으로 처리해주고, 우리가 직접 커스터마이징해야 하는 부분은 5번 사용자 정보 매핑과 6번 인증 완료 후처리 정도다.


기본 설정


1. 카카오 로그인 설정 (Kakao Developers)

Kakao Developers에서 애플리케이션을 생성한 뒤 다음 설정을 진행한다.

1) 웹 도메인 등록

웹 도메인 등록

앱 → 제품 링크 관리 → 웹 도메인에서 서비스 도메인을 등록한다. 로컬, 개발 서버, 운영 서버 세 개의 도메인을 모두 등록해두면 환경별로 번거로운 설정 변경이 없다.

2) 카카오 로그인 활성화

카카오 로그인 활성화

제품 설정 → 카카오 로그인 → 일반에서 카카오 로그인을 활성화한다.

3) 동의 항목 설정

동의 항목 설정

제품 설정 → 카카오 로그인 → 동의 항목에서 받고 싶은 사용자 정보를 설정한다. 이때 이메일을 받으려면 비즈 앱 전환이 필요한데, 비즈니스 정보 심사를 완료하면 추가 기능 신청이 가능하다.

4) Client ID / Secret 확인

플랫폼 키

앱 → 플랫폼 키에서 확인한다.

  • client-id : REST API 키
  • client-secret : REST API 키 세부 정보에 들어가면 Client Secret 코드를 확인할 수 있다

5) Redirect URI 등록

Redirect URI 등록

마찬가지로 로컬, 개발, 운영 서버 세 개의 URI를 모두 등록한다. 형식은 {도메인}/login/oauth2/code/kakao로, Spring Security OAuth2 Client의 기본 경로를 따른다.


2. 구글 로그인 설정 (Google Cloud Console)

Google Cloud Console에서 프로젝트를 생성한 뒤 다음 설정을 진행한다.

1) OAuth 동의 화면 설정

API 및 서비스 → OAuth 동의 화면으로 이동해 앱 정보를 등록한다.

2) OAuth 클라이언트 ID 생성

OAuth 클라이언트 ID 생성

클라이언트 → OAuth 클라이언트 ID 생성에서 웹 애플리케이션 유형으로 클라이언트를 생성한다.

3) 승인된 리디렉션 URI 등록

승인된 리디렉션 URI 등록

카카오와 마찬가지로 {도메인}/login/oauth2/code/google 형식으로 등록한다.

4) Client ID / Secret 확인

  • client-id : 클라이언트 ID
    클라이언트 ID
  • client-secret : 클라이언트 보안 비밀번호
    클라이언트 보안 비밀번호

Spring Boot 구현


1. 환경 변수 설정

발급받은 값들은 외부에 노출되면 안 되므로 환경 변수로 관리한다.

# 카카오
KAKAO_CLIENT_ID=카카오_REST_API_키
KAKAO_CLIENT_SECRET=카카오_Client_Secret_코드

# 구글
GOOGLE_CLIENT_ID=구글_클라이언트_ID
GOOGLE_CLIENT_SECRET=구글_클라이언트_보안_비밀번호

2. application-oauth.yml 설정

Spring Security OAuth2 Client의 설정 파일이다.

spring:
  config:
    activate:
      on-profile: local
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_CLIENT_SECRET}
            client-name: Kakao
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8090/login/oauth2/code/kakao
            client-authentication-method: client_secret_post
            scope:
              - account_email
              - profile_nickname
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            client-name: Google
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8090/login/oauth2/code/google
            client-authentication-method: client_secret_post
            scope:
              - email
              - profile
        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
          google:
            user-name-attribute: sub

success-url: http://localhost:8090/auth/callback
failure-url: http://localhost:8090/auth/error

각 설정의 역할은 다음과 같다.

  • client-authentication-method: client_secret_post : 토큰 요청 시 client_secret을 요청 바디에 포함하는 방식이다. 카카오는 이 방식만 지원하기 때문에 명시적으로 지정해야 한다.
  • provider.kakao.* : 카카오는 Spring이 기본 제공하는 Provider가 아니기 때문에 authorization-uri, token-uri, user-info-uri를 직접 지정해야 한다. 반면 구글은 기본 Provider로 등록되어 있어서 user-name-attribute만 지정하면 된다.
  • user-name-attribute : Provider가 반환하는 사용자 식별자의 JSON 키를 의미한다. 카카오는 id, 구글은 sub로 서로 다르다.
  • success-url / failure-url : 인증 결과를 프론트엔드로 전달할 리다이렉트 URL이다.

3. SecurityConfig 설정

Spring Security의 OAuth2 로그인을 활성화하고 커스텀 핸들러를 등록한다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final OAuth2UserService oAuth2UserService;
    private final JwtService jwtService;

    @Value("${success-url}")
    private String oauth2SuccessUrl;

    @Value("${failure-url}")
    private String oauth2FailureUrl;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.oauth2Login(oauth2 -> oauth2
                    .userInfoEndpoint(userInfo -> userInfo
                            .userService(oAuth2UserService))
                    .successHandler(successHandler())
                    .failureHandler(failureHandler())
                )
                .csrf(AbstractHttpConfigurer::disable)
                .cors(withDefaults());

        return http.build();
    }
}

userInfoEndpoint에 커스텀 OAuth2UserService를 등록하면, Provider로부터 사용자 정보를 받아온 뒤 내부 로직을 태울 수 있다. REST API 서버이므로 CSRF는 비활성화했다.


4. OAuth2UserService - 회원 상태별 분기 처리

DefaultOAuth2UserService를 상속받아 Provider로부터 받은 사용자 정보를 내부 회원 정보와 매핑한다. 이 부분은 프로젝트의 비즈니스 로직에 따라 커스터마이징해서 진행하면 되고, Provider로부터 사용자 정보를 받는 부분 정도만 참고하면 된다.

@Service
@RequiredArgsConstructor
public class OAuth2UserService extends DefaultOAuth2UserService {
    private final MemberQueryService memberQueryService;
    private final MemberService memberService;
    private final JwtService jwtService;

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        // 1. Provider 구분 (kakao / google)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        Constant.SocialType socialType = Constant.SocialType.getSocialType(registrationId);

        // 2. Provider별 사용자 정보 파싱
        Map<String, Object> attributes = oAuth2User.getAttributes();
        OAuth2AccountInfo oAuth2AccountInfo;
        if (socialType == KAKAO) oAuth2AccountInfo = OAuth2AccountInfo.fromKakao(attributes);
        else if (socialType == GOOGLE) oAuth2AccountInfo = OAuth2AccountInfo.fromGoogle(attributes);
        else throw new RuntimeException("LOGIN FAIL");

        // 3. 기존 회원 조회
        String id = oAuth2AccountInfo.getId();
        Member member = memberQueryService.findMemberBySocialIdAndSocialType(id, socialType, Status.ACTIVE);

        Map<String, Object> newAttributes = new HashMap<>(attributes);

        if (member == null) {
            // 신규 회원 → 임시 멤버 생성 (NOT_JOINED)
            OAuth2Account account = oAuth2AccountInfo.getOAuth2Account();
            member = memberService.createSocialMember(account.getEmail(), account.getName(), id, socialType);
            newAttributes.put("userCode", id);

        } else if (member.getJoinStatus() == Constant.JoinStatus.NOT_JOINED) {
            // 회원가입 미완료 → userCode만 전달
            newAttributes.put("userCode", id);

        } else {
            // 기존 회원 (JOINED) → JWT 발급
            String refreshToken = jwtService.createRefreshToken(member.getId());
            newAttributes.put("accessToken", jwtService.createAccessToken(member.getId()));
            newAttributes.put("refreshToken", refreshToken);
            memberService.saveRefreshToken(member, refreshToken);
        }

        newAttributes.put("memberStatus", member.getJoinStatus());

        BoardPrincipal principal = BoardPrincipal.from(member, newAttributes);
        String nameAttributeKey = registrationId.equals("google") ? "sub" : "id";

        return new DefaultOAuth2User(principal.getAuthorities(), principal.getAttributes(), nameAttributeKey);
    }
}

회원 상태를 세 가지로 나눠 분기했다.

  • 신규 회원 : 소셜 계정으로 처음 접근한 경우
    기본 정보(이메일, 이름)만 가진 임시 회원을 NOT_JOINED 상태로 생성하고 userCode를 전달한다.
  • NOT_JOINED : 이전에 소셜 인증은 거쳤지만 회원가입 양식을 완료하지 않은 경우
    추가 정보 입력이 필요하므로 JWT 없이 userCode만 전달한다.
  • JOINED : 가입이 완료된 기존 회원
    Access Token과 Refresh Token을 발급하고 DB에 Refresh Token을 저장한다.

SuccessHandler에서 사용할 데이터를 newAttributes에 미리 담아두었다. Spring Security 내부적으로 OAuth2Userattributes는 이후 단계에서도 계속 참조 가능하기 때문에, 이 방식으로 상태를 넘기면 별도의 세션 저장소 없이 깔끔하게 처리할 수 있다.


5. Provider별 데이터 파싱

카카오와 구글은 사용자 정보 응답의 JSON 구조가 다르다. Provider별 파싱 로직을 분리해서 이후 로직은 동일한 객체(OAuth2AccountInfo, OAuth2Account)로 다루도록 추상화했다.

1) OAuth2AccountInfo : 소셜 고유 ID와 계정 정보를 담는다

public class OAuth2AccountInfo {
    private String id;
    private OAuth2Account oAuth2Account;

    public static OAuth2AccountInfo fromKakao(Map<String, Object> attributes) {
        return OAuth2AccountInfo.builder()
                .id(String.valueOf(attributes.get("id")))
                .oAuth2Account(OAuth2Account.fromKakao(
                    (Map<String, Object>) attributes.get("kakao_account")))
                .build();
    }

    public static OAuth2AccountInfo fromGoogle(Map<String, Object> attributes) {
        return OAuth2AccountInfo.builder()
                .id(String.valueOf(attributes.get("sub")))
                .oAuth2Account(OAuth2Account.fromGoogle(attributes))
                .build();
    }
}

2) OAuth2Account : 이름, 이메일을 담는다

public class OAuth2Account {
    private String name;
    private String email;

    public static OAuth2Account fromKakao(Map<String, Object> attributes) {
        return OAuth2Account.builder()
                .name(getName((Map<String, Object>) attributes.get("profile")))
                .email((String) attributes.get("email"))
                .build();
    }

    public static OAuth2Account fromGoogle(Map<String, Object> attributes) {
        return OAuth2Account.builder()
                .name(String.valueOf(attributes.get("name")))
                .email(String.valueOf(attributes.get("email")))
                .build();
    }
}

구글은 name, email, sub가 모두 최상위에 있지만, 카카오는 kakao_account 객체 안에 email이 있고 kakao_account.profile 안에 닉네임이 있다.


6. BoardPrincipal - UserDetails + OAuth2User 통합

Spring Security는 로그인 방식에 따라 인증 주체(Principal)의 타입이 다르다. 일반 로그인은 UserDetails, 소셜 로그인은 OAuth2User를 사용한다. 두 인터페이스를 따로 구현하면 로그인 방식에 따라 Principal 타입을 분기해야 하는 번거로움이 생긴다.

public class BoardPrincipal implements UserDetails, OAuth2User {
    private String email;
    private String pw;
    private Collection<? extends GrantedAuthority> authorities;
    private String nickname;
    private String profileImgUrl;
    private Map<String, Object> oAuth2Attributes;

    // 일반 회원용
    public static BoardPrincipal from(Member member) { ... }

    // OAuth2 회원용 (attributes 포함)
    public static BoardPrincipal from(Member member, Map<String, Object> oAuth2Attributes) { ... }

    // Spring Security 필수 메서드
    @Override public String getUsername() { return email; }
    @Override public String getPassword() { return pw; }

    // OAuth2 필수 메서드
    @Override public Map<String, Object> getAttributes() { return oAuth2Attributes; }
    @Override public String getName() { return email; }
}

BoardPrincipal 하나에서 두 인터페이스를 모두 구현함으로써, 컨트롤러나 서비스 계층에서는 로그인 방식과 무관하게 동일한 타입으로 인증 주체를 다룰 수 있다.


7. SuccessHandler / FailureHandler - 인증 결과 처리

인증이 완료되면 SuccessHandler에서 회원 상태에 따라 프론트엔드로 다른 정보를 전달한다.

@Bean
public AuthenticationSuccessHandler successHandler() {
    return (request, response, authentication) -> {
        DefaultOAuth2User user = (DefaultOAuth2User) authentication.getPrincipal();
        Map<String, Object> attributes = user.getAttributes();

        Constant.JoinStatus joinStatus = Constant.JoinStatus.valueOf(
            String.valueOf(attributes.get("memberStatus")));

        String targetUrl;
        if (joinStatus == Constant.JoinStatus.NOT_JOINED) {
            // 회원가입 미완료 → userCode 전달 (프론트에서 추가 정보 입력 폼으로 이동)
            String userCode = attributes.get("userCode").toString();
            targetUrl = UriComponentsBuilder.fromUriString(oauth2SuccessUrl)
                    .queryParam("userCode", userCode)
                    .build().toUriString();
        } else {
            // 기존 회원 → JWT 쿠키 설정 + 토큰 쿼리 파라미터 전달
            String accessToken = attributes.get("accessToken").toString();
            String refreshToken = attributes.get("refreshToken").toString();

            response.addHeader(HttpHeaders.SET_COOKIE,
                    jwtService.createAccessTokenCookie(accessToken).toString());
            response.addHeader(HttpHeaders.SET_COOKIE,
                    jwtService.createRefreshTokenCookie(refreshToken).toString());

            targetUrl = UriComponentsBuilder.fromUriString(oauth2SuccessUrl)
                    .queryParam("accessToken", accessToken)
                    .queryParam("refreshToken", refreshToken)
                    .build().toUriString();
        }

        response.sendRedirect(targetUrl);
    };
}
  • NOT_JOINED : {success-url}?userCode={socialId} 형태로 리다이렉트한다. 프론트에서는 이 userCode를 가지고 닉네임 등 추가 정보 입력 폼으로 사용자를 유도하고, 회원가입을 완료한다.
  • JOINED : {success-url}?accessToken={token}&refreshToken={token} 형태로 리다이렉트하면서 쿠키에도 JWT를 동시에 설정한다. 쿼리 파라미터와 쿠키 둘 다 설정한 이유는 프론트엔드 구현 방식에 따라 선택해서 쓸 수 있도록 하기 위함이다.

인증 실패 시에는 단순히 failure-url로 리다이렉트한다.


마치며


Spring Security의 OAuth2 Client는 표준 플로우의 대부분을 자동으로 처리해주기 때문에, 우리가 집중해야 할 부분은 Provider에서 회원 정보를 받아와서 비즈니스 로직에 맞게 처리하는 정도다.

또한 틀이 정해져있기 때문에 소셜 로그인을 한 번 구현해두면 확장이 쉽다. 실제로 카카오를 먼저 구현하고 구글을 나중에 구현했는데, 설정 추가 및 구글에서 제공하는 회원 정보에 맞게 구조를 수정하는 작업 정도만 진행하면 됐다. 카카오를 구현했을 때보다 시간이 훨씬 단축된 걸 보고 Spring Security로 구현해두길 잘했다는 생각이 들었다. 나중에 다른 소셜 로그인 방식이 추가되어도 큰 어려움 없이 확장할 수 있겠다.


참고자료


profile
왔다 정보리

0개의 댓글