Recording/멋쟁이사자처럼 BE 13기

[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_62일차_"OAuth2 연동"

LEFT 2025. 3. 10. 18:29

🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [62]일차

🚀62차에는 Github와 연동하여 OAuth2 소셜로그인을 실습해보았다.

학습 목표 : Github의 소셜로그인 ID를 통해 연동하여 DB에 값이 잘 넘어가야함

학습 과정 : 회고를 통해 작성


OAuth

  • oauth는 인증까지만 도와주고 나머지는 시큐리티가 담당해야함

OAuth 사용 라이브러리 추가

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

 

Entity - User

  • 기존 User 코드에 추가적으로 OAuth의 소셜과 관련된 컬럼을 추가
  • OAuth 인증을 사용한 경우에도 socialId와 provider를 추가로 저장해서
    로그인 시 
    기존 사용자와 매칭할 수 있도록 설계
// ...
@Column(name = "registration_date", nullable = false, updatable = false)
private LocalDateTime registrationDate = LocalDateTime.now();

// 컬럼 추가 - OAuth의 소셜 아이디를 가져오는 부분
@Column(name = "social_id")
private String socialId;
private String provider; // 어느 소셜에서 제공중인지를 가져옴
  • registrationDate LocalDateTime ➡️가입한 날짜 (기본값: 현재 시간)

  • socialId String socialId
    ➡️ ex. google-oauth2-123456789
    ➡️기존 회원가입(username, password)과 OAuth(socialId, provider)를 모두 지원
  • provider String provider ➡️ ex. google", "github"

  • 동작 흐름 :
    OAuth 로그인한 사용자가 기존 User 테이블에 없다면, 새롭게 회원가입을 진행

SocialUser - 소셜로그인 유저를 관리할 엔티티 생성

  • OAuth 인증을 통해 로그인한 사용자의 정보를 저장하는 엔티티
  • 소셜 로그인 사용자의 계정 정보를 따로 관리
    (=일반적인 User 테이블과 별개로 소셜 로그인 전용 계정을 저장 가능)
@Entity
@Data
public class SocialUser {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String socialId;
    private String provider;

    private String username;
    private String email;
    private String avatarUrl;
}
  • @Data
    ➡️Getter & Setter, toString(), equals() & hashCode(), 기본 생성자 등을 자동으로 생성해줌
  • private String provider
    ➡️사용자가 로그인한 소셜 제공자 (ex. "google", "facebook", "github")

  • private String avatarUrl
    ➡️
    사용자의 프로필 이미지 URL(ex. 구글 프로필 이미지)
  • socialId를 사용해 중복 로그인 문제를 방지가능

Repository 설계

@Repository
public interface UserRepository extends JpaRepository<User,Long> {
    User findByUsername(String username);
    Optional<User> findByProviderAndSocialId(String provider, String socialId);
}
  • findByUsername(String username) ➡️ 일반 로그인 시, username으로 사용자를 찾음

  • findByProviderAndSocialId(String provider, String socialId)
    ➡️ OAuth 로그인 시, 소셜 제공자(provider)와 socialId로 사용자를 찾음
    (= OAuth를 통해 가입한 기존 사용자를 쉽게 조회)

    만약 기존 사용자가 없다면, 새로운 계정을 자동으로 생성 가능

  • OAuth는 username/password 방식이 아니므로 username이 아니라,
    각 플랫폼에서 제공하는 고유한 사용자 ID (socialId)로 인증해야 함

    ex. 동일한 이메일(example@gmail.com)을 사용하더라도
    Google과 Facebook에서는 서로 다른 socialId를 가질 수 있음

Service 설계

UserService

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public User saveUser(SocialUserRequestDto requestDto){
        User user = new User();
        user.setUsername(requestDto.getUsername());
        user.setName(requestDto.getName());
        user.setEmail(requestDto.getEmail());
        user.setSocialId(requestDto.getSocialId());
        user.setProvider(requestDto.getProvider());
        user.setPassword(passwordEncoder.encode("")); // 소셜 로그인으로 진행하는 사용자는 비밀번호를 비워둠

        return userRepository.save(user);
    }
    
    @Transactional(readOnly = true)
    public User findByUsername(String username){
        return userRepository.findByUsername(username);
    }
    
    @Transactional(readOnly = true)
    public Optional<User> findByProviderAndSocialId(String provider, String socialId) {
        return userRepository.findByProviderAndSocialId(provider, socialId);
    }
}
  • saveUser(SocialUserRequestDto requestDto){…}
    ➡️
    OAuth 로그인 사용자를 저장하는 메서드

    동작 흐름 :
    - OAuth 인증 후, 기존 사용자 여부를 확인하여 새로운 사용자인 경우 회원가입 처리
    - 비밀번호는 직접 입력하지 않고 password 필드를 공란("")으로 설정 후 해싱하여 저장
    - username, email 등의 정보는 OAuth 제공자로부터 받아 저장

  • ❓비밀번호를 공란으로 설정하는 이유
    ➡️일반 사용자는 비밀번호를 입력하고 가입하지만,
    소셜 로그인 사용자는 Google, Facebook 등 OAuth 제공자의 인증을 거쳐 로그인하므로  비밀번호가 필요 없음

    ✅로그인 시 OAuth 계정은 비밀번호 검증을 건너뛰도록 처리

  • findByUsername(String username) { … }
    ➡️
    일반적인 username/password 로그인 방식에서 사용
    ➡️OAuth 사용자는 username을 기반으로 로그인하지 않음

