Typescript + React 모달 시스템 설계하기
타입 검증을 보장하는 모달 시스템을 위한 구조 설계
모달을 처음 밑바닥부터 구현해야 한다면 당신은 어떤 고민을 하게 될까?
각 페이지마다 구현할 것인가? 공통 얼럿은 어떻게 처리할 것인가? 모달을 열때 데이터를 불러와야 한다면? 열고 나서 불러올 것인가 열기 전에 불러올 것인가?
이런 고민들을 하게 되지 않을까? 나의 고민은 일단 아래와 같이 정리가 되었다.
모달에 전달하는 데이터(props)
백오피스를 만들면서 Antd 컴포넌트를 사용해보았고, 그 과정에서<Modal visible={true} /> 방식으로 특정 페이지에 특정 모달을 구현하는 방식을 사용해보았다. 생각 외로 불편했다. 모달에 값을 전달하기 위한 props와 해당 props가 변경될 때를 세심하게 컨트롤해야 했기 때문이다.
특히 백오피스 같은 경우에, DB에 특정 row를 생성하기 위해, 다른 Table을 읽어와 특정 row를 선택하는 방식의 동작이 많이 필요했다. Antd의 Modal 컴포넌트는, 해당 모달이 visible 상태가 아니더라도 렌더링을 한 채로 숨겨두었다가 visible이 true가 되는 순간 보여주는 방식을 취했다. 그런데 만약 해당 모달에서 불러와야 하는 데이터가 기존 페이지에 있던 폼의 특정 값에 영향을 받는다면? 폼이 변경되었을 때엔 모달에 넣을 데이터를 다시 불러와야 한다면? 그런 데이터는 언제 불러오고 언제 업데이트를 해 주고 그 데이터가 다 불러와졌을 때 모달을 열어야 하나? 유저가 모달을 열지 않으면 불러온 데이터는 쓸모가 없지 않나?
수많은 고민 끝에 결국 모달은, 모달이 열린 후에 데이터를 불러오는 게 맞다-는 결론을 내렸다. 모달에는 더 많은 데이터를 불러오기 위한 최소한의 데이터만을 넘기고, 모달이 알아서 데이터를 불러오는게 맞다.
모달을 어디에 둘 것인가
위에 적었듯, Antd의 모달은 기본적으로 모달이 열리는 페이지에 미리 구현되어있는 것을 기본으로 한다. 대신, 공통 모달은 따로 제공한다.
하지만 동일한 모달이, 여러 곳에서 반복적으로 사용된다면 어떻게 할 것인가? 컴포넌트로 뽑아 다른 곳에 만들어두고, 컴포넌트를 import 해서 사용하면 된다.
굳이 그래야 하나? 공통 모달 — ex.Alert — 을 열듯이 편리하게 열 수 있으면 안 되나? 모달을 “어느 곳에서나" 열 수 있으면 안 되나?
모달은 모달끼리 모아두자. 모든 모달을 공통 모달처럼 만들자. 아무 페이지에서나 아무 모달을 열어도 이상하지 않게 만들자.
나는 모달이 단순한 컴포넌트와 페이지의 사이 어느정도에 있는 중간 수준 컴포넌트처럼 느껴졌기 때문에, components와 pages 사이에 modals 라는 폴더를 만들고 그 안에 모달 컴포넌트를 모조리 작성했다.
모달을 어떻게 열 것인가
아무 페이지에서나 아무 모달을 열겠다는건, 결국 모달을 여는 행위가 컴포넌트를 mount하는 행위와 직접적으로 연결되지 않는다는걸 뜻한다. 그러니까 조금 간접적인 방식으로 모달을 열어야 한다.
첫번째 고민, “모달에 전달하는 데이터"에서 도달한 것 처럼, 모달을 열 때엔 모달에 (일부나마) 데이터를 넘겨주어야 한다. 모달 역시 컴포넌트로 작성할 것이니만큼, props를 넘겨주어야 한다는 이야기이다.
간접적으로 데이터를 전달한다. 어딘가 리덕스가 떠오른다. 액션이라는 행위를 통해 스토어에 데이터를 전달한다. 그 결과로 전역 데이터가 변경되며 어느 곳에선가 렌더링이 일어나 화면이 바뀐다. 호오.
모달을 간접적으로 지정하면서, 데이터를 함께 넘길 수 있으려면, 이런 방식이 좋겠다. dispatch(openModal({ type, props }))
여기까지 도달하고 나니 꽤 많은 방식이 슬슬 눈에 보이기 시작한다.
모달에 전달하는 데이터 — 타입
하지만 한 가지 더 고민해보자. openModal에 들어가는 type과 props는 서로 엮여 있다. Alert 모달을 띄우면서 onChange 같은 props를 주는 것도 이상하다는 이야기다.
그렇기에 추가적인 목표가 하나 생겼다. 모달 타입에 따른 속성값 일치 여부가 타입시스템으로 검증 가능하길 바란다.
openModal({ type: ‘Alert’, props: { onChange: console.log })
같은 코드를 누군가 짰다면, 이런 에러가 나길 바란다.
// key onChange is not in type AlertProps
// message is missing on props
일단, 차근차근 하나씩 생각해보자.
ModalReducer
리덕스의 아이디어에서 시작했으니, useReducer를 사용해보자.
액션도 같이 정의할 수 있다.
이걸로 일단 한 발자국 나아갔다.
열린 모달 렌더링하기
단순하게 생각하기로 했다. 열린 모달의 목록이 리듀서에서 반환되는 만큼, 그냥 모달 렌더링 컴포넌트를 하나 만들고 이 목록을 그대로 전달해주자.
<ModalContainer openedModals={openedModals} />
그러면 모달 컨테이너는 이렇게 렌더링을 하는거다.
열린 모달은: 뒤에 딤 깔고, 전달받은 모달 타입에 맞춰 적당한 컴포넌트를 불러와 렌더링을 하자. 모달의 키와 컴포넌트를 매핑시켜주는 ModalComponentMap이라는 오브젝트가 하나 선언되어있다고 친다면,
아무 페이지에서나 아무 모달 열기
이제 위에서 만든 openModal을 아무 곳에서나 사용할 수 있도록 열어주자. 만약 리듀서를 리덕스 스토어를 만들 때 사용했다면, 리덕스의 액션을 연결하듯 연결해 줄 수도 있다. 하지만 이번에는 컨텍스트를 사용해 볼 것이다. useReducer의 dispatch가 리덕스의 dispatch처럼 전역적으로 사용할 수 있는 녀석은 아닌 관계로, 조금 손을 봐야 한다.
모달을 열고 닫는 함수를 컨텍스트로 전달했으니, 컨텍스트를 사용해 모달을 열어볼 차례다. withModal(App)으로 전역 컨텍스트를 설정했다고 해보자. 모달은 이런 코드를 사용해 열 수 있다.
Alert과 ModalComponentMap을 제대로 작성했다면, 커스텀하게 만든 얼럿 모달이 제대로 뜨는 걸 확인할 수 있다.
타입 체크하기
타입스크립트로 코딩을 하는 사람으로서, 이런 코드가 배포되었다가 에러가 터지는 상황을 겪고 싶진 않다. 아니, 좀더 정확하게는, “이런 코드는 타입스크립트에 단계에서 체크되어서 IDE에 빨간 줄이 그어지고 커밋이 불가능해야 한다"고 생각한다.
…커밋이 안 되게 하는 허스키 같은 이야기는 뒤로 미루고, IDE에 빨간 줄이 그어지게 하는 방법부터 다시 생각해보자. 그러기 위해선 OpenModalPayload의 타입을 좀, 많이, 손볼 필요가 있다.
모달을 매핑하는 오브젝트가 하나 있으면, 이런 것들이 가능하다.
모달의 이름만 알면, 해당 모달이 필요로 하는 Props의 타입을 알아낼 수가 있다! 그렇다면 제네릭을 조금만 활용하면 이런 동작이 가능하다.
이렇게 되면 누구나(…다른 개발자들?) ModalContext를 통해 openModal을 사용할 때, openModal에 들어오는 payload의 type과 props가 매칭되는지 확인할 수 있다!
…그러나 여기에서 조금 더 욕심을 부리기 시작하자, 타입스크립트의 문제들이 자꾸 튀어나왔다…. Part. 2에서 계속…