React 팝업과 소통하기 (팝업 SOP 에러 해결하기)

Shyuuuuni·2022년 11월 27일
8

📚 Tech-Post

목록 보기
3/9
post-thumbnail

부스트캠프 그룹 프로젝트 진행 중 Github 로그인을 팝업 브라우저에서 수행한 후 원본 브라우저에 적용하는 기능을 개발해야 했다.

  • 팝업창을 띄우고 로그인을 성공하는 기능까지 개발할 수 있었다.
  • 하지만 로그인이 완료되고 그 정보를 원본 브라우저에 적용하는데 오류가 발생했다.
  • 오류 - DOM Exception: Blocked a frame with origin "..." from accessing a cross-origin frame.

이러한 과정들을 기록하려고 포스트를 작성하게 되었다.

React 에서 OAuth 팝업 사용하기

(1) 기본 팝업 사용

전체적으로 How to create an OAuth Popup in React 아티클을 참고해서 작성했다.

Web APIs의 Window.open() 을 사용하여 팝업을 사용해보자.

// App.js
import './App.css';

function App() {
  const handleOpenPopup = () => {
    const popup = window.open("https://www.naver.com", "네이버", "popup=yes");
  }

  return (
    <div className="App">
      <button onClick={handleOpenPopup}>팝업 열기</button>
    </div>
  );
}

export default App;

window.open() API 는 아래와 같이 사용할 수 있다.

open()
open(url)
open(url, target)
open(url, target, windowFeatures)
  • url : 윈도우에서 로딩 할 페이지의 url
  • target : 윈도우 컨텍스트의 이름
  • windowFeatures : name1=value2,name2=value2,... 형태로 입력할 수 있는 윈도우의 속성

(2) OAuth 팝업 설정

  • open() 함수의 windowFeature 를 통해 윈도우의 크기, 위치를 지정할 수 있다.
  • 버튼을 누르면 Github OAuth 인가 서버로 요청을 보내 인가 코드를 받아오는 팝업을 만들어보자. (Github OAuth 과정에 대해 더 알고 싶다면 : Github 로그인으로 알아보는 OAuth 작동방식)

Github OAuth 설정

  • Github Settings - Developer Settings - OAuth Apps - Register a new application 으로 이동
  • 아래와 같이 적절히 내용을 채워 넣는다. (예시에서는 인가 코드 발행 이후 /callback으로 이동하도록 구현 할 예정이다.)

  • 내용을 모두 입력하고 Register application 버튼을 클릭하면 아래 화면과 같이 Client ID 값을 얻을 수 있다.
  • 예시에서는 인가 코드만 받아올 예정이므로 secret은 따로 생성하지 않았다.

  • 이후 쉽게 사용하기 위해 App.js 상단에 상수로 선언해주었다.
// App.js
const CLIENT_ID = "5052479e47d7a36951cd";
const GITHUB_AUTH_SERVER = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}`;
...

팝업 속성 설정

  • window.open()windowFeatures 인자로 팝업의 크기와 위치 등을 지정할 수 있다.
  • 아래는 위치를 잡기 위해서 몇가지 변수들을 정리한 이미지이다.

  • 모니터의 좌측 상단을 (0,0) 이라고 하면 (top = 0, left = 0),
  • 현재 브라우저의 좌측 상단 위치는 (window.screenX, window.screenY) 이다.
  • 그리고 브라우저의 전체 가로-세로 길이는 window.outerWidth, window.outerHeight 로 구할 수 있다.
  • 마지막으로 팝업을 생성할 위치를 (top, left), 팝업의 가로-세로 길이를 width, height 로 정의했다.

팝업의 위치는 자유지만, 팝업을 부모 브라우저의 정 중앙에 생성해보자.

// App.js
...
const handleOpenPopup = () => {
    const width = 500; // 팝업의 가로 길이: 500
    const height = 400; // 팝업의 세로 길이 : 500
    // 팝업을 부모 브라우저의 정 중앙에 위치시킨다.
    const left = window.screenX + (window.outerWidth - width) / 2;
    const top = window.screenY + (window.outerHeight - height) / 2;
    const popup = window.open(
        GITHUB_AUTH_SERVER,
        "로그인 중...",
        `width=${width},height=${height},left=${left},top=${top}`
    );
  }
  • (window.screenX + width, window.screenY + height) 위치가 부모 브라우저의 정 중앙이긴 하지만, 해당 지점부터 오른쪽/아래로 팝업을 그리면 우측 하단으로 치우치게 된다.
  • 따라서 팝업의 가로-세로 길이의 절반만큼 뺀 자리에 생성하면 화면의 정 중앙에 생성할 수 있다.

(3) 팝업에서 code 가져오기 - 문제 상황

팝업에서 로그인이 완료되면 아래와 같이 (1) 에서 등록했던 Callback URL로 이동된다.

참고했던 래퍼런스 방식대로 여기서 인가 코드를 가져와보자.

  • window.open() 으로 생성된 팝업 윈도우 객체를 상태로 저장한다.
  • useEffect 훅의 deps 에 popup 을 등록하여 변화가 생기면 effect 함수를 실행한다.
  • effect 함수에서는 팝업이 열려 있는 상태라면 지속적으로 팝업의 URL 을 관찰하면서, Callback URL에 code=[인가코드] 형식으로 반환되는 쿼리스트링을 확인한다.
  • code 쿼리스트링이 존재한다면 팝업을 닫고 code 값을 이용해 필요에 따라 인가가 필요한 API 호출한다.

아래는 위 로직을 적용한 코드 전체이다.

// App.js
import './App.css';
import {useEffect, useState} from "react";

const CLIENT_ID = "5052479e47d7a36951cd";
const GITHUB_AUTH_SERVER = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}`;