정리하자면

최초의 사용자에 대해서는 추가적인 회원가입 정보를 위하여 회원가입으로 이동하고
그 이외로 로그인하게 될때는 이미 저장되어있을 수 있으므로 Dto로 가져와서 꺼내온 후 나머지 값만 입력받도록 함


소셜로그인 흐름

1. 사용자가 OAuth 로그인 버튼 클릭
- Google, Facebook, GitHub 등의 소셜 로그인 버튼 클릭
- OAuth 제공자의 인증 페이지로 리다이렉트

2. OAuth 인증 후 콜백 (Callback) 
- 사용자가 OAuth 로그인에 성공하면, OAuth 제공자는 accessToken과 함께 사용자 정보를 반환
(=provider, socialId가 포함됨)

3. OAuth 로그인 사용자 정보 확인 (findByProviderAndSocialId)
- OAuth 로그인한 사용자가 DB에 존재하는지 확인
- 존재하면 로그인 처리, 존재하지 않으면 saveUser()를 호출하여 새롭게 가입


SocialUserService

@Service
@RequiredArgsConstructor
public class SocialUserService {
    private final SocialUserRepository socialUserRepository;

    // 소셜에서 보내준 정보를 저장하기 위한 메소드
    @Transactional
    public SocialUser saveOrUpdateUser(String socialId, String provider,
                                       String username, String email,
                                       String avatarUrl){
        Optional<SocialUser> existUser = socialUserRepository.findBySocialIdAndProvider(socialId, provider);
        SocialUser socialUser;

        if(existUser.isPresent()){
            // 1. 이미 소셜정보를 가진 사용자라면
            socialUser = existUser.get();
            socialUser.setUsername(username);
            socialUser.setEmail(email);
            socialUser.setAvatarUrl(avatarUrl);
        }else{
            // 2. 처음으로 방문한 사용자라면
            socialUser = new SocialUser();
            socialUser.setSocialId(socialId);
            socialUser.setUsername(username);
            socialUser.setEmail(email);
            socialUser.setAvatarUrl(avatarUrl);
            socialUser.setProvider(provider);
        }
        return socialUserRepository.save(socialUser);
    }
}

    • return socialUserRepository.save(socialUser);
      변경된 사용자 정보를 저장
      (=새로운 사용자이든, 기존 사용자 정보 업데이트든 결과적으로 DB에 저장)

      최초 가입이면 INSERT, 기존 회원이면 UPDATE로 동작
      (=save() 메서드는 INSERT 또는 UPDATE를 수행)

      UPDATE는 예를 들어 ex. 깃허브 이메일이 변경된 경우 수행

