OAuth2로 구글로그인 구현하기

SIHA·2026년 4월 15일

Spring Security OAuth2 구글 로그인 구현하기

현재 프로젝트에서 구글 로그인과 일반 로그인을 같이 사용하려고 한다.
또, 해당 프로젝트에서는 Access Token + Refresh Token의 JWT 인증 방식을 사용하고 있어, 이에 맞춰야 했다.


Google 로그인 흐름

  1. 클라이언트가 구글에 로그인을 요청한다.
  2. 구글이 인가 코드를 반환한다.
  3. 클라이언트가 인가 코드를 서버에 전달한다.
    • 서버가 직접 구글에 Access Token을 요청하기 위함이다. 브라우저에 Access Token을 직접 노출하지 않아 보안상 안전하다.
  4. 인가 코드를 전달받은 서버가 인가 코드와 함께 Access Token을 요청한다.
  5. 구글은 서버가 안전하다고 판단할 시 Access Token을 반환한다.
  6. 서버가 Access Token으로 사용자 정보를 요청한다.
  7. 구글이 사용자 정보를 반환한다. (email, name, sub 등)
  8. 이를 기반으로 회원을 등록하거나 조회하고 JWT 로직을 실행한다.
  9. Access Token과 Refresh Token과 함께 클라이언트로 리다이렉트한다.

참고: 4~7번 과정은 Spring Security가 자동으로 처리한다.
CustomOAuth2UserService.loadUser()가 호출될 때 이미 완료된 상태이다.


엔티티

User

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "users")
public class User extends Timestamped {

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

    @Column(nullable = false, length = 50)
    private String nickname;

    @Column(nullable = false, unique = true, length = 100)
    private String email;

    @Column
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @Column(nullable = false)
    private Integer streakDays = 0;

    @Column
    private LocalDate lastStudiedAt;

    @Builder
    public User(String nickname, String email, String password, Role role) {
        this.nickname = nickname;
        this.email = email;
        this.password = password;
        this.role = role;
    }

    public void updateNickname(String nickname) { this.nickname = nickname; }
}

OAuthAccount

@Entity
@Table(name = "oauth_accounts",
        uniqueConstraints = @UniqueConstraint(columnNames = {"provider", "provider_id"}))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OAuthAccount {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(nullable = false, length = 20)
    private String provider;

    @Column(name = "provider_id", nullable = false, length = 1000)
    private String providerId;

    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Builder
    public OAuthAccount(User user, String provider, String providerId) {
        this.user = user;
        this.provider = provider;
        this.providerId = providerId;
    }
}

User 엔티티에 providerproviderId를 nullable로 넣지 않고, OAuthAccount로 분리한 이유는 확장성 때문이다.
한 유저가 여러 OAuth 계정(구글, 카카오 등)을 연결할 수 있도록 고려했고, 일반 로그인과 확실히 구분하기 위해 이러한 선택을 했다.


CustomOAuth2User

인증된 사용자 정보(principal)를 담는 객체이다. SecurityContextAuthentication에서 principal로 사용된다.

이 프로젝트는 구글 로그인과 일반 로그인을 모두 지원하기 때문에, 두 방식 모두 동일한 principal 타입을 사용해 일관성을 유지했다.

@Getter
public class CustomOAuth2User implements OAuth2User {

    private final User user;
    private final Map<String, Object> attributes;

    public CustomOAuth2User(User user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
    }

    @Override
    public String getName() {
        return String.valueOf(user.getId());
    }
}
  • user: 우리 서비스 DB에 저장된 사용자 정보
  • attributes: 구글로부터 받은 원본 사용자 정보 (OAuth2 로그인 시 채워짐, JWT 인증 시 빈 Map)

CustomOAuth2UserService

DefaultOAuth2UserService를 상속받아 구글 로그인 시 사용자 정보를 처리하는 서비스이다.

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    private final OAuthAccountRepository oAuthAccountRepository;

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

        String provider = userRequest.getClientRegistration().getRegistrationId();
        Map<String, Object> attributes = oAuth2User.getAttributes();

        String providerId = (String) attributes.get("sub");
        String email = (String) attributes.get("email");
        String nickname = (String) attributes.get("name");

        User user = oAuthAccountRepository.findByProviderAndProviderId(provider, providerId)
                .map(OAuthAccount::getUser)
                .orElseGet(() -> registerNewUser(email, nickname, provider, providerId));

        return new CustomOAuth2User(user, attributes);
    }

    private User registerNewUser(String email, String nickname, String provider, String providerId) {
        User user = userRepository.findByEmail(email)
                .orElseGet(() -> userRepository.save(
                        User.builder()
                                .email(email)
                                .nickname(nickname)
                                .role(Role.USER)
                                .build()
                ));

        oAuthAccountRepository.save(OAuthAccount.builder()
                .user(user)
                .provider(provider)
                .providerId(providerId)
                .build());

        return user;
    }
}

super.loadUser()가 하는 일

super.loadUser()DefaultOAuth2UserService의 구현으로, 내부적으로 다음을 수행한다.

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    // ...
    OAuth2AccessToken token = userRequest.getAccessToken();
    Map<String, Object> attributes = this.attributesConverter.convert(userRequest).convert(response.getBody());
    // ...
    return new DefaultOAuth2User(authorities, attributes, userNameAttributeName);
}

userRequest 안에 구글 Access Token이 담겨 있고, 이를 이용해 구글 사용자 정보 API를 호출한다.
즉, super.loadUser() 한 줄로 구글 API 호출과 사용자 정보 파싱이 완료된다.


OAuth2SuccessHandler

구글 로그인이 성공했을 때 동작하는 핸들러이다.
CustomOAuth2UserService.loadUser()가 성공적으로 완료된 후 호출되며, JWT를 발급하고 클라이언트로 리다이렉트한다.

@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtProvider jwtProvider;
    private final RefreshTokenService refreshTokenService;

    @Value("${app.oauth2.redirect-uri}")
    private String redirectUri;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();
        Long userId = oAuth2User.getUser().getId();

        String accessToken = jwtProvider.generateAccessToken(userId);
        String refreshToken = jwtProvider.generateRefreshToken(userId);

        refreshTokenService.save(userId, refreshToken);

        String targetUrl = UriComponentsBuilder.fromUriString(redirectUri)
                .queryParam("accessToken", accessToken)
                .queryParam("refreshToken", refreshToken)
                .build().toUriString();

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

authentication.getPrincipal()CustomOAuth2User를 꺼내 userId를 가져온다.
이후 JWT를 발급하고, Refresh Token은 Redis에 저장한 뒤 클라이언트로 리다이렉트한다.

profile
뭐라도 해보자

0개의 댓글