[GooJakGyo] OAuth 2.0 소셜 로그인 구현

이재·2025년 11월 7일
post-thumbnail

📌 개요

구작교 서비스에 OAuth 2.0 기반의 소셜 로그인 기능을 구현했다.

이전 글에서는 JWT + Refresh Token + Redis 수조로 백엔드 인증 인프라를 완성했는데,
이제는 사용자가 별도의 회원가입 없이 간편하게 로그인할 수 있는 흐름을 만들었다.

Google / Kakao / Naver OAuth 2.0 연동
Access Token & Refresh Token 통합 구조 유지
신규 유저 자동 회원가입 / 기존 유저 자동 로그인

🔍 OAuth란?

인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 어플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준

OAuth는 신뢰할 수 있는 중개자처럼 작동
사용자의 민감한 정보는 보호하면서 필요한 기능은 안전하게 공유

✨ 요소

1. Client ID

  • 어플리케이션을 식별하는 고유 번호
  • OAuth 요청하는 어플리케이션을 식별
  • Access Token을 발급받을 때 필요

2. Client Secret

  • 어플리케이션의 신원을 인증하는 비밀번호
  • 등록된 어플리케이션이 맞는지 인증
  • Access Token을 발급받을 때 필요

3. Redirection Endpoint

  • Authorization Code를 전달하는 URL
  • Access Token을 발급받을 때 필요

4. Scope

  • 어플리케이션이 요청하는 권한의 범위
  • 받아올 수 있는 이메일, 프로필 정보 등의 사용자 정보 범위
  • 인가코드를 받을 때부터 받고자 하는 사용자 정보의 범위를 지정하여 요청

5. Access Token

  • Google, Kakao, Naver 서버에 API 요청을 하기 위한 인증 키
  • Access Token을 통해 SNS 로그인을 한 사용자의 정보 조회 요청
  • JWT의 Access Token과 개념은 같지만 서로 다른 존재임

6. Authorization Code

  • Access Token을 발급 받기 위한 키
  • 위 그림과 같이 SNS 로그인 시 화면의 제어권이 제 3자로 넘어갈 수 밖에 없기에,
    Redirect 형식으로 사용자 정보를 받아야 함.
    • HTTP Redirect(302)는 GET 요청을 발생시키므로, body를 포함할 수 없음
    • 이때, Access Token 또는 사용자 정보 등의 중요한 정보들을 URL 파라미터로 받게 되면,
      웹 브라우저 상에 남아있을 보안 취약점이 존재
      하므로 인가 코드를 발급하여 Access Token 발급
    • 필요한 사용자 정보를 구글 로그인 시점에서 곧바로 받지 않고,
      Authorization Code -> Access Token -> 사용자 정보 요청 의 흐름을 사용

🔁 구현 방식 및 동작 과정

대표적으로 2가지의 구현 방식이 존재한다.

Front에서 Authorization Code, Server에서 Access Token 발급

프론트 엔드에서 인가 코드를 받고, 나머지 작업은 서버에서 처리하는 방식
(가장 일반적인 방식)

흐름

  1. Front End에서 구글 로그인 화면을 구글에 요청
  2. 사용자가 해당 화면에서 구글 로그인
  3. 구글에서 사용자 화면으로 인가 코드를 URL에 담아 Redirect
  4. 인가 코드를 Front에서 Spring 서버로 전달
  5. 서버에서 인가 코드를 가지고, 구글 서버로부터 API 요청이 가능하도록 해주는 Access Token 발급
  6. Access Token을 통해 사용자 정보(email, openid 값 등)을 구글 서버에 요청 후 인증
  7. 로그인된 사용자에게 JWT Token 발급

장점

  • 사용자가 서버로부터 JWT Token을 받을 때 안전하게 body를 통해 값을 받을 수 있음
  • Authorization Code, Access Token, 사용자 정보 요청 등 매 절차마다
    라이브러리에 의존적이지 않고, 직접 요청에 의한 결과 값을 받는 방식을 통해
    직관적이고, 디버깅에 유리

단점

  • 사용자가 Client ID 값을 보관하고 있어야 하는 보안적 취약점
  • 다만 Client ID 값이 유출된다 하더라도
    요청할 수 있는 URL이 지정되어 있어 보안상 큰 취약점은 아니다!

Server에서 Authorization Code, Access Token 모두 발급

oauth2-client 의존성을 통해 서버에서 인가 코드, Token 발급, 사용자 요청들을 모두 처리하는 방식

장점

  • Spring의 의존성을 통해 모든 절차와 요청이 한꺼번에 진행되어 코드 구현의 간결함
  • Client Id 등 서버 측에서 키 값 안전하게 보관

