Post

OAuth2

OAuth2에 대해서 적어봤습니다.

OAuth2

주제 선정 이유

인증 기능을 구현하다 보면 단순히 아이디와 비밀번호를 검증하는 방식을 넘어, 구글이나 카카오와 같은 외부 플랫폼을 활용한 소셜 로그인 구조를 자연스럽게 접하게 되며, 내가 구현하는 인증 방식이 과연 안전하고 확장 가능한 구조를
갖추고 있는가라는 고민이 들게 됩니다.

현대 백엔드 개발에서 OAuth2는 표준적인 인증 방식으로 자리 잡았지만, 실제로 Access TokenRefresh Token
어떻게 동작하는지, 그리고 다양한 Grant Type이 어떤 상황에서 선택되어야 하는지에 대해서는 명확하게 이해하지
못한 채 단순히 라이브러리를 사용하는 수준에 머무르는 경우가 많으며 인증 시스템을 설계하면서 보안성과 편의성
사이에서 균형을 맞추는 것이 중요하지만, 내부 동작 원리를 제대로 이해하지 못한다면 사용자 정보를 안전하게
보호하면서도 유연한 인증 구조를 설계하기 어렵다는 점을 느끼게 됩니다.

그래서 이번 글에서는 OAuth2의 기본 개념과 토큰 기반 인증 구조를 정리해보면서, 단순한 로그인 구현을 넘어 안전하고 확장 가능한 인증 시스템을 설계하는 방향에 대해 정리해보고자 합니다.

OAuth2가 뭘까?

OAuth2(Open Authorization 2.0)는 한마디로 내 서비스가 사용자를 대신해 다른 서비스(구글, 카카오 등)의 기능을
안전하게 사용하기 위한 권한 부여 표준 프로토콜
입니다.

과거에 사용자가 우리 서비스에 자신의 구글 아이디와 비밀번호를 직접 입력해야 했다면, OAuth2를 통하면 비밀번호를 공유하지 않고도 열쇠(Token)만 빌려와서 필요한 정보에만 접근할 수 있습니다.

역할

이름설명
Resource Owner사용자로서, 자신의 리소스에 대한 접근 권한을 승인하는 주체
Client사용자 리소스에 접근하기 위해 요청을 보내는 애플리케이션
Resource Server사용자 정보 (리소스)를 실제로 저장하고 제공하는 서버
Authorization Server사용자 인증 및 권한 부여를 담당하며, Access Token을 발급하는 서버

주요 용어

이름설명
Authentication (인증)인증, 접근 자격이 있는지 검증하는 단계입니다.
Authorization (인가)자원에 접급할 권한을 부여하고 리소스 접근 권한이 담긴 Access Token을 제공합니다.
Access Token 리소스 서버에서 리소스 소유자의 정보를 획득할 때 사용되는 만료 기간이 있는 Token입니다.
Refresh TokenAccess Token 만료시 이를 재발급 받기위한 용도로 사용하는 Token입니다.

동작 흐름

1단계

  • Authorization Code 요청
    • 로그인 버튼을 누르면, 브라우저(Client)가 OAuth2 서버(Google, Kakao, Naver 등)에게 로그인 시켜줘라고
      요청합니다.
  • Redirect URL을 사용해 Authorization Code 부여
    • 사용자가 OAuth2 서버에서 로그인을 성공하면, 서버는 미리 약속된 주소 (Redirect URL)을 통해 브라우저에게 임시 통행증인 인가 코드 (Authorization Code)를 던져줍니다.
  • Authorization Code 전달
    • 브라우저는 받은 인가 코드를 우리 백엔드 서버(Server)에게 그대로 전달합니다.
    • 여기까지는 아직 진짜 열쇠가 아닙니다.

