프로젝트 구체화를 마무리하고 드디어 개발을 시작했습니다.
역시 개발은 10시간을 내리 해도 질리지 않습니다. 처음 맡게 된 기능은 로그인/회원가입입니다.
회원가입 요구사항
- 이메일 인증 회원가입
- 구글 회원가입
로그인 요구사항
- 이메일 혹은 구글 로그인
- 로그인 성공시 JWT 토큰 발행
이번 포스팅에서는 구글 로그인에 관련하여 다루겠습니다.
일정이 빠듯해서 또 언제 포스팅할 수 있을 지는 모르겠습니다ㅠㅠ


OAuth를 이용하려면 우선 클라이언트를 생성해주어야 합니다.
아래 단계를 따라하면 어렵지 않습니다.
애플리케이션 유형
: 웹 애플리케이션
이름
: 자유롭게 해도 됩니다. 저는 프로젝트명으로 했습니다. (try it on)
승인된 JavaScript 원본
웹 애플리케이션을 호스팅하는 HTTP 원본입니다. 이 값에는 와일드 카드나 경로를 포함할 수 없습니다. 80 외의 포트를 사용하는 경우 포트를 지정해야 합니다. 예: https://example.com:8080
next.js 프로젝트의 경우, http://localhost:3000 로 설정합니다.
클라이언트 ID, 클라이언트 보안 비밀번호는 기록해두었다가 서비스 이용시
사용합니다. 잘 보관합시다!

앱 게시는 시간이 걸리므로, 테스트 상태로 게시합니다.
프로덕션 단계:
앱 상태를 '프로덕션 단계'로 설정하면 Google 계정이 있는 모든 사용자가 앱을 사용할 수 있게 됩니다. OAuth 화면 구성 방식에 따라서는 인증을 위해 앱을 제출 해야 할 수도 있습니다.
테스트:
앱을 아직 테스트 및 빌드하는 중이라면 상태를 '테스트'로 설정할 수 있습니다. 이 상태에서는 제한된 수의 사용자를 대상으로 앱을 테스트할 수 있습니다.
테스트 사용자에 테스트할 이메일을 추가합니다. 여기에 추가한 이메일만 테스트할 수 있습니다.

이제 세팅은 끝!
어떻게 구현할 것인지 고민해봅시다.
결론적으로 저는 두 가지 문제를 맞닥뜨렸습니다.
우선 첫번째는 백엔드에서 모든 것을 구현하려고 시도하였을 때 마주한 문제입니다.
우리 서비스는 유저의 회원가입 시점에 이메일, 비밀번호 정보 이외에도 다양한 유저 정보가 필요합니다.
백엔드에서 구글 로그인(구글 Auth token 발급)까지 구현한다면, 프론트엔드로 리디렉션할 수 없는 문제가 발생했습니다.
이는 곧, 회원가입 이후 추가 정보를 바로 받아야 하는 요구조건을 충족시킬 수 없습니다.
프론트엔드에서 구글 로그인(구글 Auth token 발급)을 구현하고,
백엔드에서는 그 token의 유효성 검증 + 회원정보 획득 + 회원가입처리를 하도록 합니다.
참고 : https://dev-th.tistory.com/53
또 하나의 문제에 봉착했습니다.
google 버튼 하나로 로그인/회원가입 로직 분리하기
Google로 로그인하기 버튼은 단일하지만, 그 유저가 우리 서비스의 회원인지 아닌지에 따라 서비스 내부적으로 분기 처리 해주어야 합니다. 따라서 아래의 방법으로 해결하였습니다.
로그인 API와 회원가입 API를 분리한다.
로그인 API에서는 토큰의 payload에 담긴 이메일 정보를 활용하여 서비스 내 회원 여부를 확인한다. 회원이라면 로그인 처리를 하고 JWT 토큰을 발행한다.
회원가입 API에서는 토큰의 payload에 담긴 이메일 정보, 프로필 사진 정보에 더하여, 프론트엔드에서 회원가입 폼으로 입력받은 유저명, 생년월일, 성별, 휴대폰번호, 키, 체중, 신발사이즈, 선호하는 스타일을 추가하여 회원가입 처리를 한다. 사용자 경험을 고려하여 바로 JWT 토큰을 발행한다.
(기존 회원가입은 이후 로그인 단계에서 JWT 토큰을 발행한다.)
좀 더 자세히 정리하자면 아래와 같은 로직으로 설명할 수 있습니다.
| 상황 | 상태 코드 | 의미 |
|---|---|---|
| 로그인 성공 | 200 OK | 요청이 성공적으로 처리됨 |
| 가입된 이메일이 아님 | 404 Not Found | 해당 리소스를 찾을 수 없음 → 회원가입 필요 |
| 잘못된 토큰 | 401 Unauthorized | 인증되지 않음 (ex. JWT 만료, 잘못된 토큰) |
| 이메일 중복으로 회원가입 실패 | 409 Conflict | 리소스 충돌 (이메일 중복) |
| 서버 내부 오류 | 500 Internal Server Error | 예기치 못한 오류 발생 |
이제 구현을 해봅시다.
Google Oauth 인증을 수행합니다.

출처 : https://dev-th.tistory.com/53
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>
);
};
Google 인증 페이지로 리디렉션(공식 문서를 참고해주세요)
- 성공시 onSuccess
- 실패시 onError
성공시, auth code를 Google에서 반환해줍니다.
// onSuccess의 핸들러
const handleSuccess = async (credentialResponse: any) => {
if (!credentialResponse?.credential) {
console.error("Google 인증 토큰을 받지 못했습니다.");
return;
}
const idToken = credentialResponse.credential;
setIsLoading(true);
auth code로 백엔드 서버에 로그인을 시도합니다.
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;
프론트엔드에서 전달받은 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'
로그인 컨트롤러 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));
}
}
로그인 서비스 로직 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);
}
public class GoogleInfoDto {
private String email;
private String pictureUrl;
}// 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());
}
}GoogleIdTokenVerifier verifier = createGoogleIdTokenVerifier(); : Google에서 client api로 제공한 토큰 검증기Payload payload = idToken.getPayload(); : Google에서 client api로 제공한 payload 파서회원가입 컨트롤러 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);
}
}
회원가입 로직 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 객체 생성MemberRepository에 Member 객체 저장(Member에 종속된 Profile은 자동으로 저장됨)이렇게 구현하면 아래와 같이 동작됩니다.

회원가입 정보 입력 폼을 통해 추가 정보를 입력합니다.

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