댓글 및 답글 스크롤 포커싱 리팩토링

se-een·2024년 4월 4일
3

프로젝트 컨셉비

목록 보기
3/3
post-thumbnail

이전에 댓글 및 답글 무한 스크롤 조회와 입력창 스크롤 포커싱 기능을 구현하여, 사용성을 개선한 포스팅에서 이어지는 내용이다.

기존 방식의 문제점

마지막으로 적용한 스크롤 포커싱 기능은 다음과 같다.

// textareaRef: 선택한 댓글 또는 답글 입력창
const focusTextareaRef = (textareaRef: MutableRefObject<HTMLTextAreaElement | null>) => {
  if (!textareaRef.current) return;

  textareaRef.current.focus();
  textareaRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' });
};

간략하게 코드를 설명하면 사용자가 선택한 댓글 또는 답글 입력창이 화면 가운데로 스크롤링 되어 위치하도록 하는 것이다. 웬만한 상황에선 나쁘지 않게 동작했으나 다음과 같은 문제점들이 있었다.

  • 댓글이 없는 상태에서 댓글 입력창 클릭 시 스크롤 포커싱 미동작으로, 가상 키보드(키패드)에 입력창이 가려짐.
  • 댓글 입력창이 일부 가려진 상태에서 클릭 시 스크롤 포커싱 미동작으로, 가상 키보드에 입력창이 가려짐.
  • OS에 따라 가상 키보드 열림 동작이 달라 부자연스러운 포커싱 동작이 발생.

위 문제점 중 첫 번째(좌측)와 두 번째 상황(우측)을 영상으로 보면 아래와 같다.

두 기기 모두 IOS이다. 미처 해결하지 못한 window.visualViewport.height 문제점인줄 알고 여러 크기의 시뮬레이터를 동원해 디버깅을 해보았지만 그렇지 않았다. 애초에 우측 영상의 첫 부분을 보면, 댓글 입력창이 모두 보이는 상태에서 클릭할 때 입력창이 키보드 위에 정상적으로 위치하는 것을 확인할 수 있다.

대부분의 상황에선 위 로직으로 스크롤 포커싱이 가능했지만, 몇몇 엣지 케이스에서 기능이 정상적으로 동작하지 않아 사용자 경험을 헤쳐 리팩토링을 결정했다.

리팩토링 과정

우선 문제의 원인이 될 수도 있는 부분을 아래와 같이 정리했다.

  • 댓글 입력창이 하단에 고정되어 있는 형태가 아닌 스크롤에 따라 유동적으로 위치 조정이 가능한 형태
  • 댓글 입력창 클릭 시 기존 크기 약 42px에서 194px로 확장
  • 답글 입력창은 댓글 입력창과는 다르게 각 댓글마다 가장 마지막 답글 아래에 생성 가능
  • IOS는 가상 키보드 표시 방식이 타 OS와는 다름
  • element.focuselement.scrollIntoView 실행 순서 보장이 불가
  • scrollIntoView는 세밀한 스크롤 포커싱이 불가

스크롤 할 위치 직접 계산하기

scrollIntoVeiw를 활용한 스크롤 포커싱 방식에서 요소의 위치를 구해 직접 스크롤링하여 포커싱하는 방식으로 변경하고자 했다. 그 이유는 특정 위치에서 scrollIntoView의 동작이 불완전하다고 생각했었고, 이전에 구현해 둔 코드가 있었기에 방식을 변경하는데 그리 오랜 시간이 걸리지 않아서이다.

// 댓글 / 대댓글, (대)댓글수정 입력창의 위치에 영향을 주는 요소가 서로 달라 위치 조정값 분리 및 상수화
const COMMENT_DIFF = 58;
const RECOMMENT_DIFF = 108;

// textareaRef: 선택한 댓글 또는 답글 입력창
// mobileViewRef: 스크롤을 조작할 수 있는 rootElement
const focusTextareaRef = ({ textareaRef, mobileViewRef, isInComment }: FocusTextareaRefProps) => {
  if (!textareaRef.current || !mobileViewRef.current) return;

  textareaRef.current.focus();

  const textareaRect = textareaRef.current.getBoundingClientRect();
  const difference = isInComment ? RECOMMENT_DIFF : COMMENT_DIFF;

  // MobileView 컴포넌트의 스크롤 Top(가장 상단)을 유저가 선택한 (대)댓글 입력창 위치의 절대값 - 위치 조정값으로 이동
  mobileViewRef.current.scroll({
    top: mobileViewRef.current.scrollTop + textareaRect.top - difference,
    behavior: 'smooth',
  });
};

이전에 작성했던 위 코드를 바탕으로 스크롤 위치를 직접 계산해주었다. 참고로 위 코드는 아래와 같이 동작한다.

