'리뷰미'팀 프로젝트에서 웹과 모바일 브라우저에서 동작하는 형광펜 기능을 구현한 회고글입니다.
우테코 5차 데모데이가 끝나고, 팀 회의로 추가할 기능을 선정했다. 서비스의 목적에 따라 현재까지는 리뷰 작성에 도움을 주었으니 이제는 받은 리뷰를 모아서 사용자가 스스로를 파악하는 것을 더 수월하게 해주는 '리뷰 모아보기 페이지'를 구현하기로 했다.
리뷰 모아보기 페이지에는 리뷰 중 기억하고 싶은 문구에 형광펜으로 칠할 수 있는 기능이 들어간다.
처음에는 형광펜을 Draft.js 패키지를 사용해 구현했지만, 문제는 '리뷰 모아보기 페이지의 특수성'을 충족하기 어려웠다. 하나의 에디터에는 여러 답변이 들어가기 때문에, 서버에 형광펜 API 요청시, 어느 답변에 대한 형광펜 정보변경인지 알려줘야했고, 서버에는 형광펜이 적용된 데이터만 들어가야했다.
또한 리뷰 내용에 대한 편집을 허용되지 않으면서, 형광펜만 적용해야했다. 필요한 기능에 비해 에디터의 기능을 많았고 글자 선택을 가능하게 하면서 글자 드래그앤 드롭을 막는 것은 어려웠다.
그래서 결국 서비스에 맞는 형광펜 기능을 만들기로 했다 🐳
작은 버전부터 만들어 점진적으로 형광펜 기능을 키워갔다.
우선, '웹과 모바일에서 (드래그를 사용한) 글자 선택 시 형광펜을 칠하거나 삭제하는 버튼을 선택 영역 밑에 두는' 형광펜 기능을 구현했다.
Draft.js에서 글자를 개행에 따라 block으로 나누고 형광펜 적용 시작점과 형광펜 적용된 글자수로 상태관리하는 것에서 아이디어를 얻었다.
답변을 개행으로 나누고, 하나의 줄에 대한 형광펜 정보(형광펜 시작점과 끝점)을 담는 방식으로 상태를 관리하기로 했다.
export interface EditorAnswer {
content: string;
answerId: number;
answerIndex: number;
lineList: EditorLine[];
}
export interface EditorLine {
lineIndex: number; // 구문 index
text: string; // 구문 글자
highlightList: HighlightRange[]; // 하이라이트 정보, 하이라이트 정보가 없으면 빈배열
}
export interface HighlightRange {
startIndex: number;
endIndex: number;
}
아쉽게도 웹, 모바일 브라우저에서 비슷한 이벤트로 글자 선택을 감지할 수 없다.
터치 기반 장치에서는 글자를 길게 누르거나 두 번 탭하고 드래그로 글자를 선택할 수 있다. 문제는 터치 기반 장치에서 텍스트 선택과 관련된 기본 동작이 같이 이루어지고 기본 동작인 텍스트 선택이 우선순위라 touchstart
와 touchend
가 제대로 작동하지 않았다.
그래서 브라우저의 터치 지원여부에 따라 다른 이벤트를 사용해, Selection
객체로 글자 선택을 감지했다.
글자 선택 감지 이벤트 | |
---|---|
터치되는 브라우저 | selectionchange |
터치안되는 브라우저 | mouseup |
selectionchange
를 사용하면 드래그를 선택 영역을 바꿀 때 마다, 형광펜 버튼의 위치가 바뀌게된다. 즉, 레이아웃 시프트가 자주 일어나게된다.
터치가 지원되는 브라우저의 경우, 터치 기반 상호작용처럼 자주 발생하는 동작에 최적화되어서 레이아웃 시프트같은 UI 변화를 부드럽게 처리하도록 설계되어서 사용자가 느끼는 불편함은 없었다. 이에 반해 터치가 안되는 웹 브라우저의 경우 selectionchange
사용 시 화면 버벅임이 체감될 정도였다.
그래서 웹 브라우저에서는 mouse 이벤트인 mouseup
을 사용하기로 했다.
이전에는 react-contentEditable
라이브러리를 사용해서 div에서도 select event
를 사용할 수 있었지만, 지금은 리뷰어가 받은 리뷰를 편집하면 안되기 때문에 select event
를 사용할 수 없었다.
Selection
객체의 anchorOffset
과 focusOffset
는 선택된 노드에서 선택된 첫번째 글자, 마지막 글자의 index를 의미가는 것을 아니고 글자 선택 방향에 따라 변경된다.startIndex
, endIndex
는 하나의 줄에서 연속적으로 형광펜이 적용의 시작 글자 index와 끝나는 글자 index이다.즉, 사용자의 글자 선택 방향이 정방향인지 역방향인지를 파악하고 글자 선택방향과 Selection
객체를 사용해 형광펜의 시작점과 끝점에 대한 글자 index, 해당 줄 index를 알아야한다. 이때 글자 index는 해당 줄에서 글자의 index를 의미한다.
선택된 글자가 몇 번째 줄에 해당하는지는 알기위해 data-*
속성을 사용했다. data-index
에 해당 줄의 index에 대한 값을 넣고 Selection
의 anchorNode
, focusNode
를 통해 data-index
에 접근해 글자가 답변의 몇 번째 줄에 있는 지 파악할 수 있게 했다.
가나다라마바사
위에서 이미 '가나'에 형광펜이 적용되어 있고 추가로 '다라'에 형광펜을 칠한다면 형광펜 영역은 '가나','다라'로 분리되는 것이 아니라 '가나다라'로 합쳐져야한다.
이를 위해서 코딩 테스트에서 문제 풀이로 사용한 이진법을 사용했다. 답변의 글자 수가 length인 0으로 채워진 배열을 만들고, 형광펜이 적용된 글자 index는 1로 바꿨다. 그리고 배열인 형광펜 영역에 1이 하나라도 연속될 경우, 연속의 시작점과 끝점을 요소로 넣었다.
/**
* 하이라이트 적용 여부를 이진법에 따라 표시하는 배열을 생성하는 함수
* @param list 배열에 표시할 하이라이트 배열
* @param arrayLength 이진법 배열의 length이자 하이라이트 적용 대상인 line의 글자 수
*/
const createHighlightBinaryArray = ({ arrayLength, list }: CreateHighlightBinaryArrayParams) => {
const array = '0'.repeat(arrayLength).split('');
list.forEach((item) => {
const { startIndex, endIndex } = item;
for (let i = startIndex; i <= endIndex; i++) {
array[i] = '1';
}
});
return array;
};
/**
* '0','1'로 이루어진 배열을 가지고, highlightList를 만드는 함수
* 1이 하나 이상일 경우, 시작 index가 start 이고 연속이 끝나는 index가 end
* @param array
*/
const makeHighlightListByConsecutiveOnes = (array: string[]) => {
const result: HighlightRange[] = [];
let startIndex = -1; // 시작점 초기화 (아직 찾지 못한 상태)
for (let i = 0; i < array.length; i++) {
if (array[i] === '1' && startIndex === -1) {
// 1이 시작되는 지점
startIndex = i;
} else if ((array[i] === '0' || i === array.length - 1) && startIndex !== -1) {
// 1이 끝나는 지점: 0을 만났거나 배열의 끝에 도달했을 때
const endIndex = array[i] === '1' ? i : i - 1;
result.push({ startIndex, endIndex });
startIndex = -1; // 다시 초기화
}
}
return result;
};
드래그를 통해서 형광펜을 삭제하는 것외에 한번에 형광펜 영역을 삭제하는 기능이 있었으면 편리하겠다는 생각을 했다. 그래서 형광펜 칠한 영역을 길게 눌러서, 해당 영역에 대한 형광펜을 삭제할 수 있는 기능을 추가했다.
여기서 문제점은 터치 지원 브라우저에서 글자를 길게 누르면, 글자가 선택되어서 selectionChange
로 글자 선택을 감지되어 ver. 0.1.0에 만든 '드래그를 통한 형광펜 추가/삭제'와 같이 실행된다는 것이다.
그래서 터치 지원 브라우저의 경우 touchmove 이벤트를 사용해, 사용자들이 익숙한 슬라이드 방식으로 선택된 형광펜 영역을 지울 수 있게 했다.
🚨 모아보기 페이지 목 데이터를 형광펜 에디터에 적용해보니 문제가 발생했다!!
하나의 질문에 대한 여러 답변을 보여주고 있고 사용자는 여러 답변을 넘나들면서 형광펜을 적용/삭제할 수 있다.
또한 하나의 질문에 대한 답변은 많을 수 있기 때문에 해당 답변의 형광펜 데이터를 빠르게 찾을 수 있어야 한다.
답변의 id를 key로 하는 Map객체를 사용해 형광펜 데이터를 관리하기로 했다. 또한 data-*
를 사용해 선택된 글자가 어느 답변에 있는 지 파악할 수 있도록 했다.
형광펜 사용법을 알려주지 않고, 사용하려니 트래그를 통한 추가,삭제와 길게 눌렀을 때 삭제 버튼이 나오는 것을 파악하는게 쉽지 않아요.
형광펜 기능을 어느 정도 구현하고 나서, 같은 팀의 프론트 크루에게 위와 같은 피드백을 받았다. 피드백을 받고나서 형광펜 사용방법을 다시 바라봤다.
ver. 0.1.0에서 선택된 글자의 형광펜 적용 여부를 판단해 적용되어 있지 않으면 추가 버튼을, 형광펜이 적용되어 있으면 삭제 버튼을 알아서 보여주도록 했다.
이때 형광펜 적용과 미적용 부분이 같이 선택된 경우, 형광펜을 추가하는 니즈가 더 많으거라고 판단했다. 그러나 이는 나의 섯부른 판단이었고, 적용과 미적용이 같이 있을 때 형광펜을 추가할 것인지 삭제할 것인지는 사용자가 선택할 수 있게 변경하기로 했고 이 과정에서 형광펜에 대한 모든 버튼을 관리하는 형광펜 메뉴 컴포넌트를 만들었다.
형광펜 사용법을 사용자에게 알려주기 위해, 툴팁을 추가로 구현했다. 툴팁 메세지는 터치 지원에 따른 형광펜 사용법을 알려주기 위해, 사용자가 브라우저의 터치 지원에 따라 그에 맞는 형광펜 사용법을 볼 수 있도록 했다.
iOS에서 형광펜이 적용되지 않아요!!! 🤯
최종 데모데이를 3일 앞두고, iOS에서 형광펜 기능이 작동하지 않는 오류를 발견했다. iOS에서 글자 선택 시, 형광펜 메뉴는 구현 의도대로 올바른 위치에 나타나지만 형광펜 추가, 삭제 버튼을 눌렀을 때 형광펜 변경 사항이 적용되지 않았다.
형광펜 기능을 담당한 내가 iOS 기기를 사용하지 않았던 터라, 이 오류를 미리 발견하지 못했다.
Mac을 사용하면 iOS 디버깅이 비교적 쉬운 편이지만, Windows를 사용 중이었기 때문에 Inspect
를 사용하며 오류 원인을 찾고, 하루 만에 여러 방법을 시도해 해결했다.
형광펜 적용 버튼 클릭 시 document.getSelection으로 버튼 클릭 당시의 Selection 객체를 사용하는데, 디버깅을 통해 iOS에서는 버튼 클릭 시 Selection 객체가 초기화*되는 것을 발견했다.
💡 Selection 객체 초기화?
Selection 객체 자체가 사라지거나 null이 된 것이 아니라, rangeCount가 0으로 변경된 것입니다.
작성 편의상, Selection 객체가 초기화되었다고 표현하겠습니다.
디버깅과 서칭을 통해, iOS의 엄격한 Selection 관리 정책, DOM에 의존하는 Selection 객체의 특성이 복합적으로 작용된 것이 오류를 일으켰다고 추측했다.
☑️ 포커스 이동으로 인한 Selection 해제
iOS Safari는 사용자가 텍스트를 선택한 후, 다른 요소(버튼 등)를 터치하면
해당 선택이 끝났다고 판단하여 Selection 객체를 자동으로 초기화한다.
다시 말하면, 텍스트가 선택된 상황애서 버튼을 클릭(터치)하는 순간 Selection 객체가 사라지는 것이다.
이는 Android나 데스크톱 브라우저에서는 발생하지 않는 현상이며, iOS만의 UX 정책에 기인한 것이다. 그래서 형광펜 메뉴에서 버튼을 클릭 했을 때 Selection 객체가 초기화된 것이었다.
☑️ DOM 리렌더링으로 인한 Selection 해제
Selection 객체는 현재 DOM의 선택된 텍스트 영역에 대한 정보를 담고 있다. DOM이 변경되면, 텍스트 선택도 해제될 수 있다.iOS는 DOM 변경을 다른 브라우저보다 더 엄격하게 봐서, 선택된 텍스트가 포함된 DOM이 업데이트될 때 Selection 객체를 초기화한다.
이는 다음에서 어떤 문제를 일으키는 지 더 자세히 보도록 하자.
버튼 클릭 시 이전에 (형광펜 메뉴를 화면에 띄울 때), 유효한 Selection 객체를 useState를 이용해 상태로 저장하는 방법을 시도해봤다.
그러나 useState를 사용할 수 없었다.
iOS WebKit의 엄격한 Selection 정책때문이다.
Selection 객체는 DOM 노드에 의존하므로, 선택된 노드가 리렌더링으로 교체되면 모든 브라우저에서 선택이 해제될 수 있다. 특히 WebKit은 이 동작이 더 엄격하게 적용된다. setState는 DOM 리렌더링을 일으키기 때문에, Selection 객체를 setState로 저장할 때는 유효하더라도 이를 사용할 때는 선택 범위가 없는 초기화 상태가 되는 문제가 발생한다.
이때 iOS는 성능 최적화로 인해, DOM 렌러딩 지연이 일어난다. 그래서 이미 Selection객체는 초기화되었지만, 화면상에서는 여전히 글자가 선택된 것 처럼 보일 수 있다.
1. Selection 객체 즉시 저장: useRef
사용
useRef는 '참조'를 목적으로 하는 훅으로, 값을 변경하더라도 DOM 리렌더링을 일으키지 않는다.
그래서 기존에 사용하던 useState
대신 useRef
를 사용해, Selection 객체가 사라지기 전 즉시 참조하는 방식을 통해 최신 정보를 유지할 수 있도록 했다.
// 형광펜 메뉴 컴포넌트
const selectionInfoRef = useRef<SelectionInfo | undefined>(undefined);
// 메뉴 위치가 바뀌거나 열릴 때 Selection 저장
useEffect(() => {
const newSelectionInfo = findSelectionInfo();
selectionInfoRef.current = newSelectionInfo;
}, [position]);
2. Selection 속성만 따로 분리해 저장
useRef를 사용했지만 간헐적으로 오류가 발생했다. 그 이유는 형광펜 적용/삭제 버튼을 클릭 시 포커스가 버튼으로 이동해 Selection 객체가 초기화될 가능성이 여전히 존재하며 useRef는 Selection 객체의 참조값을 저장하고 있기 때문이다.
이를 보완하기 위해, 깊은 복사를 통해, 형광펜 적용 영역 계산에 필요한 속성만 분리해 저장했다.
이렇게 하면 Selection 객체가 초기화되더라도, 복사된 속성값은 안전하게 남아있다.
const findSelectionInfo = () => {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) return;
return {
anchorNode: selection.anchorNode,
anchorOffset: selection.anchorOffset,
focusNode: selection.focusNode,
focusOffset: selection.focusOffset,
};
};
🌟 해결 방법 정리
이제 iOS에서도 형광펜 기능이 안정적으로 동작한다! 🎉
iOS의 Selection 관리 정책, 비동기 DOM 처리 방식과 Selection 객체의 특성을 이해하고 대응한 것이 해결의 핵심이었다.
좀 더 근본적으로 원인을 파고들고 해결한 경험이라 특히 뿌듯한 이슈였다 😎
형광펜 기능을 작업하면서 버그가 끝이 없고 이를 혼자 담당해야 하는 게 가장 힘들었다.
형광펜 기능은 정말 예측할 수 없는 다양한 사용자 액션이 있는 기능이고 웹과 모바일의 환경 차이도 신경 써야 했다. 또 예기치 못한 iOS 버그가 있었고, iOS 기기 디버깅은 처음이라 해당 버그의 원인을 찾는 것도 막막했었다. 또한, 데모 데이 전날에 형광펜 적용 후 해당 질문으로 다시 돌아왔을 때 변경된 형광펜 내용이 반영되지 않는 오류를 긴급하게 고쳐야 했다.
팀원들이 함께 엣지 케이스를 찾아주었지만, 6차 최종 데모 데이까지 작업이 바쁘고 시간이 촉박한 데다 해당 로직이 복잡해 이해하는 시간이 꽤 걸려서, 다른 작업을 팀원들이 진행하고 나는 형광펜 기능만 신경 쓰는 것이 팀이 목표한 바를 시간 내에 완수하는 방법이라고 생각했다.
당시에는 최종 데모 데이는 고객과 약속한 시간이기 때문에 어떻게든 기한을 맞추는 게 중요하다고 생각했다. 그래서 스스로를 밀어붙이다가 체력적으로 한계가 오기도 했다. 이에 대해 포비와 면담을 진행하고 다시 생각해 보니, 기능을 만드는 데 시간적 여유가 없었던 게 아쉬웠고, 팀원들과 기능 구현 일정과 작업 분배에 대해 논의해 보면 어땠을까 싶다.
최종 데모 데이 때, 형광펜 기능에 대한 사용자 반응이 모두 좋았고 Amplitude의 분석 데이터에서도 형광펜 기능 사용률이 높아서 뿌듯했다. 부스가 붐벼서 노트북보다는 모바일 사용자가 많았고, 그중 iOS 사용자가 90%를 넘어서 긴급하게 iOS에서의 형광펜 버그를 고친 게 다행이었다.
최종 데모 데이를 앞두고 형광펜 버그 2개를 긴급하게 고쳤고, 형광펜 기능 자체가 이틀 안에 구현되었으며 총 2주 동안 버그를 수정하며 완성한 거라 확장성과 재사용성 면에서 아쉽다.
레벨 5와 우테코가 끝나도 내가 만든 형광펜 기능의 재사용성과 확장성을 보완하고 사용자 편의성을 고려하도록 보완할 예정이다.
와우 결과물이 너무 멋지네요!! 개발 과정이 더 궁금하네요~!!