Refresh Token을 적용하고 테스트하는 과정에서 다음과 같은 문제 발생하였다.

문제 요약

  • A 사용자가 Google로 test@gmail.com 가입
  • B 사용자가 Kakao로 동일 이메일로 로그인 시도 → A 계정으로 로그인 처리됨
  • 이유: 이메일이 유일한 값으로 설정되어 있어 provider 구분 없이 하나의 계정으로 인식됨

해결 전략

  • User 엔티티에 provider 필드(enum)를 추가
  • 로그인 시 email + provider 조합으로 비교는 하지 않음
  • email만을 기준으로 중복 여부를 판단하고, 이미 존재할 경우 해당 provider 정보를 포함한 에러 응답 반환

API 응답 예시

{
  "success": false,
  "message": "GOOGLE로 가입된 이메일입니다.",
  "data": {
    "email": "test@gmail.com",
    "provider": "GOOGLE"
  },
  "errorCode": "U001",
  "statusCode": 409,
  "timestamp": "..."
}

만약 ID/PW로 일반 회원가입 한 경우는

{
  "success": false,
  "message": "LOCAL로 가입된 이메일입니다.",
  "data": {
    "email": "subin4420@gmail.com",
    "provider": "LOCAL"
  },
  "errorCode": "U001",
  "statusCode": 409,
  "timestamp": "2025-07-18T16:26:04.517582"
}

장점

  • 이메일 기준 단일 계정 유지
  • 소셜 계정 중복 생성을 방지
  • 프론트에서 사용자에게 정확한 로그인 플랫폼 안내 가능
  • DB에서 복합키 없이 로직으로 관리 가능

단점

  • 탈퇴 후 재가입 시 기존 플랫폼 정보를 사용자가 기억하지 못할 수 있음 → DB에 Provider 저장으로 해결
  • 가입 플랫폼 정보를 UI에 표시하지 않으면 사용자가 혼란을 겪을 수 있음 → 응답으로 provider 제공해 어떤 이메일이 어떤 플랫폼으로 가입되었는지 안내

결론

  • 하나의 이메일로 하나의 계정만 허용
  • 이미 가입된 이메일인 경우, 어떤 플랫폼(provider)으로 가입되어 있는지 명시하여 에러 응답
  • provider는 필수로 저장하며, LOCAL(ID/PW)과 소셜 로그인을 구분함

배운 점 정리

  1. 동일 이메일로 여러 플랫폼 가입을 허용하면 안 되는 이유: 계정 충돌
  2. provider는 필수 필드이며 null이 아닌 LOCAL, GOOGLE 등으로 명확히 구분
  3. 중복 가입 방지는 서버 로직으로 처리하고, 복합키 대신 에러 응답 설계로 유연하게 대응

[기능 목표]

비밀번호를 잊은 사용자가 본인 인증 후 비밀번호를 안전하게 변경할 수 있도록 구현

기존에는 비밀번호 변경 -> 이메일 검증(/valid) -> 새로운 비밀번호 입력(/updatepassword)

이때 새로운 비밀번호 입력 요청에서 진짜 우리 프론트에서 보낸 요청인지 검증하는 부분이 빠졌음

공격자가 postman 같은 걸로 /updatepassword 이 요청에 비밀번호 넣어서 보내면 수정될 가능성 있음

 

기존에는 비밀번호 변경

-> 이메일 검증(/valid-password) + 임시토큰 발행

-> Redis에 저장 + 프론트로 전송

-> 새로운 비밀번호 + 토큰(/updatepassword)

 

주요 흐름 및 로직 구성

1. 이메일 인증 기능을 "가입용"과 "비밀번호 변경용"으로 분리

2. 비밀번호 변경용 이메일 인증 후 → 임시 토큰 발급

