Skip to content

[FE] 오브젝트 (Polyline, Marker) 캐싱

YoonDH edited this page Mar 31, 2025 · 10 revisions

오브젝트 (Polyline, Marker)캐시화로 인한 메모리 효율 개선

  • 초기 어드민 페이지에서 관찰된 문제점은 잦은 페이지 변경이 발생하여 오브젝트 생성이 많아지게 될 경우, 메모리에 동일 오브젝트들이 반복적으로 생성되어 메모리 효율이 떨어진다는 것을 발견하였습니다.
  • 현재 배포 서비스를 크롬 개발자 도구의 Memory 탭으로 측정한 결과입니다. (지도 메인 페이지 -> 불편한 길 제보 페이지 -> 지도 메인 페이지)

기존 메모리 효율

2025-02-21.11.00.42.mp4
  • _.$p 라는 오브젝트 동일 오브젝트가 반복적으로 생성되어 메모리에 732 -> 1465 -> 2197 으로 개수가 늘어나는 것을 확인할 수 있습니다. 이 숫자는 화면에 그리는 길의 숫자로 동일한 길이 반복적으로 생성되어 메모리에 누적되고 있다는 것을 확인할 수 있습니다.

  • 위와 같은 문제를 해결하기 위해 상위 Provider에서 선과 마커를 위해 Map과 Set을 생성하였습니다.

Map Set
다른 페이지에서 이전 페이지와 동일 오브젝트 반복 생성 시, 캐시된 오브젝트를 생성할 수 있도록 Element를 관리 하나의 페이지에서 리렌더링 시, 중복 생성을 방지하기 위해 필요없는 선을 삭제하기 위한 기록
type RouteCacheMap = Map<string, google.maps.Polyline>;

type MarkerCacheMap = Map<string, google.maps.marker.AdvancedMarkerElement>;

interface CacheContextType {
	cachedRouteRef: RefObject<RouteCacheMap>;
	cachedMarkerRef: RefObject<MarkerCacheMap>;
	usedRouteRef: RefObject<Set<string>>;
	usedMarkerRef: RefObject<Set<string>>;
}

export const CacheContext = createContext<CacheContextType>({
	cachedMarkerRef: { current: new Map() },
	cachedRouteRef: { current: new Map() },
	usedRouteRef: { current: new Set() },
	usedMarkerRef: { current: new Set() },
});

export default function CacheProvider({ children }: { children: React.ReactNode }) {
	const cachedRouteRef = useRef<RouteCacheMap>(new Map());
	const cachedMarkerRef = useRef<MarkerCacheMap>(new Map());
	const usedRouteRef= useRef<string>(new Set());
	const usedMarkerRef= useRef<string>(new Set());

	return (
		<CacheContext.Provider value={{ cachedRouteRef, cachedMarkerRef, usedRouteRef, usedMarkerRef }}>
			{children}
		</CacheContext.Provider>
	);
}

다른 페이지에서 오브젝트 재사용

  • Context 내부에서 useRef로 Map을 선언하였습니다. 기본적으로 Object들은 state가 변경되어도 리렌더링이 일어나지않아서 직접 지워주는 과정이 필요하여 state가 아닌 useRef로 관리하였습니다.

Key 선정 과정

Polyline Key

  • Polyline의 경우 초기에 각 coreRoute를 캐시하기에 coreNode의 2가지 값으로 사용하였습니다.
        const key = `${coreNode1Id}_${coreNode2Id}`;
  • 다음과 같이 적용시킨 결과 문제가 2가지 문제가 발생하였습니다.
  • 이 경우를 해결하기 위해 다음과 같이 coreRoute를 구성하는 모든 간선의 ID를 이어붙히고, coreNode의 id를 크기비교하여 일관성있는 방향으로 탐색할 수 있도록 하였습니다.
	const routeIds = subRoutes.map((el) => el.routeId);
	const key = coreNode1Id < coreNode2Id ? routeIds.join("_") : routeIds.reverse().join("_");
  • 이 결과 key의 길이가 몇 백 글자로 크게 형성되어 더 간소화하여 최종적으로 다음과 같이 첫 번째와 마지막 간선의 ID를 이어붙혀 사용하고 있습니다.
