Spring, Next.js로 Google OAuth 구현

설현아·2025년 6월 27일
post-thumbnail

프로젝트 구체화를 마무리하고 드디어 개발을 시작했습니다.
역시 개발은 10시간을 내리 해도 질리지 않습니다. 처음 맡게 된 기능은 로그인/회원가입입니다.

회원가입 요구사항

  • 이메일 인증 회원가입
  • 구글 회원가입

로그인 요구사항

  • 이메일 혹은 구글 로그인
  • 로그인 성공시 JWT 토큰 발행

이번 포스팅에서는 구글 로그인에 관련하여 다루겠습니다.
일정이 빠듯해서 또 언제 포스팅할 수 있을 지는 모르겠습니다ㅠㅠ

OAuth Google 인증 흐름

  1. Google API Console에서 OAuth 2.0 사용자 인증 정보를 가져옵니다.
  2. Google 승인 서버에서 액세스 토큰을 가져옵니다.
  3. 사용자가 부여한 액세스 범위를 검토합니다.
  4. 액세스 토큰을 API에 전송합니다.
  5. 필요한 경우 액세스 토큰을 새로고침합니다.

console에서 OAuth 클라이언트 생성

Google OAuth console

OAuth를 이용하려면 우선 클라이언트를 생성해주어야 합니다.
아래 단계를 따라하면 어렵지 않습니다.

  1. 애플리케이션 유형
    : 웹 애플리케이션

  2. 이름
    : 자유롭게 해도 됩니다. 저는 프로젝트명으로 했습니다. (try it on)

  3. 승인된 JavaScript 원본
    웹 애플리케이션을 호스팅하는 HTTP 원본입니다. 이 값에는 와일드 카드나 경로를 포함할 수 없습니다. 80 외의 포트를 사용하는 경우 포트를 지정해야 합니다. 예: https://example.com:8080

    next.js 프로젝트의 경우, http://localhost:3000 로 설정합니다.

  4. 클라이언트 ID, 클라이언트 보안 비밀번호는 기록해두었다가 서비스 이용시
    사용합니다. 잘 보관합시다!

  5. 앱 게시는 시간이 걸리므로, 테스트 상태로 게시합니다.

    프로덕션 단계:
    앱 상태를 '프로덕션 단계'로 설정하면 Google 계정이 있는 모든 사용자가 앱을 사용할 수 있게 됩니다. OAuth 화면 구성 방식에 따라서는 인증을 위해 앱을 제출 해야 할 수도 있습니다.

    테스트:
    앱을 아직 테스트 및 빌드하는 중이라면 상태를 '테스트'로 설정할 수 있습니다. 이 상태에서는 제한된 수의 사용자를 대상으로 앱을 테스트할 수 있습니다.

    테스트 사용자에 테스트할 이메일을 추가합니다. 여기에 추가한 이메일만 테스트할 수 있습니다.

이제 세팅은 끝!
어떻게 구현할 것인지 고민해봅시다.

결론적으로 저는 두 가지 문제를 맞닥뜨렸습니다.
우선 첫번째는 백엔드에서 모든 것을 구현하려고 시도하였을 때 마주한 문제입니다.

Google OAuth 인증 후, 추가 회원정보를 입력할 수 없다.

우리 서비스는 유저의 회원가입 시점에 이메일, 비밀번호 정보 이외에도 다양한 유저 정보가 필요합니다.

백엔드에서 구글 로그인(구글 Auth token 발급)까지 구현한다면, 프론트엔드로 리디렉션할 수 없는 문제가 발생했습니다.
이는 곧, 회원가입 이후 추가 정보를 바로 받아야 하는 요구조건을 충족시킬 수 없습니다.

프론트엔드에서 구글 로그인(구글 Auth token 발급)을 구현하고,
백엔드에서는 그 token의 유효성 검증 + 회원정보 획득 + 회원가입처리를 하도록 합니다.

이에 따른 구글 로그인 시나리오

