[FitPass] OAuth2 소셜로그인 기능 구현 (Google)

김현정·2025년 6월 16일
0

Spring Boot OAuth2 + JWT 통합 인증 시스템 구현하기

OAuth2의 장점

  • 사용자 편의성: 별도 회원가입 없이 Google, Kakao 등으로 간편 로그인
  • 보안성: 비밀번호를 직접 관리하지 않아도 됨
  • 신뢰성: 대형 플랫폼의 검증된 인증 시스템 활용

JWT의 장점

  • Stateless: 서버에 세션 저장 불필요
  • 확장성: 마이크로서비스 환경에서 토큰 공유 가능
  • 성능: 매번 DB 조회 없이 토큰으로 인증 가능

통합의 이유

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                 # 사용자 엔티티

기술적 의사결정 배경 및 요구사항

기술적 요구사항

  • 기존 JWT 인증 시스템과의 자연스러운 통합
  • Stateless 아키텍처 유지 (서버 확장성)
  • 프론트엔드 다중 환경 지원 (개발/운영)
  • 추가 정보 수집 플로우 지원

비즈니스 요구사항

  • 복잡한 회원가입으로 인한 이탈율 최소화
  • 다양한 소셜 로그인 제공자 지원 (Google, Naver 등)

시스템 플로우


1. 기본 설정 - application.properties

# 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}

2. SecurityConfig - 보안 설정의 핵심

@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();
    }
}
  • 역할: Spring Security의 OAuth2 로그인을 활성화
  • userService: OAuth2로 받은 사용자 정보를 처리할 커스텀 서비스 지정
  • successHandler: 로그인 성공 후 JWT 토큰 생성 및 리다이렉트 처리
  • failureUrl: OAuth2 로그인 실패시 이동할 URL

3. CustomOAuth2UserService - 사용자 정보 처리

@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);
    }
}
  • OAuth2 정보 수신: Google, Naver에서 받은 사용자 정보 DB처리
  • 데이터 변환: Google, Naver 형식 → 우리 시스템 형식
  • 사용자 저장/업데이트: 기존 사용자면 업데이트, 신규면 생성
  • CustomOAuth2User 반환: Spring Security가 인식할 수 있는 형태로 변환

4. OAuthAttributes - 데이터 변환 클래스

@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);
    }
}
  • 새로운 소셜 로그인 제공자 추가 시 최소한의 코드 변경
  • 각 제공자별 데이터 구조 차이 흡수

담당 업무:

  • OAuth2 제공자별 사용자 정보 추출 (Google, Naver)
  • 우리 시스템의 User 엔티티로 변환
  • 제공자별 데이터 구조 차이 흡수

5. OAuth2SuccessHandler - 로그인 성공 처리

@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();
        }
    }
}
  • 사용자 정보 추출: OAuth2User에서 실제 User 엔티티 가져오기
  • JWT 토큰 생성: 이후 API 호출에 사용할 토큰 생성
  • 조건부 리다이렉트: 추가 정보 필요 여부에 따라 다른 페이지로 이동
  • 토큰 전달: 쿼리 파라미터로 프론트엔드에 토큰 전달

6. CustomOAuth2User - OAuth2User 구현체

@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();
    }
}
  • Spring Security 연동: OAuth2User 인터페이스 구현으로 Spring Security와 호환
  • User 엔티티 보관: 실제 데이터베이스의 User 정보 보관
  • 권한 관리: 사용자의 권한 정보 제공
  • 원본 데이터 보존: OAuth2에서 받은 원본 데이터 유지
  • 인증/인가 정보와 비즈니스 도메인 객체 연결

7. RedirectUrlCookieFilter.java - 리다이렉트 URL 관리

// 역할: OAuth2 로그인 전 원래 페이지 URL을 쿠키에 저장
@Component
public class RedirectUrlCookieFilter extends OncePerRequestFilter {
    
    @Override
    protected void doFilterInternal(...) {
        if (request.getRequestURI().startsWith("/oauth2/authorization")) {
            String redirectUrl = request.getParameter("redirect_url");
            // 쿠키에 저장하여 로그인 후 원래 페이지로 돌아가기
        }
    }
}
  • 사용자가 OAuth2 로그인 전에 보던 페이지 URL 기억
  • 로그인 후 원래 페이지로 자연스럽게 돌아가는 UX 제공

핵심 구현 설계 결정사항

  1. 멀티 제공자 확장 가능한 설계
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);
    }
}

의사 결정 이유 :

  • 새로운 소셜 로그인 제공자 추가 시 최소한의 코드 변경
  • 각 제공자별 데이터 구조 차이 흡수
  1. 점진적 정보 수집 전략
public User toEntity() {
    return new User(
        email,                    // 소셜에서 가져온 확실한 정보
        picture,                  // 프로필 이미지
        "OAUTH2_TEMP",           // OAuth2 사용자 식별용 임시 비밀번호
        name,                    // 소셜에서 가져온 이름
        "NEED_INPUT",            // 나중에 입력받을 정보들
        -1,                      // 미입력 상태 표시
        "NEED_INPUT",
        Gender.MAN,              // 기본값 설정
        UserRole.USER,
        authProvider             // 어떤 소셜 로그인인지 추적
    );
}

의사 결정 이유 :

  • 소셜 로그인으로 즉시 서비스 이용 가능
  • 필수 정보는 서비스 이용 과정에서 점진적 수집
  • 초기 가입 장벽 최소화
  1. 스마트 리다이렉트 로직
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 제공

  1. 기존 사용자 정보 업데이트 로직
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;
    }
}

의사 결정 이유 : 재로그인 시 최신 소셜 정보로 자동 동기화

  1. 다중 환경 지원
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
}

의사 결정 이유 :

  • 개발/운영 환경별 다른 프론트엔드 URL 대응
  • 쿠키 기반 동적 리다이렉트 지원
  1. 쿠키 기반 리다이렉트 URL 보존
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
}

전체적인 로그인 플로우

업로드중..


주요 설정 포인트

1. Google Console 설정

애플리케이션 유형: 웹 애플리케이션
승인된 자바스크립트 원본: http://localhost:3000
승인된 리디렉션 URI: http://localhost:8080/login/oauth2/code/google, https://localhost:8080/login/oauth2/code/google

2. 환경변수 설정

# .env 파일 또는 시스템 환경변수
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
SECRET_KEY=your_jwt_secret_key

3. 프론트엔드 연동 예정

(다음에 구현하면 업로드 할 예정)


기대 효과

사용자 경험 개선

  • 가입시간 : 기존 약 3-5분 -> 30초 단축 가능
  • 간편한 가입으로 인해서 가입 완료율이 상승할 것을 예상
  • 소셜 로그인 사용자가 많아짐으로써 재방문률이 올라갈 것으로 예상

기술적 효과

  • Stateless 아키텍처로 수평 확장이 용이함으로 서버를 확장시킬 수 있다.
  • 기존 JWT 시스템과 완전 통합으로 개발 효율성이 좋음.
  • 모듈화 된 OAuth2 처리 로직이므로 유지보수성이 좋음.

마무리

사용자 편의성 향상 (간편 로그인)
보안성 강화 (토큰 기반 인증)
확장성 확보 (마이크로서비스 대응)
성능 최적화 (Stateless 인증)

이 구조를 기반으로 Kakao, Naver 등 다른 OAuth2 제공자도 쉽게 추가할 수 있으며, 모바일 앱과의 연동도 간단해진다.

0개의 댓글