2단계

  • Authorization Code를 보내 Access Token 요청
    • 우리 서버(Server)는 브라우저가 준 인가 코드를 들고 직접 OAuth2 서버를 찾아가고 이때, 이 코드 줄테니까 진짜 열쇠로 보내줘라고 요청합니다.
  • Access Token 부여
    • OAuth2 서버는 코드를 확인한 뒤, 드디어 리소스 서버의 문을 열 수 있는 진짜 열쇠인 Access Token을 서버에게 발급합니다.
  • Access Token 전달
    • 서버는 이 열쇠를 안전하게 보관하며, 사용자의 로그인 상태를 관리하게 됩니다.

그래서 어떻게 쓰라고?

OAuth2의 흐름은 브라우저와 서버를 오가기 때문에, 프론트엔드와 백엔드가 각각 어디까지 책임질지 명확히 나누는게 중요합니다.

Front-end

프론트엔드는 사용자와 직접 맞닿아 있는 인터페이스 역할에 집중합니다.

Authorization Code 요청

로그인 버튼을 누르면 구글, 네이버, 카카오의 로그인 페이지로 리다이렉트 시킵니다.

1
2
3
4
5
6
7
8
const login = (platform) => {
  const authUrls = {
    google: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${G_ID}&redirect_uri=${URI}&response_type=code&scope=email profile`,
    naver: `https://nid.naver.com/oauth2.0/authorize?client_id=${N_ID}&redirect_uri=${URI}&response_type=code&state=RANDOM`,
    kakao: `https://kauth.kakao.com/oauth/authorize?client_id=${K_ID}&redirect_uri=${URI}&response_type=code`
  };
  window.location.href = authUrls[platform];
};

Code 부여 및 전달

로그인 성공 후 주소창에 ?code=xxxx가 붙어서 돌아오면, 이 코드를 뽑아서 백엔드로 보냅니다.

1
2
3
4
5
6
7
// 리다이렉트된 페이지에서 실행되는 코드
const code = new URL(window.location.href).searchParams.get("code");

if (code) {
  // 백엔드 서버에 인가 코드 전달
  axios.post("/api/auth/login", { code: code, provider: "google" });
}

이렇게 하면 프론트엔드는 소셜 로그인에 필요한 첫번째 Authorization Code를 보내게 됩니다.

Back-end

보안이 중요한 데이터 교환과 토큰 관리는 모두 백엔드 서버에서 이루어집니다.

Access Token 요청 및 부여

백엔드는 프론트가 준 Authorization Code를 들고 OAuth2 서버에 가서 Access Token를 받아옵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public String getAccessToken(String code, String provider) {
    RestTemplate restTemplate = new RestTemplate();
    
    // 플랫폼별 요청 정보 설정
    String tokenUrl = "";
    Map<String, String> params = new HashMap<>();
    params.put("code", code);
    params.put("grant_type", "authorization_code");
    params.put("redirect_uri", REDIRECT_URI);

    if ("google".equals(provider)) {
        tokenUrl = "https://oauth2.googleapis.com/token";
        params.put("client_id", G_CLIENT_ID);
        params.put("client_secret", G_CLIENT_SECRET);
    } else if ("naver".equals(provider)) {
        tokenUrl = "https://nid.naver.com/oauth2.0/token";
        params.put("client_id", N_CLIENT_ID);
        params.put("client_secret", N_CLIENT_SECRET);
        params.put("state", "RANDOM_STATE"); // 프론트와 일치해야 함
    } else if ("kakao".equals(provider)) {
        tokenUrl = "https://kauth.kakao.com/oauth/token";
        params.put("client_id", K_CLIENT_ID);
        params.put("client_secret", K_CLIENT_SECRET); // 설정 시 필수
    }

    // OAuth 서버로 Access Token 요청
    ResponseEntity<Map> response = restTemplate.postForEntity(tokenUrl, params, Map.class);
    return (String) response.getBody().get("access_token");
}

사용자 정보 획득 및 최종 로그인