목적 설명
임시 토큰 발급 인증된 사용자가 다음 단계(비밀번호 변경 요청)를 수행할 수 있도록 임시 토큰을 Redis에 저장
key 포맷 password_reset_token:{email}
value UUID (예: 4a911ded-ac5e-4820-a25b-c59a60b4dec0)
TTL 짧은 시간만 유효 (3분)

3. 프론트에서는

 

  • 사용자 이메일로 인증 요청
  • 인증 코드 입력 → 서버에서 검증 성공 시 임시 토큰 발급
  • 클라이언트가 token과 newPassword를 포함해 비밀번호 변경 요청 전송

 

POST /api/v2/users/password-reset

{
  "email": "subin4420@gmail.com",
  "password": "newPassword123!",
  "token": "4a911ded-ac5e-4820-a25b-c59a60b4dec0"
}

4. 서버에서는?

  • password_reset_token:{email} 키로 Redis에서 토큰을 꺼냄
  • 사용자가 준 token과 일치하면 통과
  • 비밀번호 변경 후 토큰 삭제 (deleteValue)
  • 위조된 요청 방지 및 1회성 처리 보장

마무리

비밀번호 변경 기능을 구현하면서 팀원과 함께 다음 두 가지 방식을 논의했다.

  1. 이메일 인증 후 "임시 비밀번호"를 이메일로 발급하고,
    사용자가 로그인한 뒤 비밀번호를 다시 변경하는 방식
  2. 이메일 인증 후 서버에서 임시 토큰을 발급하고,
    사용자가 새로운 비밀번호를 직접 설정하는 방식 (→ 현재 적용 방식)

첫 번째 방식은 실제로 많이 사용되는 사례이긴 하지만,

  • 임시 비밀번호를 복사해 로그인해야 하고
  • 로그인 후 다시 비밀번호 변경 과정을 거쳐야 하며
  • 사용자가 임시 비밀번호를 노출할 위험도 있음

→ 보안성과 사용자 경험 측면에서 단점이 있다고 생각했다.

따라서 우리는 두 번째 방식을 선택했다.
즉, 이메일 인증 후 임시 토큰을 Redis에 저장하고,
사용자는 이 토큰을 가지고 바로 새로운 비밀번호를 설정할 수 있도록 구성했다.

이 방식은:

  • 불필요한 로그인 절차 없이 곧바로 비밀번호를 바꿀 수 있고,
  • 임시 토큰은 짧은 시간만 유효하므로 보안성도 확보할 수 있다.

결과적으로 사용자 경험과 보안 측면에서 더 나은 선택인 것 같다.

 

Redis를 통해 잠깐 쓸 데이터 저장하고(키를 통해 사용자 별로) 사용이 끝나면 지워버리는 방식을 사용해 봤는데, 개발하는 과정에서 많이 사용되는 방식 같다.

이번에 진행하는 프로젝트에서 리프레시 토큰 적용을 하게 되었는데 대충만 알고 있으니 더 자세하게 알아보기로 했다.

이게 뭔지, 왜 필요한지, 설계 시 전략은 뭐가 있는지 알아보자

 

리프레시 토큰(Refresh Token)이란

리프레시 토큰은 클라이언트가 엑세스 토큰(Access Token)이 만료된 후, 사용자의 인증 정보를 재호가인하지 않고 새로운 엑세스 토큰을 발급받기 위해 사용하는 인증 수단

즉, 재인증 없이 액세스 토큰 갱신용 장기 유효 토큰

왜 리프레시 토큰이 필요한가

  • 보안상 엑세스 토큰의 유효기간을 짧게 하는게 보안에 좋다.
    • 엑세스 토큰을 가지고 있으면 인증없이 요청을 보낼 수 있기 때문에 만료를 자주 시키는게 좋음
    • 하지만 이렇게 된다면 사용자는 짧은 시간마다 로그인을 다시 해야한다. → 사용자 경험의 악영향
    • 리프레시 토큰을 제공해 엑세스 토큰이 만료되면 자동으로 재인증 할 수 있게 하는 것

