이전 포스팅에서는 Web Component
를 이용해 MFA
를 도입하기까지의 배경에 대한 내용을 정리해뒀다. 이 글은 Preact
를 통해 Web Component
를 만들고, 사용처인 Next JS
와의 상태를 동기화하는 과정에서 겪었던 문제와 해결법에 대해 정리해 보려 한다.
분리하고자 하는 컴포넌트가 외부에서 받아야 하는 상태 정리 (예시)
env ('development', 'stage', 'production')
: 개발, 스테이지, 운영 3가지 서버를 운영하고 있고, 각 도메인 별로 요청하는 api
나 a
태그에 할당해 줘야 하는 주소가 다르기 때문에 각 도메인끼리의 런타임 통합을 위해 필요.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
내부의 상태값을 순차적으로 살펴보자.
💩 상태가 진짜 동기화 된게 맞나...?
open state
가 변경될 것이고, 해당 open
값을 useEffect
를 통해 구독하고 동기화되고 있는 Preact
내부의 상태 또한 해당 값으로 잘 set
될 것이다.true
의 값을 가지고 있고, 따라서 파란색 으로 표시한 햄버거메뉴와 dim 영역, 즉 웹 컴포넌트 내부가 잘 렌더된 모습이다. 회색 dim 영역을 클릭하면 사용처 내부의 open state
가 변경될 것이고, 해당 open
값을 useEffect
를 통해 구독하고 동기화되고 있는 Preact
내부의 상태 또한 해당 값으로 잘 set
될 것이다. 과연 그럴까 ?false
의 값을 가지고 있을까 ? 절대 아니다. Preact
내부의 상태에 따라서 해당 화면은 렌더링 된다. 다시 말하자면 햄버거 메뉴 내부의 isOpen
값에 따라서 렌더링 된다. 그리고 해당 값은 useEffect
로 외부에서 넘겨받는 open
값을 구독하고 동기화해두었기 때문에 헷갈릴 수 있다.handleHamburgerMenuClose
함수는 사용처가 아닌 내부의 isOpen
값을 제어하는 함수이다. 따라서 외부 사용처의 open
값은 true
로 계속해서 남아있다. 따라서 한 번 닫힌 햄버거 메뉴는 다시는 열리지 않는다.state
가 아니라 외부 사용처의 open
값을 제어하고, 해당 값을 동기화해서 햄버거 메뉴를 닫아야 한다.이벤트 기반이란 이벤트가 발생할 때 미리 지정해둔 작업을 수행하는 방식을 말한다.
Preact
내부에서 도대체 어떻게 외부 사용처의 상태를 변경할까 ? 해답은 이벤트
에 있다.
웹 컴포넌트를 사용해서 만든 햄버거 메뉴는 결국 사용처의 컴포넌트로 렌더 된다. 따라서 동일한 window를 참조할 수 있다.
즉, handleHamburgerMenuClose
함수를 통해 내부의 상태를 제어하는 게 아니라 customEvent
를 발행하도록 수정하고, 사용처에서는 해당 customEvent
에 open
값을 제어하는 로직을 담은 콜백을 이벤트 리스너
를 통해 등록해 주면 된다.
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
를 통해 웹 컴포넌트 내부의 상태가 외부의 상태를 구독해서 작동하게 구현되었다 ! 👏🏻