✍🏻개요
이번 프로젝트에서는 Kakao Map을 활용한 🔗로컬실록지리지 서비스를 🔗스카우트 플랫폼으로 이관 구현이 결정되어 dev서버에서 개발을 진행하면서 대량의 마커를 지도에 표시할 때 흔히 마주치는 문제로, 브라우저 성능 저하를 개선한 경험을 바탕으로 포스팅했습니다.
마커의 개수는 약 2000개로 마커를 렌더링할 때 발생하는 성능 문제를 해결했습니다.
레거시 시스템( PHP )에서는 15.3fps로 버벅이던 지도를 최적화 하여 55.7fps( 약 3.6배 향상 )로 부드럽게 동작하게 되었습니다.
🔍 레거시 시스템에서는 왜 지도 이동 시 프레임 드랍이 심했을까?

위 스크린샷은 전달 받은 PHP파일에서 직접 찾은 코드입니다.
kakao maps api에서 CustomOverlay매서드를 활용하여 content에 HTML을 직접 주입하여 마커를 사용하고 있습니다. 이렇게 구현할 경우 구현은 쉽게 끝나만, 치명적인 성능 문제가 발생합니다.
- 지도 이동/줌 시 매번 Reflow 발생
- 메인 스레드( CPU )에서 지속적으로 레이아웃 재계산
- 수백 ~ 1000개 이상의 마커의 Reflow로 인해 브라우저에 부하가 커지며 프레임 드랍이 발생하여 버벅임
💻 렌더링 파이프라인에 의한 부하
브라우저 렌더링은 다음 단계를 거쳐 화면에 표시됩니다.
- Layout ( Reflow ) - 요소의 위치/크기 계산
- Paint - 픽셀 그리기
- Composite - 레이어 합성 ( GPU 가속 )
모든 "HTML" 마커는 화면이 변경될 때 마다 1 ~ 3단계를 반복 실행합니다. 이 단계중 프레임드랍의 주된 원인으로 Layout 단계는 메인 스레드에서 CPU를 사용하여 실행되며, 단일 스레드 구조로 인해 병렬 연산에는 적합하지 않습니다.
메인 스레드는 한 번에 하나의 작업만 처리할 수 있기 때문에, 레이아웃 계산이나 복잡한 스크립트 실행 등으로 작업이 지연되면 다음 렌더링 단계가 대기하게 되어 버벅임(프레임 드랍)이 발생할 수 있습니다.
반면 3단계인 Composite( 합성 )만 실행하면 GPU 가속으로 인해 병렬 연산 효율이 높아져 많은 연산을 빠르게 병렬 처리하고, 높은 프레임을 유지 할 수 있게 됩니다!
🤔 지도를 축소 했을 때 마커를 다 보여 줄 필요가 있을까?
가장 먼저 사용자 입장에서 생각을 해보았습니다.
지도를 볼 때 축소하면 할수록 마커와 마커 사이의 간격이 줄어들고 겹쳐지며 점점 구분이 어려웠고 가려지는 마커는 존재만 할 뿐, 사실상 상호작용이 불가하여 원하는 컨텐츠를 확인 해 볼 수도 없었습니다.
그렇다면, 모든 마커를 화면에 보여주는 것 자체가 정말로 유의미한 구현 방향이었는가?
오히려 사용의 불편과 혼란을 야기하는 것이지 않을까?
스스로 떠올렸을 때는 아니라는 결론이 나왔습니다.
나만이 생각한 결론이 정답은 아니기 때문에 다양한 레퍼런스를 찾았지만, 역시나 찾아본 모든 지도 기반 서비스들이 존재하는 모든 마커를 전부 보여주는 식의 구현은 없었습니다.
지도 줌 레벨을 기준으로 특정한 범위로 그룹화하여 숫자로 표기하는 Clusterer 라는 기술을 사용하여 구현하고 있었습니다.
🍇클러스터러의 장점
- 화면에 그려지는 마커의 수가 감소하여 부하가 적어짐
- 줌 레벨에 따라 자동으로 마커 그룹화/해제
- 혼란성이 감소하고 지도가 깔끔해져 UX 향상
Clusterer 기능 추가에 대한 필요성을 제기하여 기획/디자인에도 긍정적으로 수용되었습니다.
📌 보고 있는 화면의 데이터만 가져오자! [ DB조회 최소화 ]
함께 해당 컨텐츠를 개발하기로 한 서버 개발자와 함께 기능 구현 논의를 할 때 모든 데이터를 가져오는 방식 보다는, 사용자가 보고 있는 지도 범위에 있는 데이터만 조회해서 내려주고 중복 데이터는 프론트엔드에서 캐싱하고 요청 시 excludes 리스트에 담아서 요청하면 해당 데이터를 제외하고 응답받으면 좋을 것 같다는 의견을 내어 긍정적으로 수용되었고, 개발을 진행하게 되었습니다.
개발을 진행할 때 "어떤 기준의 범위로 지정할 것인가?" 에 대해 고민한 결과, 다양한 디바이스를 고려하기 위해 map을 렌더링하는 Element의 width와 height를 비교하여 긴 변의 1/2 길이 반경의 데이터만 조회할 수 있도록 설계했습니다.
줌레벨에 따른 실거리 기준으로 m ➔ km 거리 단위로 변환하여 요청하는 것으로 논의를 마치고 코드를 작성하여 적용했습니다.
/**
* 두 좌표 사이의 거리 계산 (미터)
* @param {kakao.maps.LatLng} point1 - 첫 번째 좌표
* @param {kakao.maps.LatLng} point2 - 두 번째 좌표
* @returns {number} 두 좌표 사이의 거리 (미터)
*/
export const getDistanceBetween = (point1, point2) => {
// 임시 polyline을 만들어 거리 계산
const line = new window.kakao.maps.Polyline({
path: [point1, point2],
});
return line.getLength(); // 미터 단위 반환
};
/**
* 지도 중심점과 반경 계산 함수
* @param {kakao.maps.Map} map - 카카오 맵 인스턴스
* @returns {Object} 중심 좌표, 줌 레벨, 지도 크기, 반경 정보
* @returns {Object} return.center - 중심 좌표 {lat, lng}
* @returns {number} return.zoomLevel - 현재 줌 레벨
* @returns {Object} return.mapSize - 지도 크기 {width, height}
* @returns {number} return.radius - 반경 (미터)
* @returns {number} return.radiusKm - 반경 (킬로미터)
*/
export const calculateMapRadius = (map) => {
if (!map) return null;
// 1. 지도의 중심 좌표 가져오기
const center = map.getCenter();
const centerLat = center.getLat();
const centerLng = center.getLng();
// 2. 현재 줌 레벨
const zoomLevel = map.getLevel();
// 3. 지도의 영역(bounds) 가져오기
const bounds = map.getBounds();
// const swLatLng = bounds.getSouthWest(); // 남서쪽 좌표
const neLatLng = bounds.getNorthEast(); // 북동쪽 좌표
// 4. 지도 element의 크기 (픽셀)
const mapElement = map.getNode();
const mapWidth = mapElement.offsetWidth;
const mapHeight = mapElement.offsetHeight;
// 5. 중심점에서 각 방향 끝까지의 거리 계산 (m)
// 가로 거리: 중심에서 동쪽 끝까지
const eastPoint = new window.kakao.maps.LatLng(centerLat, neLatLng.getLng());
const horizontalDistance = getDistanceBetween(center, eastPoint);
// 세로 거리: 중심에서 북쪽 끝까지
const northPoint = new window.kakao.maps.LatLng(neLatLng.getLat(), centerLng);
const verticalDistance = getDistanceBetween(center, northPoint);
// 6. 긴 변의 1/2 거리 (= 반경)
const radius = Math.max(horizontalDistance, verticalDistance);
return {
center: { lat: centerLat, lng: centerLng },
zoomLevel,
mapSize: { width: mapWidth, height: mapHeight },
radius: radius,
radiusKm: radius / 1000,
};
};
테스트 결과 성공적으로 사용자가 보고 있는 범위내의 데이터만 조회 할 수 있었고, 중복데이터는 제외하고 조회하여 기존대비
최대 95%까지 통신자원을 절약할 수 있는 시스템을 구축할 수 있었습니다.
🎨 커스텀 마커를 GPU 가속을 활용한 Composite 렌더링으로 구현하기
HTML 대신 Canvas로 이미지를 생성하여 렌더링하는 방식을 떠올렸습니다.
구현해야 하는 커스텀 마커의 조건은 다음과 같습니다.
- [ 문제 제시 - 단건/다건 ] [ 문제 해결 - 단건/다건 ] 총 4가지 카테고리 이미지로 분기하여 커스텀 마커를 보여준다.
- 마커에 존재하는 게시글의 제목이 표기되어야하고 마커 이미지를 넘치지 않도록 말줄임표(..)로 생략한다.
- 동일 좌표에 여러 게시글이 있는 경우 최신글이 대표 제목으로 표기된다.
해당 조건을 만족하기 위해선 첫 번째로 좌표 기준으로 데이터를 그룹화하는 것이 중요했습니다.
// * 위치 맵 설정 Map setter
const setLocationMap = (positions) => {
const locationMap = new Map();
positions.forEach((pos) => {
// 가까운 경우 하나로 묶음 (소수점 6자리까지만 사용하여 아주 가까운 위치는 같은 것으로 취급)
const locationKey = `${pos.lat.toFixed(6)},${pos.lng.toFixed(6)}`;
locationMap.set(locationKey, pos);
});
return locationMap;
};
// * 그룹화된 마커 추가 함수
const addGroupedPositions = (groupedPositions, items, locationKey) => {
if (!groupedPositions?.length || !items?.length || !locationKey) {
return;
}
if (items.length === 1) {
return groupedPositions.push(items[0]);
}
const [lat, lng] = locationKey.split(",").map(Number);
const firstItem = items[0];
const groupId = `grouped_${locationKey}_${firstItem.type}`;
groupedPositions.push({
id: groupId, // 그룹 전용 고유 ID
lat,
lng,
type: firstItem.type, // QUESTION 또는 ANSWER 타입 유지
title: firstItem.title, // 첫번째 아이템의 타이틀 적용
countTag: items.length > 99 ? "99+" : `+${items.length}`, // 개수 텍스트 태그
originalItems: items, // 원본 아이템들을 저장
isGrouped: true, // 그룹화된 마커임을 표시
groupCount: items.length,
// 클릭 이벤트는 원본 아이템들의 정보를 전달
onClick: (pos, marker) => {
// 여러 아이템이 있으므로 적절한 처리 필요
console.log(`${items.length}개의 마커가 이 위치에 있습니다:`, items);
// 필요시 사용자 정의 처리 (예: 모달이나 팝업으로 리스트 표시)
if (firstItem.onClick) {
// 원본 아이템들 정보를 전달
firstItem.onClick({ ...pos, items }, marker);
}
},
});
};
/**
* 같은 위치의 마커들을 그룹화
* @param {Array} positions - 마커 위치 배열
* @returns {Array} 그룹화된 위치 배열
*/
const groupPositionsByLocation = (positions) => {
const locationMap = setLocationMap(positions);
// 그룹화된 결과를 새로운 마커 배열로 변환
const groupedPositions = [];
locationMap.forEach((items, locationKey) => {
addGroupedPositions(groupedPositions, items, locationKey);
});
return groupedPositions;
};
위처럼 함수를 구성하여 각 카테고리에 대응할 수 있고, 경도 / 위도 기준으로 데이터를 활용 가능하도록 그룹화 했습니다.
🐢 Canvas 생성도 느리다...
Canvas로 HTML을 이미지로 변환하는 과정도 리소스가 크기 때문에 한 번에 생성하면 브라우저가 순차적으로 작업을 처리하기 때문에 해당 작업을 처리하는 동안 렌더링 작업이 블로킹되어 프레임 드랍이 발생하는 문제가 생겼습니다.
"어떻게 이 문제를 해결 할 수 있을까?" 고민하다
Batch와 RAF( request animation frame )에 대한 아이디어를 떠올렸습니다!
⚙️ Batch & RAF 를 활용한 렌더링 최적화
렌더링 과부하 방지를 위해 마커 생성 작업을 50개 단위로 배치 처리했습니다.
각 배치 완료 후 requestAnimationFrame을 활용해 브라우저에게 렌더링 시간을 양보함으로써 메인 스레드 블로킹을 방지했고, 이를 통해 프레임 드랍을 최소화했습니다.
결과적으로 대량의 마커 생성 중에도 사용자가 지도를 끊김 없이 조작할 수 있게 되었습니다.
⚠️ 배치 처리 중 마커 중복 생성 이슈
추가적인 문제로 대량의 마커를 배치 처리하는 과정에서 동일한 마커가 중복으로 생성되는 문제가 간헐적으로 발생했습니다.
// 문제 발생 시나리오
배치 1: 마커 A, B, C 생성 중...
배치 2: 마커 C, D, E 생성 시작
↓
결과: 마커 C가 2번 생성!
🔍 중복 생성의 원인 탐색
배치 처리의 비동기 특성으로 인해 여러 배치가 동시에 실행되면서 발생하는 경합 상태(Race Condition)가 근본적인 원인이었습니다.
1. positions 업데이트 → useEffect #1 시작
2. useEffect #1 배치 1 처리 중 (await Promise.all 실행 중)
3. positions 재업데이트 → useEffect #2 시작
4. useEffect #2에서 existingIds 확인 시,
아직 배치 1의 마커가 markersRef에 추가되기 전!
5. 두 useEffect 모두 같은 마커를 "새 마커"로 판단
우선 이러한 문제를 보완하기 위해 3단계의 방어책을 구상하여 적용했습니다.
🛡️ 1차 방어 - 초기 필터링
// useEffect 시작 시점에서 중복 제거
const existingIds = new Set(markersRef.current.map((m) => m.id));
const newPositions = processedPositions.filter((pos) => !existingIds.has(pos.id));
if (newPositions.length === 0) {
console.log("추가할 새 마커가 없습니다.");
return;
}
위와 같이 이미 생성된 마커를 사전에 걸러내어 1차 보완했습니다.
🛡️🛡️ 2차 방어 - 배치별 실시간 체크
for (let i = 0; i < newPositions.length; i += BATCH_SIZE) {
// 각 배치 시작 시 최신 상태 확인
const currentExistingIds = new Set(markersRef.current.map((m) => m.id));
const filteredBatch = rawBatch.filter((pos) => {
if (currentExistingIds.has(pos.id)) {
console.log(`중복 ID 스킵: ${pos.id}`);
totalSkipped++;
return false;
}
return true;
});
if (filteredBatch.length === 0) {
console.log(`배치 ${batchNumber}: 모두 중복, 스킵`);
continue;
}
// ...
}
이전 배치에서 추가된 마커를 배치마다 재확인하여 2차 보완했습니다.
currentExistingIds를 배치 루프 안에서 매번 재생성하고 이전 배치에서 추가한 마커가 즉시 반영됩니다.
🛡️🛡️🛡️ 3차 방어 - 생성 후 최종 검증
const batchMarkers = await Promise.all(...); // 마커 생성 완료
const flattenedMarkers = batchMarkers.flat();
// 생성 완료 후 다시 한 번 중복 체크
const finalExistingIds = new Set(markersRef.current.map((m) => m.id));
const duplicateMarkers = [];
const uniqueMarkers = [];
flattenedMarkers.forEach((marker) => {
if (finalExistingIds.has(marker.id)) {
duplicateMarkers.push(marker);
console.log(`생성 후 중복 감지: ${marker.id}`);
} else {
uniqueMarkers.push(marker);
}
});
// 중복 마커 제거
if (duplicateMarkers.length > 0) {
try {
clusterer.removeMarkers(duplicateMarkers);
console.log(`중복 마커 ${duplicateMarkers.length}개 제거됨`);
} catch (error) {
console.warn("중복 마커 제거 중 오류 (무시 가능):", error);
}
}
// 고유한 마커만 추가
if (uniqueMarkers.length > 0) {
clusterer.addMarkers(uniqueMarkers);
markersRef.current = [...markersRef.current, ...uniqueMarkers];
}
마커 생성이 완료된 시점에서 최종 검증하여 동시성 문제로 생성된 중복 마커를 즉시 제거하는 안전망 역할을 합니다.
🤔 3차 방어를 구성한 이유는...
다음과 같은 세가지 이유에서 3중 방어책을 구현했습니다.
- 1차만 하는 경우: useEffect가 여러 번 실행될 수 있음
- 2차만 하는 경우 : 비동기 작업 완료 전 다음 배치 시작 가능
- 3차는 최후의 보루: 실제로 생성된 마커의 중복 제거
✅ 방어 성공!
결과적으로 안정적인 배치 처리 구현으로 중복 마커 생성 완전히 해결하고,
메모리 효율성이 향상되어 UX가 향상되었습니다.
🤔 데이터 요청이 너무 많은데? [ React-Query ]
데이터를 요청할 때 기본적으로 Axios만을 사용하고 있고, 캐싱 기능을 직접 구현해서 적용해야 했습니다.
근본적인 문제를 기술적으로 개선하기 위해 react-query 도입을 적극 추천하였고, 동료들과 논의 후 긍정적으로 수용되어 react-query 마이그레이션 작업을 유지보수 기획에 추가하여 점진적 마이그레이션을 진행하는 것으로 결정되었습니다.
해당 컨텐츠의 경우 지도를 조작할 때마다 데이터를 요청하기 때문에 요청이 매우 빈번하고 데이터도 많은 편에 속하기 때문에 통신 데이터 캐싱 기능이 필수불가결 핵심기능이었습니다.
때문에 해당 컨텐츠에는 react-query로 마이그레이션을 우선 진행했고, 요청하는 endpoint를 QueryKey로 지정하여 활용하였고, 평균 데이터 조회 수를 기존 대비 약 50% 이상 절감할 수 있었습니다.
🪢 마무리로 디바운싱
디바운싱 딜레이를 얼마나 주어야할 지에 대한 기준을 만들기 위해 동료들과 함께 지도를 직접 조작하며 사용자 입장에서 연계되는 조작 타이밍에 대해 분석하였고, 저희는 650ms 로 결정했습니다.
🤔 650ms로 결정하게 된 계기는?
그 이유는 너무 길게 잡아 지도 조작 간에 데이터를 한번도 받지 못했다면 조작을 멈추고 난 후에도
"어라? 왜 아무것도 안나오지?" 하며 잘못 인지 할 가능성도 있기 때문에 동료들에게 UX관점에서의 개선점을 제기하고 직접 QA를 진행하며 실사용 UX기반으로 논의하기 시작했습니다.
🐢조작을 느리게 하는 사람은 중간중간 컨텐츠 마커가 렌더링되어 인지 가능하고,
🐇조작이 빠른 사람은 너무 길지 않은 시간을 기다리면 마커를 볼 수 있는 적당한 시간
QA를 통해 약 600 ~ 800ms 정도로 의견이 좁혀졌고
650ms정도가 적당한 듯 하다는 의견으로 종합되어 결정하게 되었습니다.
🎡 로딩 스피너를 통한 사용자 인지력 강화 [ UX ]
서비스를 이용하는 일반 사용자 입장에서는 프로그램이 요구한 작업을 처리하는 동안 서비스 뒤에서 일어나는 프로그래밍 로직은 일절 인지 할 수가 없습니다.
때문에 사용자에게 현재 프로그램이 어떤 로직을 거치고 있는지 인지시켜주는 문구를 띄우고 처리중일 때는 로딩 스피너를 띄우는 등 좋은 서비스가 되기 위한 기본은 사용자 인지력이 좋은 서비스라고 생각합니다.
UX/DX 지향 개발자로서 사용자가 편리하게 서비스를 사용할 수 있도록 예측 가능한 방향성을 제공하고자 마커 데이터, 사이드바 데이터, 상세 모달 데이터 등 pending일 때와 Map에서 마커를 생성하는 배치작업을 진행중일 때 Map 중심에 로딩 스피너를 보여주도록 구현했습니다.
🐢 Map 로딩 스피너, 너도 느리구나...
표시할 마커의 총 개수가 약 2000개, 그 이상이 될 가능성도 있는 컨텐츠이기 때문에 연속적인 마커 생성을 유도하여 배치가 쌓여 순차적으로 작업을 처리하는 환경을 만들어 테스트를 진행해 보니 로딩 스피너에서 프레임 드랍이 발생했습니다.
다시 한번 고민에 빠지게 되었습니다...
가장 먼저 프레임 드랍의 주요 원인으로 유추되는 것은 크게 두가지 였습니다.
1. 마커 생성 과정에서 이미 1 frame단위로 작업을 진행하여 로딩 스피너의 동적 작업이 끼어들 틈이 없다.
2. 지도 조작 시 리렌더링 되는 지도 타일로 인한 추가 작업 발생
🖥️ 로딩 스피너의 렌더링 작업 제어권을 확보하기
우선 유추한 1번의 내용을 검증해보기 위해서 raf를 더블링/트리플/쿼드러플 ... 적용해 보았습니다.
결과는 생각보다 큰 효과를 가졌습니다.
하지만 raf내부에 raf -> raf -> raf ... 이런 형태로 콜백지옥이 형성되어 가독성이 떨어지고 보기에도 불편한(?) 문제도 있어 간결하게 재사용 하기 위해 "waitFrames" 라는 함수를 만들어 활용했습니다.
/**
* 지정된 프레임 수만큼 대기하여 브라우저에게 렌더링 여유 제공
* @param {number} frames - 대기할 프레임 수
* @returns {Promise<void>}
*/
export const waitFrames = (frames = 1) => {
return new Promise((resolve) => {
let count = 0;
const tick = () => {
count++;
if (count >= frames) {
resolve();
} else {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
});
};
배치 사이즈를 기존에 50만큼 clusterer 훅 내부에서 고정 값으로 처리 했었지만, props로 받아 제어 가능한 형태로 리팩터링하고 default value는150으로 늘렸습니다.
구성했던 waitFrames 유틸을 활용하여 5 frame 간격으로 설정하였고, Map 로딩 스피너가 제어권을 갖는 시간이 확보되어 눈에 띄게 부드러워졌습니다.
🖥️ Kakao Map Api 타일 렌더링 최적화?
kakao map api docs를 확인해 보니 tileAnimation 이라는 옵션이 있어 SDK를 사용할 때 boolean형으로 넣어주면 지도 타일 교체 시 애니메이션을 넣을지 말 지를 결정할 수 있는 옵션이 있어 false로 지정해주었더니 예상했던 것보다 프레임 드랍률이 많이 감소하여 타 애니메이션을 더 부드럽게 최적화할 수 있었습니다.
🎯 최적화의 선택과 집중
Map 로딩 스피너의 최적화를 위해 배치사이즈를 늘려, 한번에 보여 줄 마커의 개수가 150개로 늘어나 사용자가 마커를 확인하는 데에 까지 걸리는 시간이 조금 늘어나게 되었습니다.
하지만 로딩 스피너의 존재를 정의하자면 충분히 가능한 선택과 최적화였다고 생각했습니다.
데이터가 별로 없는데도 단순히 연속적인 작업이 추가되었다는 이유만으로 로딩스피너가 버벅이면 사용자 입장에서는 "왜 이렇게 느리지?" 라는 생각을 할 수도 있기 때문에 UX를 고려했을 때 합리적이라고 판단되어 해당 작업을 선택하고 최적화 작업은 마무리 했습니다.
🚩 선택한 마커 포커스 효과 만들기
사용자가 선택한 마커를 명확하게 인지할 수 있도록 지도에서 마커를 클릭하면 해당 마커만 포커스되도록 구현하고자 했습니다.
하지만 Kakao Map API에서는 지도 요소 내부에 직접 오버레이를 넣어 마커의 z-index를 핸들링하며 포커싱할 수 있는 방법을 끝내 찾아내지 못했습니다😭...
그래서 떠올린 아이디어가 바로 "선택한 마커를 복제해서 오버레이 위에 띄우면 되지 않을까?" 였고, 이를 적용하여 기획상의 기능을 구현했습니다.
💡 핵심 아이디어
- 마커 클릭 시 지도를 어둡게 하는 오버레이를 표시
- 클릭한 마커의 이미지 정보를 추출하여 복제
- 복제된 마커를 오버레이보다 높은 z-index로 화면 중앙에 표시
🛠️ 오버레이 표시 여부와 복제된 마커 정보를 관리하는 커스텀 훅입니다.
export const useMarkerOverlay = (map, mapRef) => {
const [isShowMapOverlay, setIsShowMapOverlay] = useState(false);
const [clonedMarkerElement, setClonedMarkerElement] = useState(null);
const clickedMarkerRef = useRef(null);
// 오버레이 초기화 시 마커 zIndex 복구
const resetOverlay = useCallback(() => {
if (clickedMarkerRef.current) {
clickedMarkerRef.current.setZIndex(MARKER_CONFIG.Z_INDEX.default);
clickedMarkerRef.current = null;
}
setClonedMarkerElement(null);
}, []);
// 오버레이가 닫힐 때 정리 작업
useEffect(() => {
if (!isShowMapOverlay) {
resetOverlay();
// 쿼리스트링 정리
if (query.get("lat") || query.get("lng")) {
query.removeAll(["lat", "lng"], true);
}
}
}, [isShowMapOverlay]);
// 줌 시작 시 오버레이 자동 닫기
useEffect(() => {
if (!map) return;
const handleZoomStartEvent = () => setIsShowMapOverlay(false);
window.kakao.maps.event.addListener(map, "zoom_start", handleZoomStartEvent);
// ... cleanup
}, [map]);
return {
isShowMapOverlay,
setIsShowMapOverlay,
clonedMarkerElement,
setClonedMarkerElement,
clickedMarkerRef,
};
};
📌 핵심 포인트
- 오버레이 상태와 복제 마커 상태를 함께 관리
- 줌 이벤트 발생 시 자동으로 오버레이 닫기
- 엣지케이스를 위한 안전장치( 지도 잠김 해제 )
🥹 마커 복제 요소 좌표가 일치하지 않는다
포커싱 효과를 얻긴 했지만, 사실상 원본 마커와 복제된 마커의 좌표가 일치하지 않아 포커스된 마커를 보면 미세하게 겹쳐보이는 현상이 있었고, 별도로 포지션을 약 1px만 보정하는 방식으로 해보았지만 여전히 미세하게 맞지 않았고 해상도와 PPI에 따라 다시 달라지는 문제가 있어, 근본적으로 해결 가능한 방법을 모색해야 했습니다.
공통 사항으로 포커스된 복제 요소는 원본마커와 최대한 유사하게 복제되어 정중앙에 나타난다는 것을 고려했을 때 어차피 포커스 요소인 복제 마커가 핵심 요소라면 원본 마커의 opacity를 0으로 변경하여 보이지 않도록 하고 복제 마커만을 보여주는 아이디어를 떠올렸습니다.
kakao map docs를 확인해보니 마커의 투명도를 변경할 수 있는 setOpacity매서드가 있어 매서드를 활용하여 구현했지만, 복제 마커의 render phase( 계산/결정 과정 )에서 실행하기 때문에 포커스효과가 나타나기 전 아주 잠시 깜빡임이 생기는 문제가 있었고, raf를 활용하여 1 frame을 진행시킨 후 원본 마커의 투명도를 0으로 변경하여 성공적으로 구현할 수 있었습니다.
커스텀 마커 이벤트 훅
export const useMarkerClickHandler = ({
markerPositions,
map,
lockMap,
unlockMap,
clickedMarkerRef,
setIsShowMapOverlay,
setClonedMarkerElement,
}) => {
// ...
/**
* 마커 투명도 설정 헬퍼 함수
* @param {Object} marker - 카카오맵 마커 객체
* @param {number} opacity - 투명도 (0~1)
*/
const setMarkerOpacity = (marker, opacity) => {
if (!marker) return;
try {
// 투명도 설정 메서드 호출
marker.setOpacity(opacity);
console.log(`✅ 마커 투명도 설정 (setOpacity): ${opacity}`);
} catch (error) {
console.error("❌ 마커 투명도 설정 오류:", error);
}
};
// ...
const markerPositionsWithClick = useMemo(() => {
return markerPositions.map((item) => {
// 이미 onClick이 정의되어 있으면 그대로 반환
if (item.onClick) return item;
return {
...item,
onClick: (pos, marker) => {
// ...
// 이전에 클릭된 마커가 있고, 다른 마커를 클릭한 경우
if (clickedMarkerRef.current && clickedMarkerRef.current !== marker) {
// 이전 마커의 zIndex 복구
if (clickedMarkerRef.current.setZIndex) {
clickedMarkerRef.current.setZIndex(MARKER_CONFIG.Z_INDEX.default);
console.log("이전 마커 zIndex 복구:", MARKER_CONFIG.Z_INDEX.default);
}
// 이전 마커의 투명도 복구
setMarkerOpacity(clickedMarkerRef.current, 1);
}
// ...
setIsShowMapOverlay(true);
// * step1 - 복제마커를 생성하는 1 frame 이 지난 후
requestAnimationFrame(() => {
// * step2 - 원본 마커를 투명하게 설정
setMarkerOpacity(marker, 0);
});
// ...
}
});
}, [
// ...deps
]);
return markerPositionsWithClick
}
Map 커스텀 오버레이 핸들 훅
export const useMarkerOverlay = (map, mapRef) => {
const [isShowMapOverlay, setIsShowMapOverlay] = useState(false);
const [clonedMarkerElement, setClonedMarkerElement] = useState(null);
const clickedMarkerRef = useRef(null);
// 오버레이 초기화 시 마커 zIndex / opacity 복구
const resetOverlay = useCallback(() => {
if (clickedMarkerRef.current) {
// 투명도를 1로 되돌림
clickedMarkerRef.current.setOpacity(1);
clickedMarkerRef.current.setZIndex(MARKER_CONFIG.Z_INDEX.default);
clickedMarkerRef.current = null;
}
setClonedMarkerElement(null);
}, []);
// ...
};
🔐지도 조작 Lock / Unlock, 방어적 프로그래밍
지도에 마커를 클릭 시 포커스 효과를 부여했고 오버레이까지 구현했지만
복합적인 경합상태가 발생할 수 있어, 포커스 시 Map 조작을 Lock/Unlock을 통해 강제해야 경합상태로 인한 수 많은 코너케이스 발생과 통신 자원 낭비를 최소화 할 수 있었습니다.
- 마커 클릭과 동시에 Map Lock
- idle 이벤트를 통해 panTo 이벤트가 끝난 후 Map Unlock
그럼에도 불구하고 커스텀 오버레이가 사라지고 나서도 지도가 Lock되어 조작을 하지 못하는 엣지케이스가 발생했고 경합상태로 인한 엣지케이스로 확인되어 방어적 프로그래밍으로 안전장치를 추가하여 대응했습니다.
Map 강제 Lock 대응 안전 장치 추가
export const useMarkerOverlay = (map, mapRef) => {
// ...
// 엣지케이스: 지도 잠김 상태 강제 해제
useEffect(() => {
if (!isShowMapOverlay && isWindowRef.current) {
const safetyUnlockTimer = setTimeout(() => {
// 1초 후에도 지도가 잠겨있으면 강제 unlock
if (mapRef.current) {
const isDraggable = map.getDraggable();
const isZoomable = map.getZoomable();
if (!isDraggable || !isZoomable) {
map.setDraggable(true);
map.setZoomable(true);
}
}
}, 1000);
return () => clearTimeout(safetyUnlockTimer);
}
}, [isShowMapOverlay, map, mapRef]);
return {
isShowMapOverlay,
setIsShowMapOverlay,
clonedMarkerElement,
setClonedMarkerElement,
clickedMarkerRef,
};
};
📝 성능 측정 해보기
사용중 프레임을 직접적으로 확인하기 위해 AI의 도움을 받아 테스팅 코드를 작성하여 측정을 진행하기로 했습니다.
측정 도구
const framePerformanceMonitor = {
frames: [],
lastTime: null,
rafId: null,
startTime: null,
stopTimer: null,
start(durationSeconds = 10) {
// ✅ 이전 측정이 있으면 먼저 정리
this.stop();
// 초기화
this.frames = [];
this.lastTime = performance.now();
this.startTime = performance.now();
// 측정 시작
const measure = () => {
const currentTime = performance.now();
const frameDuration = currentTime - this.lastTime;
this.frames.push(frameDuration);
this.lastTime = currentTime;
this.rafId = requestAnimationFrame(measure);
};
this.rafId = requestAnimationFrame(measure);
console.log(`🎬 측정 시작 (${durationSeconds}초간 측정)`);
// 지정된 시간 후 자동 종료 및 리포트
this.stopTimer = setTimeout(() => {
this.stop();
this.report();
}, durationSeconds * 1000);
},
stop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.stopTimer) {
clearTimeout(this.stopTimer);
this.stopTimer = null;
}
},
report() {
if (this.frames.length === 0) {
console.log('⚠️ 측정된 데이터가 없습니다.');
return;
}
const totalFrames = this.frames.length;
const testDuration = ((performance.now() - this.startTime) / 1000).toFixed(2);
// 평균 프레임 시간 계산
const avgFrameTime = (
this.frames.reduce((sum, duration) => sum + duration, 0) / totalFrames
).toFixed(2);
// 평균 FPS 계산
const avgFPS = (1000 / avgFrameTime).toFixed(1);
console.log(`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 프레임 성능 측정 결과
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
테스트 시간: ${testDuration}초
총 프레임: ${totalFrames}개
평균 FPS: ${avgFPS}
평균 프레임 시간: ${avgFrameTime}ms
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`);
return {
testDuration: parseFloat(testDuration),
totalFrames,
avgFPS: parseFloat(avgFPS),
avgFrameTime: parseFloat(avgFrameTime)
};
}
};
console.log(`
🎯 간편한 사용법:
framePerformanceMonitor.start(30) - 30초 측정 후 자동 리포트
framePerformanceMonitor.start(10) - 10초 측정 후 자동 리포트
framePerformanceMonitor.start() - 기본 10초 측정
`);
위 코드를 사이트 콘솔에 붙여넣고
framePerformanceMonitor.start(30) 를 실행하여
레거시 사이트에서 프레임 드랍이 일어나는 상황을 지속 연출하는 방식으로 각 사이트를 30초간 테스트 해보았습니다.


📊 성능 테스트 결과
| 항목 | 최적화 효율 | 향상 배수 |
| FPS 향상 | 72.5% | 3.6배 더 부드러운 애니메이션 |
| 반응 속도 효율 | 72.6% | 3.6배 빠른 프레임 처리 |
| 프레임 생성 | 71.6% | 3.5배 많은 프레임 렌더링 |
* 정확한 계산 수치가 아닌 근사치로 작성하였습니다.
📌UX 개선 요약
- 15.3fps → 55.7fps ( 약 3.6배 향상 )
- 버벅임 구간을 부드러운 구간으로 전환
- 반응 속도 72.6% 개선으로 즉각적인 피드백
✍🏻 마치며...
처음으로 kakao map SDK를 통해 약 2000개의 실 데이터 기반 커스텀 마커로 지도의 프론트엔드를 구현하고 다뤄보니 다시금 기술에 대한 딥다이브의 중요성을 느끼게 되었습니다.
만약 기존에 쌓아온 프론트엔드 지식이 없었다면 최적화하는 데에 아이디어를 떠올리기 쉽지않았을거고, 더 많은 시간과 시행착오를 겪었을 것 같습니다.
특히 Promise를 활용한 batch, request animation frame를 활용한 화면 최적화 경험을 통해 프론트엔드 기술에 대한 이해도가 한 층 더 깊어진 것 같아 성과있는 경험이었습니다.
앞으로도 더 많은 기술을 대용량 데이터 기반으로 다뤄 볼 수 있는 기회가 찾아온다면 더 나은 사고력과 이해도를 기반으로 더 나은 가치를 위해서 개발해보고 싶습니다!
'Dev Logs' 카테고리의 다른 글
| [ React + Vite & NginX: Gzip Compression ] 번들 사이즈 최적화로 UX향상 시키기 (2) | 2025.11.22 |
|---|---|
| [ React: PDF Generator ] 맞춤형 PDF 렌더링 성능 최적화 (2) | 2025.11.20 |
| [ Python: Async HTTP ] 사내 LLM 챗봇 서버 비동기 전환을 통한 요청 취소 및 논블로킹 구조 개선기 (3) | 2025.11.15 |
| [ Three.js Camera handling ] Map화면 이탈 현상 막기 (0) | 2025.11.14 |
| [ openapi-generator-cli ] 사내 API 생성 자동화 도입기 (3) | 2025.11.13 |




