function App() {
  const [popup, setPopup] = useState();

  const handleOpenPopup = () => {
    const width = 500;
    const height = 400;
    const left = window.screenX + (window.outerWidth - width) / 2;
    const top = window.screenY + (window.outerHeight - height) / 2;
    const popup = window.open(
        GITHUB_AUTH_SERVER,
        "로그인 중...",
        `width=${width},height=${height},left=${left},top=${top}`
    );
    setPopup(popup);
  }

  useEffect(() => {
    if (!popup) {
      return;
    }

    const timer = setInterval(() => {
      if (!popup) {
        timer && clearInterval(timer);
        return;
      }
      const currentUrl = popup.location.href;
      if (!currentUrl) {
        return;
      }
      const searchParams = new URL(currentUrl).searchParams;
      const code = searchParams.get('code');
      if (code) {
        popup.close();
        console.log(`The popup URL has URL code param = ${code}`);
        // 가져온 code 로 다른 정보를 가져오는 API 호출
      }}, 500)}, [popup]);


  return (
    <div className="App">
      <button onClick={handleOpenPopup}>팝업 열기</button>
    </div>
  );
}

export default App;

실제로 실행해보면 아래와 같이 잘 되는 것을 볼 수 있다.

어? 인가 코드는 잘 가져오는데, 저기 뜨는 에러는 무슨 에러지..?

팝업과 소통하기

Blocked a frame with origin "..." from accessing a cross-origin frame

JavaScript APIs like iframe.contentWindow, window.parent, window.open, and window.opener allow documents to directly reference each other.
When two documents do not have the same origin, these references provide very limited access to Window and Location objects, as described in the next two sections.
To communicate between documents from different origins, use window.postMessage.
mdn web docs

mdn의 SOP 문서에 관련 내용이 작성되어 있다.

SOP (Same-Origin-Policy) 란 간단하게 보안상 출처(Origin)이 다른 문서나 스크립트 사이에서 상호작용하는 방식을 제한하는 것을 의미한다.

위의 에러에서도 "cross-origin 에 대해서 접근하는 것을 막았다." 는 내용을 볼 수 있다.

이제 위의 문제를 문서의 마지막에 소개한 window.postMessage() API를 이용해서 해결해보자.

(사실 위의 예시에서는 Cross-Origin 일 때는 오류가 나도 크게 상관이 없다. 실제로 코드가 반환되는 시점에서는 Callback URL 로 지정된 Same-Origin 상태이기 때문에 그 시점에서 코드를 받아오면 된다. 하지만 개발자도구 콘솔이 더럽혀지는걸 볼 수는 없기 때문에..)

window.postMessage() 활용하기

데이터 송신

window.postMessage() API는 아래와 같이 사용할 수 있다.

targetWindow.postMessage(message, targetOrigin, [transfer]);
  • targetWindow 로는 메세지를 받을 목적지 윈도우를 입력한다.
  • message 에는 전달할 데이터를 입력한다.
  • targetOrigin 에는 targetWindowOrigin 을 입력한다.
    • 지금 예시의 인가 코드나 비밀번호와 같이 중요한 정보를 전달할 때, targetWindowOrigin을 명확히 설정하지 않으면 보안상 위험하기 때문에 중요하다.
    • Cross-Origin 일 경우에도 사용할 수 있지만, 이 경우 항상 보안에 유의해야 한다.

데이터 수신