위 코드를 가지고 앞서 도출한 문제의 상황들을 재연하면서, difference의 값을 변경해 스크롤 위치를 조정하는 방식으로 진행하려고 했다. 하지만 해당 방식으로 진행해본 결과, difference 값의 변경 폭이 너무 큰게 문제였다.

예를 들면, 댓글 입력창이 하단 끝에 반쯤 가려져 있을 때 difference 값을 300 쯤 맞춰야 정상적으로 스크롤 포커싱이 되고, 댓글 입력창이 중간쯤 있을 때는 화면 위로 벗어나게 된다. 그림으로 보면 아래와 같다.

따라서 difference 값을 댓글 입력창의 위치에 따라 분기 처리하여 할당해줘야 위 문제를 해결할 수 있다. 그런데 특정 위치에서만 스크롤 값이 차이가 난다는 점이 상식적으로 잘 이해가 되지 않았다. 🤔

스크롤 값이 차이가 난다면 실제 사용자가 스크롤할 때에도 특정 위치에서는 유난히 빠르거나 느리거나 해야하는데, 이 속도는 사용자가 스크롤을 하는 가속도에만 영향이 있을 뿐 특정 위치에 의해 영향을 받지 않는다.

그리고 위치에 따른 분기 처리 방식은 어느 위치를 기준으로 둘 것인지, 얼만큼 분기 처리를 할 것인지 등 고려해야 할 사항이 한 두가지가 아니다. 또한 분기 처리가 많아지고 복잡해질수록 잠재적인 오류의 발생 확률도 높아진다고 생각하기에, 이 방식은 직감적으로 좋지 않은 방식이라고 느껴졌다.

그럼에도 딱히 이렇다 하는 방법이 떠오르지 않았기에, 우선은 분기 처리를 최소화 하는데 집중했다.

키보드에 가려질 경우에만 스크롤 포커싱 해주기

모바일 환경에서 키보드 영역에 의해 댓글 입력창이 가려질 경우에만 스크롤 포커싱을 해주는 것이 분기 처리를 최소화 할 수 있는 방법이라고 생각했다.

따라서 키보드 영역에 의해 댓글 입력창이 가려지는 경우를 알아내려면 키보드의 높이를 알아내야하는데, 키보드가 올라올 때 브라우저가 리사이징 되기 때문에 다음의 코드로 키보드의 높이를 구할 수 있었다.

const innerHeight = window.innerHeight;

const calculateKeyboardHeight = (keyboardHeightRef: MutableRefObject<number>) => {
  const visualViewHeight = window.visualViewport?.height;

  // 키보드 높이 구하기
  if (visualViewHeight && keyboardHeightRef.current === 0) {
    keyboardHeightRef.current = innerHeight - visualViewHeight;
  }
};

// window를 대신하여 스크롤이 생기는 rootElement
const MobileView = () => {
  const mobileViewRef = useRef<HTMLElement | null>(null);
  const keyboardHeightRef = useRef<number>(0);

  useEffect(() => {
    if (!window.visualViewport) return;
    const windowVisualViewPort = window.visualViewport;

    const onResizeViewPortHeight = () => {
      calculateKeyboardHeight(keyboardHeightRef);
    };

    windowVisualViewPort.addEventListener('resize', onResizeViewPortHeight);

    return () => {
      windowVisualViewPort.removeEventListener('resize', onResizeViewPortHeight);
    };
  }, []);
  
  // (중략)
}

이제 위에서 작성한 스크롤 포커싱 로직에서 댓글 및 답글 입력창이 키보드에 의해 가려지지 않는 위치에 있다면, 스크롤 포커싱을 수행하지 않고 return 할 수 있도록 로직을 추가해준다.

const FOCUSING_DIFFERENCE = 1.4;
const INIT_KEYBOARD_HEIGHT = 280;

const focusTextareaRef = ({
  textareaRef,
  mobileViewRef,
  keyboardCurrent,
}: FocusUsingKeyboardHeightProps) => {
  if (!textareaRef.current || !mobileViewRef.current) return;

  const textareaRefCurrent = textareaRef.current;
  const mobileViewRefCurrent = mobileViewRef.current;
  const textareaRect = textareaRefCurrent.getBoundingClientRect();
  const innerHeight = window.innerHeight;
  const elementAbsolutePosition = mobileViewRefCurrent.scrollTop + textareaRect.top;
  
  // 최초의 키보드 높이 값은 0이므로 보통의 디바이스 평균값으로 설정
  // PC의 경우 키보드 높이 값이 항상 0이므로 280으로 고정
  const keyboardHeight = keyboardCurrent || INIT_KEYBOARD_HEIGHT;
  
  textareaRefCurrent.focus();
  
  // 댓글 및 답글 입력창이 키보드에 의해 가려지지 않는 곳이라면 return
  if (window.innerHeight - textareaRect.top >= keyboardHeight) return;

  // 댓글 및 답글 입력창이 키보드에 의해 가려진다면 스크롤 포커싱
  mobileViewRef.current.scroll({
    top: elementAbsolutePosition - innerHeight + keyboardHeight * FOCUSING_DIFFERENCE,
    behavior: 'smooth',
  });
};

