너 JS가 이벤트 기반 언어라는 거 잊지 마 (feat. MFA - 2)

응애·2023년 11월 14일
2
post-thumbnail

이전 포스팅에서는 Web Component를 이용해 MFA를 도입하기까지의 배경에 대한 내용을 정리해뒀다. 이 글은 Preact를 통해 Web Component를 만들고, 사용처인 Next JS와의 상태를 동기화하는 과정에서 겪었던 문제와 해결법에 대해 정리해 보려 한다.

🍔 햄버거 메뉴를 분리할 준비

분리하고자 하는 컴포넌트가 외부에서 받아야 하는 상태 정리 (예시)

  • env ('development', 'stage', 'production'): 개발, 스테이지, 운영 3가지 서버를 운영하고 있고, 각 도메인 별로 요청하는 apia 태그에 할당해 줘야 하는 주소가 다르기 때문에 각 도메인끼리의 런타임 통합을 위해 필요.
  • open (boolean): 열림, 닫힘 여부를 나타내는 상태.
  • login (boolean): 로그인 여부를 나타내는 상태. 사용처에서 이미 user api를 호출하기 때문에 중복 호출을 방지하고 보안상의 이슈를 피하고자 외부에서 정보를 넘기는 것으로 구현.
  • name (string): 로그인한 유저의 userName 값.

실제 내가 구현한 것에서 몇 가지 값들을 제외하고 간단한 햄버거 메뉴가 가질 수 있는 attr 들을 정리해 보았다. 외부에서 handleClose핸들러를 넘겨주지 않는 것이 조금은 어색할 수도 있다.

만약 React 기반의 컴포넌트라고 생각하고, 상위에서 isOpen, handleOpen, handleClose 등의 상태 및 핸들러를 정의한 것을 props로 손쉽게 내려줄 수 있겠지만 웹 컴포넌트에서는 불가능하다. JS에서 일급 객체로 취급 되는 함수는 객체이기 때문에 웹 컴포넌트의 속성으로 사용할 수 없기 때문이다.

🚨 웹 컴포넌트의 속성으로는 객체를 넘길 수 없다 !!

웹 컴포넌트로 넘겨진 모든 속성들은 자동으로 string의 타입을 가지게 된다. 만약 속성으로 boolean 타입의 값을 넘기더라도, 웹 컴포넌트 내부에서 해당 값의 타입을 조회해 보면 string으로 나오는 것을 알 수 있다. 이거 때문에 진짜 30분 넘게 삽질했었다...

// NextJS (주입하는 쪽)
  return (
    <hamburger-menu-widget
      env={process.env.NEXT_PUBLIC_APP_ENV}
      open={isHamburgerMenuOpen}
      login={isLoggedIn}
      name={userName}
    />
  );


// Preact (받는 쪽)

// Next JS에서 받아온 open 값 (boolean...인 줄 알았던..)
const [isOpen, setIsOpen] = useState(open);
...

// 'true' || 'false' 등 무조건 truthy 한 값.
// 따라서 if 문 내부는 무조건 falsy → 아무것도 렌더 되지 않는다.
if (!isOpen) {
  return <></>;
}

// 햄버거 메뉴 렌더 로직
return (
  <>
  	<nav>...</nav>
    <div className='dim' onClick={handleHamburgerMenuClose}/>
  </>
)

처음에 위와 같이 코드를 작성하고 계속해서 햄버거 메뉴가 노출이 되지 않길래... 웹 컴포넌트에 대한 지식도 전무하고, Preact도 사용해 본 적이 없던 나는 Preact의 문법이 아닌 custom-elements의 문법과 라이프 사이클을 이용해서 작성을 해야 하나 싶었다.

설마... 하고 typeof(open)을 콘솔에 출력해 본 후에야 바로 주입받은 속성값을 사용하면 안 된다는 것을 깨달았다.... 혹시 같은 방법을 이용해서 웹 컴포넌트를 만들어보고 싶다면 나와 같은 삽질은 안 하길 바란다.

💡 (개인적인) 웹 컴포넌트를 사용하기 좋은 환경은 ?

