코린이의 개발 일지

[mo:heyum] 사용자 멘션 추천기능 구현하기 본문

웹 (web)/모헤윰 프로젝트

[mo:heyum] 사용자 멘션 추천기능 구현하기

폴라민 2023. 2. 7. 19:12
반응형

구현 목표

모헤윰 에디터는 div 태그에 contenteditable 속성을 true로 설정해서 구현했다.

 

이 에디터에 오른쪽 그림과 같이 커서 오른쪽으로 사용자 멘션 추천 드롭 다운을 띄워줄 계획이다.

 

사용자 매 입력마다 입력된 글자로 시작하는 멘션 추천 목록을 갱신해준다.

 

 

 

 

매 입력마다 api요청을 보내는 방식보다 처음에 멘션 대상이 되는 사용자 목록(현재 로그인한 사용자가 팔로잉 하는 유저, 혹은 사용자를 팔로우하는 유저) 전체를 클리아언트에서 저장해두고 멘션 입력시 클라이언트에서 필터링해주는 방식을 택했다.

 

  const fetchMentionList = async () => {
    const response = await httpGet('/user/mentionlist');
    allMentionList = [...response.data];
  };
  
  // 처음 렌더 될때만 전체 멘션 리스트 가져옴
  useEffect(() => {
    fetchMentionList();
  }, []);

 

처음 에디터 페이지가 렌더링되면 전체 멘션 리스트를 api요청으로 받아오고 allMentionList에 저장한다.

 

 

멘션 추천 로직

자 그럼 @ 가 입력되었을 때, 드롭다운을 띄워주어야한다.

멘션 로직을 우선 생각해보자.

 

시작 조건

1. @를 입력하면 멘션 추천 드롭다운을 띄운다.

 

진행 조건

1. 드롭다운 위치는 커서 위치에 따라 계속 이동한다.

2. @뒤에 글자를 입력하면, 글자에 맞춰 멘션 추천 대상을 필터링해서 띄운다.

3. 드롭다운은 위아래 방향키로 선택자 이동이 가능해야한다.

 

종료 조건

1. 엔터를 누르면 현재 선택된 대상이 에디터에 입력, 드롭다운은 닫히고 멘션 알림을 보낼 대상에 등록된다.

2. 사용자가 멘션 대상 full 아이디를 전부 입력하고 스페이스 바를 입력하면 드롭다운은 닫히고 멘션 알림을 보낼 대상에 등록된다.

3. 멘션 입력을 완료하지 않고, backspace로 @를 지워버리는 경우.

 

멘션 알림을 보낼 사용자 목록은 전체 글 내용에서 따로 파싱을 하지 않기 때문에, 서버로 보낼때 따로 알림 보낼 멘션 대상 리스트를 담아서 보내줘야한다.

 

 

그럼 우선 시작 조건부터 구현해보자.

 

 

멘션 시작 조건 구현

const [checkMentionActive, setCheckMentionActive] = useState<boolean>(false);
const [followList, setFollowList] = useState<followUser[]>([]); // 드롭다운에 렌더링할 멘션 추천 목록
const [inputUserId, setInputUserId] = useState<string>(''); // 매 입력되는 멘션 filtering 문자
const [selectUser, setSelectUser] = useState<number>(0); // 드롭다운에서 현재 선택된 user
 
useEffect(() => {
    // 멘션 시작
  setInputUserId('');
  moveDropDown(false);
  setFollowList(allMentionList.slice(0, 5));
  setSelectUser(0);
}, [checkMentionActive]);

const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (!contentRef.current) return;
    const { key } = e;

    // 멘션 시작
    if (key === '@') {
      setCheckMentionActive(true);
      return;
    }
};

 

위의 코드를 보면 checkMentionActive 상태로 멘션이 시작된 상태인지 체크한다.

멘션이 시작되면 멘션 추천 대상을 필터링하는 문자열인 inputUserId를 초기화해주고,

멘션 드롭다운 위치를 갱신해준다. (moveDropDown)

드롭다운에 렌더링할 멘션 추천 목록을 세팅해준다. (처음에는 아무 글자 입력이 없으므로 알파벳 순으로 5명 추천)

현재 선택된 멘션 대상도 0으로 초기화 해준다.

 

위의 과정을 거치면 아래와 같은 상태가 된다. 현재 선택된 멘션 대상은 0번째 index를 가리킨다.

 

 

 

진행 조건

1. 드롭다운 위치는 커서 위치에 따라 계속 이동한다.

드롭다운 위치는 현재 커서 위치에 맞춰서 이동을 시켜줘야했기 때문에 상태로 관리했다.

글자를 삭제 했을 때와 글자를 입력했을 때 위치를 다르게 잡아줘야 했다.

const [dropDownPosition, setDropDownPosition] = useState<{ x: string; y: string }>({
    x: '0px',
    y: '0px',
});

// 드롭다운 위치 갱신
  const moveDropDown = useCallback((isBack: boolean) => {
    const cursor = window.getSelection();
    const range = cursor?.getRangeAt(0);
    if (range) {
      const bounds = range.getBoundingClientRect();
      isBack
        ? setDropDownPosition({ x: `${bounds.x}px`, y: `${bounds.y + 5}px` })
        : setDropDownPosition({ x: `${bounds.x + 20}px`, y: `${bounds.y + 5}px` });
    }
  }, []);

 

