string
을 받기 때문에, 정보 창 컴포넌트에 이벤트 핸들러를 설정한 버튼을 추가해도 제대로 동작하지 않았다.interface MarkerInfoWindowProps {
FNAME: string;
ANAME: string;
jibunAddress: string;
roadAddress: string;
}
export function MarkerInfoWindow({
FNAME,
ANAME,
jibunAddress,
roadAddress,
}: MarkerInfoWindowProps) {
return (
<div className='flex flex-col gap-y-1.5 whitespace-nowrap rounded-md border border-gray-200 bg-white px-5 py-4 shadow-[0_4px_16px_0_rgba(0,0,0,0.1)]'>
<div className='flex items-center gap-x-2'>
<p className='text-lg font-bold'>{FNAME}</p>
<p className='text-sm font-medium text-gray-500'>{ANAME}</p>
</div>
{jibunAddress && (
<div className='text-sm font-medium'>
<span className='mr-1 rounded border border-gray-400 px-1 py-0.5 text-xs font-semibold text-gray-700'>
지번
</span>
{jibunAddress}
</div>
)}
{roadAddress && <div>(도로명) {roadAddress}</div>}
<button
onClick={() => console.log('click')} // 버튼에 클릭 이벤트 핸들러 설정
className='mt-2 rounded bg-blue-500 px-2 py-1 text-white'
>
클릭
</button>
</div>
);
}
// ...
toilets.forEach((toilet) => {
const { Y_WGS84, X_WGS84, POI_ID, FNAME, ANAME } = toilet;
const marker = new naver.maps.Marker({
position: new naver.maps.LatLng(Y_WGS84, X_WGS84),
icon: {
url: POI_ID === closestToilet.POI_ID ? '/closetToilet.png' : '/aroundToilet.png',
size: new naver.maps.Size(35, 35),
scaledSize: new naver.maps.Size(35, 35),
},
});
const infoWindow = new naver.maps.InfoWindow({
// content에 정보 창 컴포넌트 전달
content: renderToStaticMarkup(
<MarkerInfoWindow FNAME={FNAME} ANAME={ANAME} jibunAddress='' roadAddress='' />,
),
anchorSize: {
width: 12,
height: 14,
},
backgroundColor: 'transparent',
borderColor: 'transparent',
});
naver.maps.Event.addListener(marker, 'click', () => {
if (infoWindow.getMap()) {
infoWindow.close();
} else {
naver.maps.Service.reverseGeocode(
{
coords: new naver.maps.LatLng(Y_WGS84, X_WGS84),
},
(status, response) => {
if (status === naver.maps.Service.Status.OK) {
const { jibunAddress, roadAddress } = response.v2.address;
const updatedContent = renderToStaticMarkup(
<MarkerInfoWindow
FNAME={FNAME}
ANAME={ANAME}
jibunAddress={jibunAddress}
roadAddress={roadAddress}
/>,
);
// 주소를 추가한 정보 창으로 content 재설정
infoWindow.setContent(updatedContent);
}
},
);
infoWindow.open(mapRef.current, marker);
}
});
});
// ...
참고
MarkerInfoWindow(정보 창) 컴포넌트를 HTML 문자열 형태로 Infowindow의 content에 전달하면 이벤트 핸들러가 작동하지 않으므로, 대신 빈 div 태그를 content로 전달하고, 사용자가 마커를 클릭할 때 해당 div 내부에 MarkerInfoWindow 컴포넌트를 렌더링하는 방법을 적용해보기로 했다. 이렇게 하면 Infowindow에 전달되는 것은 HTML 문자열 형태의 빈 div 태그뿐이며, 브라우저에서는 createRoot를 사용해 해당 DOM 노드에 대한 루트를 생성한 후, root.render를 호출하여 MarkerInfoWindow 컴포넌트를 동적으로 렌더링한다. React는 이 루트를 통해 DOM을 관리하기 때문에 이벤트 핸들러가 동작 할 것이라고 생각했다.
이 방법으로 이벤트 핸들러는 정상적으로 동작했지만, 최초로 정보창을 열 때는 스타일이 제대로 적용되지 않은 상태로 렌더링되었다가, 정보창을 닫고 다시 열면 그때부터는 CSS가 정상 반영되었다.
처음 열리는 시점에 InfoWindow가 아직 React 렌더링, Tailwind 스타일이 100% 완료되지 않은 DOM을 기반으로 레이아웃을 잡아버려, 즉시 스타일이 적용되지 않고 있었다.
toilets.forEach((toilet) => {
const { Y_WGS84, X_WGS84, POI_ID, FNAME, ANAME } = toilet;
const marker = new naver.maps.Marker({
position: new naver.maps.LatLng(Y_WGS84, X_WGS84),
icon: {
url: POI_ID === closestToilet.POI_ID ? '/closetToilet.png' : '/aroundToilet.png',
size: new naver.maps.Size(35, 35),
scaledSize: new naver.maps.Size(35, 35),
},
});
markers.push(marker);
// 1) InfoWindow에 사용할 빈 컨테이너를 만들고
const container = document.createElement('div');
// 2) ReactDOM.createRoot(container)를 통해 이 컨테이너에 React 컴포넌트를 렌더링할 수 있는 Root 생성
const root = ReactDOM.createRoot(container);
// 3) InfoWindow 생성 시 content는 빈 div 태그로 지정 후 마커를 클릭하여
// 정보 창 오픈 시 정보 창 컴포넌트를 렌더링 하는 방식
const infoWindow = new naver.maps.InfoWindow({
content: container,
anchorSize: {
width: 12,
height: 14,
},
backgroundColor: 'transparent',
borderColor: 'transparent',
});
naver.maps.Event.addListener(marker, 'click', () => {
if (infoWindow.getMap()) {
infoWindow.close();
} else {
naver.maps.Service.reverseGeocode(
{
coords: new naver.maps.LatLng(Y_WGS84, X_WGS84),
},
(status, response) => {
if (status === naver.maps.Service.Status.OK && mapRef.current) {
const { jibunAddress, roadAddress } = response.v2.address;
// 5) 컴포넌트 렌더링
root.render(
<MarkerInfoWindow
FNAME={FNAME}
ANAME={ANAME}
jibunAddress={jibunAddress}
roadAddress={roadAddress}
/>,
);
}
},
);
if (mapRef.current) infoWindow.open(mapRef.current, marker);
}
});
});
참고
flushSync를 사용하면 React의 모든 업데이트와 DOM 반영이 완전히 끝날 때까지 동기적으로 처리된다. 이 덕분에, infoWindow.open()이 실행되는 시점에는 이미 React DOM 렌더링과 Tailwind 스타일 적용이 완료된 상태가 된다. 그래서 첫 번째 클릭부터 스타일이 깨지지 않고 정상적으로 InfoWindow가 보이게 된다.
기존에는 reverseGeocode 안에서 주소 정보를 가져온 뒤, 그 외부에서 infoWindow.open()을 호출하고 있었다. 이 경우 React 렌더링이 아직 끝나지 않은 상태에서 InfoWindow가 먼저 표시되어, CSS가 적용되지 않은 레이아웃이 잠깐 노출될 수 있었다. 그러나 flushSync와 함께 reverseGeocode 콜백 내부에서 렌더링과 infoWindow.open()을 순차적으로 처리하도록 코드를 수정하니, React 렌더링이 완료된 다음에 InfoWindow가 열리도록 구조가 바뀌었다.
즉, 기존 로직에선 마커 클릭 → 즉시 infoWindow.open() 실행이라는 흐름 때문에, 아직 React DOM 업데이트와 Tailwind 스타일이 적용되지 않은 상태로 InfoWindow가 열려 잠깐 스타일이 깨져 보이는 문제가 있었다.
그러나 수정 후에는 flushSync가 먼저 React 렌더링을 모두 끝낸 뒤, 그다음에 infoWindow.open()을 호출하도록 순서를 조정했다. 이 덕분에 첫 번째 클릭 시점부터 이미 완전히 렌더링된 DOM(컨테이너)이 사용자에게 표시되므로, 스타일이 정상 적용된 InfoWindow가 곧바로 보이게 된 것이다.
toilets.forEach((toilet) => {
const { Y_WGS84, X_WGS84, POI_ID, FNAME, ANAME } = toilet;
const marker = new naver.maps.Marker({
position: new naver.maps.LatLng(Y_WGS84, X_WGS84),
icon: {
url: POI_ID === closestToilet.POI_ID ? '/closetToilet.png' : '/aroundToilet.png',
size: new naver.maps.Size(35, 35),
scaledSize: new naver.maps.Size(35, 35),
},
});
markers.push(marker);
const container = document.createElement('div');
const root = ReactDOM.createRoot(container);
const infoWindow = new naver.maps.InfoWindow({
content: container,
anchorSize: {
width: 12,
height: 14,
},
backgroundColor: 'transparent',
borderColor: 'transparent',
});
naver.maps.Event.addListener(marker, 'click', () => {
if (infoWindow.getMap()) {
infoWindow.close();
} else {
naver.maps.Service.reverseGeocode(
{
coords: new naver.maps.LatLng(Y_WGS84, X_WGS84),
},
(status, response) => {
if (status === naver.maps.Service.Status.OK && mapRef.current) {
const { jibunAddress, roadAddress } = response.v2.address;
flushSync(() => {
root.render(
<MarkerInfoWindow
FNAME={FNAME}
ANAME={ANAME}
jibunAddress={jibunAddress}
roadAddress={roadAddress}
/>,
);
});
infoWindow.open(mapRef.current, marker);
}
},
);
}
});
});
참고