팝업과 메시지 with React
브라우저의 기본 다이얼로그는 마음에 들지 않을 때가 많다. 물론 지속적인 변화를 통해 디자인도 점점 진화하고 있고, 과거와 비교하면 매우 깔끔해지기도 했다. 하지만 브라우저마다 다른 모습을 하고 있고, 커스터마이징이 어렵다보니 디자이너들은 모든 브라우저에서 같은 위치에 같은 모양의 디자인을 가질 수 있는 커스텀 팝업을 그려내는 일이 많다.
팝업/모달/다이얼로그 등 많은 용어가 있지만, 여기에서는 그냥 팝업으로 퉁쳐서 부르기로 한다.
한편 알럿은 브라우저의 모든 행위를 멈춘다. 사용자에게 간단하고 무시할 수 있는 메시지를 전하고 싶지만 알럿으로 띄우기엔 부담스러운 경우도 있다. 안드로이드의 토스트 메시지처럼 잠깐 나타났다가 사라지는 메시지를 띄울 방법은 없을까?
이런 고민들을 모아 만들어냈던 리액트 팝업 모듈과 메시지 모듈의 개발 과정을 한 번 되새겨볼까 한다.
2021년판 새로운 글이 여기 새로 나왔다 :)
물론 아래의 글이 의미없지는 않다. 이 당시 생각했던 개념적인 구조는 거의 변하지 않았으니까.
팝업
팝업의 개발 요구 사항을 정리해보면 아래와 같다.
JS 개발이 필요한 요구사항
- 어떤 컴포넌트에서든 Alert, Confirm, Prompt는 띄울 수 있어야 한다.
- 팝업은 여러 개가 뜰 수 있다. 예를 들면, 포스트 수정 팝업에서 닫기를 누를 경우 정말로 닫겠냐는 Confirm이 포스트 수정 팝업보다 위에 떠야 한다.
- 막 닫아도 문제없는 팝업의 경우, esc를 눌렀을 때 바로 닫혔으면 좋겠다.
혹은 뒤의 검은 화면을 클릭하면 팝업이 바로 닫혔으면 좋겠다.
CSS로 처리할 수 있는 요구사항
팝업이 뜨면
- 사용자가 팝업에 집중할 수 있도록 팝업 뒤 배경에 반투명한 검은 레이어가 깔려야 한다.
- 팝업 뒤 화면의 다른 항목들을 클릭할 수 없어야 한다.
- 팝업이 된 상태에서는 뒷 배경 스크롤이 되지 않는 것이 좋겠다.
자 이제 이 요구사항을 가지고 팝업을 만들어보자. 위 요구사항 중 ~하면 좋겠다 라고 쓰여진 요구사항은 권장 사항으로, 필수적으로 구현해야만 할 사항은 아니지만 더 좋은 UX를 위한 좋은 지침이다. 아래 글에서는 JS로 개발이 필요한 요구사항에 대해 대처하는 방식에 대해서만 이야기하려고 한다.
우선 각 페이지 혹은 컴포넌트에서 팝업을 띄울 방법부터 생각해보자. 일단 팝업의 검은 배경이 그려져야 하니까, 컴포넌트 내부에서 팝업을 렌더링했다간 어딘가에 존재할 position: relative인 DOM에 걸려 전체 화면을 검은 레이어로 덮어버리는 동작이 불가능할 것이다. 팝업은 컴포넌트 밖에서, 대략 <body> 근처에서 렌더링되어야 한다. 아마도 App의 root component의 children으로 바로 렌더링할 수 있다면 제일 좋을 것이다.
팝업을 여는 방법
그렇다면 팝업 컴포넌트는 화면을 구성하고 있는 다른 컴포넌트의 바깥에 존재하는 컴포넌트일 것이다. 컴포넌트 바깥에 존재하는 다른 컴포넌트를 조작하여 팝업을 띄울 방법은 Action 뿐이다. 어떤 팝업을 띄울 것인지는 constants로 선택하는게 낫겠다.
PopupActions.openPopup(POPUP.POST_EDIT)
포스트 수정 팝업을 띄운다면 어떤 포스트를 수정하려고 하는 지에 대한 정보도 전달해야 한다.
PopupActions.openPopup(POPUP.POST_EDIT, { postId })
알럿 같은 경우에는 매번 POPUP.ALERT를 지정하여 열기엔 너무 자주 사용된다. 개발 상의 편의성을 위해 알럿과 컨펌 같은 공통 팝업의 경우, 단축 함수를 만들어두자.
// PopupActions.openPopup(POPUP.ALERT, { title, message } )
PopupActions.openAlert({ title, message })
그리고 사용자가 어떤 팝업을 확인한 이후 다른 액션을 진행할 수 있도록, onConfirm 함수를 건네자. 아니면 openAlert 자체가 Promise를 반환해도 괜찮겠다. 이 때 주의할 점은, 팝업을 닫는 방식을 여러 가지로 제공할 경우 어떤 방식으로 닫았을 때 onConfirm을 부르도록 할 것인지에 대한 세심한 주의가 필요하다는 점이다. 나의 경우, Alert은 어떤 방식으로 닫더라도 onConfirm 혹은 .then이 실행될 수 있도록 openAlert 함수가 new Promise()를 반환하도록 하고, 이 Promise의 resolve 함수를 componentWillUnmount에서 호출하도록 했다.
PopupActions.openAlert({ title, message, onConfirm })
PopupActions.openAlert({ title, message }).then(() => { do something })
액션이 있으면 스토어도 있어야 한다. 스토어는 openPopup에서 전달한 팝업의 종류와, 데이터를 가지고 있으면 된다. 시스템 구조상 팝업 위에 팝업이, 그리고 그 위에 알럿창이 또 뜰 수 있다는 점을 생각한다면, 데이터는 단순한 오브젝트라기보단 스택에 가깝다.
스토어와 연결할 컨테이너 컴포넌트는 스토어에서 어떤 팝업이 떠 있는 상태인지 받아온다. 이 과정에서 팝업의 키와 구현된 팝업 컴포넌트 사이의 관계를 선언하고 연결시킬 수 있는 방법을 고민했다. 아래의 방법은 가장 단순한 방법이다.
const PopupConfig = {
[POPUP.POST_EDIT]: PostEditPopup,
...
}
하지만 나는 아래에 나올 팝업을 닫는 방법에서 팝업을 관리하기 위한 props를 선언하고 전달하기 위해 아래와 같은 형식을 사용했다. 뒤에서 조금 더 자세히 설명할 것이다.
// Popups.js
render() {
return (
<PopupContainer >
<Alert key={POPUP.ALERT} />
<Confirm key={POPUP.CONFIRM} />
<PostEditPopup key={POPUP.POST_EDIT} /> </PopupContainer>
)
}// PopupContainer는 모든 팝업을 렌더링하지 않고, children에 정의된 팝업들 중 일부를 선택적으로 렌더링한다.
팝업을 렌더링하는 방법
팝업의 키가 스토어에 들어왔을 때 팝업을 렌더링한다-는 로직과 path가 match되었을 때 route를 렌더링한다는 로직이 비슷하다고 생각해, react-router@4.0.0의 Switch.render 코드를 참고했다. PopupContainer가 Switch와 비슷한 역할을 할 예정이다.
// react-router/Switch.js
render() {
const { route } = this.context.router
const { children } = this.props
const location = this.props.location || route.location let match, child
React.Children.forEach(children, element => {
if (!React.isValidElement(element)) return const { path: pathProp, exact, strict, from } = element.props
const path = pathProp || from if (match == null) {
child = element
match = path ? matchPath(location.pathname, { path, exact, strict }) : route.match
}
}) return match ? React.cloneElement(child, { location, computedMatch: match }) : null
}
이 중에서 match를 확인하는 코드를 children에 선언된 모든 팝업들 중 현재 열려있는 팝업의 키와 매칭되는 팝업만을 순서대로 렌더링하는 방식으로 바꾸어 구현하면 되겠다. 아래의 getVisiblePopups()가 그 역할을 하고 있다.
// PopupContainer.js
getVisiblePopups() {
const allPopups = Immutable.List(this.props.children)
return this.props.openedPopups // injected from Store, instance of Immutable.OrderedMap
.map((_, key) => allPopups.find(popup => popup.key === key))
.toList()
}
render() {
const popups = this.getVisiblePopups()
.map(Element => {
const popupKey = Element.key
const popupParams = this.props.openedPopups.get(popupKey)
return React.cloneElement(Element, {
close: this.closePopup(popupKey),
...popupParams
})
})
if (popups.size === 0) return null
return (
<div id="popup-container">
{popups.map(popup => (
<div key={popup.key} className="popup-background">
{popup}
</div>
))}
</div>
)
}
자 이제 마지막이다. 저어 앞에서 어떤 컴포넌트에서든 팝업을 띄울 수 있어야 했고, 때문에 팝업은 컴포넌트 밖에서, 대략 App의 root 근처에서 렌더링되어야 한다. 고 했다. 때문에 팝업 컨테이너는 App.js(메인 파일)에 위치하게 된다.
// App.js
render() {
return (
<Router>
<div id="app">
{/* Header */}
<Header /> {/* Routes */}
<Switch>
<Route exact path="/public" component={PublicPage} />
{this.renderMainRoutes()}
</Switch> {/* Footer */}
<Footer />
{/* Common Components */}
<Popups /> // <- 이것으로, 모든 컴포넌트에서 어떤 팝업이든 열 수 있다
<Messages /> // <- 아래에서 설명할 메시지도 여기에서 렌더링한다
</div>
</Router>
)
}
팝업을 닫는 방법
누군가 코드를 읽고 눈치 챘기를 바라던 것이지만… 팝업 컨테이너의 render 부분에는 아래와 같은 코드가 있다.
// PopupContainer.js
React.cloneElement(Element, {
close: this.closePopup(popupKey),
...popupParams
})
팝업 컴포넌트의 props에 close 함수를 항상 전달하는데, 이는 팝업 컴포넌트 내부에서 자신의 popupKey를 굳이 알 필요 없이 스스로를 close할 수 있는 방법을 제공하기 위한 것이다. 이 함수를 팝업 내 onClick에 할당하면, 스스로 자신을 닫을 수 있다. 이것이 첫 번째 방법이다.
요구사항 3번을 구현하기 위해서는 팝업의 종류를 두 가지로 나누어야 한다. 막 닫아도 되는 팝업과, 막 닫으면 안 되는 팝업. 일단 막 닫아도 되는 팝업을 쉽게 닫을 수 있도록 해보자. 일단 Popups.js에서 팝업을 구분하자.
// Popups.js
render() {
return (
<PopupContainer >
<Alert key={POPUP.ALERT} escapable />
<Confirm key={POPUP.CONFIRM} />
<PostEditPopup key={POPUP.POST_EDIT} /> </PopupContainer>
)
}
이제 props.escapable == true 인 팝업에 대해서만 esc로 팝업 닫기 기능을 활성화 시키려고 한다. keyDown 이벤트는 element가 focus된 상태에서만 발생하므로, 그렇지 않은 평범한 상태에서도 keyDown 이벤트를 받고 싶다면 이벤트 리스너 함수를 body 혹은 window에 붙여야 한다.
또한 escapable이라는 속성을 Popups 내부의 컴포넌트 렌더링 시에 주었기 때문에 팝업을 열 때 추가적으로 전달할 props를 전달받는 PopupStore는 escapable의 여부를 알 수 없다. 때문에 escapable이라는 속성에 접근하여 값을 판단하는 행위는 스토어가 아닌 컨테이너가 확인해야 한다.
팝업의 키와 컴포넌트를 맵 형태로 매칭시켜두었다면 구현된 팝업의 defaultProps에 escapable과 같은 설정을 저장해두어도 동일하게 동작할 수 있다. 하지만 굳이 이런 방식으로 작성한 이유는 escapable과 같은 props는 내부 렌더링에서는 전혀 쓰이지 않고, PopupContainer가 접근하고 확인하기 위한 설정에 가깝기 때문에 구현체에서 설정하는 것 보다 바깥에서 이렇게 넣어주는 것이 훨씬 더 관리에 용이하다고 보았기 때문이다.
// PopupContainer.js
componentDidMount() {
document.body.addEventListener("keydown", this.closeLastPopup)
}closeLastPopup = e => {
if (!(e.key == "Escape" || e.keyCode == 27)) return
const lastPopup = this.getVisiblePopups().last()
this.closeIfEscapable(lastPopup)(e)
}closeIfEscapable = popup => e => {
if (popup && popup.props.escapable) {
this.props.actions.closePopup(popup.key)
}
}
이제 검은 배경 클릭시 닫히는 기능은 .popup-background의 onClick에 closeIfEscapable을 전달하면 간단하게 정리된다.
메시지
메시지는 사용자에게 단순하고 무시 가능한 메시지를 잠시 보여주는 역할을 하는 컴포넌트다. 인스턴트 메시지라고 표현하는 게 정확하겠다.
팝업은 기능을 위해, 혹은 중요한 알림(액션의 실패 등)을 알려주기 위해 사용하는 반면, 액션은 액션의 성공 여부를 알려주기 위해 사용한다. 예시로 들었던 포스트 수정 팝업의 경우, 포스트 수정이 완료되면 팝업이 닫히는 것으로 수정이 잘 되었다는 암묵적인 행동이 될 수 있지만, 메시지를 사용하면 그러한 흐름을 방해하지 않는 범위에서 액션의 성공을 조금 더 명확하게 알려줄 수 있다.
메시지와 애니메이션
메시지에서 가장 중요한 것은 애니메이션이다. 팝업처럼 짠!하고 뜨는 것 보다 스윽 하고 나타났다가 스윽 하고 사라지는 것이 사용자가 자연스레 메시지의 충분한 존재감을 느끼면서도 곧 사라질 것이라는 — 무시 가능하다는 — 사실을 눈치챌 수 있기 때문이다.
메시지는 3개의 상태를 가진다. 메시지가 생성되면 메시지 컴포넌트가 추가되어 componentDidMount가 실행된 초기 상태가 willAppear(Mount 되었으나 보이지 않음), 메시지가 나타나는 동안과 나타난 상태를 의미하는 상태인 didAppear, 메시지가 unMount 되기 전에 메시지가 사라지는 애니메이션 동안의 상태인 willDisappear.
메시지 상태의 변경
메시지 개발에서는 메시지를 생성하기만 하면 자동적으로 이 상태를 옮겨가게 하기 위해서 액션이 길어졌다. redux가 아니라 flux를 사용하고 있는 코드임을 고려하시라.
const visibleTime = 4000, disappearing = 1000createMessage(message) {
const messageId = createMessageID()
dispatch({
type: MESSAGE_LIFECYCLE.WILL_APPEAR,
result: {messageId, message}
})
setTimeout(() => {
dispatch({
type: MESSAGE_LIFECYCLE.DID_APPEAR,
result: {messageId}
})
}, 100)
setTimeout(() => {
dispatch({
type: MESSAGE_LIFECYCLE.WILL_DISAPPEAR,
result: {messageId}
})
}, visibleTime)
setTimeout(() => {
dispatch({
type: MESSAGE_LIFECYCLE.DID_DISAPPEAR,
result: {messageId}
})
}, visibleTime + disappearing)
}
마지막의 didDisappear는 메시지의 상태를 변경하는 액션이 아니라 메시지를 스토어에서 삭제하기 위한 액션이므로 액션의 상태에서는 설명하지 않았다.
마무리
팝업과 메시지에 대해 조금씩 설명하고, 그것을 구현하던 당시엔 애를 먹었던 기억이 있는 부분들을 조금씩 뽑아서 적어보았다. 적다보니 ‘내가 왜 이렇게 개발했지?’ 라는 생각이 들던 부분들이 조금씩 보였고, 그 부분들을 하나씩 수정해가며 글을 작성했다. 그러고 보니 도입부에서 이렇게 적었더랬다.
이런 고민들을 모아 만들어냈던 리액트 팝업 모듈과 메시지 모듈의 개발 과정을 한 번 되새겨볼까 한다.
정말로 되새김질이 되고, 더 좋은 방식으로 코드가 수정되는 계기가 된 것 같다.
글을 쓰며, 모든 코드를 다 가져와서 보여주질 못해 답답했다. 분명 코드를 자세히 읽던 몇몇 분들도 전체적인 맥락이 아닌 부분만 보게 되어 제대로 이해하지 못하셨을 수도 있겠다. 예제로 사용된 코드를 일부 떼어서 공개하려고 고민은 하고 있지만, 아무래도 새로 레포지터리를 파고, 글쓰는 것 만큼의 공을 들여야 뜯어볼만한 맛이 나는 레포지터리가 될 것 같아 조금 고민중이다.
코드에 대한 질문이 있으시다면 페이스북 메신저로 연락 주시면 성심성의껏 답변해드리겠다. 읽어주셔서 감사하다.