트러블 슈팅: 연속된 요청(request)으로 마커가 사라지지 않는 현상
연속된 지도 API 요청으로 마커가 사라지지 않는 현상 해결하기
프로젝트 막바지에 접어들었을 때, 내가 맡은 목적지 페이지가 잘 작동하는지 최종 점검했다.
이 과정에서 마커가 바뀌는 동작을 빠른 시간 내에 연속 요청 했을 때, 마커가 사라지지 않고 누적되는 버그를 발견하였다.
이번 글에는 마커가 바뀌는 동작에 디바운싱을 적용하여 이 문제를 해결한 과정을 기록했다.
문제 인식
목적지 리스트의 페이지가 빠르게 바뀔 때 지도에서 마커가 사라지지 않는 버그
목적지 페이지는 제주도의 여행지 목록이 표시되는 목적지 리스트와, 이 목적지 리스트와 연동되어 해당 목적지(여행지)의 위치가 마커로 표시되는 지도로 구성되어 있다.
목적지 리스트는 여행지를 10개 단위로 나누어 렌더링하도록 페이지네이션 되어있다.
목적지 리스트의 페이지가 바뀌면 이에 따라 지도에 표시되는 장소도 바뀌게 된다.
즉, 최대 10개의 마커가 목적지 리스트 페이지가 변경될 때마다 사라지고 생겨나는 구조이다.
원래 의도대로 라면 페이지 이동 버튼을 눌렀을 때 기존의 마커가 사라지고 새로운 마커가 생겨야 한다.
실제로도 일반적인 클릭 속도(약 1회/초 내외)로 테스트 했을 때 문제 없이 마커가 교체되었다.
그러나, 5.5회/초 이상의 빠르기로 페이지 이동 버튼을 연속 클릭하면 이전 페이지의 마커가 사라지지 않는 버그가 발생했다.
다음은 이해를 돕기 위한 gif 자료이다. 페이지 좌측에 목적지 리스트, 우측에 지도가 렌더링된다.
가설 설정 및 검증
가설 1. 빠르게 페이지 이동을 요청했을 때, 마커를 제거하는 로직이 제대로 실행되지 않는다.
가설 검증하기:
이 버그는 페이지 이동 버튼을 의도적으로 5.5회/초의 속도로 클릭했을 때만 발생했다.
일반적인 속도로 페이지를 이동한 경우에는 마커가 정상적으로 제거되었다.
이에 따라, 버그와 관련있는 지도 컴포넌트와 목적지 리스트의 페이지 이동 로직을 확인했다.
기존의 마커를 제거하는 용도로 사용되는
markers는 지도 컴포넌트에서useState로 상태관리하고 있었다.그리고 목적지 리스트의 변화를 감지하여 이를 상태관리하는 로직은
useEffect훅에서 실행되고 있었다.목적지 리스트 페이지가 변경되면 새로운 위치 데이터가 지도 컴포넌트로 전달된다.
useEffect는 이 위치 데이터의 변화를 감지하여 실행되고 위치 데이터를markers의 state로 변경한다.다음은 마커를 생성하고 제거하는 로직이다.
markersLocations는 부모 컴포넌트에서 제공하는 위치 데이터이며,markers는 기존의 마커를 제거하는 목적으로 상태관리되고 있는 마커 데이터다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
useEffect(() => {
if (renderedMap === null) {
return;
} else {
const positions = markersLocations?.map((marker) => {
const InspectedMarker = setLocationToNullData(marker);
return {
title: InspectedMarker.title,
content: `<p>${InspectedMarker.title}</p>`,
latlng: new kakao.maps.LatLng(
Number(InspectedMarker?.mapy),
Number(InspectedMarker?.mapx)
),
};
});
//기존 마커를 감지하고 제거하는 로직
if (markers) {
markers.forEach((marker: kakao.maps.Marker) => marker.setMap(null));
}
//markersLocations를 토대로 새로운 마커 생성
const newMarkers = positions.map(
(position) =>
new kakao.maps.Marker({
title: position.title,
position: position.latlng,
map: renderedMap,
})
);
//마커 상태변경
setMarkers(newMarkers);
}
}, [markersLocations, renderedMap, search]);
위 코드에서 기존의 마커를 제거하는 로직은
markers의 state가 falsy한 값이 아닐 때 실행된다.즉, 마커가 제거되지 않고 누적되는 건 useEffect가 거듭 실행되어도
markers의 state가 변경되지 않고 falsy한 값으로 유지되고 있음을 의미한다.나는 이미 5.5회/초 이상의 속도로 목적지 리스트가 변경될 때와 그 이하의 일반적인 속도로 변경될 때, 마커 제거 여부가 달라지는 점을 직접 실험으로 확인했다.
이를 종합하면, 동일한 로직임에도 전자는 마커의 상태가 초기값(null)에서 변경되지 않았고 후자는 정상 변경되었다고 추측할 수 있다.
이에 따라, 위치 데이터가 빠르게 변경되면
useEffect내부의 setter가 제대로 작동하지 않는다는 가정을 새롭게 세웠다.
가설 2. 위치 데이터가 빠르게 변경되면 useEffect 내부의 setter가 제대로 작동하지 않는다.
가설 검증하기:
가설을 검증하고자
console.log를 활용하여 코드 실행 여부를 파악했다.위 코드에서 다음의 부분에
console.log를 추가해markers의 setter가 잘 실행되는지 확인했다.추가로, 마커를 제거하는 로직 내부에도
console.log를 찍어, 마커 제거 로직이 실행되고 있는지 점검했다.코드는 다음과 같다.
1
2
3
4
5
6
7
8
9
if (markers) {
markers.forEach((marker: kakao.maps.Marker) => {
marker.setMap(null);
return;
});
console.log("마커 제거로직 실행");
}
useEffect();
- 위 코드를 실행한 결과, 놀랍게도 마커를 제거하는 로직은 계속 실행되고 있었다. markers의 setter도 정상 실행되어 페이지가 바뀌면 markers의 state가 잘 바뀌었다.
위 결과로, 페이지 변경 속도에 상관없이 모든 로직이 잘 작동하는 것으로 판명됐다.
결국 내가 세운 모든 가설들은 성립하지 않았다.
이에 따라, 문제를 해결하기 위해 마커를 제거하는 로직을 수정하는 대신, 페이지 변경 속도를 조절하는 방향으로 문제 해결 관점을 바꾸었다.
문제 해결
1. 페이지 이동 함수에 디바운싱 개념 도입하기
웹 서비스 제공자가 사용자에게 ‘마커가 증식하는 버그가 있으니 페이지 이동 버튼을 과격하게 클릭하지 말아주세요.’라고 부탁하는건 현실적으로 어불성설이다.
사용자의 action에 상관없이 잠재적 오류에 선제대응해야 한다.
나는 사용자가 연속된 요청을 보내도 이를 자체적으로 제어할 수 있는 방법을 조사했다.
그리고 디바운싱(debouncing) 개념을 알게 되었다.
디바운싱이란, 이벤트가 일정한 주기 내에 여러 번 발생하면 이들을 하나로 간주하고, 가장 마지막 또는 처음의 이벤트만 인정하는 매커니즘이다.
디바운싱을 적용한 이벤트가 연달아 발생하면 바로 실행되지 않고 계속 delay된다. 이후 특정 시간(주기)이 경과해도 새로운 이벤트가 발생하지 않으면 비로소 한 번 실행된다.
일반적인 속도로 페이지를 변경하면 문제가 없지만, 빠르게 페이지를 넘겼을 때 버그가 생기는 나의 경우에 이러한 디바운싱이 적절한 method라고 생각했다.
이에 따라, 페이지 이동 함수에 디바운싱을 적용할 방법을 고민했다.
디바운싱은 라이브러리를 도입하거나 타겟 함수에 타이머(timer)를 설정하여 간단히 구현할 수 있었다.
나의 경우, 타겟 함수가 복잡하지 않고 후자의 방식을 어떻게 코드에 적용할 지 그려졌기 때문에 구태여 라이브러리를 도입하지 않기로 했다.
다음은 디바운싱을 적용할 코드이다. 모두 페이지를 이동하는 함수이며, 사용자가 페이지 이동 버튼을 클릭했을 때 실행된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const handlePreviousPageClick = () => {
if (page > 1) {
handlePageQueryChange(page - PAGES.PAGES_TO_SKIP);
}
};
const handlePageClick = (pageNumber: number) => {
handlePageQueryChange(pageNumber);
};
const handleNextPageClick = () => {
if (page < totalPages) {
handlePageQueryChange(page + PAGES.PAGES_TO_SKIP);
}
};
const handleFirstPageClick = () => {
handlePageQueryChange(PAGES.START_INDEX_OF_PAGE);
};
const handleLastPageClick = () => {
handlePageQueryChange(totalPages);
};
이 함수들에 비동기 timer를 설정하여 사용자의 action에 즉시 응답하지 않고 delay가 생기도록 하였다.
이 때, delay는 사용자 경험에 영향을 주지 않도록 150ms로 설정했다.
함수가 연속 호출 됐을 때, 이전에 생성된 timer를 제거하도록
clearTimeout도 추가했다.세부 코드는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
useEffect(() => {
return () => {
clearTimeout(clickTimeoutRef.current as NodeJS.Timeout);
};
}, []);
//모든 페이지 이동 함수를 동일한 형식으로 변경
const handlePreviousPageClick = () => {
clearTimeout(clickTimeoutRef.current as NodeJS.Timeout);
clickTimeoutRef.current = setTimeout(() => {
if (page > 1) {
handlePageQueryChange(page - PAGES.PAGES_TO_SKIP);
}
}, 150);
};
- 모든 페이지 이동 함수에 디바운싱을 일괄 적용하자, 다음과 같이 빠르게 페이지가 바뀌어도 마커가 정상적으로 제거되었다.
고찰
지금까지는 예상치 못한 동작이나 버그가 발생하면 내가 작성한 코드 구조가 잘못된 경우였다.
물론 이번 트러블 슈팅 사례도 내가 미처 캐치하지 못한 코드상의 문제일 가능성도 있다.
하지만 기존의 로직을 변경하지 않고 새로운 개념을 도입해서 버그를 개선한 적은 처음이기 때문에 좋은 경험이 되었다.
프로젝트 진행기간 동안 디바운싱을 급하게 공부하고 적용했기 때문에 시간이 넉넉하지는 않았다.
그러나 잠재적 오류를 파악하여 사용자의 action에 선제대응하는게 꼭 필요한 과정이라고 생각했기 때문에 어설프게나마 코드를 작성했고, 성공하여 보람도 느낄 수 있었다.
무엇보다도, 내가 이렇게 페이지의 기능을 점검하여 오류를 발견할 수 있었던 건 함께 프로젝트를 수행한 팀원 분 덕분이다.
프로젝트 중반에 이번 트러블 슈팅과 유사한 문제가 카테고리 필터 버튼에도 발생했는데, Q/A 경험을 보유하신 팀원분께서 내 페이지를 점검하다 발견하시고 알려주셨기 때문이다.
이후 나 또한 페이지를 수시로 점검하면서 괜히 버튼을 빠르게 클릭도 해보고, 일반적인 user flow가 아니더라도 페이지를 옮겨다니고 이 기능, 저 기능을 마구잡이로 사용해보기도 했다.
내가 미처 발견하지 못한 문제를 파악하고 새로운 insight를 발견하는 점이야 말로, 팀의 큰 강점이 아닐까 한다.