참고 : https://dev-th.tistory.com/53

  • 구글 로그인 → 서버에서 토큰 검증 → 이메일로 회원 조회
    • 있으면 → 바로 로그인 처리 (JWT 발급)
    • 없으면 → 회원가입에 필요한 정보(이메일, 프로필 사진) 응답 → 프론트에서 추가정보(키, 체중, 이름, 선호하는 스타일 등) 입력 폼 노출 → 회원가입 API 호출 → 회원가입 후 JWT 발급 및 로그인 처리

또 하나의 문제에 봉착했습니다.

google 버튼 하나로 로그인/회원가입 로직 분리하기


Google로 로그인하기 버튼은 단일하지만, 그 유저가 우리 서비스의 회원인지 아닌지에 따라 서비스 내부적으로 분기 처리 해주어야 합니다. 따라서 아래의 방법으로 해결하였습니다.

로그인 API와 회원가입 API를 분리한다.

  • 로그인 API에서는 토큰의 payload에 담긴 이메일 정보를 활용하여 서비스 내 회원 여부를 확인한다. 회원이라면 로그인 처리를 하고 JWT 토큰을 발행한다.

  • 회원가입 API에서는 토큰의 payload에 담긴 이메일 정보, 프로필 사진 정보에 더하여, 프론트엔드에서 회원가입 폼으로 입력받은 유저명, 생년월일, 성별, 휴대폰번호, 키, 체중, 신발사이즈, 선호하는 스타일을 추가하여 회원가입 처리를 한다. 사용자 경험을 고려하여 바로 JWT 토큰을 발행한다.
    (기존 회원가입은 이후 로그인 단계에서 JWT 토큰을 발행한다.)

좀 더 자세히 정리하자면 아래와 같은 로직으로 설명할 수 있습니다.

1) 로그인 API (POST /api/auth/google/login)

  • 구글 토큰 받고
  • 토큰 검증 후 이메일로 회원 존재 여부 확인
  • 회원 있으면 JWT 발급 및 로그인 응답
  • 회원 없으면 HTTP 404 혹은 HTTP 200 + { needsSignup: true } 응답

2) 회원가입 API (POST /api/auth/google/signup)

  • 구글 토큰 + 추가 회원 정보 받고
  • 회원가입 진행
  • 회원가입 완료 후 JWT 발급 및 로그인 응답
상황상태 코드의미
로그인 성공200 OK요청이 성공적으로 처리됨
가입된 이메일이 아님404 Not Found해당 리소스를 찾을 수 없음 → 회원가입 필요
잘못된 토큰401 Unauthorized인증되지 않음 (ex. JWT 만료, 잘못된 토큰)
이메일 중복으로 회원가입 실패409 Conflict리소스 충돌 (이메일 중복)
서버 내부 오류500 Internal Server Error예기치 못한 오류 발생

이제 구현을 해봅시다.


Next.js (프론트엔드)

Google Oauth 인증을 수행합니다.

출처 : https://dev-th.tistory.com/53