SocialLoginInfoService

  • OAuth 로그인 시 사용자의 소셜 로그인 정보를 저장 및 조회
@Service
@RequiredArgsConstructor
public class SocialLoginInfoService {
    private final SocialLoginInfoRepository socialLoginInfoRepository;

    @Transactional(readOnly = true)
    public SocialLoginInfo saveSocialLoginInfo(String provider, String socialId){
        SocialLoginInfo socialLoginInfo = new SocialLoginInfo();
        socialLoginInfo.setProvider(provider);
        socialLoginInfo.setSocialId(socialId);
        return socialLoginInfoRepository.save(socialLoginInfo);
    }

    @Transactional(readOnly = true)
    public Optional<SocialLoginInfo> findByProviderAndUuidAndSocialId(String provider, String uuid, String socialId){
        return socialLoginInfoRepository.findByProviderAndUuidAndSocialId(provider, uuid, socialId);
    }
}

  • SocialUserService와는 달리 uuid 필드를 추가하여 식별 및 보안 관리가 가능하도록 함

❓uuid (고유 식별자)를 추가한 이유

➡️socialId만으로는 동일한 사용자가 여러 소셜 계정을 연결할 때 구별하기 어려울 수 있으므로 uuid를 추가하여
특정 애플리케이션 내에서 사용자를 정확하게 식별 가능

(=여러 개의 OAuth 계정을 사용할 가능성이 있는 경우를 대비해 uuid를 추가한 것)


SecurityConfig 설계

  • OAuth2 소셜 로그인을 포함한 Spring Security 설정을 담당
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final SocialUserService socialUserService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/loginform","/userregform", "/").permitAll()
                        .requestMatchers("/oauth2/**", "/login/oauth2/code/github", "/registerSocialUser", "/saveSocialUser").permitAll()// 기본지정해주어야하는 약속들, 이 형태는 정해진 형태 (/login/oauth2/code/github)
                        .anyRequest().authenticated()

                )
                .csrf(csrf -> csrf.disable())
                .formLogin(form -> form.disable())
                .cors(cors -> cors.configurationSource(configurationSource()))
                .httpBasic(httpBasic -> httpBasic.disable())
                .oauth2Login(oauth2 -> oauth2
                        .loginPage("/loginform")
                        .failureUrl("/loginFailure")
                        .userInfoEndpoint(userInfo -> userInfo
                                .userService(this.oauth2UserService())
                        )
                        //.successHandler()
                );

        return http.build();
    }
	//... 이 외 설정
}
  • @EnableWebSecurity ➡️ Spring Security 활성화
    ➡️기본 보안 설정을 직접 커스터마이징하기 때문에 @EnableWebSecurity가 필수

 

@Bean
public OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService(){
    DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
    return OAuth2UserRequest -> {
        OAuth2User oAuth2User = delegate.loadUser(OAuth2UserRequest);

        // 토큰도 필요 시 얻어올 수 있음
//            String token = OAuth2UserRequest.getAccessToken().getTokenValue();
        String provider = OAuth2UserRequest.getClientRegistration().getRegistrationId();
        String socialId = (String) oAuth2User.getAttributes().get("id");
        String username = (String) oAuth2User.getAttributes().get("login");
        String email = (String) oAuth2User.getAttributes().get("email");
        String avatarUrl = (String) oAuth2User.getAttributes().get("avatar_url");

        socialUserService.saveOrUpdateUser(socialId, provider, username, email, avatarUrl);
        return oAuth2User;
    };
}
//... 이 외 설정
  • DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
    ➡️기본 OAuth2 사용자 서비스 객체 생성
    ➡️OAuth 제공자로부터 사용자 정보를 가져올때 사용

  • getAttributes() : 사용자 정보 추출

