💻 뚝딱뚝딱/북북클럽

[개발일지 #011] Oauth 로그인 구현 (구글)

뚜루리 2025. 4. 24. 11:09
728x90
320x100

🎯 오늘의 목표

  • Oauth 로그인 구현 (구글)

 


⚙️ 진행한 작업

  • Oauth 로그인 구현 (구글)

🛠️ 개발내용

✅ Oauth 로그인을 도입하는 이유

장점 설명
🔑 로그인 개발/유지보수 부담 감소 비밀번호 저장, 해싱, 변경, 분실 처리 로직이 없어도 됨
🪪 신뢰성 있는 사용자 정보 제공 구글, 네이버 등에서 직접 검증된 정보(email 등)를 제공
📱 소셜 연동 쉽게 구현 가능 로그인 + 프로필 불러오기 + 인증 절차까지 한 번에 가능
👌 사용자 진입 장벽 낮음 사용자는 회원가입 없이 "구글로 로그인"으로 빠르게 진입
🔒 보안적으로 우수함 인증/인가를 제공업체(Google 등)에게 위임 → 우리가 직접 비밀번호를 다루지 않음
🌍 다양한 플랫폼과 연동 하나의 인증 방식으로 웹, 모바일, 외부 API까지 모두 대응 가능

위와 같은 장점들로 인해 Oauth를 도입하려고 함. 그러나 단점도 존재한다. 

 

단점 설명
🔁 추가 정보가 부족함 기본 프로필 정보만 제공되므로 닉네임/약관 동의 등은 별도 처리 필요
🌐 외부 서비스 의존도 증가 구글/네이버 API 장애 시 로그인 불가 가능성 있음
🧩 프로바이더별 파싱 방식이 다름 Google, Naver, Kakao 등 API 응답 포맷이 다 달라서 provider별 분기 필요
🧷 추가적인 보안 검토 필요 redirect_uri 위변조, CSRF, 토큰 탈취 등 보안 고려사항 있음
🔁 동일 이메일로 다른 소셜 로그인 가능성 같은 이메일로 구글/네이버 둘 다 가입하면 충돌 가능 → providerId로 구분 필요
🧠 개념이 많고 복잡하다 OAuth2 흐름, Security 필터 체인, 토큰 처리까지 전반적으로 학습 난이도 높음

즉, 주도권이 외부서비스에 있다보니 API 장애시 로그인이 불가능하고, 아무래도 설정하는 것이 다소복잡하다는 것이 단점임.

 

Oauth를 적용하게 되면 처음엔 로그인/회원가입이 이뤄지고 그 다음부터는 로그인만 이뤄지도록 흐름 구성
구글, 네이버, 카카오, 애플 총 4개의 로그인을 구현하려 했으나 애플은 돈이 든다는 소리에.....
일단 구글, 네이버, 카카오 3곳만 연결해보려함.

 


 

📌  User 엔티티 컬럼 추가

  • 적용하기 전에 User엔티티에 구글, 네이버, 카카오 등 provider를 구분할 수 있는 컬럼을 추가함.
  • 소셜 로그인 제공자 구분 컬럼 (provider) : 유지보수, 분기처리, 통계 등 다양하게 사용함
  • 제공자별 ID 저장 컬럼 (providerId) : 동일 이메일로 여러 소셜 가입 방지하기 위해

why?

  • 사용자 식별 : 어떤 소셜(Google, Kakao, Naver 등)로 가입했는지 확인 가능
  • 기능 분기 처리 : 예: 구글 로그인 사용자만 이메일 인증 생략 등
  • 일반 로그인 사용자와 구분
  • 중복 사용자 관리 : 같은 이메일로 다른 소셜 로그인 가입 방지할 수 있음

 

User.java

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class User {
	
    //생략//

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

    @Column(nullable = false)
    private String providerId;

 	//생략//
    
    public static User create(String email, String encodedPassword, String nickname) {
        User user = new User();
        user.email = email;
        user.password = encodedPassword;
        user.nickname = nickname;
        user.role = Role.USER;
        user.status = UserStatus.ACTIVE;
        user.provider = AuthProvider.LOCAL;      // ✅ 추가!
        user.providerId = "LOCAL";               // ✅ 구분용 더미 값
        return user;
    }

    public static User createSocialUser(String email, String nickname, AuthProvider provider, String providerId) {
        User user = new User();
        user.email = email;
        user.password = "oauth2"; // 의미 없는 값
        user.nickname = nickname;
        user.role = Role.USER;
        user.status = UserStatus.ACTIVE;
        user.provider = provider;
        user.providerId = providerId;
        return user;
    }

}

 

📌  AuthProvider

package ddururi.bookbookclub.domain.user.enums;

import lombok.Getter;

@Getter
public enum AuthProvider {
    LOCAL("일반 가입"),
    GOOGLE("구글"),
    KAKAO("카카오"),
    NAVER("네이버"),
    APPLE("애플");

    private final String description;

    AuthProvider(String description) {
        this.description = description;
    }
}

 

 


📌  Google Developer Console 설정

당연히 구글 아이디가 있어야함.

 

  • https://console.cloud.google.com 접속하여 상단에 [프로젝트 생성]클릭함.
  • 이미지 상으로는 프로젝트 생성이 보이지 않는데, 난 이미 생성해놔서 bookbookclub 이라는 프로젝트 명만 뜨는거임.

 

작성하라는대로 쭉쭉 작성하여 프로젝트를 일단 생성해줌.

 

그리고 Oauth를 설정할 프로젝트를 선택해줌.

 


좌측 메뉴에 [API 및 서비스] - [사용자 인증 정보] 이동.

 

그럼 위와 같은 화면이 뜰꺼임. 

 

상단에 [사용자 인증 정보 만들기] - [Oauth 클라이언트 ID] 클릭

 

 

폼을 입력하고 만들기!

 

그후(사진은 없지만ㅠㅠ) 팝업창에 나오는 클라이언트 ID클라이언트 시크릿 복사해놓기

 


📌  application.yml에 구글 OAuth2 설정 추가

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: YOUR_GOOGLE_CLIENT_ID
            client-secret: YOUR_GOOGLE_CLIENT_SECRET
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope:
              - profile
              - email
        provider:
          google:
            authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
            token-uri: https://oauth2.googleapis.com/token
            user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
            user-name-attribute: sub

clinet-id, client-secret은 아까 발급받은 값으로 변경해줘야 함.

 

📌  SecurityFilterChain 설정 (OAuth2 + JWT + 필터)

  • 소셜 로그인 URL을 허용하고, 로그인 성공 시 커스텀 핸들러로 넘겨주는 설정을 담당
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final OAuth2UserProviderRouter oAuth2UserProviderRouter;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/api/users/login",
                                "/api/users",
                                "/api/users/check-email",
                                "/api/users/check-nickname",
                                "/api/email/**",
                                "/api/users/token/refresh",
                                "/api/users/logout",
                                "/oauth2/**", // 소셜 로그인 관련 요청 허용
                                "/login/**" // OAuth2 redirect 관련 요청 허용
                        ).permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfo -> userInfo
                                .userService(oAuth2UserProviderRouter) // 사용자 정보 받아오기
                        )
                        .successHandler(oAuth2SuccessHandler) // JWT 발급 및 응답
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }

}

 