이렇게 되면 궁금한 점이 생긴다.

엑세스 토큰을 짧게하고 싶어서 길게 유지하는 또 다른 토큰을 유지하는거면 동일한 거 아냐?

아래의 각 토큰의 차이 때문에 리프레시 토큰을 사용하는게 좋은 것이다.

엑세스 토큰

  • API 요청에 직접 사용됨
  • 가지고 있으면 즉시 인증을 통과해서 요청을 할 수 있음
  • 그렇기에 탈취 시 바로 악용 가능함
  • 보안상 만료 시간을 짧게 해야함
  • 보통 서버에서 상태비저장(JWT) 그렇기에 발급해주고 나면 서버는 그 토큰을 누가 주든 믿을 수 밖에 없음 → 회수 불가함

→ 파워가 센 토큰임, 이거에 유효기간이 길면 보안이 약해짐

리프레시 토큰

  • 엑세스 토큰이 만료되었을 때 사용
  • 서버에서 보관함, 클라이언트는 HttpOnly 쿠키와 같이 보호되는 영역에 저장함
    • 그렇기에 JS가 리프레시 토큰을 건들 수 없이 단지 요청에 포함할지 말지만 정할 수 있다. → 안전
  • IP, User-Agent, 발급 이력 등으로 추가 검증이 가능함 → 상태저장하니까 이게 가능함
  • 상태저장 중이니 로그아웃, 블랙 리스트 등록 등을 리프레시 토큰을 통해 할 수 있다.

동작 흐름

[1단계] 최초 로그인

  • 사용자가 ID/PW 또는 소셜 로그인 진행
  • 서버는 다음 2개 토큰 발급해줌
    • access_token(짧은 유효기간)
    • refresh_token(긴 유효기간)
  • access_token은 클라이언트에 저장(LocalStorage or 메모리)
  • refresh_token은 보안성을 위해
    • 서버 DB 저장 + 클라이언트에는 HttpOnly 쿠키로 전달하게 하는게 좋음

[2단계] 엑세스 토큰이 만료되었을 때

  • 클라이언트가 API 요청 시 401 Unauthorized 응답 발생
  • 이 때 클라이언트는 refresh token을 서버에 보내 새 엑세스 토큰 요청
  • 서버는 다음을 수행함
    • refresh token 유효성 확인
    • DB에서 해당 토큰 존재 여부 확인
    • 새 access token 발급 → 응답
    • refresh token도 새로 갱신(이건 옵션이지만 보안성 높아짐 이게 로테이션 전략)

[3단계] 리프레시 토큰 만료 또는 이상 감지

  • 서버는 refresh token 만료 시 401 Unauthorized 또는 403 Forbidden 응답
  • 클라이언트는 재로그인 요구

서버 구조 설계

  1. refresh token은 서버에 저장
    1. DB에 저장할지
    2. Redis와 같이 메모리에 저장할지
  2. Refresh는 HttpOnly + Secure 쿠키 사용하는게 좋음
    1. JS가 접근 못함
  3. 사용자 로그아웃되면 refresh token 삭제 로직 필요

Refresh Token 유지 전략

1. JWT + Redis

Refresh Token을 인-메모리 구조

  • 빠름
  • 서버 재시작 시 초기화
  • 즉 서버 다운 시 사용자는 다시 로그인할 필요있음

2. JWT + DB

  • 느림
  • 서버 재시작되어도 유지
  • 보안 추적하기 좋음
  • 복잡한 조회 가능

3. 하이브리드 방식

  • DB, Redis 양쪽에 저장
  • 평소에는 Redis에서 빠르게 검증
  • Redis에 없거나 조건 필요 시 → DB 조회
  • DB에 항상 같이 끄기에 쓰기 속도는 느림, 읽기는 Redis이기에 빠름
  • 여기서는 DB가 Source of Truth임 그렇기에 레디스에 있고 DB에 없으면 그 토큰 제거

