Spring Boot OAuth2 소셜 로그인 - Google

수민·2026년 4월 14일

[내일배움캠프] Spring_2기 96일차


1. 구글 Cloud Console 설정

🔗 Google Cloud Console

2. 의존성 추가 (build.gradle)

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

3. yml 설정

# application.yml
spring:
  profiles:
    include: oauth

# application-oauth.yml 생성
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: profile, email

4. AuthProvider enum 생성

public enum AuthProvider {
    GOOGLE, KAKAO, NAVER
}

5. User 엔티티 수정

  • password nullable
@Column
private String password;
  • 소셜 로그인용 정적 팩토리 메서드 추가
public static User ofSocial(String email, UserRole role) {
	User user = new User();
    user.email = email;
    user.password = null;
    user.role = role;
    user.rating = null;

    return user;
}

6. UserSocialAccount 엔티티 생성

@Getter
@Entity
@Table(name = "user_social_accounts", uniqueConstraints = {
        @UniqueConstraint(columnNames = {"user_id", "provider"}),
        @UniqueConstraint(columnNames = {"provider", "provider_id"})
})	// 복합 유니크 제약
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserSocialAccount {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private Long userId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private AuthProvider provider;

    @Column(nullable = false)
    private String providerId;
}

7. UserSocialAccountRepository 생성

public interface UserSocialAccountRepository extends JpaRepository<UserSocialAccount, Long> {
    Optional<UserSocialAccount> findByProviderAndProviderId(AuthProvider provider, String providerId);
}

8. OAuthAttributes 생성

  • 각 제공자별 응답을 공통 객체로 변환
@Getter
@Builder
public class OAuthAttributes {

    private String email;
    private AuthProvider provider;
    private String providerId;
    private String nameAttributeKey;

    public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .email((String) attributes.get("email"))
                .provider(AuthProvider.GOOGLE)
                .providerId((String) attributes.get("sub"))
                .nameAttributeKey(userNameAttributeName)
                .build();
    }
}

9. CustomOAuth2UserService 작성

  • 소셜 로그인 성공 후 유저 조회/생성 로직
  • 동일 이메일 일반가입 유저 존재 시 예외 반환
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final UserSocialAccountRepository userSocialAccountRepository;

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

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

        User user = saveOrLoad(authAttributes);

        Map<String, Object> principalAttributes = new HashMap<>(oAuth2User.getAttributes());
        principalAttributes.put("userId", user.getId());

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())),
                principalAttributes,
                authAttributes.getNameAttributeKey()
        );
    }

    private User saveOrLoad(OAuthAttributes authAttributes) {
        Optional<UserSocialAccount> socialAccount = userSocialAccountRepository.findByProviderAndProviderId(
                authAttributes.getProvider(), authAttributes.getProviderId());

        if (socialAccount.isPresent()) {
            return userRepository.findByIdAndDeletedFalse(socialAccount.get().getUserId()).orElseThrow(
                    () -> new ServiceErrorException(UserErrorEnum.USER_NOT_FOUND));
        }

        if (userRepository.existsByEmail(authAttributes.getEmail())) {
            throw new ServiceErrorException(AuthErrorEnum.SOCIAL_LOGIN_EMAIL_CONFLICT);
        }

        try {
            User newUser = User.ofSocial(authAttributes.getEmail(), UserRole.USER);
            userRepository.save(newUser);

            UserSocialAccount newSocialAccount = UserSocialAccount.of(newUser.getId(), authAttributes.getProvider(), authAttributes.getProviderId());
            userSocialAccountRepository.save(newSocialAccount);

            return newUser;
        } catch (DataIntegrityViolationException e) {
            Optional<UserSocialAccount> existing = userSocialAccountRepository.findByProviderAndProviderId(
                    authAttributes.getProvider(), authAttributes.getProviderId());

            if (existing.isPresent()) {
                return userRepository.findByIdAndDeletedFalse(existing.get().getUserId()).orElseThrow(
                        () -> new ServiceErrorException(UserErrorEnum.USER_NOT_FOUND));
            }

            if (userRepository.existsByEmail(authAttributes.getEmail())) {
                throw new ServiceErrorException(AuthErrorEnum.SOCIAL_LOGIN_EMAIL_CONFLICT);
            }

            throw e;
        }
    }
}
  • 흐름 정리
loadUser() 호출
    ↓
구글 응답 → OAuthAttributes 변환
    ↓
providerId로 기존 소셜 유저 조회
    ├── 있으면 → 그냥 로그인
    └── 없으면
         ├── 동일 이메일 일반가입 유저 있으면 → 예외
         └── 없으면 → 신규 유저 + 소셜 계정 생성

10. OAuth2SuccessHandler 작성

  • 인증 성공 후 JWT 발급
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtProvider jwtProvider;
    private final UserRepository userRepository;
    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper;

    private static final String REFRESH_TOKEN_PREFIX = "refresh:";

    @Value("${jwt.refreshExpire}")
    private long refreshTokenExpireTime;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();

        Number userId = oAuth2User.getAttribute("userId");
        User user = userRepository.findByIdAndDeletedFalse(userId.longValue()).orElseThrow(
                () -> new ServiceErrorException(UserErrorEnum.USER_NOT_FOUND));

        String accessToken = jwtProvider.createAccessToken(user.getId(), user.getRole().name());
        String refreshToken = jwtProvider.createRefreshToken(user.getId());

        redisTemplate.opsForValue().set(
                REFRESH_TOKEN_PREFIX + user.getId(),
                refreshToken,
                refreshTokenExpireTime,
                TimeUnit.MILLISECONDS
        );

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(
                BaseResponse.success(HttpStatus.OK.name(), "소셜 로그인 성공", new AuthLoginResponse(accessToken, refreshToken))));
    }
}
  • 흐름 정리
소셜 로그인 성공
    ↓
OAuth2SuccessHandler.onAuthenticationSuccess() 호출
    ↓
authentication에서 이메일 꺼내기
    ↓
DB에서 유저 조회
      ↓
JWT 발급 + Redis에 refreshToken 저장
    ↓
응답으로 accessToken, refreshToken 반환

11. SecurityConfig 수정

  • oauth2Login() 설정 추가
.oauth2Login(oauth2 -> oauth2
	.userInfoEndpoint(endpoint -> endpoint
		.userService(customOAuth2UserService))
    .successHandler(oAuth2SuccessHandler))



Spring Security OAuth2 공식 문서
참고 블로그

0개의 댓글