const key = coreNode1Id < coreNode2Id ?`${edges[0].routeId}_${edges.slice(-1)[0].routeId}` :`${edges.slice(-1)[0].routeId}_${edges[0].routeId}`

Marker Key

  • 마커의 경우 위험 / 주의 마커에 대해서만 캐시를 진행하고 있습니다. 마커의 경우 하나의 간선 위에 생성되기에 명확하게 간선의 아이디를 사용하였습니다,
  • 초기에는 간선의 ID 만을 사용하였는데, 사용자가 제보하여 위험 -> 주의 / 주의 -> 위험 으로 바뀌는 것을 감지하기 어렵다고 생각하여 현재 마커의 종류도 key에 포함하였습니다.
	const key = `DANGER_${routeId}`;
	const key = `CAUTION_${routeId}`;
  • 기존의 3 페이지에서는 공통적으로 drawRoute를 일부씩 변형하여 사용하고 있습니다.
  • 길을 생성할 때, key로 Context에서 관리중인 CacheMap에 접근하여 존재하는 경우, map 현재 map으로 할당함으로서 생성된 오브젝트를 재사용할 수 있습니다.
        const drawRoute = (coreRouteList: CoreRoutesList) => {
                if (!map || !cachedRouteRef.current) return;
                
                for (const coreRoutes of coreRouteList) {
                        const { coreNode1Id, coreNode2Id, routes: edges } = coreRoutes;
                        
                        const subNodes = [edges[0].node1, ...edges.map((el) => el.node2)];
                        
                        const key = coreNode1Id < coreNode2Id ?`${edges[0].routeId}_${edges.slice(-1)[0].routeId}` : `${edges.slice(-1)[0].routeId}_${edges[0].routeId}`
                                
                        const cachedPolyline = cachedRouteRef.current.get(key);
                        
                        if (cachedPolyline) {
                                removeAllListener(cachedPolyline);
                                cachedPolyline.setMap(zoom <= 16 ? null : map);
                                continue;
                        }
                        
                        const routePolyLine = createPolyline({
                                map: null,
                                path: subNodes.map((el) => {
	                                return { lat: el.lat, lng: el.lng };
                                }),
                                strokeColor: "#3585fc",
                        });
                        
                        if (!routePolyLine) continue;
                        
                        if (cachedRouteRef.current) {
                                cachedRouteRef.current.set(key, routePolyLine);
                        }
                }
        };
  • 위와 같이 key로 조회하여 있는 경우 (cache Hit), 지도에 그린다. 그리고 다른 페이지에서 등록한 이벤트들이 남아있는 경우를 대비하여 모든 이벤트를 지우는 함수를 분리하여 생성하였습니다

  • 만일 cache Miss가 발생했을 경우, cacheMap에 해당 key로 오브젝트를 삽입하였습니다.

  • 위의 로직으로 대부분의 페이지에서 오브젝트 재사용으로 인한 길 캐시가 성공적으로 동작하였습니다.

하나의 페이지에서 재요청하게 될 경우, 중복생성 & 만료 데이터 문제

  • 하나의 페이지에서 API 요청이 2번 불러질 때 (캐시 만료), 오브젝트가 불필요한 오브젝트를 삭제하는 이유가 필요합니다.

  • 현재 모든 지도 페이지에서는 useSuspenseQuery를 사용하고 있습니다. useSuspenseQuery의 특징은 데이터가 만료되어 refetch가 필요한 경우, 만료된 데이터를 먼저 return한 후, 해당 데이터로 화면을 만드는 동안 refetch를 진행하여 새로운 데이터를 return한다는 것입니다.

  • 일반적인 서비스의 경우, state가 변화되면서 화면이 재렌더링되어 문제가 없지만, 지도에 그리져는 오브젝트는 state의 변화를 감지하지 않고 element를 직접 div (map)에 생성하는 개념으로 위의 로직을 적용하기 위해서는 오래된 데이터와 새로운 데이터로, 새로 그려야할 오브젝트, 재사용할 오브젝트, 제거해야할 오브젝트를 구분하는 로직이 필요하였습니다.