이걸 기반으로 Spring boot의 ID/PW와 OAuth2 기반 인증/인가에 Refresh Token 적용해보자

사이드 프로젝트 시작한 지 한 달 정도 되었다.

처음부터 일지를 적었으면 좋았겠지만 지금이라도 적어야겠다.

 

현재까지 내가 한 일

OAuth 적용

비밀번호 찾기

회원 정보 변경 추가

유저 관련 스웨거 작성

 

더 알아봐야하는 것

1. 테스트 코드 작성법

2. 스웨거 작성법

3. Redis

 

회고

이번 주까지 User 관련 작업을 끝내고,

이제 메인 기능 개발 시작해야 한다.

Redis를 미리 공부해야 한다.

개발 시작하고 찾아보느라 진도 늦춰지면 안 되니까.

OAuth 적용할 때 찾아보며 개발하느라 많이 지체되었다.

 

 

프로젝트에 OAuth2를 적용해봤다.

구글, 네이버, 카카오 이렇게 3개 플렛폼을 넣었다.

 

기존에 있는 건 ID/PW 기반 로그인 + JWT

 

먼저 OAuth2를 스프링에 적용하는 과정은 크게 보면 다음과 같다.

  • OAuth2 의존성 추가
  • SecurityConfig에 OAuth2 Filter 추가
  • OAuth2UserService 작성
  • SuccessHandler 작성
  • FailureHandler 작성

이렇게만 이해하고 실제로 적용하려니 신경써야하는 부분이 많았다.

 

SecurityConfig에 OAuth2 Filter 추가

.oauth2Login(oauth -> oauth
        .userInfoEndpoint(user -> user
                .userService(customOAuth2UserService)
        )
        .successHandler(customSuccessHandler)
        .failureHandler(customFailureHandler)
)

 

이런 식으로 추가한다.

여기에 OAuth2UserService와 SuccessHandler, FailureHandler가 들어간다.

 

.oauth2Login() 필터가 oauth2 로그인 전반을 설정하는 것이다.

/oauth2/authorization/kakao 가 들어왔을 때

Spring Security는 이를 감지하고 OAuth2 인증 흐름을 시작한다.

 

1. OAuth2AuthorizationRequestRedirectFilter 동작

/oauth2/authorization/kakao 이 요청이 들어오면 사용자를 해당 Provider에게 리디렉션을 해주는 역할

 

2. 사용자는 인증 후 리디렉션 되고, 브라우저는 다음 경로로 GET 요청을 보낸다.

여기에는 백엔드 서버가 인증서버로 보낼 Authorization Code가 들어있다.

 

3. OAuth2LoginAuthenticationFilter 동작

이 필터가 위에 인증 코드가 담긴 요청을 가로채서 처리함

내부적으로 OAuth2LoginAuthenticationProvider 호출하게 된다.

1. 인증코드를 인증 서버에 보내 Access Token을 받아온다.

2. 이 Access Token을 통해 사용자 정보를 받아온다.

3. 이때 내가 넣은 .userService()로 등록된 customOAuth2UserService가 동작한다.

 

4. customOAuth2UserService 호출

이 건 DefaultOAuth2UserService를 상속해 확장한 것이다.

이걸 호출하는 주체가 OAuth2LoginAuthenticationProvide이다.

