ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 프론트엔드에서 백엔드 관점에서 본: OAuth2
    Web 2025. 9. 20. 00:35
     
     

    서론

    실무에서 주로 프론트엔드 개발자 관점에서 소셜 로그인을 구현을 했었다. 솔직히 말하면, 프론트엔드 개발자로 일할 때는 OAuth가 그렇게 복잡한 개념인지 몰랐다. redirect URI만 잘 설정하고, 백엔드 분들이 code를 달라고 하면 넘겨주면 그만이었으니까. 그런데 백엔드를 공부하면서 직접 소셜 로그인을 구현해보니, 얼마나 많은 고민과 보안 장치들이 숨겨져 있는지 알게 됐다. 오늘은 백엔드 관점에서 다시 마주한 OAuth와 그 구현 과정에 대해 이야기해보려고 한다.

     

     


     

    OAuth가 뭔데?

    OAuth는 'Open Authorization'의 약자로, 한 서비스가 다른 서비스의 사용자 정보에 접근할 수 있는 권한을 부여하는 개방형 표준 프로토콜이다. 복잡한 말은 뒤로하고, 쉽게 말해 '비밀번호를 직접 공유하지 않고, 다른 서비스의 정보를 가져오는 열쇠를 받는 과정'이라고 이해하면 된다

     

    우리 서비스에 회원가입을 하려면 이메일 주소, 이름 같은 정보가 필요하다. 그런데 이걸 사용자가 일일이 입력하게 하는 대신, 이미 사용하고 있는 네이버, 카카오, 깃허브 같은 서비스의 정보를 빌려오는 거다.

    이때 중요한 건 비밀번호를 절대 공유하지 않는다는 점이다. OAuth는 마치 대리인에게 '내 대신 저 가게에서 물건 좀 찾아와줘' 하고 부탁하는 것과 비슷한 구조이다. 이때 대리인에게 신분증이나 비밀번호를 통째로 넘기는 게 아니라, '물건만 받을 수 있는' 임시 허가증(Access Token)을 준다고 이해하면 된다.

     

     

     

    OAuth의 등장 배경

    그러니까 2000년대 초반에는 요즘처럼 '구글 계정으로 로그인', '네이버 아이디로 로그인' 같은 기능이 없었다. 만약 어떤 서비스가 다른 서비스의 사용자 정보를 사용해야 할 일이 생기면, 사용자가 직접 자신의 비밀번호를 넘겨주는 방식이었다. 

    예를 들어, '내 블로그에 페이스북 사진을 올리고 싶다'고 가정해 보자. 사용자는 블로그 서비스에 자신의 페이스북 아이디와 비밀번호를 직접 입력해야하는 구조다. 그럼 블로그 서비스는 이 정보를 가지고 페이스북에 접속해서 사진을 가지고 온다.

    이런 방식은 다음과 같은 이유로 보안 측면에서는 최악이다. 

    • 비밀번호 노출 위험: 블로그 서비스가 해킹당하면, 사용자의 페이스북 비밀번호도 함께 유출된다.
    • 전체 권한 위임: 비밀번호를 넘겨주는 건 '내 계정에 대한 모든 권한'을 넘겨주는 것과 같아. 예를들어, 나는 사진만 올리고 싶은데 블로그 서비스가 내 페이스북 계정을 마음대로 삭제할 수도 있다. 
    • 불필요한 권한: 사용자가 더 이상 블로그를 사용하지 않아도, 블로그 서비스는 여전히 사용자의 페이스북 계정에 접근할 수 있었어.

     

    비밀번호 → 허가증의 시대

    이런 문제점들을 해결하기 위해 새로운 방식이 필요했고, 그 결과 OAuth라는 프로토콜이 등장하게 되었다. OAuth는 사용자에게 '비밀번호를 넘기는 대신, 필요한 권한만 담은 허가증(Access Token)을 발급받아'라고 하는것이다. 

    • 비밀번호를 몰라도 가능: 우리 서비스는 사용자의 페이스북 비밀번호를 전혀 몰라도 된다. 그냥 페이스북이 발급해 준 허가증(액세스 토큰)만 있으면 사용자 정보를 가져올 수 있다. 
    • Scope 지정: 우리는 사용자에게 '프로필과 이메일 정보만 사용'라고 미리 허락을 받고 사용한다. 만약 다른 정보가 필요하면 다시 허락을 구해야 한다.
    • 언제든 취소 가능: 사용자는 깃허브 설정 페이지에서 언제든지 우리 서비스에 부여했던 권한을 취소할 수 있다. 허가증을 회수하는 것이다.

    결국, OAuth는 '비밀번호 공유'라는 위험한 관행을 끝내고, 안전하고 제한적인 권한 위임을 가능하게 함으로써 사용자 데이터를 보호하는 기술이다.

     

     

     

    OAuth 2.0 흐름

    내가 구현한 OAuth 2.0의 Authorization Code Grant 방식은 여러 단계에 걸쳐 토큰을 주고받으며, 모든 과정이 외부 공격에 안전하게 설계되어있다. 

     

    SNS는 깃허브를 예로들어 설명하겠다. 

     

    1단계: 사용자의 로그인 클릭

    1. 사용자가 서비스 웹사이트에서 '깃허브로 로그인' 버튼을 누른다.
    2. 프론트엔드는 사용자의 클릭을 감지하고, 브라우저를 깃허브의 인증 페이지로 리디렉션한다. 이때 쿼리 파라미터로 client_id, redirect_uri, scope 등의 정보를 함께 보낸다.

     

    2단계: 사용자 (인증 및 권한 부여)

    1. 사용자는 깃허브의 로그인 페이지에서 자신의 아이디와 비밀번호를 입력한다.
    2. 깃허브는 사용자에게 '이 서비스에 어떤 정보에 접근하는 것을 허용하겠습니까?'라고 묻는다.
    3. 사용자가 동의하면, 깃허브는 redirect_uri로 사용자를 다시 보내면서 URL에 인가 코드(Authorization Code)를 포함시킨다.

     

    3단계: 인가 코드 & 액세스 토큰 교환

    1. 프론트엔드는 URL에 포함된 인가 코드를 추출해서 서비스의 백엔드로 보낸다.
    2. 백엔드는 받은 인가 코드와 client_secret을 가지고 깃허브의 토큰 발급 API에 직접 요청을 보낸다. 이 과정은 서버 간 통신으로 이루어져 외부에 노출되지 않는다.
    3. 깃허브는 요청이 유효하면 액세스 토큰(Access Token)을 백엔드에 발급해 준다.

     

    4단계: 사용자 정보 요청 및 로그인 완료

    1. 백엔드는 획득한 액세스 토큰을 이용해서 깃허브의 리소스 서버 API에 사용자 프로필 정보를 요청한다.
    2. 깃허브는 액세스 토큰을 검증하고, 요청받은 사용자 정보를 백엔드에 전달한다.
    3. 백엔드는 전달받은 사용자 정보를 데이터베이스에 저장하거나 업데이트한다.
    4. 최종적으로 백엔드는 프론트엔드에 로그인 성공 응답과 함께 필요한 사용자 정보를 보낸다.

     

    5단계: 최종 화면 갱신

    1. 프론트엔드는 백엔드로부터 로그인 성공 응답을 받고, 사용자에게 필요한 화면을 렌더링한다.
    2. 사용자는 "로그인이 완료되었습니다."와 같은 메시지를 보며 로그인 절차가 끝났음을 확인한다.

    이처럼 OAuth2의 Authorization Code Grant 방식은 사용자가 직접 비밀번호를 넘기지 않아도, 프론트엔드와 백엔드가 각자의 역할에 따라 데이터를 안전하게 주고받으며 인증을 완료하는 효율적인 구조이다.

     

     

     


     

     

     

    GitHub OAuth2 로그인 구현기

    'AuthController'와 'GithubService'로 역할을 명확하게 나누었다.

    AuthController.java: 길 안내자 & 중간 다리 역할

    이 컨트롤러는 사용자가 로그인 버튼을 눌렀을 때 깃허브로 보내는 역할과, 깃허브에서 돌아온 인가 코드를 백엔드 서비스로 넘겨주는 역할을 한다.

     

    로그인 버튼을 누르면 프론트에서 해당 컨트롤러를 호출하게 되고, 백엔드에서 깃허브로 리다이렉션 한다. 

    // AuthController.java 
    @GetMapping("/login")
    public String redirectToGithubLogin(HttpSession session) {
        String state = UUID.randomUUID().toString();
        session.setAttribute("oauth_state", state);
    
        String githubAuthUrl = "https://github.com/login/oauth/authorize"
                + "?client_id=" + clientId
                + "&redirect_uri=" + redirectUri
                + "&scope=read:user,user:email"
                + "&state=" + state;
    
        return "redirect:" + githubAuthUrl;
    }
     
     

     인가 코드를 받는 부분이다. 

    // AuthController.java 
    @GetMapping("/callback")
    @ResponseBody
    public ResponseEntity<ApiResponseWrapper<UserResponse>> githubCallback(
            @RequestParam String code,
            @RequestParam String state,
            HttpSession session) {
        String sessionState = (String) session.getAttribute("oauth_state");
        if (sessionState == null || !sessionState.equals(state)) {
            throw new RuntimeException("State mismatch.");
        }
        session.removeAttribute("oauth_state"); 
    
        String accessToken = githubService.getAccessToken(code);
        GithubUserResponse githubUser = githubService.getGithubUser(accessToken);
        User user = githubService.registerOrLoginUser(githubUser, accessToken, "read:user,user:email");
        
        UserResponse userResponse = UserResponse.builder()...
        return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK ,userResponse));
    }

     

     

    GithubService.java: 모든 뒷처리를 담당

    이 서비스는 AuthController로부터 받은 인가 코드를 액세스 토큰으로 바꾸고, 이 토큰으로 사용자 정보를 가져와 회원가입/로그인 처리를 담당한다. 

    // GithubService.java
    @Service
    @RequiredArgsConstructor
    public class GithubService {
        
        public String getAccessToken(String code) {
            // 인가 코드를 이용해 깃허브에 액세스 토큰 발급을 요청
            HttpHeaders headers = new HttpHeaders();
            headers.setAccept(List.of(MediaType.APPLICATION_JSON));
    
            MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
            params.add("client_id", clientId);
            params.add("client_secret", clientSecret);
            params.add("code", code);
            params.add("redirect_uri", redirectUri);
    
            HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
            ResponseEntity<GithubTokenResponse> response = restTemplate.postForEntity(
                    tokenUrl, request, GithubTokenResponse.class);
    
            return response.getBody().getAccessToken();
        }
    
        public GithubUserResponse getGithubUser(String accessToken) {
            // 발급받은 액세스 토큰으로 깃허브 사용자 정보 API를 호출
            HttpHeaders headers = new HttpHeaders();
            headers.setBearerAuth(accessToken);
    
            HttpEntity<Void> entity = new HttpEntity<>(headers);
    
            ResponseEntity<GithubUserResponse> response = restTemplate.exchange(
                    userUrl, HttpMethod.GET, entity, GithubUserResponse.class);
    
            return response.getBody();
        }
    
        public User registerOrLoginUser(GithubUserResponse githubUser, String accessToken, String scope) {
    
            String encryptedAccessToken = encryptToken(accessToken); 
            // 암호화 이후 User DB에 저장
        }
    }

     

     

     


     

     

    백엔드 주도 vs.프론트엔드 주도: 나는 왜 백엔드 주도를 택했나?

    OAuth2 흐름을 자세히 보니 한 가지 의문이 생겼다. '인가 코드를 받는 주체는 누구여야 할까?' 바로 이 질문에 대한 답에 따라 백엔드 주도 방식프론트엔드 주도 방식으로 나뉜다.

     

     

    1. 내가 선택한 방식: 백엔드 주도 OAuth2 

    내가 구현한 방식은 redirect_uri를 백엔드 서버의 URL로 설정하는 것이다.

    사용자가 로그인 버튼을 누르면, 프론트엔드는 백엔드의 API를 호출하고, 백엔드가 직접 깃허브로 사용자를 리디렉션한다. 이후 깃허브에서 돌아온 인가 코드를 프론트엔드를 거치지 않고 백엔드가 바로 받는다.

    • 장점: 보안성 강화
      • client_secret을 사용해 액세스 토큰을 받아오는 과정이 모두 백엔드 서버에서 이루어져, 민감한 정보가 외부에 노출될 가능성이 극히 낮다.
      • 프론트엔드는 복잡한 OAuth2 흐름을 신경 쓸 필요가 없어 개발 부담이 적다.
    • 단점: 유연성 제한
      • 프론트엔드와 백엔드의 URL이 달라서 CORS 설정이나 다른 부가적인 작업이 필요할 수 있다.

     

    2. 또 다른 방법: 프론트엔드 주도 OAuth2 

     

    또 다른 방식은 redirect_uri를 프론트엔드 URL로 설정하는 것이다. 사용자는 깃허브에서 인증을 마친 후 인가 코드를 프론트엔드로 직접 받는다. 프론트엔드는 이 코드를 백엔드 API로 보내고, 백엔드가 액세스 토큰을 교환하는 식이다. 

     

    이 방식의 장점은

    • 프론트엔드가 로그인 과정을 좀 더 세밀하게 제어할 수 있다.

    단점

    • 인가 코드가 브라우저를 통해 노출되는 과정이 추가된다는 점에서 보안에 좀 더 신경 써야한다.

     

     

     

    그래서 나는 왜 백엔드 주도를 선택했을까?

    결론적으로, 나는 보안과 백엔드 로직의 단순화를 최우선으로 생각했기 때문에 백엔드 주도 방식을 택했다. 프론트엔드에서 불필요한 데이터를 주고받지 않고, 백엔드가 모든 보안 프로세스를 책임지는 게 가장 안전하다고 판단했다. 

     

     

     

     

     

     

     

Designed by Tistory.