📌  OAuth2UserProviderRouter.java

  • provider 마다 따로 서비스를 분리하기로 했음.
  • 핵심 라우터 역할.provider가 "google"이면 GoogleOAuth2UserService에 위임하는 방식

[패키지 구조]

ddururi.bookbookclub
└── global
    └── security
        └── oauth
            ├── OAuth2UserProviderRouter.java          // 등록된 provider 별 서비스 라우팅
            ├── userinfo
            │   ├── GoogleOAuth2UserService.java       ✅ 구글 전용
            │   ├── NaverOAuth2UserService.java        ✅ 네이버 전용 (나중에 추가)
            │   └── KakaoOAuth2UserService.java        ✅ 카카오 전용 (나중에 추가)
package ddururi.bookbookclub.global.security.oauth;

import ddururi.bookbookclub.global.security.oauth.userinfo.GoogleOAuth2UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class OAuth2UserProviderRouter implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final GoogleOAuth2UserService googleOAuth2UserService;
    // 추후 NaverOAuth2UserService, KakaoOAuth2UserService 등 추가 가능

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        String provider = userRequest.getClientRegistration().getRegistrationId(); // "google"

        return switch (provider) {
            case "google" -> googleOAuth2UserService.loadUser(userRequest);
            // case "naver" -> naverOAuth2UserService.loadUser(userRequest);
            // case "kakao" -> kakaoOAuth2UserService.loadUser(userRequest);
            default -> throw new OAuth2AuthenticationException("지원하지 않는 소셜 로그인입니다: " + provider);
        };
    }
}

 

 

 

 