여기에 들어있는 

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken)authentication;
    if (loginAuthenticationToken.getAuthorizationExchange().getAuthorizationRequest().getScopes().contains("openid")) {
        return null;
    } else {
        OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthenticationToken;
        try {
            authorizationCodeAuthenticationToken = (OAuth2AuthorizationCodeAuthenticationToken)this.authorizationCodeAuthenticationProvider.authenticate(new OAuth2AuthorizationCodeAuthenticationToken(loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange()));
        } catch (OAuth2AuthorizationException var9) {
            OAuth2AuthorizationException ex = var9;
            OAuth2Error oauth2Error = ex.getError();
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
        }

        OAuth2AccessToken accessToken = authorizationCodeAuthenticationToken.getAccessToken();
        Map<String, Object> additionalParameters = authorizationCodeAuthenticationToken.getAdditionalParameters();
        OAuth2User oauth2User = this.userService.loadUser(new OAuth2UserRequest(loginAuthenticationToken.getClientRegistration(), accessToken, additionalParameters));
        Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper.mapAuthorities(oauth2User.getAuthorities());
        OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange(), oauth2User, mappedAuthorities, accessToken, authorizationCodeAuthenticationToken.getRefreshToken());
        authenticationResult.setDetails(loginAuthenticationToken.getDetails());
        return authenticationResult;
    }
}

이 코드에서  Authentication 객체를 만들어준다.

Authentication을 만들기 위해

여기에서 customOAuth2UserService이걸 호출하는 것이다.

customOAuth2UserService이건 DefaultOAuth2UserService를 상속받은 것이고

 

그 내부에는 

