솔미는 성장중
사소해보여도 크다! 프론트에서 UX를 개선해보자! (feat. 스크롤 / 페이지 이동, next/router event 동작 방식) 본문
사소해보여도 크다! 프론트에서 UX를 개선해보자! (feat. 스크롤 / 페이지 이동, next/router event 동작 방식)
solming 2024. 9. 1. 14:36회원가입 플로우에서 이탈율을 줄여야한다는 미션이 들어왔다!
물론 기획/디자인 적으로도 개선 가능하겠지만, 기술적으로 해결하는 방법에 대해 고민해보았다.
요구사항은 회원가입 플로우 관련이었지만, 앱 전반적으로 적용할 수 있도록 여러가지 방안을 생각하고 적용해보았다 :)
3가지 정도로 추려보았다.
1. 키패드가 올라올 때 포커싱된 Input으로 화면을 스크롤 시켜 사용자의 피로를 줄인다.
2. 페이지를 이동할 때 특정 페이지들에선 스크롤이 기억되도록 한다
3. 페이지 이동 시에 데이터를 초기화시키는 것이 유저에게 노출되지 않도록 한다. (= 페이지 이동 후 데이터 초기화 = 페이지 깜빡임 줄이기)
1. 키패드가 올라올 때 포커싱된 input으로 화면 스크롤 시키는 로직 추가하기
미리 알고 넘어가야 하는 내용
- window.innerHeight :해당 값을 통해 화면 전체의 높이값을 파악할 수 있다.
- window.visualViewport.height: 눈에 보이는 화면에 대한 높이값을 파악할 수 있다.
위 내용을 알아야하는 이유는 키보드 올라왔다를 판단할 수 있어야 하기 때문이다!
aos, ios 차이점이 있었다. 이 링크를 참고해서 읽어보고 오는 걸 추천한다:)
아래는 OS의 차이점을 고려해 작성한 코드이다!!
const msgRef: any = useRef<HTMLElement | null>(null); // 스크롤 되어야하는 요소에 ref 달아주기
useEffect(() => {
if (!msgRef.current) {
msgRef.current = document.getElementById('password-container') as HTMLElement | null;
}
// 화면에 존재한느 모든 input에 이벤트를 달아준다. 포커스 시 스크롤되도록 해주는 로직.
const inputs = document.querySelectorAll('input');
inputs.forEach((input) => {
input.addEventListener('focus', (e) => scrollToInput(msgRef.current, e));
});
// 키보드가 올라오면 resize 이벤트가 일어난다.
window.visualViewport?.addEventListener('resize', () => {
msgToBottom(true);
});
// 이벤트 해제
return () => {
inputs.forEach((input) => {
input.removeEventListener('focus', (e) => scrollToInput(msgRef.current, e));
});
window.visualViewport?.removeEventListener('resize', () => msgToBottom(true));
};
}, [msgRef]);
// 키보드 올라와있는지 여부 판단용
useEffect(() => {
if (!window.visualViewport) return;
const handleResize = () => {
// AOS 조건 || IOS 조건
setKeyboardVisible(fullHeight.current > window.innerHeight || window.visualViewport!.height < window.innerHeight);
};
fullHeight.current = window.innerHeight;
window.visualViewport?.addEventListener('resize', handleResize);
handleResize(); // 초기 상태 설정
return () => {
window.visualViewport?.removeEventListener('resize', handleResize);
};
}, []);
2. 스크롤 기억하기 (feat. SessionStorage)
스크롤은 앱을 끄고 나서도 기억하고 있을 필요는 없기에 sessionStorage에 저장하는 방식을 선택하였다!
useInitScrollPosition 이라는 훅을 하나 만들어서 필요한 곳 어디에서든 사용할 수 있도록 하였다.
import Router from 'next/router';
import { useLayoutEffect, useMemo } from 'react';
export const getPageScrollPosition = (pageName: string) => {
return JSON.parse(sessionStorage.getItem(pageName) || '0');
};
export const setPageScrollPosition = (pageName: string, position: number) => {
sessionStorage.setItem(pageName, JSON.stringify(position));
};
/**
* 스크롤 위치를 기억하는 훅
* @param scrollContainerRef 스크롤 위치를 초기화할 컨테이너의 ref
* @description getPageScrollPosition : 세션 스토리지에서 이전 스크롤 위치를 가져옴
* @description setPageScrollPosition : 세션 스토리지에 현재 스크롤 위치를 저장
*/
const useInitScrollPosition = (scrollContainerRef: any) => {
const pageName = Router.pathname.split('/')[1];
// 스크롤 기억해줄 페이지 목록
const SCROLL_MEMORIZE_PAGES = ['topics', 'history', 'textbook'];
const hasMatched = useMemo(() => SCROLL_MEMORIZE_PAGES.includes(pageName), [pageName]);
useLayoutEffect(() => {
if (!hasMatched || !scrollContainerRef.current) {
console.log('Skipping effect: no match or no ref');
return;
}
const scrollContainer = scrollContainerRef.current;
// 이전 스크롤 위치가 있다면 세션 스토리지에서 가져와 이전 스크롤 위치로 조정. 없으면 0.
const prevScrollPosition = getPageScrollPosition(pageName) || 0;
scrollContainer.scrollTo({ top: prevScrollPosition, behavior: 'instant' });
const handleScroll = () => {
setPageScrollPosition(pageName, scrollContainer.scrollTop);
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
if (pageName !== 'history') setPageScrollPosition(pageName, scrollContainer.scrollTop);
};
}, [scrollContainerRef, pageName, hasMatched]);
};
export default useInitScrollPosition;
3. 페이지 이동이 완료된 후에 화면을 변화시키는 초기화 로직을 돌리기
- api 호출로 인해 뒤로가기 속도가 살짝 딜레이 되는 상황이었음
- 화면 바꾸는 두 로직의 순서를 Router.back()뒤에서 호출한다고 해서 해결되지 않았음.
- Router에서 제공하는 events를 활용해, 페이지 이동 후 로직이 돌아갈 수 있도록 처리했다!
// 이전
Router.back();
resetChipNum();
setPageScrollPosition('history', 0); // sessionStorage 스크롤 값 초기화
// 수정 후
const handleCloseButton = () => {
try {
postReadReportInfo();
} catch (e) {
console.error(e);
}
// NOTE: 페이지 이동 완료 후 로직 실행 (페이지 이동 전에 history 화면이 바뀌는 게 보여지지 않도록 제어)
const handleRouteChangeComplete = () => {
// 아래 두 로직은 화면을 변화시키는 동작
resetChipNum();
setPageScrollPosition('history', 0); // sessionStorage 스크롤 값 초기화
Router.events.off('routeChangeComplete', handleRouteChangeComplete); //이벤트 해제
};
Router.events.on('routeChangeComplete', handleRouteChangeComplete); //이벤트 등록
Router.back();
};
그런데 문득 궁금해졌다.
Router.events.off에 handler(ex. handleRouteChangeComplete)를 넣어줘야 하는데, 만약에 호출이 일어나는거라면 재귀적으로 도는게 아닌가?!
+
handleRouteChangeComplete를 선언해주지 않고 바로 로직을 handler 위치에 각각 넣어주면 이벤트 해제가 제대로 일어나지 않으려나?
그래서 Next/Router 코드를 살펴보았다.
mitt.ts 코드에 궁금증을 해결해줄 코드가 담겨있었다.
export default function mitt(): MittEmitter<string> {
const all: { [s: string]: Handler[] } = Object.create(null)
return {
on(type: string, handler: Handler) {
;(all[type] || (all[type] = [])).push(handler)
},
off(type: string, handler: Handler) {
if (all[type]) {
all[type].splice(all[type].indexOf(handler) >>> 0, 1)
}
},
emit(type: string, ...evts: any[]) {
// eslint-disable-next-line array-callback-return
;(all[type] || []).slice().map((handler: Handler) => {
handler(...evts)
})
},
}
}
위 코드를 참고하면 on 이벤트를 실행할 시에 push로 배열에 넣어주고, off 이벤트 실행할 시에 indexOf로 데이터를 찾아서 splice로 제거해주는 것을 볼 수 있다.
indexOf 개념에 대해서도 한번 짚고 넘어갈 필요가 있다.
- 기본형 데이터(Primitive types): 숫자, 문자열 등은 값을 비교합니다.
- 참조형 데이터(Reference types): 객체, 배열 등은 메모리 참조를 비교합니다. 즉, 두 객체가 서로 다른 메모리 주소를 가지고 있다면, 내용이 같더라도 indexOf()는 다른 객체로 인식합니다.
'메모리 참조'를 비교하는 것이므로 함수를 따로 선언해주고 그 이름을 handler로 넣어줘야 올바른 이벤트 해제가 일어난다.
// 이벤트가 제대로 해제되지 못해 메모리 누수 발생
Router.events.on('routeChangeComplete', ()=>{console.log('a')});
Router.events.off('routeChangeComplete', ()=>{console.log('a')});
// 올바른 이벤트 해제 방법
const A = ()=>{console.log('a')}
Router.events.on('routeChangeComplete', A);
Router.events.off('routeChangeComplete', A);
해당 작업을 진행하고 나서 회원가입을 진행하다가 도중에 이탈하는 유저가 30%가량 줄어들었다는 소식을 들었다.
물론 다른 부서에서의 변동 사항도 있었기에 오로지 프론트 쪽에서 진행한 UX 개선 작업이 해당 효과를 주었다고는 볼 수 없지만, 어찌보면 사소해보이는 부분이 실제 서비스에서는 더 중요하다는 것을 깨닫는 계기가 되었다!
또한 next/router 코드를 찾아보면서 '대충 그래서 이런 식으로 써야 하는 거겠지' 라는 생각으로 넘어가지 않고, 직접 눈으로 확인하니까 더 이해가 잘 되는 느낌이었다! 또한 next/router가 이벤트를 배열로 관리하고 있다는 것을 추가적으로 알게되었다!
항상 내 코드에 대해 설명할 때 '왜 이렇게 짰는지'를 설명할 수 있는 태도를 가져야 겠다고 다시 한번 다짐하는 계기가 되었답:)