티스토리 뷰

반응형

안녕하세요. 오늘은 Google OAuth 인증을 팝업모드로 구현한 과정을 소개하고자 합니다. 

 

Background

Google Ads 계정을 가져와야 하는 로직이 있었습니다. 

 

Process

1. 서버에서 Google 계정 연동을 위한 URL을 받습니다.

2. 받은 URL로 Google 계정 연동 버튼 클릭 시 팝업 창을 띄웁니다.

3. Google 연동 후 Callback URL에서 코드를 가져옵니다.

4. 가져온 코드로 User의 Google Account ID를 저장한 후 다음 단계로 넘어갑니다.

 

1번~3번의 과정은 클라이언트에서도 Google Client ID를 사용하면 code를 받을 수 있어 처음에는 @react-oauth/google이라는 라이브러리를 설치하여 구현하였습니다. 

 

하지만 특정 이유로 백엔드분께서 구글 계정 연동을 위한 URL를 내려주시는 걸 만드시게 되었고, 라이브러리를 사용하지 않게되어 2번~3번의 과정을 직접 구현해야 했습니다. 

 


 

팝업 or 리디렉션 모드 

Google OAuth 인증은 두 가자의 인증 방식이 있습니다. 

 

팝업 모드

웹 페이지에서 연동 버튼을 누르면, 구글 계정 연동 창이 팝업으로 열리며, 사용자는 자신의 계정 정보를 입력하여 인증을 마칩니다. 이때, 웹 페이지는 로그인 성공 여부를 팝업 창에서 받아와서 처리합니다. 이 방식은 인증 과정이 웹 페이지에서 완료되기 때문에 간단하고 사용하기 쉬우며, 사용자 경험도 좋습니다. 하지만 팝업 차단 기능이 있는 브라우저에서는 인증이 실패할 수 있습니다.

 

리디렉션 모드 

웹 페이지에서 연동 버튼을 누르면, 구글 계정 연동 페이지로 이동합니다. 사용자는 자신의 계정 정보를 입력하여 인증을 마치면, 인증 결과가 리디렉션 주소로 전달됩니다. 이때, 웹 페이지는 리디렉션 주소에서 인증 결과를 받아와서 처리합니다. 이 방식은 팝업 차단 기능이 있는 브라우저에서도 인증이 가능하며, 보안성도 더 높습니다. 하지만 사용자 경험이 팝업 모드에 비해 복잡할 수 있습니다.

 

저는 사용자 경험이 더 좋은 팝업모드를 선택하였습니다. 

 


 

구글 연동 창 팝업 모드로 열기 window.open()

새로운 창을 열기 위해서 window객체에서 제공하는 open() 메서드를 사용하였습니다.

window.open()은 JavaScript에서 새 창이나 탭을 열 때 사용되는 함수입니다. 이 함수는 새로운 브라우저 창을 열거나, 이미 존재하는 창을 대체하거나, 또는 새로운 탭을 열 수 있습니다.

 

팝업 모드로 창을 열기 위해서는 다음과 같은 인자를 넣으면 됩니다. 

  • URL: 팝업으로 열 창의 URL 주소 
  • Name: 팝업으로 열 창의 이름 
  • Features: 창의 크기, 위치, 스크롤바 여부 등을 설정하는 문자열 
  • Replace: 이미 열린 창을 대체할지 여부를 나타내는 불리언 값
// open(url, target, windowFeatures)
window.open("https://www.mozilla.org/", "mozillaWindow", "popup");

 

저는 이를 활용하여 useOAuthPopUp()이라는 hook을 만들었는데요.

해당 hook에는 창을 열어주는 open()과 callback url에서 가져온 code를 반환합니다. 

 

useOAuthPopup - open()

left와 top정보는 창을 window창의 중앙에서 열기 위해 구했습니다. 열린 popUp이라는 window 객체는 상태에 저장해 줍니다. 

const useOAuthPopUp = () => {
  const [popUp, setPopUp] = useState<Window | null>(null);
  const [code, setCode] = useState<string | null>(null);

const open = useCallback((url: string, width: string, height: string) => {
    const left = window.screen.width / 2 - parseInt(width, 10) / 2;
    const top = window.screen.height / 2 - parseInt(height, 10) / 2;
    const popUp = window.open(
      url,
      'Connect Google Account',
      `width=${width},height=${height},left=${left},top=${top}`,
    );
    setPopUp(popUp);
  }, []);
  
  return useMemo(() => ({ open, code, close }), [open, code, close]);
};

export default useOAuthPopUp;

 

팝업 창 열기

 const handleClickConnectGA = useCallback(() => {
    if (!data) return;
    oAuthPopUp.open(data.getCodeUrl, '500', '600');
  }, [data, oAuthPopUp]);

 

 


 

팝업 창에서 반환되는 callback URL 가져오기 window.message()

연동을 성공하면 아래와 같은 URL를 반환합니다. 팝업 창의 URL 데이터를 가져오기위해 window객체에서 제공하는 message event를 사용하였습니다. 

http://localhost:3000/social_login/callback? state=Spsp8 jOrkzEcgMyo0I2E6 tKfa0 ZaEQ&code=4/0 AVHEtk6 WHeaWnVoNqcACHVpA2 Ugn-xWnGhH6 fhAXFXMDwZjBL8O6 ljPRDqv0 YHnRzeQsDw&scope=https://www.googleapis.com/auth/adwords

 