// DefaultOAuth2UserService 내부
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		...
        RequestEntity<?> request = (RequestEntity)this.requestEntityConverter.convert(userRequest);
        ResponseEntity<Map<String, Object>> response = this.getResponse(userRequest, request);
		...
    }
    
    private ResponseEntity<Map<String, Object>> getResponse(OAuth2UserRequest userRequest, RequestEntity<?> request) {
        OAuth2Error oauth2Error;
        try {
            return this.restOperations.exchange(request, PARAMETERIZED_RESPONSE_TYPE);
        } catch (OAuth2AuthorizationException var6) {
        ...

이렇게 yml에 작성된 인증 서버에서 userinfo를 보내달라는 요청을 보낸다. 그 후 response를 받아온다.

Map<String, Object> attributes = (Map)((Converter)this.attributesConverter.convert(userRequest)).convert((Map)response.getBody());

여기서 받아 온 response를 attributes로 만드는 것

여기에 attributes는 인증 플랫폼이 주는 JSON 형태 그대로를 받아온다.

그 형식은 플랫폼 마다 다르게 주기 때문에 여러 플랫폼은 연동하려면 이걸 맞춰주는 과정이 필요하다.

 

구글 응답 예시

{
  "sub": "113409583049857309458",
  "name": "홍길동",
  "given_name": "길동",
  "family_name": "홍",
  "picture": "https://lh3.googleusercontent.com/a/abc123",
  "email": "hongildong@gmail.com",
  "email_verified": true,
  "locale": "ko"
}

네이버 응답 예시

{
  "resultcode": "00",
  "message": "success",
  "response": {
    "id": "Qy0VwYkT63c8B8K7Jr1F6AGGJasUaw==",
    "email": "hongildong@naver.com",
    "name": "홍길동",
    "nickname": "길동이",
    "profile_image": "https://ssl.pstatic.net/static/pwe/address/img_profile.png",
    "age": "20-29",
    "gender": "M",
    "birthday": "10-01"
  }
}

카카오 응답 예시

{
  "id": 123456789,
  "connected_at": "2023-06-25T01:23:45Z",
  "kakao_account": {
    "profile_needs_agreement": false,
    "profile": {
      "nickname": "홍길동",
      "thumbnail_image_url": "http://xxx.jpg",
      "profile_image_url": "http://xxx.jpg"
    },
    "has_email": true,
    "email_needs_agreement": false,
    "is_email_valid": true,
    "is_email_verified": true,
    "email": "hongildong@kakao.com"
  }
}

 

이런 형식이다.

 

그걸 위해 CustomOAuth2UserService를 만들어야하는 것이다.

 

CustomOAuth2UserService

public class CustomOAuth2UserService extends DefaultOAuth2UserService {
...

@Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    	...
            OAuth2User oauth2User = super.loadUser(userRequest);        
            OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oauth2User.getAttributes());

	...
    }

 

DefaultOAuth2UserService가 JSON을 Attributes로 바꿔주는 로직은 그대로 써서 받아온다.

이걸 안하려면 내가 그걸 다 구현해줘야한다.

이제 받아온 걸 기반으로 각 플랫폼의 형식을 통일해줘 userInfo를 만들어주고

이걸 기반으로 DB에서 기존 유저인지 아닌지를 파악 후 OAuth2User를 반환해주면 되는 것이다.

 

그럼 OAuth2LoginAuthenticationProvider 내부에서 이렇게 만들어진 OAuth2User을 포함해 Accesstoken 등 다양한 인증정보를 가진 Authentication 객체를 만드는 것이다.

 

5. SuccessHandler & FailureHandler

이렇게 만들어진 Authentication 객체가 리턴될 때 token.setAuthenticated(true); 이렇게 인증이 됐는지 안됐는지 체크하는 필드가 있음 이게 True면 등록한 successHandler가 실행된다.

 

만약 OAuth2LoginAuthenticationProvider.authenticate() 안에서 예외(OAuth2AuthenticationException)가 발생하면 failureHandler가 실행되는 것이다.

실패할 때는 실패했다는걸 프론트로 보내주게 했고

 

성공 시에는 해당 정보를 통해 JWT를 만들어 프론트로 응답하게 했다.

 

 

마무리

교육 듣고 있는 것도 있고, 처음 다뤄본 부분이라 이걸 2주 가까이 걸렸다.

이전 프로젝트 내용도 참고하고, 내 친구(지피티)의 도움으로 연결할 수 있었고,

Spring Security가 특히 내부적으로 이렇게 저렇게 해주는 부분이 참 많아서 내부를 이해하는데,

조금은 힘들었다.

기존 프로젝트에서 발전시켜 상용화할 목적으로 진행하는 프로젝트에 참여하게 됐다.

기존 프로젝트가 있지만 처음부터 새롭게 만드는 걸 목표로 하고 있다.

내가 OAuth2 구현을 맞게 되었지만, 처음하는 부분이라 많이 생소했다.

먼저 혼자서 OAuth2 흐름을 이해하려 했고, 한 번 프로젝트에 적용시켜 봤다.

하지만 각 플랫폼 별 제공하는 유저데이터 형식이 다다르다는 걸 알게 됐고,

프론트로 JWT를 제공하는 방식도 내가 했던 부분과 조금 차이가 있었다.

OAuth2의 전반적인 흐름을 알기 위해 기존에 프로젝트에서 구현하셨던 부분을 분석하고,

큰틀에서 이해해보며, 내가 한 번 프로젝트에 적용시켜보려고 한다.

Spring Security에서의 OAuth2 흐름

  1. 사용자가 소셜 로그인 버튼 클릭
  2. 프론트에서 해당 소셜 로그인 페이지로 리다이렉션
  3. 사용자는 로그인 시행
  4. 소셜에서 자신의 회원임이 확인되면 인증코드가 담긴 요청을 서버로 리다이렉션 시킴
  5. 서버는 해당 인증코드가 담긴 요청을 받게 되고 그 요청을 OAuth2LoginAuthenticationFilter가 가로채고 code를 추출함
  6. 내부적으로 해당 코드를 소셜에 보내 Access Token을 요청
  7. access_token으로 유저 프로필 정보를 요청
  8. CustomOAuth2UserService.loadUser() 호출 됨
  9. OAuth2User 반환 후 Authentication 생성
  10. 인증 성공 후 OAuth2LoginSuccessHandler.onAuthenticationSuccess() 호출됨

소셜 로그인이 들어와 내부 동작으로 얻게 되는건 Access Token이다.

결국 개발자는 이걸 통해 구글에 유저 정보를 받아와서 내 DB에 넣는 로직과 이를 기반으로 JWT를 만들면 된다.

개발자가 할 일

  1. Access Token으로 유저 정보를 요청해 얻어 오고 이를 통해 DB에 이미 있는지 확인 없다면 추가
    • 소셜 유저를 우리 시스템 유저로 통합
    • 보통 CustomOAuth2UserService를 만듬
    • DefualtOAuth2UserService를 사용하거나 이걸 상속받아 커스텀
    • 기본은 플랫폼 마다 다르게 오게 됨 이걸 커스텀에서 우리 시스템에 맞게 파싱 필요
  2. 성공한 유저에게 줄 JWT를 생성 → OAuth2LoginSuccessHandler
  3. 실패한 유저에게 할 행동 수행 → OAuth2LoginFailureHandler

이 세 가지를 할 코드만 만들면 됨

1. CustomOAuth2UserService

1. User을 저장할 Repository 선언

 

2. loadUser 메서드 오버라이드→ 여기서 단순히 Key:Value로 매핑해주는 작업은 DefaultOAuth2UserService().loadUser()가 해줄 수 있음

  • 이 메서드가 단순하게 키값으로 매핑된 OAuth2User을 돌려줌
  • OAuth2UserRequest는 인증이 완료된 정보, 즉 Access Token만 들어 있는 상태
  • 이때 우리가 확인해야 하는 게 registrationId→ userRequest.getClientRegistration() 호출로 확인 가능
  • ClientRegistration을 통해 어떤 플랫폼(Google, Naver, Kakao 등)에서 로그인했는지를 구분할 수 있음

3. 인증 서버에 사용자 정보를 요청할 Endpoint 확인

userRequest.getClientRegistration()
           .getProviderDetails()
           .getUserInfoEndpoint()

→ 이 안에 있는 .getUserNameAttributeName() 메서드가 고유 식별자 키를 알려줌

→ 즉, 결과적으로 userNameAttributeName은 sub, id 등이 될 수 있음 (구글: sub, 네이버/카카오: id 등)

 

4. DefaultOAuth2UserService가 만들어준 OAuth2User에서 정보를 받아오고

→ 플랫폼에 따라 email, name, nickname 키 값이 다 다르기 때문에 이를 통일된 형식으로 변환할 로직 필요

→ 이걸 플랫폼 별 파서를 둬서 정제하는 구조로 가져가야 함

 

5. 이 로직을 기반으로 우리 시스템의 User로 등록 (DB 저장)→ 이 과정이 끝나면 Spring Security 내부에서 Authentication 객체로 자동 저장됨

→ 이미 있다면 패스, 없다면 새로 저장

 

6. 마지막으로 CustomOAuth2User 또는 DefaultOAuth2User를 반환

추가적으로, OAuthAttribute라는 클래스를 두고→ 우리 시스템에서 사용할 수 있는 공통 형식으로 매핑

→ 들어온 플랫폼 정보 (registrationId)와 원본 데이터를 받아

 

이때 사용할 수 있는 구조는 다음과 같이 구성:

OAuth2UserInfo → 추상 클래스

  • getEmail(), getName(), getProviderId() 같은 추상 메서드 정의

GoogleOAuth2UserInfo, NaverOAuth2UserInfo, KakaoOAuth2UserInfo 등 → 플랫폼 별 구현체

 

7. 최종적으로 userNameAttributeName은 플랫폼 별로 다르기 때문에→ 이를 기준으로 사용자 식별 및 등록 처리 진행

→ sub, id 등 플랫폼에 맞게 분기 처리하여 식별자 추출하고

 

8. 그리고 이 Access Token을 통해 사용자 정보를 불러오는 로직은→ 즉, 여기서 받은 정보를 기반으로 사용자 엔티티를 만들고 반환하면 끝

→ loadUser 내부에서 이미 DefaultOAuth2UserService().loadUser(request)를 통해 처리 가능

 

2. OAuth2LoginSuccessHandler

구글에서 사용자 인증을 받아 관련 Access Token까지 받고 우리 시스템에 맞게 변환까지 된 상황이다.

즉 CustomOAuth2User 객체가 만들어진 상태

1. 인증 성공 시 SecurityContext에 Authentication.getPricipal()에 들어감

→ Authentication 인터페이스의 구현체인 OAuth2AuthenticationToken에 들어가는 것

이게 JWT에서는 UsernamePasswordAuthenticationToken을 사용했었음

 

2. 이제 로그인이 성공했으니

CustomOAuth2User에 정보들로 JWT토큰을 만들어야함

email + 권한을 넣어 토큰을 만든다.

    public void setAccessToken(HttpServletResponse response, String accessToken) {
        response.setHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + accessToken);
    }

 

