[나만무/개발단계]OAuth 로그인 구현

CHO WanGi·2025년 7월 1일

KRAFTON JUNGLE 8th

목록 보기
80/89

https://www.youtube.com/watch?v=Mh3LaHmA21I&ab_channel=%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC

인증과 인가?

인증 (Authentication)

특정 서비스에 접근하는 사람(사용자)가 누구인 지 확인하는 것
회원가입과 로그인 과정.

인가 (Authorization)

사용자가 서비스에 대한 권한을 허락하는 것.
접근 권한을 부여하는 것.

인증은 내가 누군지 증명하는 것이고, 인가는 내가 들어갈 수 있는가? 를 판단하는 것.
이런 의미에서 인증 -> 인가는 가능하지만 인가 -> 인증의 순서는 이어지기 힘들다는 것.

OAuth?

우리 프로젝트는 구글의 Email, Profile 서비스를 이용하고자 한다.

  • BEFORE

사용자는 우리 서비스에 구글 ID/PW를 제공하고,
우리 서비스는 이를 통해 구글 로그인을 수행.
구글로부터 정보를 얻어 사용자에게 서베스를 제공하는 순서이다.

이게 왜? 할 수 있지만
지금 구글이 보안에 아무리 신경써도
이런식으로 외부 서비스에서 ID/PW를 딸깍해서 외부로 유출시킨다면?
구글 입장에선 자기네들의 보안 수준과 상관 없이 보안에 취약점을 갖게 된다.

우리 서비스 입장에서도 구글의 보안 정보를 다루는 만큼, 보안적으로 부담이 갈 수 밖에 없고
이로 인해 구글 측에 손해라도 준다면 바로 고소미 엔딩이기 때문..

  • AFTER

그래서 우리의 서비스는 권한만 얻고
사용자와 구글이 인증 절차를 수행한다.
이렇게 되면 우리 서비스는 구글 아이디와 패스워드를 다루지 않고
권한을 얻고 구글 서비스에 접근할 수 있게 된다.

Auth Code Flow

  • Resource Owner
    인증을 수행하는 사용자

  • User Agent
    구글 크롬 브라우저 같은 브라우저라고 생각하자.

  • Third Party App
    우리 프로젝트, 즉 사용자에게 서비스를 제공하는 웹 프로덕트이다.

  • Auth Server & Resource Server
    이 두 서버는 쉽게 구글로 생각하면된다.
    인증 검증에 대한 권한을 부여하는 Auth Server,
    인가 수행에 필요한 리소스를 제공하는 Resource Server.

여기서 주목할 점은 Authorization 과정에서 사용자(Resource Owner)와 Auth Server(Google)만 참여한다는 점이다.

이 인증과정에서 Client라고 하는 우리 서비스는 구글 로그인의 공식 페이지 URL만 제공할뿐
인증 과정에 참여하지 않는다.
(물론 이를 위해 response_type, client_id, redirect_url, scope)를 제공해야한다.

이해가 안된다?

개발 하다가 보면
Auth Server가 제공하는 Authorizaion Code를 token이라고 이야기할 수 있다.

근데 우리 서비스는 JWT를 활용해서 Access TokenRefresh Token을 활용하기 때문에
구글이 우리앱의 Access Token을 준다고? 라고 이해했다.

이때 기술 용어들을 왜 정확히 써야하고, 왜 원리를 이해하고 사용해야 하는지 느낄 수 있었다.
만약 OAuth 로직 공부없이 구글이 Access Token 를 발급한다고 했다면
FrontEnd 로직에서 Callback을 처리하지 않고, 구글이 준 Authorizaion Code 를 갖고
우리 백엔드 서버로 인가를 시도했다면 당연히 권한을 주지 않았을 것이다.

그러니 확실이 구분하고 이해합시다!

그래서 Social login은 어떤 Flow인데요?(with JWT AT & RT)

  • FrontEnd -> Google

이렇게 생긴 소셜 로그인 버튼을 누르게되면
프론트엔드는 사용자를 window.location.href 를 활용하여
response_type, client_id, redirect_url, scope 을 담은 리다이렉트 링크로 사용자를 리다이렉트 한다.