단점

  • 최종적으로 사용자에게 JWT Token을 줄 때,
    Redirect 방식을 취할 수 밖에 없어서 보안상 취약점 존재

  • 모든 절차가 라이브러리를 통해 통합되어 있고, 자동화 되어 있어
    코드 파악의 어려움과 디버깅의 어려움

  • 인가 코드가 Redirect될 때, 인가코드 요청을 했던 동일 서버가 아닌 경우 문제 발생

    • 로드밸런서에서 별도로 sticky 옵션 지정 필요

    위 2가지 방식 중에 GooJakGyo는 첫번째 방식으로 구현했습니다.
    GooJakGyo가 분산 서버 환경을 기반으로 개발 중이고 또한 여러 보안상 이점Code의 관리 및 디버깅 측면에서 첫번째 방식이 유리하다고 판단했습니다.

☕ MemberController - OAuth 요청 진입

   @PostMapping("/{provider}/doLogin")
public ResponseEntity<?> oauthLogin(@PathVariable String provider, @RequestBody RedirectDto redirectDto) {
 SocialType socialType = SocialType.valueOf(provider.toUpperCase());

 // access token 발급 및 사용자 정보 얻기
 // 회원가입이 되어 있다면 Access Token 및 Refresh Token 발급
 var result = oauthLoginService.loginOrCheck(redirectDto.getCode(), socialType);

 // 회원가입이 되어 있지 않다면 추가 정보 입력 후 회원가입
 if (result.isNewUser()) {
   SocialProfile profile = result.getSocialProfile();

   return ResponseEntity.ok(Map.of(
       "status", "NEED_OAUTH_CREATE",
       "socialId", profile.getSocialId(),
       "socialType", profile.getSocialType(),
       "name", profile.getName(),
       "email", profile.getEmail()
   ));
 }

 LoginResDto loginResDto = result.getLoginResDto();

 //Refresh Token 쿠키로 설정
 ResponseCookie cookie = ResponseCookie.from("refreshToken", loginResDto.getRefreshToken())
     .httpOnly(true)
     .secure(false) // https 아니면 쿠키가 안들어가므로 개발중엔 false
     .sameSite("Strict")
     .path("/")
     .maxAge(Duration.ofDays(3))
     .build();

 Map<String, Object> loginInfo = new HashMap<>();
 loginInfo.put("memberId", loginResDto.getId());
 loginInfo.put("accessToken", loginResDto.getAccessToken());
 loginInfo.put("name", loginResDto.getName());

 return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()).body(loginInfo);
}

/{provider}/doLogin

  • Google, Kakao, Naver 모두 같은 엔드 포인트로 처리

redirectDto.getCode()

  • Front에서 받은 Authorization Code

oauthLoginService.loginOrCheck()

  • 실제 인증 및 토큰 발급 비즈니스 로직 호출

분기 처리 로직

  • 신규 회원인 경우
    • "NEED_OAUTH_CREATE" 상태 반환
    • Front에서 추가 정보들을 입력 받아 회원 가입 API 호출
  • 기존 회원인 경우
    • JWT Access Token, Refresh Token 발급 및 로그인 완료

☕ OAuthLoginService - 소셜 인증 로직

@Service
@Transactional
public class OAuthLoginService {

  // "GOOGLE", "KAKAO", "NAVER"
  private final Map<String, OAuthApiClient> clients;
  private final MemberRepository memberRepository;
  private final AuthService authService;

  public OAuthLoginService(Map<String, OAuthApiClient> clients, MemberRepository memberRepository,
      AuthService authService) {
    this.clients = clients;
    this.memberRepository = memberRepository;
    this.authService = authService;
  }

  public LoginOrProfileResDto loginOrCheck(String code, SocialType socialType) {
    OAuthApiClient client = clients.get(socialType.name());
    var token = client.requestAccessToken(code);
    SocialProfile profile = client.requestProfile(token.getAccess_token());

    Member member = memberRepository.findBySocialId(profile.getSocialId()).orElse(null);

    if (member == null) {
      return LoginOrProfileResDto.builder()
          .isNewUser(true)
          .socialProfile(profile)
          .build();
    }

    LoginResDto loginResDto = authService.issueTokens(member);
    return LoginOrProfileResDto.builder()
        .isNewUser(false)
        .loginResDto(loginResDto)
        .build();
  }
}

Map<String, OAuthApiClient> 구조

  • 다형성 극대화
  • Google, Kakao, Naver의 API 클라이언트를 전부 OAuthApiClient 인터페이스로 관리
    • 새로운 SNS가 추가되어도 구조를 변경할 필요 없음

로그인 로직 흐름

  1. Authorization Code를 이용해 소셜 서버에서 Access Token 획득
  2. Access Token으로 유저 프로필 정보 요청
  3. DB에 해당 소셜 ID로 가입된 회원이 있는지 조회
  4. 없다면 isNewUser=true로 신규 가입 절차 안내
  5. 있다면 AuthService.issueTokens() 호출로 JWT Token 발급

☕ GoogleApiClient - Provider별 Client 구현