3. 위와 같이 헤더에 만들어진 토큰을 담아 프론트로 보내준다.

 

4. 여기서 추가 기능을 위해 Redis에 토큰의 상태를 저장할 수 있다.

 

Redis로 JWT 상태유지

로그인이 되어서 JWT를 줬다면 유효기간 동안은 계속 요청을 보낼 수 있다.

만약 해당 사용자가 부정을 일으키거나, 다른 기기에서 로그인 혹은 탈퇴를 했다면 그 즉시 토큰을 무효화 해야한다.

그러기 위해서 로그인 성공과 동시에 유저의 토큰 정보를 Redis에 저장하는 것

 

이것도 상태 비저장을 위해 만든 HTTP에 상태저장의 이점을 위해 쿠키나 세션을 만든 것과 같이 서버 측 상태저장을 안하려고 만든거지만 이점을 위해 상태저장을 어느 정도는 하는 것이다.

OAuthAttribute

소셜 로그인으로 가져온 유저 데어터는 플랫폼마다 키 값도 다르고 구조도 다름

  • 구글은 sub, email, name
  • 카카오는 id, kakao_account.email, properties.nickname
  • 네이버는 response.id, response.email, response.name

이걸 하나의 통일된 형태로 변환해서 우리 시스템의 객체로 만들기 위한 중간 DTO 역할을 하는 게 이 클래스의 목적이다.

 