다만, 최초의 키보드 높이 값은 브라우저 리사이징 이벤트가 발생하기 전이므로 0이기에, 디바이스 키보드의 평균값인 280으로 세팅해주었다. 그 이후에는 각 사용자의 키보드 값으로 세팅된다.

또한 PC의 경우 가상 키보드가 없으므로 키보드 높이가 항상 0이 된다. 따라서 keyboardHeight의 값이 항상 280으로 고정이 되는데, 이는 답글 입력창 스크롤 포커싱 때 사용된다.

위 코드를 적용한 결과는 다음과 같다.

키보드 영역에 의해 댓글 입력창이 가려질 경우에만 스크롤 포커싱이 동작하는 것을 볼 수 있다. 또한 difference 값을 사용자의 키보드 높이 값과 IPhone SE ~ IPhone 15 Pro Max를 커버하는 평균값으로 (keyboardHeight * FOCUSING_DIFFERENCE 부분) 설정하여 분기 처리를 하지 않고 스크롤 포커싱이 자연스럽게 동작하도록 했다.

비동기로 포커싱 실행 순서 보장하기

리팩토링을 진행하다가 아래와 같이 댓글 입력창이 조금 가려진 상태에서 스크롤 포커싱이 간헐적으로 이상하게 동작하는 경우가 있었다. difference 값을 충분히 설정해주었음에도 그랬다.

작성한 코드를 가만히 분석해보니, focus와 scroll을 모두 사용하고 있다는 점이 문제가 될 수 있겠다고 생각이 들었다.

focus 메서드 자체에도 스크롤 포커싱 동작이 있다. focus() 한 요소가 화면에 보이지 않으면 화면에 보이게끔 스크롤링 해준다는 점에서 scrollIntoView와 비슷하게 느껴졌다. 따라서 focus와 scroll의 실행 순서를 보장하지 못해서 간헐적으로 스크롤 포커싱이 이상하게 동작하는게 아닐까? 라고 가설을 세워보았다.

따라서 다음과 같이 scroll 로직을 setTimeout으로 감싸서 이벤트 루프의 특성을 이용해 실행 흐름을 보장해보았다.

const focusUsingKeyboardHeight = ({
  textareaRef,
  mobileViewRef,
  keyboardCurrent,
}: FocusUsingKeyboardHeightProps) => {
  if (!textareaRef.current || !mobileViewRef.current) return;

  const textareaRefCurrent = textareaRef.current;
  const mobileViewRefCurrent = mobileViewRef.current;
  const textareaRect = textareaRefCurrent.getBoundingClientRect();
  const innerHeight = window.innerHeight;
  const keyboardHeight = keyboardCurrent || INIT_KEYBOARD_HEIGHT;
  const elementAbsolutePosition = mobileViewRefCurrent.scrollTop + textareaRect.top;

  textareaRefCurrent.focus();

  // focus() 동작 완료 이후 포커싱 로직 동작토록 의도적으로 비동기 상황으로 수행
  const timerId = setTimeout(() => {
    clearTimeout(timerId);

    // 가상 키보드 내에 댓글 및 답글 입력창이 가려질 가능성이 있는 경우에만 포커싱 로직 동작
    if (innerHeight - textareaRect.top >= keyboardHeight) return;
    
    mobileViewRefCurrent.scroll({
      top: elementAbsolutePosition - innerHeight + keyboardHeight * FOCUSING_DIFFERENCE,
      behavior: 'smooth',
    });
  }, 0);
};

그 결과 다음과 같이 댓글 입력창이 정상적으로 스크롤 포커싱 되어 보이는 것을 볼 수 있었다. 좌측이 적용 전, 우측이 적용 후이다.

이 방법은 솔직히 미심쩍다. 실험적으로 진행한 방식이기도 하고, 무언가 근본적인 원인을 파악하지 못한.. 문제 덮어버리기 코드의 느낌이 들기도 했다. 🤔

하지만 구글에 focus setTimeout 이라고 검색해보면 어렵지 않게 '원래는 포커싱이 되지 않았는데, setTimeout을 썼더니 되더라.' 하는 글들을 볼 수 있다. 따라서 완전히 근본 없는 해결 방법은 아니라고 생각하기에 이 로직을 적용하기로 결심했다.

IOS가 아닌 경우 스크롤 포커싱 동작 달리하기