SecurityConfig - successHandler() 메소드 추가

  • 소셜 로그인 성공 후 해야할 일을 작성
.userInfoEndpoint(userInfo -> userInfo
        .userService(this.oauth2UserService())
)
// 소셜 인증 성공 후 수행할 일
.successHandler(customOAuth2AuthenticationSuccessHandler)
  • OAuth 로그인 성공 시 기본 동작만 수행하던 것을
    successHandler를 추가하여 OAuth 로그인 성공 후 커스텀 로직을 수행 가능

    ex. 로그인 후 JWT 발급, 추가 정보 저장, 리다이렉트 등을 수행할 수 있음

 

CustomOAuth2AuthenticationSuccessHandler

@Component
@RequiredArgsConstructor
public class CustomOAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private final UserService userService; // 유저 정보를 저장하기 위함
    private final SocialLoginInfoService socialLoginInfoService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        String requestURI = request.getRequestURI();
        String provider = extractProviderFromUri(requestURI);

        // provider가 없는 경로로 요청되었으면 문제 발생
        if(provider == null){
            response.sendRedirect("/");
            return;
        }
        // 문제없었으면
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        DefaultOAuth2User defaultOAuth2User = (DefaultOAuth2User) auth.getPrincipal();

        String socialId = defaultOAuth2User.getAttributes().get("id").toString();
        String name = defaultOAuth2User.getAttributes().get("name").toString();

        Optional<User> userOptional = userService.findByProviderAndSocialId(provider, socialId);

        if(userOptional.isPresent()){
            User user = userOptional.get();

            // CustomUserDetails 생성
            CustomUserDetails customUserDetails = new CustomUserDetails(user.getUsername(),
                    user.getPassword(),
                    user.getName(),
                    user.getRoles()
                            .stream()
                            .map(Role::getName)
                            .collect(Collectors.toList())
            );

            Authentication newAuth = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(newAuth);

            response.sendRedirect("/welcome");

        }else{
            SocialLoginInfo socialLoginInfo = socialLoginInfoService.saveSocialLoginInfo(provider, socialId);
            response.sendRedirect("/registerSocialUser?/provider="+provider+"&socialId="
                    +socialId+"&name="+name+"&uuid="+socialLoginInfo.getUuid());
        }
    }
    private String extractProviderFromUri(String uri) { //... 처리 부분 }
}
  • requestURI ➡️사용자가 요청한 URL (ex. /login/oauth2/code/github)
    요청 정보로부터 provider를 얻어옴
    ex. redirect-uri : "{baseUrl}/login/oauth2/code/{registrationId}" : registrationId 에 github, google등이 들어올 것

  • if(userOptional.isPresent()){ ... }
    소셜로 회원가입이 된 상태일때 (=로그인을 성공한 적이 있는 유저)
    이 사용자가 서비스를 이미 사용한 정보가 있으면 = User에 정보가 담겨있을 것

  • user.getRoles()
    ➡️Roles는 Set<Role> roles 처럼 들어가있을 것
    따라서 stream()으로 꺼냄

  • extractProviderFromUri()
    ➡️ex. github, google 등의 OAuth 제공자 이름(provider)을 추출
if(provider == null){
    response.sendRedirect("/");
    return;
}
  • provider가 없으면 홈으로 리다이렉트
  • provider가 정상적으로 추출되지 않으면 로그인 요청이 잘못된 것이므로 홈으로 이동

  • Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    ➡️ 현재 인증된 사용자의 정보를 SecurityContextHolder에서 가져옴

  • response.sendRedirect("/welcome"); ➡️로그인 성공 후 /welcome 페이지로 리다이렉트

  • response.sendRedirect("/registerSocialUser?/provider="+provider+"&socialId="
                        +socialId+"&name="+name+"&uuid="+socialLoginInfo.getUuid());
    ➡️소셜로 회원가입이 되지 않은 상태일때 "회원가입 페이지"로 리다이렉트
    ➡️사용자가 소셜로그인으로 앱에 처음들어왔을때 사용자의 정보를 앱의 Service에도 남겨두고 싶을때
    ➡️소셜 로그인 정보를 저장함
    (/?provider 형식으로 provider를 controller에 넘김, &로 넘기고 싶은 나머지 값들을 한 줄로 연결시킴)

