
내가 이번에 구현할 서버의 인증 흐름은 다음과 같다.
/oauth2/authorization/google로 로그인 요청code를 우리 서버로 보내줌code로 사용자 정보를 받아와 DB에 저장/업데이트(loadOrRegisterUser)JwtTokenProvider를 호출해 JWT (티켓) 발급localhost:3000)로 리디렉션하며 token=...을 전달Authorization: Bearer ... 헤더에 담아 API 요청userId를 컨트롤러에 전달application.properties에 OAuth2 설정을 추가한다.
그런데 나는 이번 프로젝트를 깃허브에 public으로 올리고 있어서, gitignore 된 application-oauth.properties를 생성하여 추가하였다.
# ======== OAuth2 Client 설정 ========
# --- Google (구글) ---
spring.security.oauth2.client.registration.google.client-id=[구글 Client ID 입력]
spring.security.oauth2.client.registration.google.client-secret=[구글 Client Secret 입력]
# Google은 'openid, profile, email'이 기본 scope라 별도 설정이 필요 없을 수 있음. 만약 필요하면 아래 주석 해제
# spring.security.oauth2.client.registration.google.scope=profile,email
# --- Facebook (페이스북) ---
spring.security.oauth2.client.registration.facebook.client-id=[메타 Client ID 입력]
spring.security.oauth2.client.registration.facebook.client-secret=[메타 Client Secret 입력]
# (Spring 기본 scope: public_profile, email)
# 프로필 사진(picture)을 가져오기 위해 user-info-uri를 재정의함
spring.security.oauth2.client.provider.facebook.user-info-uri=https://graph.facebook.com/me?fields=id,name,email,picture.type(large)
# --- Naver (네이버) ---
# Naver는 Spring Boot 기본 제공자가 아니므로, provider 정보까지 모두 입력해야 함
spring.security.oauth2.client.registration.naver.client-id=[네이버 Client ID 입력]
spring.security.oauth2.client.registration.naver.client-secret=[네이버 Client Secret 입력]
spring.security.oauth2.client.registration.naver.client-name=Naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
# (TODO : 배포할 때 localhost 주소를 도메인으로 바꿔야함)
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
# Provider 설정 (네이버 전용)
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
# Naver 응답 JSON이 { "resultcode": "00", "message": "success", "response": { ... } } 형태이므로,
# 실제 사용자 정보가 있는 'response' 객체를 user-name-attribute로 지정해야 함
spring.security.oauth2.client.provider.naver.user-name-attribute=response
# --- Kakao (카카오) ---
# Kakao도 Spring Boot 기본 제공자가 아니므로, provider 정보까지 모두 입력해야 함
spring.security.oauth2.client.registration.kakao.client-id=[카카오 Client ID 입력]
spring.security.oauth2.client.registration.kakao.client-secret=[카카오 Client secret 입력]
spring.security.oauth2.client.registration.kakao.client-name=Kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
# (TODO : 배포할 때 localhost 주소를 도메인으로 바꿔야함)
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email,profile_image
spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post
# Provider 설정 (카카오 전용)
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
# Kakao의 고유 ID(PK)는 'id' 필드
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
# ======== JWT (Json Web Token) 설정 ========
jwt.secret-key=[64Byte 이상의 secret key 임의 입력]
# 만료시간 / 24시간 * 60분 * 60초 * 1000ms = 86400000
jwt.token-validity-in-milliseconds=86400000
Client ID/Secret를 받는 방법은 다음과 같다.
포털 : Google Cloud Console (구글 클라우드 콘솔)
발급 경로 :
1. 새 프로젝트를 생성한다.
2. API 및 서비스 > OAuth 동의 화면으로 이동하여 앱 이름, 이메일 등 기본 정보를 등록한다.
3. API 및 서비스 > 사용자 인증 정보로 이동한다.
4. 사용자 인증 정보 만들기 > OAuth 클라이언트 ID를 선택한다.
5. 애플리케이션 유형을 "웹 애플리케이션"으로 선택합니다.
6. "승인된 리디렉션 URI"에 리디렉션 할 주소(e.g., http://localhost:8080/login/oauth2/code/google)를 추가한다. (서버 배포할 때에는 http://localhost:8080 대신 실제 도메인 주소로 바꿔서 추가해야 함)
7. 생성 버튼을 누르면 Client ID/Scret이 발급된다.
포털 : Meta for Developers (Meta 개발자 포털)
발급 경로 :
1. 로그인 후 내 앱 > 앱 만들기를 선택한다.
2. 앱 유형으로 '비즈니스' 또는 '소비자' 등을 선택하고 기본 정보를 입력한다.
3. 앱 대시보드가 열리면, 왼쪽 메뉴에서 앱 설정 > 기본 설정으로 이동한다.
4. 여기에 "앱 ID (App ID)" (Client ID)와 "앱 시크릿 (App Secret)" (Client Secret)을 받아온다.
5. 왼쪽 메뉴 제품 > Facebook 로그인 > 설정으로 이동하여 유효한 OAuth 리디렉션 URI에 .../facebook 주소를 등록한다.
포털 : Kakao Developers (카카오 개발자 센터)
발급 경로 :
1. 로그인 후 내 애플리케이션 > 애플리케이션 추가하기를 선택한다.
2. 앱 정보를 입력하고 애플리케이션을 생성한다.
3. 앱 설정 > 요약 정보 탭으로 이동하여 REST API 키를 받아온다. 이것이 Client ID이다.
4. 왼쪽 메뉴 제품 설정 > 카카오 로그인으로 이동하여 활성화 설정을 ON으로 변경한다.
5. Redirect URI 항목에 .../kakao 주소를 등록한다.
6. 제품 설정 > 카카오 로그인 > 보안 탭으로 이동한다.
7. Client Secret 항목에서 발급 버튼을 누르면 Client Secret 코드가 발급된다.
포털 : Naver Developers (네이버 개발자 센터)
발급 경로 :
1. 로그인 후 Application > 애플리케이션 등록을 선택한다.
2. 앱 이름 등을 입력하고 사용 API에서 네이버 로그인을 선택한다. (필요한 정보: 이름, 이메일, 프로필 사진)
3. 로그인 오픈 API 서비스 환경에서 PC 웹을 선택하고, 서비스 URL (예: http://localhost:8080)과 "Callback URL" (.../naver)을 등록한다.
4. 등록이 완료되면 내 애플리케이션의 앱 상세 정보에서 Client ID/Secret 코드를 받아온다.
OAuthAttributes(DTO) : 응답 규격 통일구글, 카카오, 네이버, 페이스북은 모두 JSON 응답 구조가 다르다.
subidresponse 객체 안에 중첩되어 있음picture.data.url안에 중첩되어 있음OAuthAttributes라는 DTO를 만들어, 제각각인 JSON을 표준화된 (provider, oauthId, email, name, ...)객체로 파싱했다.
// OAuthAttributes.java
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
if ("naver".equals(registrationId)) {
return ofNaver(userNameAttributeName, attributes);
}
if ("kakao".equals(registrationId)) {
return ofKakao(userNameAttributeName, attributes);
}
if ("facebook".equals(registrationId)) {
return ofFacebook(userNameAttributeName, attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
// 카카오 파싱 예시
private static OAuthAttributes ofKakao(...) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
String email = null;
String nickname = null;
if (kakaoAccount.containsKey("email")) {
email = (String) kakaoAccount.get("email");
}
// ...
return OAuthAttributes.builder()
.name(nickname)
.email(email)
// ...
.build();
}
AuthService : 사용자 저장/업데이트loadOrRegisterUser라는 핵심 메소드를 만들었다. OAuthAttributes를 받아 UserRepository를 뒤져보고, 기존 회원이라면 User 엔티티의 name이나 profileImage를 업데이트한다. (@Transactional의 더티 체킹 활용)
신규회원이라면 OAuthAttributes.toEntity()를 호출해 User 엔티티를 새로 save()한다.
로그인 성공 직후의 로직은 다음과 같다.
JwtTokenProvider : 토큰 생성기jjwt라이브러리를 사용해 토큰을 생성한다. application-oauth.properties에서 jwt.secret-key(Base64 인코딩된 비밀 키)와 만료 시간을 주입받는다.
// JwtTokenProvider.java
public String createToken(Authentication authentication) {
// ...
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Long userId = userPrincipal.getUserId();
return Jwts.builder()
.setSubject(userId.toString())
.claim("userId", userId)
.claim("auth", authorities) // (예: "ROLE_USER")
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
여기서 보통 email로 설정하지만, 나는 email을 선택적으로 받아오게 해서, 사용자가 동의하지 않으면 null일 수가 있다. 따라서 토큰의 Subject를 null이 되지 않을 userId로 설정했다.
OAuth2LoginSuccessHandler : 성공 처리기SecurityConfig에 등록된 이 핸들러가 모든 것을 처리한다.
// OAuth2LoginSuccessHandler.java
@Override
public void onAuthenticationSuccess(...) {
// 1. Spring Security가 준 인증 정보(Principal)를 가져옴
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
// 2. 공급자("kakao")와 PK 필드명("id")을 동적으로 가져옴
String providerId = oauthToken.getAuthorizedClientRegistrationId();
ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(providerId);
String userNameAttributeName = clientRegistration.getProviderDetails()...getUserNameAttributeName();
// 3. 'OAuthAttributes'로 파싱
OAuthAttributes attributes = OAuthAttributes.of(providerId, userNameAttributeName, oAuth2User.getAttributes());
// 4. 'AuthService'로 DB에 저장/업데이트
User user = authService.loadOrRegisterUser(...);
// 5. 'UserPrincipal' (우리가 만든 인증 객체) 생성
UserPrincipal userPrincipal = new UserPrincipal(user);
// 6. 'JwtTokenProvider'로 JWT 발급
String jwtToken = jwtTokenProvider.createToken(createAuthentication(userPrincipal));
// 7. JWT를 담아 프론트엔드('localhost:3000')로 리디렉션
String targetUrl = UriComponentsBuilder.fromUriString(FRONTEND_REDIRECT_URL)
.queryParam("token", jwtToken)
.build().toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
이제 프론트엔드는 API를 요청할 때마다 헤더에 Authorization: Bearer <JWT>를 담아 보낸다.
Spring Security의 UsernamePasswordAuthenticationFilter 앞에 우리가 만든 JwtAuthenticationFilter를 배치했다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//...
// --------------- JWT 필터 등록
// Spring Security의 기본 인증 필터보다 직접 만든 JWT 필터를 먼저 실행하도록 순서를 지정
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final JwtTokenProvider jwtTokenProvider;
/**
* 실제 필터링 로직: 요청마다 JWT 토큰을 검사함
*/
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// Request Header에서 토큰을 꺼냄
String jwt = resolveToken(request);
// 토큰 유효성 검증
// (StringUtils.hasText: null, "", " "가 아닌지 확인)
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
// 토큰이 유효하면, 토큰에서 Authentication(인증 정보) 객체를 가져옴
Authentication authentication = jwtTokenProvider.getAuthentication(jwt);
// SecurityContextHolder에 인증 정보를 저장
// (이 코드가 실행되면, Spring Security는 이 요청을 '인증된 사용자'로 간주)
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 다음 필터로 요청 전달
filterChain.doFilter(request, response);
}
/**
* Request Header에서 "Bearer " 접두사를 제거하고 토큰 값만 추출
*/
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7); // "Bearer " (7글자) 이후의 토큰 반환
}
return null;
}
}
이 필터는 요청을 가로채서 헤더의 토큰을 검증(jwtTokenProvider.validateToken())하고, 유효하다면 JwtTokenProvider.getAuthentication()을 호출한다.
// JwtTokenProvider.java
/**
* JWT 토큰의 유효성을 검증
*/
public boolean validateToken(String token) {
try {
jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
getAuthentication()메소드는 토큰의 Payload에서 userId: 55번을 꺼내, Long 타입 55를 SecurityContextHolder에 저장한다.
// JwtTokenProvider.java
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// Payload에서 userId 가져오기
Long userId = claims.get("userId", Long.class);
// UserPrincipal 대신 userId를 principal로 사용 (컨트롤러에서 @AuthenticationPrincipal Long userId로 받기 위함)
return new UsernamePasswordAuthenticationToken(userId, token, authorities);
}
JwtAuthenticationFilter가 userId(Long 타입)를 저장해 둔 덕분에, 컨트롤러는 @AuthenticationPrincipal Long userId 어노테이션 한 줄로 현재 로그인한 사용자의 ID(55)를 주입받을 수 있다.
// AuthController.java
@GetMapping("/api/auth/me")
public ResponseEntity<UserResponseDto> getMyInfo(@AuthenticationPrincipal Long userId) {
// ...
UserResponseDto userInfo = authService.getUserInfo(userId);
return ResponseEntity.ok(userInfo);
}
이론은 간단했지만, 실제로는 수많은 오류와 싸워야 했다.
authorization_request_not_found (카카오/네이버):
STATELESS 설정 때문에, Spring Security가 로그인 요청 정보를 임시 저장할 세션이 없어서 발생.HttpCookieOAuth2AuthorizationRequestRepository를 구현하여, 세션 대신 임시 쿠키에 요청 정보를 저장하도록 SecurityConfig를 수정했다.401 [no body] (카카오):
client_secret_basic - 헤더 전송)을 사용하여, 카카오가 요구하는 client_secret_post (바디 전송) 방식과 불일치했다.application-oauth.properties에 client-authentication-method=client_secret_post를 명시적으로 추가하여 해결했다. 이 부분을 해결하는 데 오래 걸렸는데, Gemini가 이 부분을 명시 안 하면 자동으로 body 전송이 된다고 우겨서, header 방식으로 명시했다가 풀었다가 반복만 시켜서다. AI를 계속 맹신했으면 더 삽질했을 뻔.NullPointerException (카카오):
401 해결 후, 사용자가 카카오에서 '프로필 정보(닉네임)' 제공에 동의하지 않자, OAuthAttributes.ofKakao가 null인 profile 객체에 접근하려다 오류가 발생.ofKakao() 메소드에 profile 객체와 email이 null일 수 있음을 대비하는 null 체크(방어 코드)를 추가했다.Invalid Scopes: email (페이스북):
ClassCastException (Google OIDC):
원인: 구글(OIDC) 로그인은 Spring의 기본 OidcUserService를, 카카오 등은 우리가 만든 CustomOAuth2UserService를 타려고 하면서 인증 객체(Principal) 타입이 충돌했다.
해결: CustomOAuth2UserService를 삭제했다. 대신, OAuth2LoginSuccessHandler가 Spring 기본 인증 객체(OAuth2User)를 받은 뒤, AuthService를 호출하여 DB에 저장/조회하는 방식으로 로직을 성공 핸들러로 이전시켰다.