웹 컴포넌트는 앞서 설명했듯이 객체를 속성으로 가질 수 없다. 따라서 어떤 값을 fetch 해온 후 외부에서 해당 데이터를 넘기고, 웹 컴포넌트에서 보여주는 용도로 사용하기엔 적합하지 않다고 생각한다. 즉, 외부에서는 정말 간단한 데이터(위에 정리해둔 예시처럼)만 넘기는 컴포넌트거나, 혹은 아예 필요가 없는 반복되는 컴포넌트도 괜찮다. 하지만 하나의 저장소만 운영하고 있거나, 모노레포 위에 모든 저장소를 올려 공통으로 사용할 수 있는 코드를 정리할 곳이 있다면 마찬가지로 필요 없다. 이전 포스팅에 왜 도입하게 되었는지 정리해둔 것을 참고하길 바란다.

  • 햄버거 메뉴 : 간단한 정보만 넘길 수 있다면 분리할 수 있다. (필자의 예시)
  • GNB : 마찬가지로 보통 모든 도메인에 걸쳐 상단에 위치하게 될 것이고, 네비게이션의 역할을 수행할 것이기 때문에 좋다고 생각한다.
  • Footer : GNB와 동일하다. 심지어 여기에는 높은 확률로 넘겨줄 속성도 필요가 없이 그냥 렌더만 해도 될 경우가 많을 것 같다.

다른 예시로는 외부에서 인자를 넘기면, 해당 인자를 바탕으로 api 요청을 보낸 후 그 데이터를 뿌려주는 컴포넌트가 될 것 같다. 하지만 복잡한 형태의 데이터를 웹 컴포넌트의 attr로 넘기기보다 간단한 string들을 넘겼을 때 해당 attr들을 바탕으로 웹 컴포넌트 내부에서 fetch 한 후 같은 모양으로 뿌려주는 컴포넌트는 괜찮을 것 같다. 다음과 같은 예시가 되겠다.

// 사용처 - 간단한 속성들만 넘겨준다
return <my-web-component id='3' type='banner'/>

// 웹 컴포넌트 - 내부에서 전달받은 간단한 속성들을 기반으로 fetch
const { isPossibleToRender, data } = useFetch(
    somethingFetchFn,
  	id,
    type,
);

if (!isPossibleToRender || !data || !data.length) {
  return <></>
}

// 렌더링 로직
return <section>...</section>

String으로 넘어오는 속성들을 state로 동기화하자

분명 사용처에서는 boolean 타입의 값을 넘기더라도, 받는 Preact에서는 string 타입으로 인식한다. 따라서 boolean 타입으로의 변환이 필요하다.

// 매번 'true'를 오타 없이 칠 거라는 보장이 없고, 반복되는 코드니까 함수를 미리 만들어두자
const compareValueWithStringTrue = (value: string): boolean =>
  value === 'true';

const [isOpen, setIsOpen] = useState(compareValueWithStringTrue(open));
const isLogged = compareValueWithStringTrue(login);
const handleHamburgerMenuClose = () => {
  setIsOpen(false)
}

// 주입받는 open 상태와 내부의 상태 동기화
useEffect(() => {
  setIsOpen(compareValueWithStringTrue(open));
}, [open]);

// 이제는 boolean 타입이 확정이 되었기 때문에, 렌더링이 잘 된다.
if (!isOpen) {
  return <></>;
}

// 햄버거 메뉴 렌더 로직
return (
  <>
  	<nav>...</nav>
    <div className='dim' onClick={handleHamburgerMenuClose}/>
  </>
)

string이라 항상 truthy 했던 값을 내부에서 boolean으로 변환해서, 이제는 내가 원하는 대로 햄버거 메뉴가 잘 열린다 !! 하지만... 한번 닫히고 나서는 다시 열리지 않는다? 햄버거 메뉴를 클릭할 때의 사용처와 Preact 내부의 상태값을 순차적으로 살펴보자.


