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

email, name, sub 등)참고: 4~7번 과정은 Spring Security가 자동으로 처리한다.
CustomOAuth2UserService.loadUser()가 호출될 때 이미 완료된 상태이다.
@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; }
}
@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 엔티티에 provider와 providerId를 nullable로 넣지 않고, OAuthAccount로 분리한 이유는 확장성 때문이다.
한 유저가 여러 OAuth 계정(구글, 카카오 등)을 연결할 수 있도록 고려했고, 일반 로그인과 확실히 구분하기 위해 이러한 선택을 했다.
인증된 사용자 정보(principal)를 담는 객체이다. SecurityContext의 Authentication에서 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)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()는 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 호출과 사용자 정보 파싱이 완료된다.
구글 로그인이 성공했을 때 동작하는 핸들러이다.
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에 저장한 뒤 클라이언트로 리다이렉트한다.