@Component("GOOGLE")
public class GoogleApiClient implements OAuthApiClient{

  private final GoogleService googleService;

  public GoogleApiClient(GoogleService googleService) {
    this.googleService = googleService;
  }

  @Override
  public AccessTokenDto requestAccessToken(String code) {
    return googleService.getAccessToken(code);
  }

  @Override
  public SocialProfile requestProfile(String accessToken) {
    GoogleProfileDto googleProfileDto = googleService.getGoogleProfile(accessToken);

    return SocialProfile.builder()
        .socialId(googleProfileDto.getSub())
        .name(googleProfileDto.getName())
        .email(googleProfileDto.getEmail())
        .socialType(SocialType.GOOGLE)
        .build();
  }
}

@Component("GOOGLE")

  • Map 주입 시 key로 사용됨 (Spring Bean 이름)

OAuthApiClient 인터페이스 구현

  • requestAccessToken(), requestProfile() 표준화

Google API의 응답(sub, name, email) SocialProfile로 통일

  • 서비스 단에서 동일하게 처리 가능

☕ GoogleService - 실제 Google 서버와 통신

@Slf4j
@Service
public class GoogleService {

  @Value("${oauth.google.client-id}")
  private String googleClientId;

  @Value("${oauth.google.client-secret}")
  private String googleClientSecret;

  @Value("${oauth.google.redirect-uri}")
  private String googleRedirectUri;

  public AccessTokenDto getAccessToken(String code) {
    // 인가 코드, clientId, client_secret, redirect_uri, grant_type

    // Spring6부터 RestTemplate 비추천(Future Deprecate)이기 때문에 RestClient 사용
    RestClient restClient = RestClient.create();

    // MultiValueMap을 통해 자동으로 form-data 형식으로 body 조립 가능
    MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
    params.add("code", code);
    params.add("client_id", googleClientId);
    params.add("client_secret", googleClientSecret);
    params.add("redirect_uri", googleRedirectUri);
    params.add("grant_type", "authorization_code");

    ResponseEntity<AccessTokenDto> response =  restClient.post()
        .uri("https://oauth2.googleapis.com/token")
        .header("Content-Type", "application/x-www-form-urlencoded")
    // ?code=xxxx&client_id=yyyy&
        .body(params)
    // retrieve:응답 body값만을 추출
        .retrieve()
        .toEntity(AccessTokenDto.class);

    log.info("AccessToken JSON : {}", response.getBody());
    return response.getBody();
  }

  public GoogleProfileDto getGoogleProfile(String token) {
    RestClient restClient = RestClient.create();
    ResponseEntity<GoogleProfileDto> response =  restClient.get()
        .uri("https://openidconnect.googleapis.com/v1/userinfo")
        .header("Authorization", "Bearer " + token)
        .retrieve()
        .toEntity(GoogleProfileDto.class);

    log.info("Profile JSON : {}", response.getBody());
    return response.getBody();
  }

}

RestClient

  • Spring 6부터 새롭게 추가된 HTTP 통신 도구
  • RestTemplate 대체

getAccessToken()

  • 인가 코드, Client ID, Secret Key, Redirect URI, Grant Type을 포함한 form-data 요청
  • Google의 토큰 발급 서버 호출

getGoogleProfile()

  • Access Token을 Authorization 헤더에 실어 userinfo API 호출
  • 사용자의 Google 프로필(sub, name, email) 반환

🔄 전체 구조 요약

1. [Frontend] → code 전달
2. [MemberController]
3. [OAuthLoginService]
4. [GoogleApiClient]
5. [GoogleService → Google API 호출]
6. [MemberRepository] 회원 존재 여부 확인
7. [AuthService.issueTokens()] JWT + Redis 저장
8. [ResponseCookie] Refresh Token 저장 후 응답

  • Google / Kakao / Naver 확장 시 Controller는 그대로 유지
  • 비즈니스 로직(Service) 레벨에서만 Provider 교체 가능
  • 인증 흐름이 완전히 모듈화 되고 유지보수가 쉬운 구조

🎯 마무리

OAuth는 문서상 "간단한 인증 위임"으로 보이지만,
직접 구현해보니 각 Provider 별 요청 파라미터, 응답 구조, 정책이 전부 달랐다.
각 서비스 사의 공식 문서를 읽어보며 적용했다.

표준 위에 얹힌 수많은 예외를 핸들링하는 과정이 진짜 OAuth의 핵심이었다.

이번 과정을 통해,
API 통신 구조를 더 깊이 이해하게 되었고
JWT 기반 로그인 구조와 완전히 통합된 OAuth 인증 시스템을 완성할 수 있었다.

이제 사용자는 소셜 로그인 한 번이면
자동으로 Access Token과 Refresh Token을 발급받고
"로그인 -> 인증 -> 토큰 재발급" 전체 흐름이 자동화 된다.

profile
고민을 좋아하는 개발자

0개의 댓글