받은 Access Token으로 사용자 정보를 가져와 로그인을 마무리 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public UserProfile getUserInfo(String accessToken, String provider) {
    String userUrl = "";
    if ("google".equals(provider)) userUrl = "https://www.googleapis.com/oauth2/v3/userinfo";
    else if ("naver".equals(provider)) userUrl = "https://openapi.naver.com/v1/nid/me";
    else if ("kakao".equals(provider)) userUrl = "https://kapi.kakao.com/v2/user/me";

    HttpHeaders headers = new HttpHeaders();
    headers.setBearerAuth(accessToken); // Header에 "Bearer {accessToken}" 추가
    HttpEntity<String> entity = new HttpEntity<>(headers);

    // 각 플랫폼 서버에서 프로필 정보(이메일, 이름 등) 수신
    ResponseEntity<Map> response = restTemplate.exchange(userUrl, HttpMethod.GET, entity, Map.class);
    
    // 이후 데이터 파싱 및 DB 저장(회원가입/로그인) 로직 진행
    return parseUserProfile(response.getBody(), provider);
}

이렇게까지 하면 로그인까지 진행이 완료됩니다.

추가

위에서 다룬 방식이 OAuth2의 정석적인 흐름이라면, 실제 실무에서는 생산성과 성능을 위해 전용 라이브러리와
현대적인 스택을 조합해서 사용합니다.

Front-end 전용 라이브러리 (React 기준)

직접 window.location.href를 조작하고 쿼리 스트링을 파싱하는 번거로움 대신, 검증된 라이브러리를 사용하면 코드의 양이 획기적으로 줄어듭니다.

  • 구글 (@react-oauth/google)
    • 보안과 효율을 위해 앱 최상단을 GoogleOAuthProvider로 감싸서 키를 주입하는 방식을 사용합니다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    import { GoogleOAuthProvider, GoogleLogin } from '@react-oauth/google';
    
    const GoogleLoginButton = () => {
      return (
        // 최상단이나 버튼 부모를 Provider로 감싸고 키를 넣습니다.
        <GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
          <GoogleLogin
            onSuccess={res => {
              // 인가 코드가 포함된 응답을 백엔드로 전송
              axios.post("/api/auth/login", { code: res.credential, provider: 'google' });
            }}
            onError={() => console.log('구글 로그인 실패')}
          />
        </GoogleOAuthProvider>
      );
    };
    
  • 네이버 (react-naver-login)
    • 전용 라이브러리를 통해 버튼 스타일과 로그인 로직을 한 번에 관리합니다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    import NaverLogin from 'react-naver-login';
    
    <NaverLogin
      clientId={NAVER_CLIENT_ID} // 네이버 클라이언트 ID
      callbackUrl={REDIRECT_URI}
      render={(props) => <button onClick={props.onClick}>네이버 로그인</button>}
      onSuccess={(res) => {
        axios.post("/api/auth/login", { code: res.code, provider: 'naver' });
      }}
      onFailure={() => console.log('네이버 로그인 실패')}
    />
    
  • 카카오 (react-kakao-login)
    • Javascript Key를 사용하여 간편하게 구현 가능합니다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    import KakaoLogin from 'react-kakao-login';
    
    <KakaoLogin
      token={KAKAO_JAVASCRIPT_KEY} // 카카오 자바스크립트 키
      onSuccess={(res) => {
        // 카카오는 응답 객체 구조에 따라 access_token 등을 추출
        axios.post("/api/auth/login", { code: res.response.access_token, provider: 'kakao' });
      }}
      onFail={() => console.log('카카오 로그인 실패')}
      render={(props) => <button onClick={props.onClick}>카카오 로그인</button>}
    />
    

Back-end: Spring Security 대신 WebClient 활용

