본문 바로가기
Spring/Security

[Spring Security] OAuth2 로그인 성공 후: 신규와 기존 사용자를 가르는 설계의 모든 것

by coding_whale 2026. 5. 11.
반응형

OAuth2 인증 성공은 끝이 아니라 새로운 데이터 흐름의 시작이다. 서버는 공급자(Google, Naver 등)가 던져준 원본 데이터를 받아서 우리 시스템의 사용자 모델로 연결하고, 상태에 따라 다른 토큰을 쥐여줘야 한다. 

 

1. 전체 구조 조망

데이터가 어떤 컴포넌트를 거쳐 흘러가는지 전체적인 숲을 보자.

 

상세 시퀀스 다이어그램

이 코드가 돌아가는 순서를 엄격하게 정의하면 다음과 같다.

 

 

2. 설계의 5가지 핵심 이론 (Concepts & Theory)

단순히 코드를 짜는 것보다 '왜' 이렇게 설계했는지가 중요하다. 이 로직에는 5가지 중요한 아키텍처 원칙이 녹아 있다.

  1. 정규화 (Normalization): Google과 Naver의 응답 JSON 구조는 완전히 다르다. 이걸 서비스 로직에서 직접 다루면 if-else 분기가 곳곳에 퍼진다. OAuthAttributes로 데이터를 한 번 표준화하면 이후의 모든 로직은 공급자에 의존하지 않는 깨끗한 코드가 된다.
  2. 식별자 안정성 (Identity Invariant): 이메일은 사용자가 바꿀 수 있거나, 공급자가 제공하지 않을 수도 있다. 내부 식별은 반드시 provider + oauthId 기준으로 삼아야 한다. 이것이 시스템에서 변하지 않는 절대 식별자(Invariant)다.
  3. 권한을 상태 신호(Signal)로 활용: NEW_USER는 여기서 보안적인 접근 제어라기보다, 핸들러에게 보내는 "이 유저는 온보딩으로 보내라"는 비즈니스 신호다. 이 패턴을 쓰면 SuccessHandler의 로직이 아주 단순해진다.
  4. Fail-Fast 전략: 필수 식별값인 oauthId가 없으면 즉시 예외를 던져 로직을 중단시켜야 한다. 잘못된 데이터를 뒤로 넘기면 토큰 발급 단계에서 원인을 알 수 없는 유령 회원이나 장애가 발생한다.
  5. 인증(Authentication)과 인가(Authorization)의 분리: CustomOAuth2UserService는 "누구인가?(인증)"와 "회원인가?"를 판별하고, SuccessHandler와 Service는 "이 사람에게 어떤 권한을 줄 것인가?(인가/토큰 발급)"를 결정한다.

 

 

3. 파일별 코드 심층 분석

A. SecurityConfig: 체인 연결의 시작점

파일 위치: config/SecurityConfig.java

서비스와 핸들러를 시큐리티 필터 체인에 등록하는 설정이다. 여기서 모든 설정이 꼬이면 아래 로직들은 시작조차 못 한다.

// SecurityConfig 설정 요약
http
    .oauth2Login(oauth2 -> oauth2
        .userInfoEndpoint(userInfo -> userInfo
            .userService(customOAuth2UserService) // [2]단계: 사용자 로딩 서비스 등록
        )
        .successHandler(oAuth2JwtSuccessHandler) // [5]단계: 로그인 성공 후 처리를 위한 핸들러
    );

 

B. CustomOAuth2UserService: 판별과 신호 생성

파일 위치: auth/service/CustomOAuth2UserService.java

이 클래스의 loadUser는 OAuth 인증 성공 '직후'에 실행된다. 여기서 DB를 조회해 신규/기존 권한이라는 '깃발'을 꽂는 게 핵심이다.

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
    // 1. 부모의 기능을 빌려 Provider(구글/네이버)로부터 원본 속성들을 가져옴
    OAuth2User oAuth2User = super.loadUser(userRequest);

    // 2. 어떤 공급자인지 식별 (google, naver 등)
    String registrationId = userRequest.getClientRegistration().getRegistrationId();

    // 3. [Normalization] 공급자별 응답을 우리만의 표준 DTO(OAuthAttributes)로 변환
    OAuthAttributes attributes = OAuthAttributes.of(registrationId, oAuth2User.getAttributes());

    // 4. 다음 단계(SuccessHandler)로 넘길 커스텀 데이터 맵 구성
    // 이 맵이 나중에 SuccessHandler에서 꺼내 쓸 '재료'가 된다.
    Map<String, Object> customAttributes = new HashMap<>();
    customAttributes.put("provider", attributes.getOauthProvider().name());
    customAttributes.put("oauthId", attributes.getOauthId());
    customAttributes.put("email", attributes.getEmail());
    customAttributes.put("name", attributes.getName());

    // 5. [Identification] DB에서 기존 사용자 존재 여부 확인
    // 이메일이 아니라 '어디서 온 누구(provider+id)'인지를 기준으로 찾는다.
    boolean exists = userRepository.existsByOauthProviderAndOauthId(
            attributes.getOauthProvider(),
            attributes.getOauthId()
    );

    // 6. [Authority Signal] 결과에 따라 권한 부여
    // 존재하면 USER, 없으면 NEW_USER라는 신호를 principal에 심어서 반환
    // 이 깃발을 보고 나중에 SuccessHandler가 길을 갈라준다.
    if (exists) {
        return new DefaultOAuth2User(
                Set.of(new SimpleGrantedAuthority("USER")),
                customAttributes,
                "oauthId" // 유저 식별자의 키값 (mapping용)
        );
    }

    return new DefaultOAuth2User(
            Set.of(new SimpleGrantedAuthority("NEW_USER")),
            customAttributes,
            "oauthId"
    );
}

 