Window Message Event  

다른 창에서 메시지를 보내거나, 현재 창에서 postMessage() 메서드를 사용하여 보낸 메시지를 수신할 때 발생합니다. 이벤트 핸들러에는 MessageEvent 인터페이스가 전달되며, 해당 인터페이스에는 data, origin, source 등의 속성이 포함됩니다.

 

Window.postMessage()

targetWindow.postMessage(message, targetOrigin, [transfer]);

 

targetWindow 

메시지를 전달 받을 window의 참조. 

message 

다른 window에 보내질 데이터.

targetOrigin

targetWindow의 origin을 지정합니다.

 

// postMessage(message, targetOrigin, transfer)
// A.html
window.opener.postMessage('Hello from A.html!', 'http://example.com');

// B.html
window.addEventListener('message', (event) => {
  if (event.origin !== 'http://example.com') return;
  console.log(event.data); // "Hello from A.html!"
});

 


팝업창 -> 부모창 메세지 보내기

아래의 사이드 이펙트는 window객체에서 opener가 없다면 리턴 시킴으로써 팝업창에서만 실행되는 로직입니다. 구글 연동 팝업창의 URL에서 code를 가져와 postMessage 이벤트를 통해 부모창의 URL에 code를 보내줍니다. 

  useEffect(() => {
    if (!window.opener) return; // 부모창은 opener를 가지고 있지 않아 팝업창에서만 아래 로직이 실행됩니다.
    const openerURL = window.opener.location.href; // 부모창 주소
    const searchParams = new URLSearchParams(window.location.search);
    const code = searchParams.get('code'); // 팝업창 URL parameter 에서 가져온 code
    if (code) {
      window.opener.postMessage({ code }, openerURL); // 부모창에 code객체 전달
      window.close(); // 팝업창 닫기
    }
  }, []);

 


부모창에서 메시지 받기

이번에는 window객체에서 opener가 있으면 리턴 시켜 부모창에서만 실행되는 사이드 이펙트입니다.  사이드 이펙트 안에서 팝업창에서 보낸 메시지를 받는 oAuthCodeListener 함수를 생성해 'message'이벤트에 등록하고,  컴포넌트가 원마운트될 때 이벤트 핸들러를 제거해 줍니다.

  useEffect(() => {
    if (window.opener) return;
    const oAuthCodeListener = (event: MessageEvent) => {
      if (event.origin !== window.location.origin) return;

      const { code } = event.data;
      if (!code) return;
      setCode(code);
    };

    window.addEventListener('message', oAuthCodeListener, false);

    return () => {
      window.removeEventListener('message', oAuthCodeListener);
    };
  }, [close]);

 


전체 코드입니다. 저는 해당 훅을 부모창과 callback으로 받는 창(팝업창)에서 호출하여 사용하였습니다. 

import { useCallback, useEffect, useMemo, useState } from 'react';

interface OAuthPopUp {
  open: (url: string, width: string, height: string) => void;
  code: string | null;
  close: () => void;
}

const useOAuthPopUp = (): OAuthPopUp => {
  const [popUp, setPopUp] = useState<Window | null>(null);
  const [code, setCode] = useState<string | null>(null);

  const open = useCallback((url: string, width: string, height: string) => {
    const left = window.screen.width / 2 - parseInt(width, 10) / 2;
    const top = window.screen.height / 2 - parseInt(height, 10) / 2;
    const popUp = window.open(
      url,
      'Connect Google Account',
      `width=${width},height=${height},left=${left},top=${top}`,
    );
    setPopUp(popUp);
  }, []);

  const close = useCallback(() => {
    if (!popUp) return;
    popUp.close();
  }, [popUp]);

  useEffect(() => {
    if (!window.opener) return;
    const openerURL = window.opener.location.href;
    const searchParams = new URLSearchParams(window.location.search);
    const code = searchParams.get('code');
    if (code) {
      window.opener.postMessage({ code }, openerURL);
      window.close();
    }
  }, []);

  useEffect(() => {
    if (window.opener) return;
    const oAuthCodeListener = (event: MessageEvent) => {
      if (event.origin !== window.location.origin) return;

      const { code } = event.data;
      if (!code) return;
      setCode(code);
    };

    window.addEventListener('message', oAuthCodeListener, false);

    return () => {
      window.removeEventListener('message', oAuthCodeListener);
    };
  }, [close]);

  return useMemo(() => ({ open, code, close }), [open, code, close]);
};

export default useOAuthPopUp;

 

 


구글 계정 연동을 직접 구현하면서 window Javascript에서 제공하는 내장 모듈을 다양하게 써 볼 수 있었습니다. 특히 부모, 자식창간에서 소통하는 과정에서 겪었던 origin 오류를 통해서 코드를 수정하면서 보안상 이슈가 날 수 있는 부분을 잘 핸들링할 수 있는 경험을 했습니다. 이번 과정을 통해 OAuth 인증 방식에 대해 더욱 이해할 수 있었고, 팝업 모드를 구현하는 방법을 익힐 수 있었습니다. 향후 OAuth 인증을 구현할 때 더욱 유용하게 활용할 수 있을 것 같습니다. 

 

반응형