@react-oauth/google 라이브러리 사용

  1. 라이브러리에서 제공하는 GoogleLogin 컴포넌트를 사용하여 Google OAuth 인증을 수행합니다.
  return (
    <div className="max-w-md mx-auto p-6">
      <h2 className="text-2xl font-bold mb-6">회원가입</h2>
      <div className="flex justify-center">
        <GoogleLogin
          onSuccess={handleSuccess}
          onError={handleError}
          useOneTap={false}
          auto_select={false}
          theme="outline"
          size="large"
          text="signup_with"
          shape="rectangular"
        />
      </div>
      {isLoading && (
        <div className="text-center mt-4">
          <p>처리 중...</p>
        </div>
      )}
    </div>
  );
};
  1. Google 인증 페이지로 리디렉션(공식 문서를 참고해주세요)
    - 성공시 onSuccess
    - 실패시 onError

  2. 성공시, auth code를 Google에서 반환해줍니다.

    // onSuccess의 핸들러
    const handleSuccess = async (credentialResponse: any) => {
        if (!credentialResponse?.credential) {
          console.error("Google 인증 토큰을 받지 못했습니다.");
          return;
        }
    
        const idToken = credentialResponse.credential;
        setIsLoading(true);
  3. auth code로 백엔드 서버에 로그인을 시도합니다.

    • 로그인 성공시, 서버에서 응답 받은 JWT 토큰으로 access token을 설정합니다.(저는 local storage를 사용하였습니다.)
          try {
            // 먼저 로그인 시도
            const loginRes = await signinWithGoogle(idToken);
      
            // 로그인 성공
            if (loginRes.accessToken) {
              setAccessToken(loginRes.accessToken);
              router.push("/"); // 홈으로 리다이렉트
            }
          } catch (error: any) {
            console.error("로그인 에러:", error);
      
            // 404 에러 또는 회원가입이 필요한 경우
            if (error.response?.status === 404 || error.response?.needsSignup) {
              setPendingIdToken(idToken);
              setShowSignupForm(true);
            } else {
              alert("로그인 중 오류가 발생했습니다. 다시 시도해주세요.");
            }
          } finally {
            setIsLoading(false);
          }
      // 로그인 실패시 로직이 아래에 포함됩니다.
    • 로그인 실패시, 백엔드 서버에 회원가입을 시도합니다.
      이 때는 프론트엔드 단에서 추가 회원 정보 폼을 띄워주는 로직도 포함되어야 합니다.
      저는 showSignupForm 으로 상태 관리를 해주었고, 로그인에 실패하면 해당 상태를 true 로 변경하여 폼이 노출될 수 있도록 설계하였습니다.
      if (showSignupForm) {
       return (
         <div className="max-w-md mx-auto p-6">
           <h2 className="text-2xl font-bold mb-6">회원가입 정보 입력</h2>
           <SignupForm onSubmit={handleSignup} isLoading={isLoading} />
         </div>
       );
      아래 코드는 api 코드만 포함하였으며, api 코드 또한 function으로 분리하여 사용하였음을 참고해주시기 바랍니다.
        // 로그인 성공시 로직이 위에 포함됩니다.
          const handleSignup = async (data: any) => {
            if (!pendingIdToken) return;
        
            setIsLoading(true);
            try {
              const signupRes = await signupWithGoogle({
                idToken: pendingIdToken, // Google에서 받은 token
                ...data, // 기타 데이터(선호 스타일, 키, 체중 등)
              });
        
              if (signupRes.accessToken) {
                router.push("/"); // 홈으로 리다이렉트
              }
            } catch (error: any) {
              alert("회원가입 중 오류가 발생했습니다. 다시 시도해주세요.");
            } finally {
              setIsLoading(false);
            }
          };
  • 전체 코드

    "use client";
    
    import { GoogleLogin } from "@react-oauth/google";
    import { signinWithGoogle } from "@/api/auth";
    import { signupWithGoogle } from "@/api/auth";
    import { setAccessToken } from "@/utils/auth";
    import { useState } from "react";
    import { useRouter } from "next/navigation";
    import SignupForm from "@/app/signup/_components/SignupForm";
    
    const Signup = () => {
     const [isLoading, setIsLoading] = useState(false);
     const [showSignupForm, setShowSignupForm] = useState(false);
     const [pendingIdToken, setPendingIdToken] = useState<string | null>(null);
     const router = useRouter();
    
     const handleSuccess = async (credentialResponse: any) => {
       if (!credentialResponse?.credential) {
         console.error("Google 인증 토큰을 받지 못했습니다.");
         return;
       }
    
       const idToken = credentialResponse.credential;
       setIsLoading(true);
    
       try {
         // 먼저 로그인 시도
         const loginRes = await signinWithGoogle(idToken);
    
         // 로그인 성공
         if (loginRes.accessToken) {
           setAccessToken(loginRes.accessToken);
           console.log("로그인 성공:", loginRes);
           router.push("/"); // 홈으로 리다이렉트
         }
       } catch (error: any) {
         console.error("로그인 에러:", error);
    
         // 404 에러 또는 회원가입이 필요한 경우
         if (error.response?.status === 404 || error.response?.needsSignup) {
           console.log("회원가입이 필요합니다");
           setPendingIdToken(idToken);
           setShowSignupForm(true);
         } else {
           console.error("로그인 오류:", error.response || error.message);
           alert("로그인 중 오류가 발생했습니다. 다시 시도해주세요.");
         }
       } finally {
         setIsLoading(false);
       }
     };
    
     const handleSignup = async (data: any) => {
       if (!pendingIdToken) return;
    
       setIsLoading(true);
       try {
         const signupRes = await signupWithGoogle({
           idToken: pendingIdToken,
           ...data,
         });
    
         if (signupRes.accessToken) {
           console.log("회원가입 및 로그인 완료:", signupRes);
           router.push("/"); // 홈으로 리다이렉트
         }
       } catch (error: any) {
         console.error("회원가입 오류:", error);
         alert("회원가입 중 오류가 발생했습니다. 다시 시도해주세요.");
       } finally {
         setIsLoading(false);
       }
     };
    
     const handleError = () => {
       console.log("Google 로그인 실패");
       alert("Google 로그인에 실패했습니다. 다시 시도해주세요.");
     };
    
     if (showSignupForm) {
       return (
         <div className="max-w-md mx-auto p-6">
           <h2 className="text-2xl font-bold mb-6">회원가입 정보 입력</h2>
           <SignupForm onSubmit={handleSignup} isLoading={isLoading} />
         </div>
       );
     }
    
     return (
       <div className="max-w-md mx-auto p-6">
         <h2 className="text-2xl font-bold mb-6">회원가입</h2>
         <div className="flex justify-center">
           <GoogleLogin
             onSuccess={handleSuccess}
             onError={handleError}
             useOneTap={false}
             auto_select={false}
             theme="outline"
             size="large"
             text="signup_with"
             shape="rectangular"
           />
         </div>
         {isLoading && (
           <div className="text-center mt-4">
             <p>처리 중...</p>
           </div>
         )}
       </div>
     );
    };
    
    export default Signup;

Spring (백엔드)

프론트엔드에서 전달받은 token으로 Google에 유저 정보를 요청하여 로그인/회원가입 처리를 합니다.

아래와 같이 종속성을 추가합니다. api client(OAuth Google에서 client란, auth 로직을 처리하는 백엔드단을 의미합니다.)

	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
	implementation group: 'com.google.api-client', name: 'google-api-client', version: '2.8.0'
	implementation 'com.google.http-client:google-http-client-jackson2:1.43.3'

로그인

  1. 로그인 컨트롤러 GoogleAuthController

    @RestController
    @RequestMapping("/api/auth/google")
    public class GoogleAuthController {
        private final AuthService authService;
    
        public GoogleAuthController(AuthService authService) {
            this.authService = authService;
        }
    
        @PostMapping("/login")
        public ResponseEntity<?> login(@RequestBody GoogleSigninRequestDto dto) {
            try {
                SigninResponseDto response = authService.loginWithGoogle(dto);
                return ResponseEntity.ok(response);
            } catch (BusinessException e) {
                return ResponseEntity
                    .status(HttpStatus.NOT_FOUND) // 404 Not Found
                    .body(Map.of("error", e.getMessage(), "needsSignup", true));
            }
        }
  2. 로그인 서비스 로직 AuthService

    import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
    import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload;
    import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
    import com.google.api.client.http.javanet.NetHttpTransport;
    import com.google.api.client.json.jackson2.JacksonFactory;
    // 로그인 요청을 처리하고 JWT 토큰 반환
    public SigninResponseDto loginWithGoogle(GoogleSigninRequestDto dto) {
        GoogleInfoDto googleInfo = authenticate(dto.getIdToken());
        String email = googleInfo.getEmail();
    
        Member member = memberRepository.findByEmail(email)
            .orElseThrow(() -> new BusinessException("가입되지 않은 회원입니다. 회원가입이 필요합니다."));
    
        String jwt = jwtUtil.createJwt(email, member.getRole().name(), 60 * 60 * 1000L);
    
        return new SigninResponseDto(member.getUsername(), member.getEmail(), jwt);
    }
    • GoogleInfoDto: 토큰으로 가져올 구글 회원 정보를 정의
      public class GoogleInfoDto {
          private String email;
          private String pictureUrl;
      }
    • authenticate: 토큰으로 사용자 정보 반환
      // OAuth 토큰을 검증하여 사용자 정보 반환
      public GoogleInfoDto authenticate(String token) {
          return extractUserInfoFromToken(token);
      }
      
      // 토큰에서 Google 사용자 정보 추출
      private GoogleInfoDto extractUserInfoFromToken(String token) {
          try {
              validateClientId();
              
              GoogleIdTokenVerifier verifier = createGoogleIdTokenVerifier();
              // 토큰 검증
              GoogleIdToken idToken = verifier.verify(token);
              if (idToken == null) {
                  throw new BusinessException("유효하지 않은 ID 토큰입니다.");
              }
              Payload payload = idToken.getPayload();
              log.info("Token verified successfully for email: {}", payload.getEmail());
              
              // Payload로부터 사용자 정보 추출
              return convertPayloadToGoogleInfoDto(payload);
      
          } catch (GeneralSecurityException e) {
              throw new BusinessException("Google 토큰 검증 실패: " + e.getMessage());
          } catch (IOException e) {
              throw new BusinessException("Google 토큰 검증 실패: " + e.getMessage());
          } catch (Exception e) {
              throw new BusinessException("Google 토큰 검증 실패: " + e.getMessage());
          }
      }
      • validateClientId : client id 검증 (client id란? 위에서 생성하고 발급받은 구글 OAuth 클라이언트 ID)
      • GoogleIdTokenVerifier verifier = createGoogleIdTokenVerifier(); : Google에서 client api로 제공한 토큰 검증기
      • Payload payload = idToken.getPayload(); : Google에서 client api로 제공한 payload 파서

회원가입

  1. 회원가입 컨트롤러 GoogleAuthController

    @RestController
    @RequestMapping("/api/auth/google")
    public class GoogleAuthController {
        private final AuthService authService;
    
        public GoogleAuthController(AuthService authService) {
            this.authService = authService;
        }
    
        @PostMapping("/signup")
        public GoogleSignupResponseDto signup(@RequestBody GoogleSignupRequestDto dto) {
            return authService.signupWithGoogle(dto);
        }
    }
  1. 회원가입 로직 AuthService

    // 회원가입 요청을 처리하고 JWT 토큰 반환
    @Transactional
    public GoogleSignupResponseDto signupWithGoogle(GoogleSignupRequestDto dto) {
        try {
            GoogleInfoDto googleInfo = authenticate(dto.getIdToken());
            String email = googleInfo.getEmail();
            
            // 이메일 중복 체크
            memberRepository.findByEmail(email).ifPresent(m -> {
                throw new BusinessException("이미 가입된 회원입니다.");
            });
    
            Member member = Member.builder()
                .email(email)
                .username(dto.getUsername())
                .birthDate(dto.getBirthDate())
                .gender(dto.getGender())
                .phoneNum(dto.getPhoneNum())
                .provider(AuthProvider.GOOGLE)
                .role(UserRole.USER)
                .build();
    
            Profile profile = Profile.builder()
                .height(dto.getHeight())
                .weight(dto.getWeight())
                .shoeSize(dto.getShoeSize())
                .preferredStyle(Style.valueOf(dto.getPreferredStyle()))
                .profileImageUrl(googleInfo.getPictureUrl())
                .member(member)
                .build();
    
            member.setProfile(profile);
    
            Member saved = memberRepository.save(member);
    
            String jwt = jwtUtil.createJwt(email, member.getRole().name(), 60 * 60 * 1000L);
            return GoogleSignupResponseDto.from(saved, jwt);
            
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            throw new BusinessException("회원가입 처리 중 오류가 발생했습니다: " + e.getMessage());
        }
    }
    • GoogleInfoDto에서 email, pictureUrl을 사용하여 Member, Profile 객체 생성
    • MemberRepositoryMember 객체 저장(Member에 종속된 Profile은 자동으로 저장됨)
    • 구글 로그인에 한하여 회원가입과 동시에 JWT를 발급 (사용자 경험을 고려)

이렇게 구현하면 아래와 같이 동작됩니다.

  • 비회원인 상태에서 로그인을 시도합니다.
  • 회원가입 정보 입력 폼을 통해 추가 정보를 입력합니다.

  • 회원인 상태에서 로그인을 시도합니다.

profile
어서오세요! ☺️ 후회 없는 내일을 위해 오늘을 열심히 살아가는 개발자입니다.

0개의 댓글