C. OAuthAttributes: 정규화 도구

파일 위치: auth/dto/OAuthAttributes.java

공급자마다 다른 JSON 필드를 파싱하여 공통 계약으로 변환한다. 새로운 소셜 로그인을 추가할 때 여기만 고치면 된다.

public static OAuthAttributes of(String registrationId, Map<String, Object> attributes) {
    // 다형성을 사용하여 공급자별 메서드 분기
    return switch (registrationId) {
        case "google" -> ofGoogle(attributes);
        case "naver" -> ofNaver(attributes);
        default -> throw new IllegalArgumentException("지원하지 않는 OAuth 공급자: " + registrationId);
    };
}

private static OAuthAttributes ofGoogle(Map<String, Object> attributes) {
    return OAuthAttributes.builder()
            .name((String) attributes.get("name"))
            .email((String) attributes.get("email"))
            .oauthProvider(OauthProvider.GOOGLE)
            .oauthId((String) attributes.get("email")) // 주의: 실무에선 이메일 대신 'sub' 권장
            .build();
}

private static OAuthAttributes ofNaver(Map<String, Object> attributes) {
    // 네이버는 'response'라는 맵 안에 정보가 층층이 쌓여있음
    Object responseObj = attributes.get("response");
    Map<String, Object> response = responseObj instanceof Map ? (Map<String, Object>) responseObj : attributes;

    String id = (String) response.get("id");
    // [Fail-Fast] 식별자가 없으면 즉시 중단. 뒤로 쓰레기 데이터를 넘기지 않는다.
    if (id == null) throw new IllegalArgumentException("NAVER oauthId가 누락되었습니다.");

    return OAuthAttributes.builder()
            .name(response.get("name") != null ? (String) response.get("name") : "Naver User")
            .email((String) response.get("email"))
            .oauthProvider(OauthProvider.NAVER)
            .oauthId(id)
            .build();
}

 

D. OAuth2JwtSuccessHandler: 경로 분기

파일 위치: auth/handler/OAuth2JwtSuccessHandler.java

꽂혀 있는 권한 신호를 읽어서 사용자의 다음 행선지를 결정하는 '교통 관제사'다.

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
    // 1. [Signal Consumption] 권한 목록에서 NEW_USER가 있는지 확인
    boolean isNewUser = authentication.getAuthorities().stream()
            .anyMatch(a -> a.getAuthority().equals("NEW_USER"));

    // 2. principal에서 아까 담아둔 속성들을 꺼냄
    OAuth2User principal = (OAuth2User) authentication.getPrincipal();
    String provider = (String) principal.getAttributes().get("provider");
    String oauthId = (String) principal.getAttributes().get("oauthId");
    String email = (String) principal.getAttributes().get("email");
    String name = (String) principal.getAttributes().get("name");

    // 3. 토큰 발급 로직 실행 (OAuthLoginSuccessService에 위임)
    var result = successService.handle(provider, oauthId, email, name, isNewUser);

    // 4. 신규 여부에 따라 다른 앱 딥링크로 리다이렉트
    if (result.newUser()) {
        // [신규] 온보딩 경로: 나이/성별 입력 화면으로 이동하며 임시 토큰 전달
        response.sendRedirect("프론트url://onboarding/age?tempToken=" + result.tempToken());
    } else {
        // [기존] 인증 경로: 메인 화면으로 이동하며 정식 토큰 세트 전달
        response.sendRedirect("프론트url://auth?accessToken=" + result.accessToken() + "&refreshToken=" + result.refreshToken() + "&newUser=false");
    }
}

 

E. OAuthLoginSuccessService: 토큰 정책 확정

파일 위치: auth/service/OAuthLoginSuccessService.java

실질적으로 어떤 종류의 열쇠(Token)를 깎아서 줄 것인지 최종 집행한다.