아래처럼 커서의 위치에따라 드롭다운이 잘 이동되도록 구현하였다.

2. @뒤에 글자를 입력하면, 글자에 맞춰 멘션 추천 대상을 필터링해서 띄운다.

이제 멘션이 active된 상태에서 글자 입력마다 드롭다운에 띄워줄 추천 대상을 필터링해보자

 

@뒤에 글자 매 입력마다 필터링 해준다.

 

1. 입력한 내용은 `inputUserId` 상태에 업데이트한다. (Backspace가 입력되었을 경우 끝에 한글자 삭제)

2. inpuUserId가 업데이트되면 조건에 맞는 멘션 추천 리스트를 필터링한다.

 

보면 영어 글자가 아닌 상황은 (Shift와 CapsLock제외) 아예 멘션 추천 리스트를 비워버렸는데, 그 이유는 \ 와 같은 문자가 inputUserId에 들어가게 되면 정규표현식 생성과정에서 오류가 나기 때문에 영어 글자가 아닌 특수키 입력은 전부 예외처리를 해주었다.

(아이디를 추천해주는 거라 반드시 영어 혹은 _ 만 가능한 문자들만 들어온다.)

 

Shift와 CapsLock은 아이디 입력시 필요한 특수키라 예외처리를 해주었다. 좀더 꼼꼼히 예외처리를 해줬어야 했는데 (기타 특수키) 이 부분은 좀 아쉽다. 

// 사용자가 입력한 검색할 문자, 전체 mentionList에서 필터링
  useEffect(() => {
    if (inputUserId === '') {
      setFollowList([]);
      return;
    }
    const regex = new RegExp(`^${inputUserId}`);
    const filteredList = allMentionList.filter((user) => regex.test(user.userid) && user);
    setFollowList(filteredList.slice(0, 5));
  }, [inputUserId]);

const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (!contentRef.current) return;
    const { key } = e;
    const cursor = window.getSelection();
    if (!cursor) return;

    if (key === 'Backspace') {
      setInputUserId((prevState) => prevState.slice(0, prevState.length - 1));
      setSelectUser(0);
      moveDropDown(true);
      return;
    }

    if (checkMentionActive) {
      // 멘션 키 active 상태일 때, 단어 입력하는 동안 발생하는 이벤트
      if (key.match(/^\w$/i)) {
        setInputUserId((prevState) => prevState + key);
        setSelectUser(0);
      } else if (key !== 'CapsLock' && key !== 'Shift') {
        setFollowList([]);
      }
      // 기능키 제외
      if (key !== 'CapsLock' && key !== 'Shift') {
        moveDropDown(false); // 기능키 제외 문자키 입력마다 모달창 위치 계속 갱신해줘야함
      }
    }
  };

 

 

3. 드롭다운은 위아래 방향키로 선택자 이동이 가능해야한다.

위아래 방향키 입력시 selectUser 상태를 갱신해서 선택자 이동이 가능하도록 해주었다.

const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (!contentRef.current) return;
    const { key } = e;
    const cursor = window.getSelection();
    if (!cursor) return;

    if (checkMentionActive) {
      switch (key) {
        // 멘션 리스트 모달창 선택 대상 이동
        case 'ArrowDown':
          e.preventDefault();
          setSelectUser((prevState) => (prevState + 1 > followList.length - 1 ? 0 : prevState + 1));
          break;
        case 'ArrowUp':
          e.preventDefault();
          setSelectUser((prevState) => (prevState - 1 < 0 ? followList.length - 1 : prevState - 1));
          break;
        default:
          // 생략
      }
    }
  };

 

 

 

종료 조건

1. 엔터를 누르면 현재 선택된 대상이 에디터에 입력, 드롭다운은 닫히고 멘션 알림을 보낼 대상에 등록된다.

자 이제 종료 조건을 구현해보자.

첫번째 종료 조건은 엔터 입력시 아이디 전체가 자동으로 에디터에 입력되고 알림보낼 리스트에 등록된다.

 

코드를 살펴보면 엔터 입력시, 필터링된 멘션 추천 목록에서 현재 선택된 index에 해당하는 userId를 가져온다.

그리고 이미 에디터에 입력되어있는 inputUserId를 제외한 나머지 글자를 pasteAction으로 붙여준다. (pasteAction은 복사 붙여넣기 기능 구현을 위해 직접 구현한 함수이다.)

그 다음 멘션 active상태를 false로 바꿔서 드롭다운을 닫아주고, 알림보낼 리스트(mentionList)에 등록해준다.

 

const [mentionList, setMentionList] = useState<string[]>([]);