oauth2-client 라이브러리를 사용하면 설정 파일만으로 OAuth2 흐름을 자동으로 처리할 수 있습니다만 저는
컨트롤러에서 WebClient를 활용해 직접 구현하는 방식이 더 좋다고 생각합니다.

  • Stateless 구조 유지
    • 인증 정보를 서버 세션에 저장하지 않고, 소셜 로그인 성공 직후 JWT를 발급하여 관리하기 위함입니다.
  • 일반 로그인 / 소셜 로그인 흐름 통일
    • 일반 로그인과 소셜 로그인 모두 동일한 방식으로 JWT를 발급하고 검증하게 되어 전체적인 인증 아키텍처를
      단순화할 수 있습니다.
  • 유연한 커스터마이징
    • 엑세스 토큰 요청부터 사용자 정보 파싱, 예외 처리까지 프레임워크에 숨겨진 로직이 아니라 제가 작성한 코드로 명확하게 제어하기 위해서입니다.
  • 플랫폼별 Access Token 요청
    • 프론트엔드에서 넘겨받은 code를 사용해 소셜 서버로부터 진짜 열쇠인 Access Token을 비동기적으로
      받아옵니다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    
    public Mono<String> getAccessToken(String code, String provider) {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("code", code);
        formData.add("grant_type", "authorization_code");
        formData.add("redirect_uri", REDIRECT_URI);
    
        String tokenUrl;
        if ("google".equals(provider)) {
            tokenUrl = "https://oauth2.googleapis.com/token";
            formData.add("client_id", G_CLIENT_ID);
            formData.add("client_secret", G_CLIENT_SECRET);
        } else if ("naver".equals(provider)) {
            tokenUrl = "https://nid.naver.com/oauth2.0/token";
            formData.add("client_id", N_CLIENT_ID);
            formData.add("client_secret", N_CLIENT_SECRET);
            formData.add("state", "RANDOM_STATE");
        } else { // kakao
            tokenUrl = "https://kauth.kakao.com/oauth/token";
            formData.add("client_id", K_CLIENT_ID);
            formData.add("client_secret", K_CLIENT_SECRET);
        }
    
        return WebClient.create()
                .post()
                .uri(tokenUrl)
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .body(BodyInserters.fromFormData(formData))
                .retrieve()
                .bodyToMono(Map.class)
                .map(res -> (String) res.get("access_token"));
    }
    
  • 플랫폼별 사용자 정보 가져오기
    • 발급된 Access Token을 헤더에 담아 각 플랫폼의 리소스 서버에서 필요한 유저 데이터를 가져옵니다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    public Mono<Map> getUserInfo(String accessToken, String provider) {
      String userUrl = switch (provider) {
          case "google" -> "https://www.googleapis.com/oauth2/v3/userinfo";
          case "naver" -> "https://openapi.naver.com/v1/nid/me";
          case "kakao" -> "https://kapi.kakao.com/v2/user/me";
          default -> throw new IllegalArgumentException("지원하지 않는 플랫폼입니다.");
      };
    
      return WebClient.create()
              .get()
              .uri(userUrl)
              .headers(h -> h.setBearerAuth(accessToken)) // Header에 "Bearer {token}" 장착
              .retrieve()
              .bodyToMono(Map.class);
    }
    
    • 이렇게 직접 구현하면 Spring Security는 복잡한 로그인 로직을 몰라도 되고 발급된 토큰을 검증하고 경로
      접근 권한을 관리하는 방패 역할만 수행하면 됩니다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http
                .csrf(csrf -> csrf.disable()) // API 서버이므로 비활성화
                .sessionManagement(session -> session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // JWT 중심의 무상태성 유지
                .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/api/auth/**").permitAll() // 로그인 관련 경로는 프리패스
                    .anyRequest().authenticated() // 나머지는 인증된 사용자만 가능
                );
    
            return http.build();
        }
    }
    

마무리

OAuth2에 대해 깊이 파헤쳐보며 핵심을 다시 정리해보면 다음과 같습니다.

  1. 직접 비밀번호를 관리하는 위험 부담에서 벗어나, 구글이나 카카오 같은 신뢰할 수 있는 플랫폼에 인증을 위임하여 서비스의 보안 본질에 집중하게 합니다.
  2. 프론트엔드는 임시 티켓(Authorization Code)만 다루고, 백엔드는 비밀키를 통해 진짜 열쇠(Access Token)를
    교환하는 이중 구조를 통해 데이터 탈취 위험을 원천적으로 차단합니다.
This post is licensed under CC BY 4.0 by the author.