맨 처음에 스크롤 포커싱 기능을 만들 때 키보드 바로 위에 댓글 입력창을 위치하는게 목표였다. 여러 번 실험해본 결과 아이폰 외의 디바이스들은 scrollIntoView로 포커싱을 수행해주는 것이 목표한 동작에 더 가까웠다.

또한 서비스 기획상 댓글 입력창과 답글 입력창의 동작 방식이 전혀 달라 스크롤 포커싱을 달리 적용해야 했고, PC의 경우 특별히 스크롤 포커싱을 수행하지 않아 입력창이 가려지는 문제도 발생했다.

따라서 다음과 같이 'IOS인 경우'와 '댓글 입력창인 경우'라는 분기 처리를 수행했다. 아래가 최종으로 수정된 코드이다.

const isIphone = /ip/i.test(navigator.userAgent.toLowerCase());
const INIT_KEYBOARD_HEIGHT = 280;
const FOCUSING_DIFFERENCE = 1.4;

const focusUsingKeyboardHeight = ({
  textareaRef,
  mobileViewRef,
  keyboardCurrent,
  isComment,
}: FocusUsingKeyboardHeightProps) => {
  if (!textareaRef.current || !mobileViewRef.current) return;

  const textareaRefCurrent = textareaRef.current;
  const mobileViewRefCurrent = mobileViewRef.current;
  const textareaRect = textareaRefCurrent.getBoundingClientRect();
  const innerHeight = window.innerHeight;
  const keyboardHeight = keyboardCurrent || INIT_KEYBOARD_HEIGHT;
  const elementAbsolutePosition = mobileViewRefCurrent.scrollTop + textareaRect.top;

  textareaRefCurrent.focus();

  const timerId = setTimeout(() => {
    clearTimeout(timerId);

    if (innerHeight - textareaRect.top >= keyboardHeight) return;

    // 댓글 입력창 및 IOS 디바이스는 아래 포커싱 로직으로 동작
    if (isIphone && isComment) {
      mobileViewRefCurrent.scroll({
        top: elementAbsolutePosition - innerHeight + keyboardHeight * FOCUSING_DIFFERENCE,
        behavior: 'smooth',
      });
      return;
    }

    // 답글 입력창 및 IOS 외 모든 디바이스는 아래 포커싱 로직으로 동작
    textareaRefCurrent.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }, 0);
};

안드로이드의 경우 다음과 같이 댓글 및 답글 입력창에 대해 스크롤 포커싱이 조금 더 자연스럽게 동작하는 것을 확인해볼 수 있다.

댓글이 없을 때 Fallback UI 표시하기

이제 모든 문제가 마무리 되었다고 생각하고 PR을 날리려던 중, 댓글이 하나도 없는 경우에 댓글 입력창을 클릭할 경우 여전히 키보드에 의해 가려지는 문제점을 발견했다.

좌측 영상이 댓글이 하나도 없을 경우이다.

동일한 로직인데도 위와 같이 다르게 동작한다. 하루 정도 디버깅을 해보았는데 문제의 원인을 도통 알 수가 없었다. 🫠 (혹시 원인을 아시는 분이 계신다면 댓글로 알려주시면 감사하겠습니다. 🙇🏻‍♂️)

이슈는 많은데 처리할 시간이 많지 않아 트레이드 오프를 고려해서 다른 방법으로 이 문제를 해결하기로 마음을 먹었다. 마음 같아선 문제의 원인을 발견할 때까지 계속 디버깅 하고 싶지만, 우테코에서 괜찮을지도 팀 프로젝트를 진행하면서 한정된 시간 속에서 최선의 방법을 찾는 능력도 중요하다는 걸 경험해봤기에.. 깔끔하게 포기하고 다른 방법으로 접근했다.

위 영상에서 알 수 있는 것은 댓글 입력창 아래에 어느정도 공백이 있어야 작성한 스크롤 포커싱 로직이 정상적으로 동작한다는 것이다. 따라서 아래처럼 댓글이 없을 경우 '댓글이 없어요~'와 같은 Empty Section을 구현해두면 될 것 같았다.

팀원분들과 의견을 나눠본 결과 모두 긍정적인 반응이었고, Empty Section으로 댓글 입력창 하단에 빈 공간을 생성했다. 그 결과 다음과 같이 정상적으로 스크롤 포커싱이 동작하는 걸 확인할 수 있었다.

리팩토링 결과

웹, Android, IOS 모두 정상적으로 스크롤 포커싱 기능이 정상적으로 동작하는 것을 확인해볼 수 있다.

이로써 댓글 및 답글 스크롤 포커싱 리팩토링을 마무리 하고자 한다. 😇

profile
- woowacourse FE 5th, depromeet Web 15th

0개의 댓글