OAuth 로그인 (github)

- 사용자가 https://myapp.com/oauth2/authorization/github 클릭
- GitHub OAuth 인증 페이지로 이동
- 사용자가 인증을 완료하면 GitHub이 https://myapp.com/login/oauth2/code/github?code=xyz로 리다이렉트
- OAuth2 로그인 성공
- SecurityConfig의 .oauth2Login()에서 CustomOAuth2AuthenticationSuccessHandler 호출
- onAuthenticationSuccess()에서 provider, socialId 등의 정보를 가져옴
- 기존 사용자면 로그인 처리 후 /welcome으로 이동
- 신규 사용자면 회원가입 페이지(/registerSocialUser)로 이동


▶️실습 - application github 설정 추가 

#  security:
#    user:
#      name: Jun
#      password: 1234

  security:
    oauth2:
      client:
        registration:
          github:
            client-id: 클라이언트 ID
            client-secret: 클라이언트 비밀키
            scope:
              - email
              - profile
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            client-name: GitHub
        provider:
          github:
            authorization-uri: <https://github.com/login/oauth/authorize>
            token-uri: <https://github.com/login/oauth/access_token>
            user-info-uri: <https://api.github.com/user>
            user-name-attribute: id

 

loginform.html

<form action="/login" method="post">
	<!--로그인 폼들--> 
	<a href="/oauth2/authorization/github"></a>
</form>

 

⚠️오류 발생 해결

순환 참조 관계

  1. SecurityConfig → CustomOAuth2AuthenticationSuccessHandler 필요
  2. CustomOAuth2AuthenticationSuccessHandler → UserService 필요
  3. UserService → SecurityConfig 필요 (직간접적으로 의존)

서로가 서로를 의존하는 구조로 되어 있어 Spring이 Bean을 생성할 수 없다는 오류

기존 구조

- SecurityConfig가 CustomOAuth2AuthenticationSuccessHandler를 주입 받음
- CustomOAuth2AuthenticationSuccessHandler가 UserService를 주입 받음
- UserService가 다시 SecurityConfig에 의존하거나, 그 안의 socialUserService를 사용

✅해결방법 :

UserService에 PasswordEncoder를 사용하지 않고,
Controller에서 PasswordEncoder를 사용 후 UserService에 넘기는 방식으로 구현해야할 것

UserController

private final UserService userService;
private final SocialLoginInfoService socialLoginInfoService;
private final PasswordEncoder passwordEncoder;

// 로그인 폼
@GetMapping("/loginform")
public String loginform(){
    return "oauth/users/loginform";
}

// UserService로 Password를 인코딩해서 넘기도록 함 (순환참조 문제 해결)
@GetMapping("/registerSocialUser")
public String registerSocialUser(){
    return "";
}
  • password를 인코딩해서 넘기도록 함
  • oauth/users/loginform 처럼 loginform이 겹칠 수 있으므로 경로명을 적어줌

UserService

private final UserRepository userRepository;
//    private final PasswordEncoder passwordEncoder; // 순환참조 문제 발생
@Transactional
public User saveUser(SocialUserRequestDto requestDto, PasswordEncoder passwordEncoder){
    User user = new User();
    user.setUsername(requestDto.getUsername());
    user.setName(requestDto.getName());
    user.setEmail(requestDto.getEmail());
    user.setSocialId(requestDto.getSocialId());
    user.setProvider(requestDto.getProvider());
    user.setPassword(passwordEncoder.encode("")); // 소셜 로그인으로 진행하는 사용자는 비밀번호를 비워둠

    return userRepository.save(user);
}
  • PasswordEncoder 주석처리 후 saveUser()메소드에서 PasswordEncoder를 인자로 받게되면 해결 가능

  • 하이퍼링크가 localhost:8080/oauth2/authrorization/github를 가리키고 있음
  • 이처럼 아직 provider, social_id는 존재하지 않는 것을 확인할 수 있다.

