🏅오늘의 목표
- Oauth 로그인 - 구글
- Oauth 로그인 - 네이버
✅ 진행한 작업
- Oauth 로그인 - 구글
- Oauth 로그인 - 네이버
📃 개발내용
OAuth 도입 장점
- 로그인 개발 / 유지보수 부담 감소
- 비밀번호 저장, 해싱, 변경, 분실 처리 로직이 없어도 됨
- 신뢰성 있는 사용자 정보 제공
- 구글, 네이버 등 직접 검증된 정보 제공
- 소셜 연동 쉽게 구현 가능
- 사용자 진입 장벽 낮음
- 보안적으로 우수함
- 인증/인가를 제공업체에게 위임
단점
- 추가 정보 부족
- 기본 프로필 정보만 제공되므로 닉네임/약관 동의 등 별도 처리
- 외부 서비스 의존도 증가
- 프로바이더별 파싱 방식이 다름
- 동일 이메일로 다른 소셜 로그인 가능성
- providerId로 구분 필요
구현
Google 과 Naver 설정은 아래의 블로그를 참고 바란다.
https://ddururiiiiiii.tistory.com/618#google_vignette
https://velog.io/@chrkb1569/OAuth-2.0을-활용한-로그인-구현
UserEntity 개선 - Local + OAuth2 통합
@Entity
@Table(
name="users",
uniqueConstraints = {
@UniqueConstraint(name = "uk_users_provider",columnNames = {"authProvider","providerId"})
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {
@Column(nullable = false, unique = true, length = 255)
private String email; // 이메일(아이디)
@Column(nullable = true, length = 60)
private String password; // 비밀번호
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role; // 회원 역할
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserStatus status; // 회원 상태
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AuthProvider authProvider; // OAuth
@Column(length = 255)
private String providerId; // OAuth 고유 ID
private LocalDateTime lastLoginAt; // 마지막 로그인 시간
private LocalDateTime deletedAt; // 탈퇴 시점
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private Profile profile;
// Local 로그인
public static User createLocal(String email, String encodedPassword) {
User user = new User();
user.email = email;
user.password = encodedPassword;
user.role = Role.USER;
user.authProvider = AuthProvider.LOCAL;
user.providerId = null;
user.status = UserStatus.ACTIVE;
return user;
}
// OAuth 로그인
public static User createOAuth(String email, AuthProvider authProvider, String providerId) {
if (providerId == null || providerId.isBlank()) {
throw new BaseException(UserErrorCode.PROVIDER_ID_REQUIRED);
}
if (authProvider == null || authProvider == AuthProvider.LOCAL) {
throw new BaseException(UserErrorCode.INVALID_AUTH_PROVIDER);
}
User user = new User();
user.email = email;
user.password = null;
user.role = Role.USER;
user.authProvider = authProvider;
user.providerId = providerId;
user.status = UserStatus.ACTIVE;
return user;
}
public void attachProfile(Profile profile) {
if (profile == null) {
throw new BaseException(UserErrorCode.PROFILE_CANNOT_NULL);
}
this.profile = profile;
profile.attachUser(this);
}
// 휴먼 계정 전환
public void markInactive() {
if (this.status == UserStatus.WITHDRAWN) {
throw new BaseException(UserErrorCode.USER_ALREADY_WITHDRAW);
}
this.status = UserStatus.INACTIVE;
}
//탈퇴 여부 확인
public boolean isWithdraw(){
return this.status == UserStatus.WITHDRAWN;
}
// 탈퇴
public void withdraw(){
if (this.status == UserStatus.WITHDRAWN) {
throw new BaseException(UserErrorCode.USER_ALREADY_WITHDRAW);
}
this.status = UserStatus.WITHDRAWN;
this.deletedAt = LocalDateTime.now();
}
// 마지막 로그인 시간
public void updateLastLoginAt() {
if(this.status == UserStatus.WITHDRAWN){
throw new BaseException(UserErrorCode.USER_ALREADY_WITHDRAW);
}
this.lastLoginAt = LocalDateTime.now();
}
}
추가 필드
- authProvider : 로그인 방식, 제공자를 구분
- providerId : OAuth 제공자가 발급해주는 고유 사용자 식별값
OAuth 계정 password 설정
- OAuth 는 비밀번호를 직접 검증하지 않음
- 더미값을 넣으면 나중에 유지보수에 혼란이 생김
- 이 계정은 비밀번호가 있는 계정인가?
- 공통 비밀번호 검증 로직이 OAuth 계정에도 적용될 수 있음
- password → nullable 변경
- Local 로 가입 시 필수적으로 password 입력하게 설정
OAuth 중복 계정 생성 금지
- 같은 소설 계정인데도 신규 유저가 계속 생성되는 중복 생성 이슈 방지
- 제약 추가
- 같은 authProvider 에서 같은 providerId 를 가진 유저는 1명만 존재 가능
생성자 → 정적 팩토리 메서드
- new User() 사용을 막고, 가입 방식에 따라 생성 규칙을 강제하는 정적 팩토리 메서드 도입
OAuth 생성 시 도메인 검증
- OAuth 계정 생성 시 반드시 지켜야 하는 규칙 추가
- providerId 는 무조건 있어야 함
- authProvider 는 LOCAL 이 될 수 없음
application.yml
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
redirect-uri: ""
scope:
- profile
- email
naver:
client-id: ${NAVER_CLIENT_ID}
client-secret: ${NAVER_CLIENT_SECRET}
redirect-uri: ""
scope:
- name
- email
client-name: Naver
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
naver:
authorization-uri: <https://nid.naver.com/oauth2.0/authorize>
token-uri: <https://nid.naver.com/oauth2.0/token>
user-info-uri: <https://openapi.naver.com/v1/nid/me>
user-name-attribute: response
- client-id, client-secret 값은 발급받은 값으로 변경해줘야 함
OAuth2UserProviderRouter
- 소셜 로그인 제공자별 처리 로직을 분리하고, Spring Security 와 자연스럽게 연결하기 위한 라우터 역할
@Service
@RequiredArgsConstructor
public class OAuth2UserProviderRouter implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final GoogleOAuth2UserService googleOAuth2UserService;
private final NaverOAuth2UserService naverOAuth2UserService;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
String provider = userRequest.getClientRegistration().getRegistrationId(); // google, naver ..
return switch (provider){
case "google" -> googleOAuth2UserService.loadUser(userRequest);
case "naver" -> naverOAuth2UserService.loadUser(userRequest);
default -> throw new OAuth2AuthenticationException("지원하지 않는 소셜 로그인입니다. : " + provider);
};
}
}
OAuth2UserService
- Spring Security 가 OAuth2 로그인 과정에서 유저 정보를 가져올 때 호출하는 인터페이스
- OAuth2 인증 과정에서 Access Token 을 발급받은 이후, Security 는 이 토큰을 이용해 실제 사용자 정보를 조회함
- 이때 그 책임을 위임받는 컴포넌트
- 하는 일
- OAuth2UserRequest 안에 있는 정보(Access Token, ClientRegistration 등) 확인
- Provider 의 userInfo endpoint 호출(구글,네이버 등 사용자 정보 API)
- 응답을 파싱해서 OAuth2User 로 변환
- Spring Security 설정에서 기존 OAuth2UserService 자리에 주입 가능
OAuth2UserRequest
- OAuth2 로그인에 성공한 이후, userInfo 를 조회하기 위한 모든 재료를 담고 있는 객체
- ClientRegistration
- registrationId : google, naver
- userInfoEndpoint : URI, scope 등
- AccessToken
- userInfo API 호출에 필요한 토큰
GoogleOAuth2UserService
- 로그인한 사용자의 구글 정보를 받아와서 우리 서비스의 User 엔티티와 연결
구글 OAuth2 사용자 정보 응답 예시
{
"sub": "109876543210987654321", // 고유 식별자
"email": "example.user@gmail.com",
"email_verified": true,
"name": "Example User",
"given_name": "Example",
"family_name": "User",
"picture": "<https://lh3.googleusercontent.com/a/AAcHTteexample>",
"locale": "ko"
}
코드
@Service
@RequiredArgsConstructor
public class GoogleOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService();
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = defaultOAuth2UserService.loadUser(userRequest);
String email = oAuth2User.getAttribute("email");
String name = oAuth2User.getAttribute("name");
String providerId = oAuth2User.getAttribute("sub");
if (email == null || providerId == null) {
throw new OAuth2AuthenticationException("Google OAuth2 응답에 email/sub가 없습니다.");
}
User user = userRepository.findByAuthProviderAndProviderId(AuthProvider.GOOGLE,providerId)
.map(this::loginExistingUser)
.orElseGet(() -> createGoogleUser(email, name,providerId));
return new CustomUserDetails(user);
}
private User loginExistingUser(User user) {
user.updateLastLoginAt();
return user;
}
private User createGoogleUser(String email, String name, String providerId) {
User user = User.createOAuth(email, AuthProvider.GOOGLE,providerId);
String safeName = (name == null || name.isBlank()) ? "GoogleUser" : name.trim();
String nickname = makeUniqueNickname(safeName);
Profile profile = new Profile(nickname, safeName, "010-0000-0000", null, null);
user.attachProfile(profile);
user.updateLastLoginAt();
return userRepository.save(user);
}
private String makeUniqueNickname(String name) {
String base = "Runner_" + name;
String candidate = base;
int cnt = 0;
while (userRepository.existsByProfile_Nickname(candidate)) {
cnt++;
candidate = base + "_" + cnt;
}
return candidate;
}
}
DefaultOAuth2UserService
- Spring Security 가 기본으로 제공하는 OAuth2 사용자 정보 조회 구현체
- 흐름
- OAuth2 인증이 끝나고 Access Token 을 받음
- Provider 의 userInfo endpoint 에 요청을 보냄
- 사용자의 정보를 받아 OAuth2User 로 만들어줌
loadUser
- Spring Security 가 OAuth2 로그인 흐름에서 userInfo 를 조회할 때 호출하는 진입점
- 흐름
- Provider(userInfo) 에서 사용자 정보 가져오기
- Access Token 을 이용해 Google userInfo API 호출
- 응답을 OAuth2User 로 받음
- 응답에서 필요한 속성 추출
- 기존 유저 로그인 vs 신규 유저 생성 분기
- 이미 가입된 유저면 로그인 처리
- 없으면 새로 만들어 저장
- SecurityContext 에 들어갈 principal 반환
- Provider(userInfo) 에서 사용자 정보 가져오기
loginExistingUser
- 기존 가입 유저가 OAuth 로 다시 로그인한 경우
- 로그인한 시간 업데이트
createGoogleUser
- 구글 OAuth 로 회원가입
- 흐름
- OAuth 유저 생성
- 프로필 기본값 세팅
- Profile 생성
- 로그인 시간 갱신
- 저장
makeUniqueNickname
- OAuth 로 가입할 때 사용자가 닉네임을 입력하지 않으므로 기본 닉네임 만드는 메서드
NaverOAuth2UserService
Naver OAuth2 사용자 정보 응답 예시
{
"resultcode": "00",
"message": "success",
"response": {
"id": "32784923498234",
"email": "example@naver.com",
"name": "홍길동",
"nickname": "길동이",
"profile_image": "<https://ssl.pstatic.net/static/pwe/address/img_profile.png>",
"gender": "M",
"age": "20-29",
"birthday": "10-15",
"birthyear": "1998",
"mobile": "010-1234-5678"
}
}
코드
@Service
@RequiredArgsConstructor
public class NaverOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService();
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = defaultOAuth2UserService.loadUser(userRequest);
Map<String, Object> response = oAuth2User.getAttribute("response");
if (response == null) {
throw new OAuth2AuthenticationException("Naver OAuth2 response가 없습니다.");
}
String email = (String)response.get("email");
String name = (String)response.get("name");
String providerId = (String)response.get("id");
if (email == null || providerId == null) {
throw new OAuth2AuthenticationException("Naver OAuth2 응답에 email/id가 없습니다.");
}
User user = userRepository.findByAuthProviderAndProviderId(AuthProvider.NAVER, providerId)
.map(this::loginExistingUser)
.orElseGet(() -> createNaverUser(email, name, providerId));
return new CustomUserDetails(user);
}
private User loginExistingUser(User user) {
user.updateLastLoginAt();
return user;
}
private User createNaverUser(String email, String name, String providerId) {
User user = User.createOAuth(email, AuthProvider.NAVER, providerId);
String safeName = (name == null || name.isBlank())
? "NaverUser"
: name.trim();
String nickname = makeUniqueNickname(safeName);
Profile profile = new Profile(
nickname,
safeName,
"010-0000-0000",
null,
null
);
user.attachProfile(profile);
user.updateLastLoginAt();
return userRepository.save(user);
}
private String makeUniqueNickname(String name) {
String base = "Runner_" + name;
String candidate = base;
int cnt = 0;
while (userRepository.existsByProfile_Nickname(candidate)) {
cnt++;
candidate = base + "_" + cnt;
}
return candidate;
}
}
네이버 응답 구조 처리
- 네이버 OAuth2 의 가장 큰 특징은 실제 사용자 정보가 response 객체 안에 들어있음
- 이후는 위에서 설명한 Google 과 똑같음
OAuth2SuccessHandler - OAuth 로그인 성공 후 JWT 발급 + RefreshToken 저장/전달
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
private static final long REFRESH_EXPIRATION_DAYS = 7;
private final JWTUtil jwtUtil;
private final JwtRefreshTokenService jwtRefreshTokenService;
private final ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
// jwt 발급
JWTUserDto jwtUserDto = new JWTUserDto(userDetails.getUserUuid(), userDetails.getEmail(), userDetails.getName(), userDetails.getRole());
String accessToken = jwtUtil.createAccessToken(jwtUserDto);
String refreshToken = jwtUtil.createRefreshToken();
// Redis 저장
jwtRefreshTokenService.save(userDetails.getUserUuid(),refreshToken,REFRESH_EXPIRATION_DAYS, TimeUnit.DAYS);
// RefreshToken은 HttpOnly 쿠키로만 내려주기
ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.secure(false)
.path("/")
.maxAge(TimeUnit.DAYS.toSeconds(REFRESH_EXPIRATION_DAYS))
.sameSite("Lax")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
// AccessToken은 JSON으로 응답
response.setStatus(HttpServletResponse.SC_OK);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(), new ApiResponse<>("success", new TokenResponse(accessToken)));
}
}
public record TokenResponse(
String accessToken
) {
}
OAuth2SuccessHandler
- JWT 방식으로 인증 상태를 유지하기 위한 클래스
AuthenticationSuccessHandler
- Spring Security 가 인증 성공 시 호출하는 표준 Hook
- 흐름
- 인증 성공
- Authentication 객체 생성
- 그 안에 Principal(인증된 사용자) 을 넣음
- SuccessHandler 호출
onAuthenticationSuccess()
- OAuth 로그인 성공 직후 1회 호출
- 흐름
- 인증된 사용자 정보 추출
- JWT 발급(Access,Refresh)
- RefreshToken Redis 에 저장
- AccessToken → JSON 응답 본문 , RefreshToken → HttpOnly Cookie
- 클라이언트에게 최종 응답 반환
토큰 전달 방식
- RefreshToken (HttpOnly Cookie)
- 목적**:** XSS 공격으로부터 보호
- 설정
- httpOnly: true - JavaScript로 접근 불가
- secure: false - 개발환경 (운영환경에서는 true로 변경 필요)
- path: / - 모든 경로에서 전송
- maxAge: 7일
- sameSite: Lax - CSRF 공격 방어
- AccessToken (응답 본문)
- 목적**:** 프론트엔드에서 자유롭게 사용
- 형식**:** JSON 형태로 전달
- 사용**:** API 요청 시 Authorization 헤더에 포함
참고자료
'런닝 코스 공유 서비스' 카테고리의 다른 글
| [런닝 코스 공유 서비스] - 19. 회원 탈퇴 (0) | 2026.01.15 |
|---|---|
| [런닝 코스 공유 서비스] - 18. 회원 프로필 업로드 (0) | 2026.01.12 |
| [런닝 코스 공유 서비스] - 16. AccessToken 블랙리스트 (0) | 2026.01.11 |
| [런닝 코스 공유 서비스] - 15. Refresh Token (0) | 2026.01.11 |
| [런닝 코스 공유 서비스] - 14. 회원가입 전, 이메일 인증 (0) | 2026.01.11 |