export const authService = {
  /**
   * 지정된 소셜 로그인 페이지로 리디렉션
   */
  redirectToProvider(provider: Provider) {
    const url = oauthLoginUrls[provider];
    if (url) {
      window.location.href = url;
    } else {
      console.error(`Unsupported provider: ${provider}`);
    }
  },

  • Google -> FrontEnd

사용자는 리다이렉트 된 구글의 공식 로그인 페이지에서 로그인(인증)을 진행한다.
인증이 성공적으로 진행되었다면
구글은 FrontEnd에게 Authorizaion Code를 발급한다.

  • FrontEnd -> BackEnd

프론트엔드는 이 Authorizaion Code를 갖고 우리 백엔드 서버로 가서
우리 앱의 Access Token 과 Refresh Token을 요청
한다.

  async handleOAuthCallback(
    code: string,
    state: Provider
  ): Promise<AuthResult> {
    try {
      // state를 body에 담아 보내기.
      const response = await apiClient.post('/api/user/oauth/login', {
        code,
        state,
      });

code에는 Authorizaion Code가,
state에는 google 인지 kakao인지 등의 인증 서버 주체를 담아서 POST를 수행.

  • BackEnd -> FrontEnd

둘이 협의한 형태로 헤더에 담아주든 바디에 담아주든 우리 앱의 Access Token 과 Refresh Token을
프론트로 돌려 준다.

우리 프로젝트는 Authorizaion 헤더에 Access Token을, Set-Cookie와 httponly 옵션을 통해
JS로 접근 못하도록 하여 RT를 쿠키로 전송하기로 하였다.

또한 AT에 사용자 정보를 인코딩 해서 발송하기로 하였다.

  • Front End

      const authHeader = response.headers['authorization'];
      const accessToken = authHeader?.split(' ')[1];

      const decodedToken = jwtDecode<DecodedToken>(accessToken);

      const user = {
        userId: decodedToken.userId,
      };

      // 응답에서 AT와 사용자 정보를 추출하여 반환
      return { accessToken, user };

협의한 대로 jwt-decoder 라이브러리를 활용하여 디코딩하여
AT와 user 정보를 프론트엔드 단에서 활용하면 된다.

꼬여버린 우리의 야심찬 계획

JWT에서 토큰을 어디에다 저장할 것인가? 라는 것은 다양한 방법이 있다.
Local Storage, Session Storage, Cookie, Private 변수 등등

우린 AT는 메모리상 변수로 저장하고 RT는 httponly로 쿠키에 담기로 했다.

    const processLogin = async (prov: Provider, authCode: string) => {
      try {
         const { accessToken, user } = await authService.handleOAuthCallback(
          authCode,
          prov
        );

        if (accessToken && user) {
          setAuth(accessToken, user);
          console.log(user);
          navigate('/canvas'); // 성공! 메인 페이지로 이동
        }
        } else {
          throw new Error('Authentication failed');
        }
      } catch (error) {
        navigate('/login-failed');
      }
    };

문제점 : 페이지 이동 시 Access Token은 왜 사라졌을까?

BackEnd에서 FrontEnd로 AT와 RT를 보내고 이를 받아서 프론트엔드는
다시 메인 페이지로 navigate 시킨다.
근데 이렇게 되면 우리가 메모리 상 변수에 저장한 AT는 새로고침하면서 날아가게 된다.

즉 AT를 백엔드에서 잘 보내주고 프론트에서 잘 받았음에도
프론트 로직상 AT에 접근할 수 없는 문제점이 발생했다.

흐름으로 보는 문제점

Gemini가 야무지게 정리를 해서 공유합니다&&

  1. /auth/callback 페이지 도착

    • authService가 백엔드로부터 AT를 성공적으로 받아옵니다. ✅
  2. Zustand 스토어에 저장

    • setAuth(AT, ...)가 호출됩니다.
    • 받아온 AT가 Zustand 스토어, 즉 '임시 알바생의 칠판'에 저장됩니다. ✅
  3. 메인 페이지로 이동 명령

    • Maps('/')가 호출됩니다. ➡️
  4. 메모리 소멸 (AT 소멸 💥)

    • 브라우저가 / 페이지를 로드하기 위해, /auth/callback 페이지와 그곳에서 사용된 모든 JavaScript 메모리(방금 AT를 저장한 칠판 포함)를 파괴합니다.
  5. 메인 페이지(/) 로딩

    • 새로운 App 컴포넌트가 로드되고, 새로운 Zustand 스토어(깨끗한 새 칠판)가 생성됩니다.
    • 이 새로운 스토어에서 isLoggedIn 상태를 꺼내보면, 당연히 초기값인 false가 됩니다.

해결책

결국 이 AT를 받은 상태를 유지하는 것과 완벽한 보안 사이에서 딜레마를 겪었다.
그래서 현실적으로 가기로 했다.

물론 sessionStorageXSS(Cross-Site Scripting) 공격에 취약할 수 있다는 단점이 있다.
그렇지만 AT를 메모리에서 즉시 사용하고 페이지 이동 후 바로 삭제하는 방식으로
편의성과 보안 사이에서 균형점을 찾았다.

Navigate 전 세션 스토리지에 AT 값을 저장해놓고
Navigate 하여 AT를 꺼내어 Zustand를 활용하여 Private 변수에 저장.
이후 세션 스토리지를 지우는 방식으로 진행하기로 하였다.

        const authResult = await authService.handleOAuthCallback(
          authCode,
          prov
        );

        console.log(authResult);

        if (authResult?.accessToken && authResult?.user) {
          // 객체를 JSON 문자열로 변환
          const authResultString = JSON.stringify(authResult);

          // 'authResult'라는 키(key)로 sessionStorage에 저장합니다.
          sessionStorage.setItem('authResult', authResultString);

          // 성공했으니 메인 페이지로 이동
          navigate('/');
// App.jsx
  useEffect(() => {
    const authResultString = sessionStorage.getItem('authResult');

    if (authResultString) {
      sessionStorage.removeItem('authResult');

      const { accessToken, user } = JSON.parse(authResultString);

      setAuth(accessToken, user);

      setIsLoading(false);
    }

useEffect를 사용하여 마운트 후 바로 세션 스토리지를 지워
최대한 AT가 세션 스토리지에서 빠르게 지워지도록 하였다.

결론

결국 핵심은 "인증"은 구글이, 우리 서비스는 "인가"의 결과를 받아 사용자에게 토큰을 발급하는 것.
전체 흐름을 이해하고 각 주체의 역할을 이해해야지 견고한 인증 및 인가 시스템을 구현할 수 있구나를
느꼈던 값진 하루...

profile
제 Velog에 오신 모든 분들이 작더라도 인사이트를 얻어가셨으면 좋겠습니다 :)

0개의 댓글