📌  GoogleOAuth2UserService.java

  • 로그인한 사용자의 **구글 정보(email, name 등)**을 받아와서 우리 서비스의 User 엔티티랑 연결해주는 로직

[✅ 구글 OAuth2 사용자 정보 응답 예시]

{
  "sub": "109103204190219023923",     // 구글 고유 ID (providerId로 사용)
  "name": "홍길동",                    // 사용자 이름 (nickname 후보)
  "given_name": "길동",               
  "family_name": "홍",               
  "picture": "https://lh3.googleusercontent.com/a/abc123...", // 프로필 이미지 URL
  "email": "honggildong@gmail.com",   // 사용자 이메일
  "email_verified": true,             // 이메일 인증 여부
  "locale": "ko"                      // 언어/지역
}
@RequiredArgsConstructor
@Service
public class GoogleOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = new DefaultOAuth2UserService().loadUser(userRequest);

        String email = oAuth2User.getAttribute("email");
        String name = oAuth2User.getAttribute("name");
        String providerId = oAuth2User.getAttribute("sub"); // 구글 ID
        AuthProvider provider = AuthProvider.GOOGLE;

        return userRepository.findByEmail(email)
                .map(CustomUserDetails::new)
                .orElseGet(() -> {
                    // 닉네임 중복 검사
                    if (userRepository.existsByNickname(name)) {
                        throw new OAuth2AuthenticationException("이미 사용 중인 닉네임입니다.");
                    }

                    User newUser = userRepository.save(User.createSocialUser(email, name, provider, providerId));
                    return new CustomUserDetails(newUser);
                });
    }
}

 

📌  OAuth2SuccessHandler.java

  • 소셜 로그인 성공 시 JWT 발급해서 클라이언트에 응답하는 역할
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;
    private final JwtRefreshTokenService refreshTokenService;
    private final ObjectMapper objectMapper;

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

        // JWT 발급
        String accessToken = jwtUtil.createToken(userDetails.getEmail());
        String refreshToken = jwtUtil.createRefreshToken();

        // Redis 저장
        refreshTokenService.save(userDetails.getId(), refreshToken, REFRESH_EXPIRATION_DAYS, TimeUnit.DAYS);

        // 응답
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(
                ApiResponse.success(new TokenResponse(accessToken, refreshToken))
        ));
    }
}

 

📌  TokenResponse.java

@Builder
@Getter
@AllArgsConstructor
public class TokenResponse {
    private String accessToken;
    private String refreshToken;
}

 


[테스트]

  • Postman에서는 구글 로그인 팝업 처리가 안 되기 때문에 브라우저로 직접 로그인 URL을 호출해야 함.

 

http://localhost:8080/oauth2/authorization/google 입력하면 위와 같은 이미지가 뜸.

 

계정을 선택하면 로그인 할수 있는 화면이 뜨고 계속을 누르면 아래와 같은 결과값이 나옴.

 

 

 

728x90
320x100