public Result handle(String providerStr, String oauthId, String email, String name, boolean ignored) {
    OauthProvider provider = OauthProvider.valueOf(providerStr);
    Optional<User> existing = userRepository.findByOauthProviderAndOauthId(provider, oauthId);

    // [기존 사용자] 정식 토큰 쌍(Access/Refresh)을 발급하여 즉시 서비스 이용 허용
    if (existing.isPresent()) {
        User user = existing.get();
        return new Result(
            false, 
            jwtTokenProvider.createAccessToken(user), 
            jwtTokenProvider.createRefreshToken(user), 
            null
        );
    }

    // [신규 사용자] 정식 AccessToken을 주지 않는다! (DB에 유저가 없으므로 PK 기반 토큰 생성 불가)
    // 대신 소셜 정보를 JSON -> Base64로 말아서 '임시 토큰(TempToken)'에 담아 보냄
    Map<String, Object> temp = new HashMap<>();
    temp.put("oauthProvider", provider.name());
    temp.put("oauthId", oauthId);
    temp.put("email", email);
    temp.put("name", name);

    String json = objectMapper.writeValueAsString(temp);
    // URL 세이프한 Base64로 인코딩하여 가입 정보를 안전하게 실어 보냄
    String base64 = Base64.getUrlEncoder().encodeToString(json.getBytes());
    String tempToken = jwtTokenProvider.createTempToken(base64, 60 * 60 * 1000); // 1시간 유효

    return new Result(true, null, null, tempToken);
}

 

 

4. 실무 관점에서의 심층 리뷰 (Practical Insights)

구현한 코드가 실무적인 관점에서 어떤 가치가 있는지, 그리고 어떤 고민을 더 해야 하는지 정리해 본다.

① 이 코드는 실무에서 정말 쓰는 방식인가?

결론부터 말하면: Yes. 특히 '추가 정보 입력이 필수인 모바일 앱' 환경에서 매우 정석적인 방식이다.

실무에서 신규 가입자를 처리하는 방식은 크게 두 가지다.

  • 방안 A (현재 코드 방식 - Stateless): 가입 완료 버튼을 누르기 전까지 DB에 행(Row)을 생성하지 않는다. TempToken에 공급자 정보를 들고 있는 방식이다. DB에 '가입하다 탈주한 유저'의 쓰레기 데이터가 쌓이지 않아 매우 깔끔하다.
  • 방안 B (Stateful): 일단 유저를 DB에 생성하되 status=PENDING 같은 상태값을 둔다. 통계(어디서 많이 탈주했는가)를 뽑기는 좋지만, 서비스 로직 곳곳에서 "미완성 유저"를 걸러내야 하는 비용이 발생한다.
  • 현재 구현한 방안 A는 데이터 무결성을 중요시하는 팀에서 선호하는 아주 세련된 방식이다.

 

② 토큰 전략 비교 (Access vs Temp)

 

③ 보안 및 확장성 팁

  • 식별자 선택: 구글의 경우 email을 ID로 썼는데, 실무에서는 구글이 주는 고유 식별자인 sub(Subject) 값을 쓰는 게 더 안전하다. 이메일은 극히 드문 확률로 사용자가 바꿀 수 있기 때문이다.
  • 데이터 크기: TempToken 안에 가입 정보를 너무 많이 담으면(프로필 이미지 URL 등), 토큰 길이가 길어져 리다이렉트 시 브라우저에서 잘릴 수 있다. 정보가 많아지면 Redis에 5분 정도만 저장하고 그 Key값만 토큰에 담는 방식으로 고도화하면 완벽하다.

 

 

5. 장애 시나리오 및 디버깅 포인트

  1. 로그인 성공 후 앱 복귀 실패:
    • 체크: response.sendRedirect에 들어가는 스킴(프론트url://)이 모바일 앱 설정(AndroidManifest.xml 등)에 정확히 등록되어 있는지 확인하라.
  2. 기존 유저인데 자꾸 신규로 뜸:
    • 체크: existsByOauthProviderAndOauthId의 쿼리 파라미터가 정확히 매핑되는지 확인하라. 특히 provider가 Enum인지 String인지에 따른 변환 오류가 잦다.
  3. 토큰 생성 예외:
    • 체크: successService.handle 내부의 objectMapper.writeValueAsString 단계에서 직렬화 에러가 나는지 로그를 살펴라.

 

 

6. 마무리 한 줄 정리

"D4 로직의 핵심은 외부 응답을 우리만의 표준(Normalization)으로 바꾸고, 권한이라는 신호(Signal)를 통해 정식 토큰과 임시 토큰의 경로를 안전하게 분리하는 데 있다."

이 구조를 따르면 새로운 소셜 로그인을 추가하는 것이 놀랍도록 쉬워지고, 가입 프로세스가 명확해지며, 잘못된 유저 데이터 유입을 입구에서부터 막을 수 있다.

반응형