로직 필요 이유 로직 원리

필요 이유

로직 필요 이유
스크린샷 2025-02-20 오후 3 11 00
투명한 길로 나타냈을 경우, 중복된 길이 생성되는 것을 확인할 수 있습니다.
  • Provider에서 2가지 Set을 만들고 초기 렌더링 시, 캐시맵에 생성된 그려진 Object의 id를 저장하고, 2번째 그려질때, 해당 ID들과 현재 그려야할 ID들을 비교하여, 불필요한 오브젝트를 제거하는 방식입니다.
        const drawRoute = (coreRouteList: CoreRoutesList) => {
                if (!map || !cachedRouteRef.current) return;
            
                let isReDraw = false;
                
                if (usedRouteRef.current!.size !== 0) isReDraw = true;
                
                const usedKeys = new Set();
            
                for (const coreRoutes of coreRouteList) {
                        const { coreNode1Id, coreNode2Id, routes: edges } = coreRoutes;
                        
                        const subNodes = [edges[0].node1, ...edges.map((el) => el.node2)];
                        
                        const key =
                        coreNode1Id < coreNode2Id
	                        ? `${edges[0].routeId}_${edges.slice(-1)[0].routeId}`
	                        : `${edges.slice(-1)[0].routeId}_${edges[0].routeId}`;
                        
                        const cachedPolyline = cachedRouteRef.current.get(key);
                        
                        if (cachedPolyline) {
                                if (!isReDraw) {
	                                usedRouteRef.current!.add(key);
                                } else {
	                                usedKeys.add(key);
                                }
                                
                                removeAllListener(cachedPolyline);
                                cachedPolyline.setMap(zoom <= 16 ? null : map);
                                tempLines.push(cachedPolyline);
                                continue;
                        }
                        
                        const routePolyLine = createPolyline({
                                map: null,
                                path: subNodes.map((el) => {
	                                return { lat: el.lat, lng: el.lng };
                                }),
                                strokeColor: "#3585fc",
                        });
                        
                        if (!routePolyLine) continue;
                        
                        if (cachedRouteRef.current) {
                                cachedRouteRef.current.set(key, routePolyLine);
                        }
                }
                
                if (isReDraw) {
                        const deleteKeys = usedRouteRef.current!.difference(usedKeys) as Set<string>;
                        
                        deleteKeys.forEach((key) => {
                        console.log("DELETED CORE ROUTE", key);
                        cachedRouteRef.current!.get(key)?.setMap(null);
                        cachedRouteRef.current!.delete(key);
                        });
                }
        };
  • isReDraw를 false로 선언하고,drawRoute가 재발동될 경우, usedRouteRef의 set에 값이 이미 존재할 것이기에, isReDraw를 true로 설정합니다.
  • 그리고, 2번째 draw에서 사용중인 key를 보관하기 위핸 usedKeys Set을 생성합니다. 지워야할 대상들을 파악하면 되기에 캐시 여부를 확인하는 과정에서 isReDraw의 여부로 1회차 렌더링 시에는 usedRouteRef에 값을 넣고 2회차 렌더링 시에는 usedKeys에 넣습니다.
  • 모든 그리기 과정이 종료된 이후에는 usedRouteRef Set과 usedKeys Set의 차이를 구할 경우, 지워야할 대상들이 남아서 해당 대상들을 지도에서 지우고 cacheMap에서도 삭제합니다.

최종 로직

오브젝트 캐싱 최종 로직

적용 결과

로직 적용 결과
스크린샷 2025-02-20 오후 3 07 36
투명한 길로 나타냈을 경우, 중복된 길이 삭제되는 것 로그와 함께을 확인할 수 있습니다.
  • 위의 로직을 Marker에도 동일하게 적용할 수 있습니다.

캐시로 인한 오브젝트 생성 감소

2025-02-21.11.23.40.mp4

메모리 개선 이전

2025-02-21.10.57.36.mp4

메모리 개선 이후

2025-02-21.10.53.35.mp4

PR

오브젝트 (Polyline, Marker)캐시화로 인한 메모리 효율 개선

Clone this wiki locally