OAuth2로 최초 인증을 받고, 이후 API 호출에는 JWT 토큰을 사용함으로써 두 방식의 장점을 모두 활용할 수 있다.
src/main/java/com/example/fitpass/
├── common/
│ ├── security/
│ │ └── SecurityConfig.java # 보안 설정
│ ├── oAuth2/
│ │ ├── CustomOAuth2UserService.java # OAuth2 사용자 정보 처리
│ │ ├── OAuth2SuccessHandler.java # 로그인 성공 핸들러
│ │ ├── OAuthAttributes.java # OAuth2 데이터 변환
│ │ └── CustomOAuth2User.java # OAuth2User 구현체
│ └── jwt/
│ └── JwtTokenProvider.java # JWT 토큰 생성/검증
└── domain/user/
└── entity/User.java # 사용자 엔티티
# Google OAuth2 클라이언트 설정
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=profile,email
spring.security.oauth2.client.registration.google.redirect-uri=http://localhost:8080/login/oauth2/code/google
# Google OAuth2 제공자 설정
spring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/auth
spring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token
spring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo
spring.security.oauth2.client.provider.google.user-name-attribute=sub
# JWT 설정
jwt.secret=${SECRET_KEY}
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final RedisService redisService;
private final CustomUserDetailsService customUserDetailsService;
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/auth/**",
"/oauth2/**",
"/login/**"
).permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
// JWT 필터 추가
.addFilterBefore(
new JwtAuthenticationFilter(jwtTokenProvider, redisService, customUserDetailsService),
UsernamePasswordAuthenticationFilter.class
)
// OAuth2 로그인 설정
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService) // 커스텀 사용자 서비스
)
.successHandler(oAuth2SuccessHandler) // 로그인 성공 핸들러
.failureUrl("/login?error=oauth2_failed") // 실패 URL
)
.build();
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 기본 OAuth2UserService로 사용자 정보 가져오기
OAuth2User oauth2User = super.loadUser(userRequest);
// 제공자 정보 추출 (google, kakao 등)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// 사용자 식별 키 (Google의 경우 'sub')
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
log.info("OAuth2 로그인 시도: registrationId = {}", registrationId);
// OAuth2 사용자 정보 추출 및 변환
OAuthAttributes attributes = OAuthAttributes.of(
registrationId,
userNameAttributeName,
oauth2User.getAttributes()
);
// 사용자 저장 또는 업데이트
User user = saveOrUpdate(attributes);
// CustomOAuth2User 반환 (Spring Security가 인식할 수 있도록)
return new CustomOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_" + user.getUserRole().name())),
attributes.getAttributes(),
attributes.getNameAttributeKey(),
user
);
}
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(existingUser -> {
log.info("기존 사용자 발견: {}", existingUser.getEmail());
// 기존 사용자 정보 업데이트 로직 (필요시)
return existingUser;
})
.orElseGet(() -> {
log.info("새로운 OAuth2 사용자 생성: {}", attributes.getEmail());
// 새 사용자면 생성
return attributes.toEntity();
});
return userRepository.save(user);
}
}
@Getter
@RequiredArgsConstructor
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
// 생성자
public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey,
String name, String email, String picture) {
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
}
// 제공자별 데이터 변환
public static OAuthAttributes of(String registrationId, String userNameAttributeName,
Map<String, Object> attributes) {
if ("google".equals(registrationId)) {
return ofGoogle(userNameAttributeName, attributes);
}
// 다른 제공자 추가 가능 (kakao, naver 등)
throw new IllegalArgumentException("지원하지 않는 소셜 로그인입니다: " + registrationId);
}
// Google 데이터 매핑
private static OAuthAttributes ofGoogle(String userNameAttributeName,
Map<String, Object> attributes) {
return new OAuthAttributes(
attributes,
userNameAttributeName,
(String) attributes.get("name"), // 사용자 이름
(String) attributes.get("email"), // 이메일
(String) attributes.get("picture") // 프로필 이미지
);
}
// User 엔티티 생성
public User toEntity() {
return new User(
email, // 이메일
picture, // 프로필 이미지 URL
"OAUTH2_TEMP", // 임시 비밀번호 (OAuth2 사용자 구분용)
name, // 이름
"NEED_INPUT", // 전화번호 (나중에 입력받음)
-1, // 나이 (나중에 입력받음)
"NEED_INPUT", // 주소 (나중에 입력받음)
Gender.MAN, // 기본 성별 (나중에 수정 가능)
UserRole.USER // 기본 권한
);
}
}
이 클래스는 다른 OAuth2 제공자 (Kakao, Google Naver 등)을 쉽게 추가할 수 있도록 설계되었다.
예시) -> 멀티 제공자 확장 가능한 설계
public static OAuthAttributes of(String registrationId, String userNameAttributeName,
Map<String, Object> attributes) {
switch (registrationId) {
case "google":
return ofGoogle(userNameAttributeName, attributes);
case "kakao":
return ofKakao(userNameAttributeName, attributes);
case "naver":
return ofNaver(userNameAttributeName, attributes);
default:
throw new IllegalArgumentException("지원하지 않는 소셜 로그인입니다: " + registrationId);
}
}
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
// CustomOAuth2User에서 User 엔티티 추출
User user = null;
if(oAuth2User instanceof CustomOAuth2User) {
user = ((CustomOAuth2User) oAuth2User).getUser();
}
if(user == null) {
log.error("OAuth2 로그인 성공했지만 사용자 정보를 찾을 수 없습니다.");
response.sendRedirect("/login?error=user_not_found");
return;
}
// JWT 토큰 생성
String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getUserRole().name());
String refreshToken = jwtTokenProvider.createRefreshToken(user.getEmail(), user.getUserRole().name());
log.info("OAuth2 로그인 성공 : email = {}, UserRole = {}", user.getEmail(), user.getUserRole().name());
// 추가 정보 입력이 필요한지 확인
boolean needsAdditionalInfo = isAdditionalInfoNeeded(user);
// 프론트엔드로 리다이렉트 (조건부)
String targetUrl = buildTargetUrl(accessToken, refreshToken, needsAdditionalInfo);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
// 추가 정보 필요 여부 판단
private boolean isAdditionalInfoNeeded(User user) {
return "NEED_INPUT".equals(user.getPhone()) ||
user.getAge() == -1 ||
"NEED_INPUT".equals(user.getAddress());
}
// 리다이렉트 URL 생성
private String buildTargetUrl(String accessToken, String refreshToken, boolean needsAdditionalInfo) {
if (needsAdditionalInfo) {
// 추가 정보 입력 페이지로 리다이렉트
return UriComponentsBuilder.fromUriString("http://localhost:3000/auth/additional-info")
.queryParam("accessToken", accessToken)
.queryParam("refreshToken", refreshToken)
.queryParam("needsInfo", "true")
.build().toUriString();
} else {
// 메인 페이지로 리다이렉트
return UriComponentsBuilder.fromUriString("http://localhost:3000/oauth/callback")
.queryParam("accessToken", accessToken)
.queryParam("refreshToken", refreshToken)
.build().toUriString();
}
}
}
@Getter
@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User {
private final Collection<? extends GrantedAuthority> authorities; // 사용자 권한
private final Map<String, Object> attributes; // OAuth2 원본 데이터
private final String nameAttributeKey; // 사용자 식별 키
private final User user; // 실제 User 엔티티
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getName() {
return attributes.get(nameAttributeKey).toString();
}
}
// 역할: OAuth2 로그인 전 원래 페이지 URL을 쿠키에 저장
@Component
public class RedirectUrlCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(...) {
if (request.getRequestURI().startsWith("/oauth2/authorization")) {
String redirectUrl = request.getParameter("redirect_url");
// 쿠키에 저장하여 로그인 후 원래 페이지로 돌아가기
}
}
}
public static OAuthAttributes of(String registrationId, String userNameAttributeName,
Map<String, Object> attributes) {
switch (registrationId.toLowerCase()) {
case "google": return ofGoogle(userNameAttributeName, attributes);
case "naver": return ofNaver(userNameAttributeName, attributes);
default: throw new IllegalArgumentException("지원하지 않는 소셜 로그인: " + registrationId);
}
}
의사 결정 이유 :
public User toEntity() {
return new User(
email, // 소셜에서 가져온 확실한 정보
picture, // 프로필 이미지
"OAUTH2_TEMP", // OAuth2 사용자 식별용 임시 비밀번호
name, // 소셜에서 가져온 이름
"NEED_INPUT", // 나중에 입력받을 정보들
-1, // 미입력 상태 표시
"NEED_INPUT",
Gender.MAN, // 기본값 설정
UserRole.USER,
authProvider // 어떤 소셜 로그인인지 추적
);
}
의사 결정 이유 :
private boolean isAdditionalInfoNeeded(User user) {
return "NEED_INPUT".equals(user.getPhone()) ||
user.getAge() == -1 ||
"NEED_INPUT".equals(user.getAddress()) ||
user.getName() == null ||
user.getName().equals("NEED_INPUT");
}
private String buildAdditionalInfoUrl(String accessToken, String refreshToken) {
return UriComponentsBuilder.fromUriString(baseUrl + "/auth/additional-info")
.queryParam("accessToken", accessToken)
.queryParam("refreshToken", refreshToken)
.queryParam("needsInfo", "true")
.build().toUriString();
}
의사 결정 이유 : 사용자 상태별 맞춤형 UX 제공
private void updateUserIfNeeded(User existingUser, OAuthAttributes attributes) {
boolean needUpdate = false;
// 프로필 이미지 업데이트
if (attributes.getPicture() != null &&
!attributes.getPicture().equals(existingUser.getUserImage())) {
existingUser.updateUserImage(attributes.getPicture());
needUpdate = true;
}
// 이름 업데이트 (기존에 없거나 "NEED_INPUT"인 경우)
if (attributes.getName() != null &&
(existingUser.getName() == null ||
existingUser.getName().equals("NEED_INPUT"))) {
existingUser.updateOAuthInfo(attributes.getName(), null);
needUpdate = true;
}
}
의사 결정 이유 : 재로그인 시 최신 소셜 정보로 자동 동기화
private String getRedirectBaseUrl(HttpServletRequest request) {
// 쿠키에서 redirect_url 우선 확인
Optional<String> cookieRedirectUrl = extractFromCookie(request);
if (cookieRedirectUrl.isPresent()) {
return extractBaseUrl(cookieRedirectUrl.get());
}
// 기본값: 개발환경 URL
return LOCAL_REDIRECT_URL; // http://localhost:5173
}
의사 결정 이유 :
private String getRedirectBaseUrl(HttpServletRequest request) {
// 쿠키에서 redirect_url 우선 확인
Optional<String> cookieRedirectUrl = extractFromCookie(request);
if (cookieRedirectUrl.isPresent()) {
return extractBaseUrl(cookieRedirectUrl.get());
}
// 기본값: 개발환경 URL
return LOCAL_REDIRECT_URL; // http://localhost:5173
}
애플리케이션 유형: 웹 애플리케이션
승인된 자바스크립트 원본: http://localhost:3000
승인된 리디렉션 URI: http://localhost:8080/login/oauth2/code/google, https://localhost:8080/login/oauth2/code/google
# .env 파일 또는 시스템 환경변수
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
SECRET_KEY=your_jwt_secret_key
(다음에 구현하면 업로드 할 예정)
사용자 편의성 향상 (간편 로그인)
보안성 강화 (토큰 기반 인증)
확장성 확보 (마이크로서비스 대응)
성능 최적화 (Stateless 인증)
이 구조를 기반으로 Kakao, Naver 등 다른 OAuth2 제공자도 쉽게 추가할 수 있으며, 모바일 앱과의 연동도 간단해진다.