💩 상태가 진짜 동기화 된게 맞나...?

  1. 먼저 햄버거 메뉴를 열기 전이다. 해당 빨간색 동그라미 부분을 클릭하면 사용처 내부의 open state가 변경될 것이고, 해당 open 값을 useEffect를 통해 구독하고 동기화되고 있는 Preact 내부의 상태 또한 해당 값으로 잘 set 될 것이다.

  1. 햄버거 메뉴를 열었다. 내가 의도한 대로 양쪽 다 true의 값을 가지고 있고, 따라서 파란색 으로 표시한 햄버거메뉴와 dim 영역, 즉 웹 컴포넌트 내부가 잘 렌더된 모습이다. 회색 dim 영역을 클릭하면 사용처 내부의 open state가 변경될 것이고, 해당 open 값을 useEffect를 통해 구독하고 동기화되고 있는 Preact 내부의 상태 또한 해당 값으로 잘 set 될 것이다. 과연 그럴까 ?

  1. 햄버거 메뉴를 열었다가 닫았다. 내가 의도한 대로 양쪽 다 false의 값을 가지고 있을까 ? 절대 아니다. Preact 내부의 상태에 따라서 해당 화면은 렌더링 된다. 다시 말하자면 햄버거 메뉴 내부의 isOpen 값에 따라서 렌더링 된다. 그리고 해당 값은 useEffect로 외부에서 넘겨받는 open 값을 구독하고 동기화해두었기 때문에 헷갈릴 수 있다.
    하지만 handleHamburgerMenuClose 함수는 사용처가 아닌 내부의 isOpen 값을 제어하는 함수이다. 따라서 외부 사용처의 open 값은 true로 계속해서 남아있다. 따라서 한 번 닫힌 햄버거 메뉴는 다시는 열리지 않는다.
    여기서 해결해야 하는 문제가 생긴다. 내부 state가 아니라 외부 사용처의 open 값을 제어하고, 해당 값을 동기화해서 햄버거 메뉴를 닫아야 한다.

언제나 JS가 이벤트 기반 언어라는 사실을 잊지 말자 !

이벤트 기반이란 이벤트가 발생할 때 미리 지정해둔 작업을 수행하는 방식을 말한다.

Preact 내부에서 도대체 어떻게 외부 사용처의 상태를 변경할까 ? 해답은 이벤트에 있다.
웹 컴포넌트를 사용해서 만든 햄버거 메뉴는 결국 사용처의 컴포넌트로 렌더 된다. 따라서 동일한 window를 참조할 수 있다.

즉, handleHamburgerMenuClose 함수를 통해 내부의 상태를 제어하는 게 아니라 customEvent를 발행하도록 수정하고, 사용처에서는 해당 customEventopen 값을 제어하는 로직을 담은 콜백을 이벤트 리스너를 통해 등록해 주면 된다.

customEvent를 발행하도록 handleHamburgerMenuClose 함수를 다음과 같이 수정해주자.

// 웹 컴포넌트 내부
const compareValueWithStringTrue = (value: string): boolean =>
  value === 'true';

const [isOpen, setIsOpen] = useState(compareValueWithStringTrue(open));
const isLogged = compareValueWithStringTrue(login);
// customEvent를 발행해주자
const handleHamburgerMenuClose = () => {
  const closeEvent = new CustomEvent('hamburger-menu-close');
  window.dispatchEvent(closeEvent);
}

// 주입받는 open 상태와 내부의 상태 동기화
useEffect(() => {
  setIsOpen(compareValueWithStringTrue(open));
}, [open]);

if (!isOpen) {
  return <></>;
}

// 햄버거 메뉴 렌더 로직
return (
  <>
  	<nav>...</nav>
    <div className='dim' onClick={handleHamburgerMenuClose}/>
  </>
)


// 사용처에서 해당 customEvent에 따른 콜백 함수 등록
const {
  isOpen: isHamburgerMenuOpen,
  onOpen: onOpenHamburgerMenu,
  onClose: onCloseHamburgerMenu,
} = useToggle(false);

useEffect(() => {
  window.addEventListener('hamburger-menu-close', onCloseHamburgerMenu);

  return () => {
    window.removeEventListener('hamburger-menu-close', onCloseHamburgerMenu);
  };
}, []);

이제 성공적으로 customEvent를 통해 웹 컴포넌트 내부의 상태가 외부의 상태를 구독해서 작동하게 구현되었다 ! 👏🏻

profile
2년 차 프론트엔드 응애입니다. 아무도 안보지만 글은 가끔 씁니다.

0개의 댓글