window.postMessage() 로 보낸 데이터는 "message" 이벤트로 수신하여 처리할 수 있다.

아래는 mdn에서 권장하는 수신 방법 예시이다.

window.addEventListener("message", receiveMessage, false);

function receiveMessage(event)
{
  if (event.origin !== "http://example.org:8080")
    return;

  // ...
}
  • window에 "message" 이벤트에 대한 listener 함수를 등록한다.
  • listener 함수에서도 마찬가지로 origin 체크를 진행한다.

코드에 적용하기

  1. 부모 브라우저에서는 (로그인 버튼을 클릭하는 등) 팝업을 여는 이벤트가 발생하면 팝업을 열고, 상태로 저장한다.
  2. 상태가 변경되면 부모 브라우저에서 "message" 이벤트 수신을 시작한다.
  3. 따로 라우팅 설정을 하지 않았기 때문에 팝업에서는 Callback URL (localhost:3000/callback?code=...) 으로 이동하더라도 App.js가 실행된다.
  4. 첫번째 useEffect 훅에서 팝업의 URL에 code 쿼리스트링이 있는지 체크하고, 만약 있다면 팝업을 연 부모 브라우저 (window.opener) 에게 code를 전달한다.
  5. 2번에서 수신하던 이벤트가 발생하여 origin 체크와 code 체크를 한다. 모두 만족한다면 code 값을 이용해 API 를 호출하는 등 활용한다.

아래는 위 과정이 모두 포함된 전체 코드이다.

// App.js
import './App.css';
import {useEffect, useState} from "react";

const CLIENT_ID = "5052479e47d7a36951cd";
const GITHUB_AUTH_SERVER = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}`;

function App() {
  const [popup, setPopup] = useState();

  const handleOpenPopup = () => {
    const width = 500;
    const height = 400;
    const left = window.screenX + (window.outerWidth - width) / 2;
    const top = window.screenY + (window.outerHeight - height) / 2;
    const popup = window.open(
        GITHUB_AUTH_SERVER,
        "로그인 중...",
        `width=${width},height=${height},left=${left},top=${top}`
    );
    setPopup(popup);
  }

  useEffect(() => {
    const currentUrl = window.location.href;
    const searchParams = new URL(currentUrl).searchParams;
    const code = searchParams.get("code");
    if (code) {
      window.opener.postMessage({ code }, window.location.origin);
    }
  }, []);

  // 로그인 팝입이 열리면 로그인 로직을 처리합니다.
  useEffect(() => {
    if (!popup) {
      return;
    }

    const githubOAuthCodeListener = (e) => {
      // 동일한 Origin 의 이벤트만 처리하도록 제한
      if (e.origin !== window.location.origin) {
        return;
      }
      const { code } = e.data;
      if (code) {
        console.log(`The popup URL has URL code param = ${code}`);
      }
      popup?.close();
      setPopup(null);
    };

    window.addEventListener("message", githubOAuthCodeListener, false);

    return () => {
      window.removeEventListener("message", githubOAuthCodeListener);
      popup?.close();
      setPopup(null);
    };
  }, [popup]);

  return (
    <div className="App">
      <button onClick={handleOpenPopup}>팝업 열기</button>
    </div>
  );
}

export default App;

그 외

  • 지금은 간단한 예시여서 라우팅 없이 App.js 하나의 컴포넌트에서 처리하느라 부모 브라우저에서도 useEffect 훅이 실행되는데, 라우팅을 적용하여 /callback 의 경우 다른 컴포넌트에서 처리하면 더 깔끔한 코드가 될 수 있다.
  • 이벤트 리스너를 등록하는 부분을 보면 window.addEventListner(...) 와 같이 특정 컴포넌트에 종속된 로직이 아니기 때문에 커스텀 훅으로 관리하면 재사용 하기 더 편하다.
    • 재사용을 위한 커스텀 훅 예시는 WeView 프로젝트를 하면서 사용했기 때문에 링크로 대체한다. WeView: useOAuthPopup.ts

래퍼런스

profile
배짱개미 개발자 김승현입니다 🖐

2개의 댓글

comment-user-thumbnail
2022년 12월 1일

팀원과의 소통도 좀 부탁드립니다.

답글 달기
comment-user-thumbnail
2023년 8월 27일

좋은 글 감사합니다!
이 글 참고하여 제 문제를 해결할 수 있었습니다.
저는 부모-자식 창을 별도의 컴포넌트로 처리하였습니다.
제 경험도 정리해서 공유해드려요 ㅎㅎ 감사합니다!
https://dd5dd5.tistory.com/17

답글 달기