런닝 코스 공유 서비스

[런닝 코스 공유 서비스] - 17. Oauth 로그인

sson-coding 2026. 1. 12. 23:49

🏅오늘의 목표

  • 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();
	}

}

추가 필드

  1. authProvider : 로그인 방식, 제공자를 구분
  2. 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 사용자 정보 조회 구현체
  • 흐름
    1. OAuth2 인증이 끝나고 Access Token 을 받음
    2. Provider 의 userInfo endpoint 에 요청을 보냄
    3. 사용자의 정보를 받아 OAuth2User 로 만들어줌

loadUser

  • Spring Security 가 OAuth2 로그인 흐름에서 userInfo 를 조회할 때 호출하는 진입점
  • 흐름
    1. Provider(userInfo) 에서 사용자 정보 가져오기
      1. Access Token 을 이용해 Google userInfo API 호출
      2. 응답을 OAuth2User 로 받음
    2. 응답에서 필요한 속성 추출
    3. 기존 유저 로그인 vs 신규 유저 생성 분기
      1. 이미 가입된 유저면 로그인 처리
      2. 없으면 새로 만들어 저장
    4. SecurityContext 에 들어갈 principal 반환

loginExistingUser

  • 기존 가입 유저가 OAuth 로 다시 로그인한 경우
  • 로그인한 시간 업데이트

createGoogleUser

  • 구글 OAuth 로 회원가입
  • 흐름
    1. OAuth 유저 생성
    2. 프로필 기본값 세팅
    3. Profile 생성
    4. 로그인 시간 갱신
    5. 저장

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
  • 흐름
    1. 인증 성공
    2. Authentication 객체 생성
    3. 그 안에 Principal(인증된 사용자) 을 넣음
    4. SuccessHandler 호출

onAuthenticationSuccess()

  • OAuth 로그인 성공 직후 1회 호출
  • 흐름
    1. 인증된 사용자 정보 추출
    2. JWT 발급(Access,Refresh)
    3. RefreshToken Redis 에 저장
    4. AccessToken → JSON 응답 본문 , RefreshToken → HttpOnly Cookie
    5. 클라이언트에게 최종 응답 반환

토큰 전달 방식

  1. RefreshToken (HttpOnly Cookie)
  • 목적**:** XSS 공격으로부터 보호
  • 설정
    • httpOnly: true - JavaScript로 접근 불가
    • secure: false - 개발환경 (운영환경에서는 true로 변경 필요)
    • path: / - 모든 경로에서 전송
    • maxAge: 7일
    • sameSite: Lax - CSRF 공격 방어
  1. AccessToken (응답 본문)
  • 목적**:** 프론트엔드에서 자유롭게 사용
  • 형식**:** JSON 형태로 전달
  • 사용**:** API 요청 시 Authorization 헤더에 포함

참고자료

https://ddururiiiiiii.tistory.com/618#google_vignette

https://velog.io/@chrkb1569/OAuth-2.0을-활용한-로그인-구현