토스트는 로그를 출력하듯이 간단한 문자열만 파라미터로 넘기더라도 호출할 수 있어야 합니다.
토스트는 다른 DOM 요소에 관계 없이 일정한 위치에 랜더링돼야 합니다.
토스트는 위치에 제약 없이 모든 곳에서 호출할 수 있어야 합니다. 만약 토스트가 훅으로 구성되어 있다면 컴포넌트나 훅 내부에서만 호출할 수 있겠죠. console.log를 호출할 수 있는 곳이라면 토스트 또한 호출할 수 있어야 합니다.
1번, 2번 조건을 만족시키는 것은 함수의 파라미터와 css만 조금 조정하면 되니 그다지 어렵지 않습니다.
하지만 3번 조건은 좀 까다로운데요.
토스트도 결국 내부를 보면 컴포넌트인데, 컴포넌트의 랜더링을 컴포넌트나 훅이 아닌 곳에서 제어할 수 있어야 하기 때문이죠. 그래서 이를 구현하기 위해 아래와 같은 구조로 토스트를 설계했습니다.
ToastContainer와 ToastItem은 그냥 컴포넌트간 부모 자식 관계라서 익숙하지만 eventManger가 약간 생소할텐데요. eventManger를 도입한 이유는 사용처와 상관 없이 toast.info("토스트의 내용")과 같은 문법으로 화면에 랜더링되는 요소를 관리하기 위함입니다.
ToastContainer에서 토스트의 추가, 삭제에 대한 이벤트를 구독하고 있기 때문에 프로젝트의 어느 곳에서든 toast 이벤트 dispatch 함수를 실행해서 UI를 제어할 수 있게 되는것이죠.
토스트 이벤트를 관리하기 위해 위와같은 eventManger를 구현했습니다. on, off 부분은 일반적인 이벤트 매니저 구조와 동일하니 설명하지 않고 넘어가겠습니다.
여기서 중심적으로 봐야할 것은 emitQueue 입니다. 이미 list에서 이벤트를 관리하고 있는데 왜 별도의 emitQueue가 필요할까요? 바로 이벤트의 콜백함수가 동기함수가 아닐 경우를 대비하기 위함입니다.
emit으로 이벤트 dispatch시 이벤트 list에서 콜백함수를 setTimeout에 넣어서 비동기적으로 실행하고 있습니다. 이렇게 하면 이벤트 루프 내에서 콜백 함수를 다음 주기로 밀어내서 현재 실행중인 동기 작업이 모두 종료된 이후에 콜백이 실행될 수 있도록 합니다. 이 때 emitQueue를 사용해서 타이머에 대한 정보를 별도의 큐로 관리하고, cancelEmit에서 대기중인 이벤트를 취소할 수 있습니다.
아직은 토스트를 띄우고 제거할 때 동기 함수만 사용하기 때문에 emitQueue와 cancelEmit은 필요하지 않습니다. 하지만 추후 토스트에서 Promise의 상태를 표시하는 기획이 추가될 것을 염두하고 있기 때문에 이 부분을 추가했습니다.
토스트는 사용자에게 간단한 정보를 표시해주기 위한 UI로 일정 시간이 지나면 사라져야 합니다.
이 기능을 구현하기 위한 선택지는 두 가지가 있는데요. 첫 번째는 setTimeout을 사용해 일정 시간 이후 토스트가 돔에서 제거되도록 하는 것이고, 두 번째는 animationend 이벤트를 활용하는 것 입니다.
이 중에 저는 animationend 이벤트를 활용하는 방식을 선택했습니다. toast 하단에 나타나는 프로그래스바를 표시하기 위해서는 이에 대한 애니메이션을 일정 시간만큼 보여줘야합니다. 따라서 별도의 타이머를 두는 것 보다 하나의 애니메이션 시간에 종속성되도록 구현하는 것이 더 효율적이라고 생각했기 때문입니다.
토스트의 시간이 경과함에 따라 progressBar를 표시해주기 위해 ToastProgressBarAnimation 을 keyframe으로 추가했습니다. ToastProgressBarAnimation은 width 속성이 아니라 scaleX 속성을 사용했는데요.
width속성은 element의 실제 크기를 변화시키기 때문에 애니메이션이 진행되는 동안 reflow 과정이 발생하게 됩니다. 반면 scaleX 속성은 transform 속성의 일부로, 실제 크기를 변화시키지 않은 채 변형만 일어납니다. 또한 GPU 가속을 활용할 수 있기 때문에 성능적으로 더 우수합니다.
onAnimationEnd를 사용해서 애니메이션 종료시 종료 상태를 나타내는 isExiting 상태를 true로 업데이트 해줬습니다. 토스트를 바로 제거하지 않고 isExiting를 따로 둔 이유는 아래에서 자세히 설명하겠지만, 요약하자면 토스트가 서서히 사라지는 애니메이션을 주기 위함입니다.
animation-play-state 속성과 onMouseEnter, onMouseLeave 이벤트 리스너를 사용해서 토스트에 마우스 호버시 프로그래스바 애니메이션이 정지하도록 구현했습니다.
현재 각 position에 따른 toastContainer의 위치는 fixed 속성으로 관리되지만, 내부에 있는 토스트들의 리스트는 flexbox 속성으로 관리되고 있습니다.
top 속성을 가진 토스트들의 배열에서는 시간 순서에 따라 위쪽에 있는 toast가 먼저 제거되는데요. 이 때문에 fade-out 이후 DOM에서 토스트 제거시 flexbox의 높이가 줄어들면서 렌더 트리의 각 요소의 크기와 위치를 계산하는 reflow과정이 일어나게 됩니다.
때문에 토스트의 높이가 변하는 동작과 애니메이션이 합쳐져서 저런 뚝뚝 끊기는 현상이 발생하게 되었죠.
위 문제를 해결하기 위해 requestAnimationFrame을 도입했습니다. requestAnimationFrame은 브라우저의 렌더링 사이클에 맞춰 함수를 실행해서 레이아웃 계산이나 DOM 조작 연산을 효율적으로 수행할 수 있게 도와줍니다.
아래와 같은 방식으로 requestAnimationFrame 함수를 사용할 수 있습니다.
requestAnimationFrame을 적용했음에도 불구하고 여전히 뚝뚝 끊기는 듯한 느낌이 사라지지 않았습니다.
왜냐하면 근본적으로 flexbox의 사이즈가 줄어들면서 토스트 위치가 위로 올라간다는 사실은 변하지 않았기 때문인데요.
토스트를 console.log 처럼 사용하기 위해 react-toastify 라이브러리의 내부 코드를 분석하며 위 토스트를 구현했습니다. 라이브러리 내부를 처음 열어봤을 때 갑자기 등장한 eventManger로 인해 엄청 어렵게만 느껴졌는데요. 왜 eventManger를 사용했어야 했는가에 대해서 중점적으로 코드를 분석해보니 생각보다 어렵지 않은 것을 알 수 있었습니다. eventManger를 통해 토스트의 UI를 관리하는 것은 pub/sub 패턴이라는 것을 깨닫고 난 이후부터는 코드가 술술 읽히기 시작했습니다. 덕분에 이렇게 사용하기 편리하면서도 이쁜 토스트를 구현할 수 있었습니다.
아직 UX를 다듬기 전이라 toast가 적극적으로 사용되고 있지 않아서 토스트의 사용 경험에 대한 피드백은 듣지 못한 상황인데요. 토스트를 잠깐 사용해보신 성인님 아주 극찬을 해주셔서 굉장히 뿌듯합니다 ㅎㅎ
추후 프로젝트에서 toast가 적극적으로 사용될 때 토스트에 대한 후기에 대해 더 남겨보도록 할게요!