Controller 수정

  • URL에 담긴 값으로 RequestParam을 활용하여 회원가입 페이지로 리다이렉트 되도록 구현
@GetMapping("/registerSocialUser")
public String registerSocialUser(@RequestParam("provider") String provider,
                                 @RequestParam("socialId") String socialId,
                                 @RequestParam("name") String name,
                                 @RequestParam("uuid") String uuid,
                                 Model model){
    // Model에 담아주어야함 - 회원가입 페이지에 넘어갈때 이 값들을 사용할 수 있도록 하는 것
    model.addAttribute("provider", provider);
    model.addAttribute("socialId", socialId);
    model.addAttribute("name", name);
    model.addAttribute("uuid", uuid);

    return "oauth/users/registerSocialUser"; // 소셜로그인 회원가입 페이지로 보냄
}
  • @RequestParam
    ?provider="+provider+"&socialId="+socialId+"&name="+name+... 와 같은 구조로 들어올 것
    따라서 @RequestParam을 이용하여 그 값들을 받아옴

    OAuth 로그인 후 리다이렉트될 때 쿼리 파라미터(?provider=google&socialId=1234 등)를 사용하여 정보를 전달

  • OAuth 로그인 후 기존 회원이 아니라면 추가 정보 입력을 위해 회원가입 페이지로 이동하도록 처리

UserController - saveSocialUser 메소드 추가

@PostMapping("/saveSocialUser")
public String saveSocialUser(@ModelAttribute SocialUserRequestDto requestDto){
    Optional<SocialLoginInfo> socialLoginInfoOptional = socialLoginInfoService.findByProviderAndUuidAndSocialId(
            requestDto.getProvider(), requestDto.getUuid(), requestDto.getSocialId());

    if(socialLoginInfoOptional.isPresent()){
        SocialLoginInfo socialLoginInfo = socialLoginInfoOptional.get();
        LocalDateTime now = LocalDateTime.now(); // 현재 시간 구하기
        Duration duration = Duration.between(socialLoginInfo.getCreateAt(), now); // 소셜로그인 정보가 생긴 시간과 현재 시간을 비교

        if(duration.toMinutes() > 20) {// 20분을 초과했으면
            return "redirect:/error"; // 에러페이지로 리다이렉트할 것
        }

        userService.saveUser(requestDto, passwordEncoder);
        return"redirect:/info";
    }
    else{ // 정보가 없다면
        return "redirect:/error";
    }
}
  • 사용자가 OAuth 로그인 후 회원가입을 진행할 때, 보안상 20분 이내에 가입해야 하는 제한을 두는 기능 포함
  • 만약 20분이 초과하면 가입할 수 없도록 하고, 사용자를 /error 페이지로 리다이렉트
  • 회원가입이 성공하면 /info 페이지로 이동하여 사용자의 정보를 보여주도록 설계

🚀실습 - Login With Github 테스트

  • 소셜로그인을 호출할 수 있다


🚀회고 결과 :

소셜로그인을 사용만 해보고 직접 구현하는 것은 처음이었다.
소셜로그인의 동작 흐름이 어떤지를 회고를 통해서 배울 수 있었고,

빠르게 지나갔던 내용들을 다시 메소드 별로 학습하면서 메소드 간 상호작용이 어떻게 수행되고 있는지를 파악할 수 있었다.

오류도 많이 일어났었는데 오류 로그를 분석해보면서 검색을 통해 해결할 수 있었다.

향후 계획 : 

- 소셜 로그인 로그아웃 후 재인증 테스트