nameAttributeKey 로그인 시 PK 쓸 키 이름

OAuth2UserInfo 각 플랫폼에 맞게 파싱된 사용자 정보

 

어떤 플랫폼이고, pk 키 이름은 뭐고, 어떤 유저 데이터를 가졌는지 넘겨주면

해당 플랫폼의 맞는 파서를 통해 Attribute를 만들어 줄 것이다.

OAuth2UserRequest 객체란?

Spring Security가 OAuth2 인증을 마친 후, access token을 담아서 우리에게 전달해주는 객체다.

즉 OAuth2 로그인 성공 후, access token + 클라이언트 등록 정보 등을 담아서 넘겨주는 것

ClientRegistration

OAuth2AcessToken

Map<String, Object> additionalParameters

OAuth2User 란?

Spring Security가 OAuth 로그인 사용자 정보를 통합적으로 다루기 위해 만든 인터페이스

필수 메서드

String getName(); // 사용자 ID (unique key)
Map<String, Object> getAttributes(); // 소셜에서 받은 사용자 정보
Collection<? extends GrantedAuthority> getAuthorities(); // 권한 (ROLE_XXX)
  • DefaultOAuth2User
    • 기본 OAuth2User 구현 클래스
    • getName() 메서드 구현 해둠
    • 하지만 email, role 같은 다른 정보는 .getAttributes().get(”email”) 같이 써야함
  • CustomOAuth2User
    • 우리 시스템에선 email과 role을 자주 사용할 것이기 때문에
    • getEmail, getRole 같은 메서드를 만들어 확장하면 편하게 사용 가능하다.

 

마무리

기본적인 멀티플랫폼 OAuth2에 동작원리는 이해했다.

이해한 것과 적용하는 건 또 차이가 있기 때문에 팀원분에 도움을 좀 받으면서 말끔하게 동작하도록

적용해 봐야겠다.

 

 

+ Recent posts