OAuth - OAuth를 활용한 세션 로그인 구현

TopOfTheHead·2026년 5월 24일

Spring OAuth

목록 보기
7/12

세션 로그인 / 회원가입
권한부여승인코드

사전 설정

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/bblog
    username: ${userDbId}
    password: ${userDbPw}
  jpa:
    hibernate:
      ddl-auto: create
  security:
    oauth2:
      client:
        registration:
          google:
            client-name: Google
            client-id: ${googleClientId}
            client-secret: ${googleClientSecret}
            redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
            scope:
              - email
              - profile
          kakao:
            client-name: Kakao
            client-id: ${kakaoClientId}
            client-secret: ${kakaoClientSecret}
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
            scope:
              - profile_nickname
              - profile_image
          naver:
            client-name: Naver
            client-id: ${naverClientId}
            client-secret: ${naverClientSecret}
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
            scope:
              - id
              - nickname
              - email
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            user-name-attribute: id
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize 
            user-name-attribute: response
            token-uri: https://nid.naver.com/oauth2.0/token 
            user-info-uri: https://openapi.naver.com/v1/nid/me
  • Config 정의
@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity){
        return httpSecurity
                .csrf(csrf -> csrf.disable())
                .cors(cors -> cors.disable())
                .oauth2Login(Customizer.withDefaults())
                .authorizeHttpRequests(
                        auth -> auth.requestMatchers("/login", "/sign-up")
                                .anonymous()
                                .requestMatchers("/user/**")
                                .hasAnyAuthority("USER","ADMIN")
                                .requestMatchers("/admin/**")
                                .hasAuthority("ADMIN")
                                .anyRequest()
                                .denyAll()
                )
                .build();
    }
}

로그인 기능 활용하기

  • Entity Class 정의하기
    。OAuth 2.0 구현 시 보통 이메일을 식별용 아이디로서 데이터의 유일한 식별자 역할을 수행
    ▶ 중복회원을 방지하는 용도로 사용

    。 카카오는 사업자로 등록되지 않으면 이메일 계정을 요청할 수 없으므로 서브ID로서 ID + @kakao.com을 설정하여 유일하게 식별하도록 설정
@Column(unique = true, nullable = false)
private String email;

▶ Entity 설계 시 다음처럼 설정

。provider 필드는 OAuth Provider( = kakao, google, ... )를 지정하는 필드

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String nickname;
    private String role = "USER";
    @Column(unique = true, nullable = false)
    private String email;
    private String provider;
    @Builder
    public Member(String name, String nickname, String email, String provider) {
        this.name = name;
        this.nickname = nickname;
        this.email = email;
        this.provider = provider;
    }
}
  • DTO 역할의 OAuth2User 정의
    。OAuth2UserService( = UserServiceDetails )에 의해 사용되는 UserDetails 구현체를 정의 시 OAuth2User를 구현
    OAuth2User

    。OAuth Provider에서 제공되는 사용자정보에 해당하는 attrobutes 필드를 정의
    ▶ OAuth2UserService에 의해 사용자 정보를 포함하여 생성
@Getter
public class CurretUser implements OAuth2User {
    private String email;
    private String name;
    private Map<String,Object> attributes;
    @Setter
    private String role;
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(
                new SimpleGrantedAuthority("ROLE_" + this.role.name())
        );
    }
    @Builder
    public CurretUser(String email, String name, Map<String, Object> attributes){
        this.email = email;
        this.name = name;
        this.attributes = attributes;
    }
}
  • Repository Layer 정의
@Repository
public interface MemberRepository extends JpaRepository<Member,Long> {
    Optional<Member> findByEmail(String email);
    default Member findByEmailOrThrow(String email){
        return findByEmail(email).orElseThrow(()->new RuntimeException("이메일에 해당하는 계정을 찾지 못했습니다."));
    }
}

▶ 카카오는 사업자로 등록되지 않으면 이메일 계정을 요청할 수 없으므로 서브ID로서 ID + @kakao.com을 설정하여 유일하게 식별하도록 설정

  • 각 OAuth Provider의 자원 응답 구조에 대한 양식을 정의 및 커스텀 OAuth2User 구현체를 반환하는 팩토리 패턴 클래스 정의
    。각 OAuth Provider의 API 문서의 응답 구조 를 보고 설계
    ▶ google / kakao / naver는 사용자 정보 요청 시 반환하는 사용자정보의 json 구조가 다르므로

    。OAuth2UserService에 의해 사용자 정보를 포함하여 생성된 Oauth2User 구현체를 팩토리 패턴 클래스를 통해 각 OAuth Provider의 응답구조에 대응하는 로직을 각각 정의 후 커스텀 OAuth2User 구현체를 생성해서 반환하도록 설정