const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (!contentRef.current) return;
    const { key } = e;
    const cursor = window.getSelection();
    if (!cursor) return;

    if (checkMentionActive) {
      let word: string = '';
      let userId: string;
      switch (key) {
        // 생략
        case 'Enter':
          e.preventDefault();
          if (followList.at(selectUser)) {
            userId = followList.at(selectUser)?.userid as string;
            if (userId) word = userId.slice(inputUserId.length, userId.length);
            pasteAction(`${word} `);
            setCheckMentionActive(false);
            if (userId) {
              setMentionList((prevState) => prevState.concat(userId));
            }
          }
          break;
        default:
          // 생략
      }
    }
  };

 

 

결과

 

 

 

2. 사용자가 멘션 대상 full 아이디를 전부 입력하고 스페이스 바를 입력하면 드롭다운은 닫히고 멘션 알림을 보낼 대상에 등록된다.

 

두번째 종료조건은 사용자가 full 아이디를 전부 입력하고 스페이스 바를 입력했을 때이다.

위의 로직과 거의 비슷하고 차이가 있다면 이미 full 아이디가 에디터에 입력된 상태기 때문에 따로 파싱해서 더 입력해줄 글자는 없다.

const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (!contentRef.current) return;
    const { key } = e;
    const cursor = window.getSelection();
    if (!cursor) return;

    if (checkMentionActive) {
        // 멘션 입력 완료, 멘션 active 종료 => 직접 fullname 입력한 경우
        case ' ':
          e.preventDefault();
          pasteAction(` `);
          setCheckMentionActive(false);
          if (inputUserId) {
            setMentionList((prevState) => prevState.concat(inputUserId));
          }
          break;
        default:
         // 생략
      }
    }
  };

결과

 

 

3. 멘션 입력을 완료하지 않고, backspace로 @를 지워버리는 경우

아까 Backspace입력을 처리해줬던 로직에 @를 지웠을 경우 예외처리를 해주었다. @를 지우면 멘션 active 상태가 종료되면서 드롭다운이 닫힌다.

const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (!contentRef.current) return;
    const { key } = e;
    const cursor = window.getSelection();
    if (!cursor) return;
    
    // 생략

    // 멘션 종료 조건
    if (key === 'Backspace') {
      // @지우면 모달창 닫음, 멘션 active 종료
      if (checkMentionActive && /@<\/div>$/.test(contentRef.current.innerHTML)) {
        setCheckMentionActive(false);
        return;
        // 그외에는 멘션 active 유지.
      }
      setInputUserId((prevState) => prevState.slice(0, prevState.length - 1));
      setSelectUser(0);
      moveDropDown(true);
      return;
    }
    // 생략
  };

결과

 

 

 

트러블 슈팅

드롭다운 윈도우 width 변할때

모헤윰은 반응형 UI를 지원하는데, window width가 변할 때마다 dropdown 위치도 갱신해주어야했다.

 

 

// 윈도우 창 조절시 드롭다운 위치 재조정
  useEffect(() => {
    const handleResize = () => {
      moveDropDown(false);
    };
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

 

결과

 

 

 

최종 결과

 

 

 

 

아쉬웠던 점

예외처리

모든 가능한 상황에 대한 처리를 해주지 못했다.

예를 들면, 사용자가 멘션할 사람을 등록해서 mentionList에 이미 업로드가 된 상태에서, 다시 멘션한 대상을 @까지 삭제하면 최종 작성완료를 눌렀을 때 삭제한 대상에게는 알림이 가면 안된다.

 

하지만 그 부분을 예외처리 해주지 못하였다. 그렇게 하려면 글 작성완료를 누르는 시점에 글을 파싱해서 멘션 알림 보낼 사람 목록을 추려야 하는데, 파서에서 그 부분을 따로 구현하지 않아서 차선책을 택했다.

 

이외에도 특수키 입력시(Shift, Capslock 제외) 드롭다운 닫힘, 등등 자잘한 오류들이 은은하게 있다..

 

에디터 직접 구현하며 느낀건 모든 상황에 대한 예외를 찾고 처리를 해주는게 생각보다 쉽지 않다는 점이었다.

 

추천 방식

다른 sns 서비스의 경우 글자 입력마다 api요청을 보내서 그 응답을 바탕으로 추천해준다. 그리고 내가 팔로잉 혹은 나를 팔로우 하는 유저가 아닌 전체 유저 중에서 입력 글자에 맞춰 추천해준다.

사실 sns라는 점을 생각한다면 전체 유저에서 필터링 해서 보여주는 것이 맞다. 그 점도 좀 아쉽다.

 

추후에 이 기능을 리팩토링 해본다면, 매 입력마다 api요청을 보내는 방식으로 바꾸고, 추천 대상은 전체 사용자로 변경할 거 같다. 대신 멘션 입력에는 입력이 끝날때까지 디바운싱을 걸어 api요청 횟수를 최소화해주고, 한번 api 요청으로 받아온 추천 목록은 해당 페이지를 나갈 때까지 캐싱해두어 같은 입력은 api요청없이 바로 추천 목록이 뜨도록하면 api요청도 어느정도 줄여 사용자 경험 측면도 챙기면서 전체 사용자 대상으로 추천이 가능하다.

반응형

'웹 (web) > 모헤윰 프로젝트' 카테고리의 다른 글

[mo:heyum] 스크롤 복원하기  (0) 2022.12.14
Comments