개발자
류준열

3년전에 작성한 코드 리팩토링

약 3년전에 하이빌리지 라는 위치기반 가까운 관광지를 보여주는 서비스를 만들었었다. (블로그 글)

퇴사하고 쉬는 기간에 하이빌리지의 코드를 다시 보니 useMap.tsx에서 수정할 내용들이 보였다.

일단 react와 react가 아닌 코드의 분리(=리렌더링과 무관한)는 둘째치고서라도, 성능 저하와 잠재적으로 버그를 유발할 수 있는 내용들이 있었다.

  • ui 변경과 무관한데 state로 관리하는 코드
  • 카카오맵 dragend에 실행될 콜백이 반복해서 등록됨
  • 렌더링중에 DOM 변경함

인상깊었던건 'ui 변경과 무관한데 state로 관리하는 코드'를 개선하니 테스트코드가 터졌다.

나는 테스트코드를 의심했지만, 원인은 논리적으로 충돌하는 코드들이 불필요한 리렌더링에 의존하며 우연히 정상작동하고 있었고, 그런 문제가 드러난것이었다.

ui 변경과 무관한데 state로 관리하는 코드

useState는 리렌더링을 관리하는 방법이다. (https://ko.react.dev/learn/state-a-components-memory)

당시 useMap.tsx 에서는 marker가 누적되지 않도록 이전 marker 목록을 다음 렌더링때 제거하기 위해 보관하는데, 문제는 이 보관용 값을 state로 관리한다는 것이다. (marker는 아래 이미지 참고)

BEFORE: 단순 변수를 state로 관리

구조를 단순화 하면 이런 느낌이었다.

const [prevMarkers, setPrevMarkers] = useState<MarkerType[]>([]);

const updateMarkers = () => {
  prevMarkers && removeMarkers(prevMarkers);
  const newMarkers = makeMarkers(kakaoMap.current, places || []);
  setPrevMarkers(newMarkers);
};

여기서 prevMarkers의 역할은 단순하다.

  • UI를 바꾸는 상태가 아님 (=리렌더링과 무관)
  • 직전 marker 목록을 저장하는 용도
  • 다음 updateMarkers() 실행 때 removeMarkers(prevMarkers) 하려고 들고 있는 값

즉, prevMarkers는 리렌더링과 무관하고 카카오맵 객체를 관리하는 임시 변수다.

하지만, setPrevMarkers(newMarkers)를 호출하기 때문에 React입장에서는 컴포넌트를 다시 렌더링시키는 구조였다. 결과적으로 저장만 해두면 되는 값 때문에 리렌더링이 발생하게 되었다.

그래서 이 부분을 아래처럼 ref로 옮겼다.

AFTER: 리렌더링과 무관한건 ref로 관리

const prevMarkersRef = useRef<MarkerType[]>([]);

const updateMarkers = () => {
  prevMarkersRef.current.length && removeMarkers(prevMarkersRef.current);
  const newMarkers = makeMarkers(kakaoMap.current, places || []);
  prevMarkersRef.current = newMarkers;
};

이렇게 바꾸면 의미가 더 선명해진다.

  • BEFORE: setPrevMarkers() 호출 시 리렌더링됨
  • AFTER: prevMarkersRef.current에 저장만 하고 리렌더링 없음

리렌더링을 정상화시키니, 불필요한 리렌더링에 의존하던 코드들에 사이드이펙트가 발생하면서 테스트가 실패하기 시작했다.

카카오맵 dragend에 실행될 콜백이 반복해서 등록됨

백문이 불여일견, 바로 보여드리겠다. 아래와 같이 콘솔을 찍었다.

export const onDragMap = (map: any, setPickPoint: (position?: PositionType) => void) => {
  new window.kakao.maps.event.addListener(map, 'dragend', () => {
    const latlng = map.getCenter();
    console.count('드래그 이벤트 발생 횟수:');
    setPickPoint({ lat: latlng.Ma, lon: latlng.La });
  });
};

드래그를 몇번 하던 콜백은 한번만 실행되어야 하는데, 드래그를 할수록 콜백 호출 횟수가 이전 횟수에서 누적되어 쌓이고 있었다.

  • drag 1회 → listener 1개 실행
  • drag 2회 → listener 2개 실행 (1번 실행되어야 함)
  • drag 3회 → listener 3개 실행 (1번 실행되어야 함)
  • drag n회 → listener n개 실행 (drag를 몇번 하던 listener는 1회만 실행되어야함)
  • listener에는 setState있음 -> 점점 setState 많아짐. -> drag 반복하면 점점 무거워짐

원인: dragend addListner에 대한 이해 부족

3년전의 나는 addListener에 대한 이해가 부족했던 것 같다.

드래그시 발생할 콜백을 지도 초기화 때 1회 등록하면 끝나는 것인데, useEffect에 넣어서 드래그 발생할때마다 반복등록 시켰으니 말이다.


export const onDragMap = (map: any, setPickPoint: (position?: PositionType) => void) => {
  new window.kakao.maps.event.addListener(map, 'dragend', () => {
    const latlng = map.getCenter();
    setPickPoint({ lat: latlng.Ma, lon: latlng.La });
  });
};

...

useEffect(()=>{
...
    onDragMap(kakaoMap.current, setPickPoint);
...
},[pickPoint])

네이밍 수정: onDragend -> addDragEndListener

먼저 이름부터 바꿨다.

export const addDragEndListener = (map: any, setPickPoint: (position?: PositionType) => void) => {
  new window.kakao.maps.event.addListener(map, 'dragend', () => {
    const latlng = map.getCenter();
    setPickPoint({ lat: latlng.Ma, lon: latlng.La });
  });
};

Event Listener는 초기화에 1번 등록

dragend listener는 state 변화시마다 붙는 로직이 아니라, 지도가 생성될 때 1회만 초기화 시키는 로직이다.

따라서 초기화 함수에 addDragEndListener를 추가했다.

 const initializeMapAndAddZoomControl = () => {
    if (mapRef.current) {
      const container = mapRef.current;
      kakaoMap.current = new kakao.maps.Map(container, option);
      addZoomControler(kakaoMap.current);
      // 추가
      addDragEndListener(kakaoMap.current, setPickPoint);
    }
  };

수정후에는 drag를 몇번하던 listener는 1번만 실행된다.

DOM 변경을 렌더링중에 처리함

위 img.title을 지우기 위해 DOM을 직접 건드는 코드가 있었는데, prevMarker를 ref로 관리한 작업 이후 img.title이 지워지지 않는 사이드 이펙트가 있었다. (prevMarker를 ref로 관리한 이유는 ui 변경과 무관한 데이터를 state로 관리하여 리렌더링을 일으키는 현상 개선이다.)


export const removeImageTitle = () => {
  const allImgsWithPresentationRole = document.querySelectorAll('img[role="presentation"]');
  allImgsWithPresentationRole.forEach(img => {
    img.removeAttribute('title');
  });
};

export const useMap=(..)=>{
...
  useEffect(()=>{...},[..])
	...

  removeImageTitle();

  return { map: kakaoMap };
}

위와 같이 img의 title을 제거하는 함수를 useEffect 밖에서 처리하고 있었다. (DOM 건드는 코드를 왜 useEffect 밖에 썼던거지..?)

어쨌든 최종 DOM 렌더링이 완료된 이후에 작동하도록 useEffect안에 넣어주니 해결되었다.

부검

removeImageTitle()은 불필요한 setPrevMarkers 가 유발하는 리렌더링에 우연히 의존하여 정상작동 하던 것이었다.