public class MemberDetailsFactory {
    public static CurretUser memberDetails(String provider, OAuth2User oAuth2User){
        Map<String,Object> attributes = oAuth2User.getAttributes();
        return switch (provider.toUpperCase()) {
            case "GOOGLE" -> CurretUser.builder()
                    .email(attributes.get("email").toString())
                    .name(attributes.get("name").toString())
                    .attributes(attributes)
                    .build();
            case "KAKAO" -> {
                Map<String, Object> properties = (Map<String, Object>) attributes.get("properties"); //
                yield CurretUser.builder()
			// "id"의 경우 "properties"와 동일 레벨에 존재
                        .email(attributes.get("id").toString() + "@kakao.com") // 별칭을 이메일로서 동적으로 지정
                        .name(properties.get("nickname").toString())
                        .attributes(attributes)
                        .build();
            }
            case "NAVER" -> {
                Map<String, Object> properties = (Map<String, Object>) attributes.get("response");
                yield CurretUser.builder()
                        .email(properties.get("email").toString())
                        .name(properties.get("nickname").toString())
                        .attributes(attributes)
                        .build();
            }
            default -> throw new IllegalArgumentException("지원하지 않는 제공자입니다. :" + provider);
        };
    }
}
  • Service Layer 정의 및 회원가입 로직 작성
    。OAuth를 통한 로그인 시 해당 백엔드 서버에 회원가입을 자동으로 수행하여 DB에 저장하도록 설정

    。이떄, OAuth를 통한 로그인 시 매번 호출되므로, 동일 계정으로 로그인 시 중복 회원가입되는 문제가 발생
    ▶ 최초 로그인일때만 회원가입을 수행하고, 아닌 경우 DB에 존재하는 사용자정보를 기반으로 Role을 설정한 커스텀 OAuth2User 생성 및 반환하도록 설정

    。동일한 이메일이 존재하는 경우 다른 Provider의 이메일인지 확인하는 로직을 작성
    ex) naver의 wjdtn747@naver.com이 등록된 상태에서 google의 wjdtn747@naver.com로 로그인 시 차단
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OAuthService extends DefaultOAuth2UserService {
    private final MemberRepository memberRepository;
    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // ~ 권한부여승인코드 상 1 ~ 6번이 자동으로 수행
        //
        // 권한부여승인코드 7 ~ 8번
        // OAuth2UserRequest의 Access Token, ClientRegistration을 기반으로
        // OAuth Provider에게 요청 및 반환받은 사용자정보를 기반으로 OAuth2User 구현체 생성
        OAuth2User oAuth2User = super.loadUser(userRequest);
        // OAuth Provider 정보 가져오기 ( kakao, ... )
        String providerId = userRequest.getClientRegistration().getRegistrationId();
        // 각 OAuth Provider의 각각 다른 응답구조에 대응하면서
        // 커스텀 OAuth2User 구현체를 생성하여 반환하는 Factory 메서드 사용
        CurretUser curretUser = MemberDetailsFactory.memberDetails(providerId, oAuth2User);
        //
        // 회원가입 분기점
        // 각 상호작용 후 currentUser의 Role을 설정해야한다.
        //
        String role;
        if (isNoExistingMember(curretUser.getEmail())) {
            // 가입된 계정이 없는 경우 회원가입
            role = saveMemberByCurrentUserAndReturnRole(providerId, curretUser);
        } else {
            // 동일한 이메일이 존재하는 경우 다른 Provider의 이메일인지 확인
            // ex) naver의 wjdtn747@naver.com이 등록된 상태에서
            // google의 wjdtn747@naver.com로 로그인 시 차단
            role = ValidateSameProviderAndReturnRole(providerId, curretUser.getEmail());
        }
        // 커스텀 OAuth2User에 Role 설정 및 로그인 완료
        curretUser.setRole(role);
        return curretUser;
    }
    public boolean isNoExistingMember(String email){
        return memberRepository.findByEmail(email).isEmpty();
    }
    public String ValidateSameProviderAndReturnRole(String currentProviderId, String email){
        Member founded = memberRepository.findByEmailOrThrow(email);
        if(founded.getProvider().equals(currentProviderId)){
            // 동일한 Provider인 경우 Role 반환
          return founded.getRole();
        } else {
            // 다른 Provider인데 동일 이메일인 경우 예외 발생
            throw new RuntimeException("중복이메일 입니다.");
        }
    }
    public String saveMemberByCurrentUserAndReturnRole(String providerId, CurretUser curretUser){
        Member saved = memberRepository.save(
                Member.builder()
                        .name(curretUser.getName())
                        .email(curretUser.getEmail())
                        .provider(providerId)
                        .nickname("별명")
                        .build()
        );
        return saved.getRole();
    }
}

▶ 반환된 OAuth2User 구현체 ( = UserDetails )는 이후 Authentication 구현체의 Principal에 포함되어 SecurityContextHolder의 현재 스레드의 SecurityContext에 저장됨


▶ Provider : google의 wjdtn747@naver.com이 등록된 상태에서 Provider : naver의 wjdtn747@naver.com이 회원가입 시 중복이메일 처리됨

profile
공부기록 블로그

0개의 댓글