위 이미지는 현재까지 구현한 지도의 모습이다. 구현된 기능은 다음과 같다.
- 충전소 정보를 서버에 요청해 받아온 충전소 정보를 바탕으로 화면에 마커를 표시하는 기능
- 화면이 이동하거나 줌인, 줌 아웃을 할 시 화면의 마커 정보가 최신화 되는 기능
- 마커 정보를 최신화 할 때 화면에서 사라진 마커를 dom에서 제거하는 기능
- 마커 정보를 최신화 할 때 이전 화면에서도 있었던 마커를 재생성 하지 않는 기능
- 마커를 클릭했을 시 해당 마커에 대한 간단 정보를 모달로 띄워주는 기능
- 화면에 표시된 마커들에 대한 충전소 정보를 리스트로 보여주는 기능
이번에 새로 추가하고자 한 기능은 다음과 같다.
- 충전소 리스트에서 충전소를 선택하면 화면의 중심이 선택한 충전소 마커로 이동하고, 충전소의 간단 정보를 모달로 띄워주는 기능
위 기능을 구현하기 위해선 google maps api의 InfoWindow객체를 이용해야 한다. 사용 방식은 다음과 같다.
const infowindow = new google.maps.InfoWindow({
content: contentString,
ariaLabel: 'Uluru',
});
const marker = new google.maps.Marker({
position: uluru,
map,
title: 'Uluru (Ayers Rock)',
});
infowindow.open({
anchor: marker,
map,
});
간단하게 요약하자면 다음과 같다.
InfoWindow
생성자 함수를 통해infoWindow
인스턴스를 생성한다.- 생성시 dom 요소 혹은 string을 전달해
infoWindow
가 생성될 dom위치를 지정해준다.
- 생성시 dom 요소 혹은 string을 전달해
marker
인스턴스를infoWindow
인스턴스의open
메서드에 인자로 전달한다.infoWindow
생성 시 전달했던 dom요소의 위치가marker
의 위치로 고정되면서 화면에 그려진다.
충전소 정보를 보여주는 위 StationList
컴포넌트는 충전소 정보에 접근할 때 react-query를 통해 서버 상태를 직접 내려 받아 컴포넌트 내부 리스트를 렌더링 한다.
또한, StationMarkersContainer
에서도 충전소 정보를 react-query의 서버 상태에서 참조해 마커를 렌더링 하고 있다.
따라서 StationList
컴포넌트와 StationMarkersContainer
는 각각 따로 서버 상태에 접근해 렌더링을 수행하고 있으므로 둘 사이에는 어떠한 연결 고리가 없다.
여기서 문제가 발생하게 되었다.
현재까지의 코드에서는 infoWindow
인스턴스를 StationMarkersContainer
컴포넌트에서 생성한다. 이를 하위 컴포넌트인 StationMarker
에 내려주고, 이 컴포넌트 내부에서 marker
인스턴스를 생성한다.
이번에 구현하기로 한 기능은 StationList
의 항목 중 하나를 선택했을 시 선택된 충전소에 해당하는 마커에 간단 정보 모달이 뜨며 화면을 해당 마커가 중심으로 오도록 이동 시키는 것이었다.
하지만 지금의 코드 구조상 StationList
와 StationMarkersContainer
사이에는 어떠한 연결 고리도 없으므로 infoWindow
와 marker
에 StationList
는 접근할 수 없는 상태가 된다.
이를 해결하기 위해서 다음과 같은 방법을 사용하기로 했다.
infoWindow
인스턴스를 root 단에서 생성해 전역적으로 관리한다.- 생성될
marker
인스턴스들을 배열 형태의 전역 상태로 관리한다.
위 내용을 말로만 본다면 별로 어려울 것 없어 보이지만 실제 구현을 진행해보니 내부적으로 큰 문제가 두 가지 존재했다.
- 따로 모듈을 분리해
infoWindow
를 생성할 수 없다. marker
인스턴스를 생성하는 주체가StationMarkersContainer
가 되어서는 안된다.
각각의 문제점을 살펴보자.
1. 따로 모듈을 분리해 infoWindow
를 생성할 수 없다.
infoWinodw
를 전역 상태로 만들어 사용하기 위해 처음으로 했던 생각은 infoWindowStore.ts
로 모듈을 분리하여 infoWindow
를 생성해 store의 초기값으로 지정하는 것이었다.
위 생각을 가지고 그대로 구현해보았더니 google
을 참조할 수 없다는 에러가 발생했다. InfoWindow
생성자 함수는 google.maps.InfoWindow
를 통해 접근할 수 있기 때문에 해당 에러는 infoWindow
인스턴스를 생성할 수 없다는 것을 의미했다.
왜 google
을 참조할 수 없는지 이유를 분석해보니 이유는 다음과 같았다.
우리 팀이 구글 지도 로드를 위해 선택한 라이브러리는 @googlemaps/react-wrapper
이다. 이 라이브러리의 동작을 살펴보면 다음과 같다.
Wrapper
컴포넌트가@googlemaps/js-loader
라이브러리의Loader
생성자 함수를 호출한다.- 생성된
loader
인스턴스의load
메서드를 실행시켜 지도의 로딩 작업을 시작한다.load
메서드는 최종적으로Promise<typeof google>
을 반환하는데, 지도 로드에 성공하면resolve(window.google)
을 실행시켜google
을 전역적으로 사용 가능하도록 만들어준다.
- 지도의 로딩이 완료되면
Wrapper
의render
props를 통해 받은 콜백 함수를 실행시킨다.render
콜백 함수는 로딩 상태를 나타내는 Status를 파라미터로 넘겨 받아 호출된다.
최종적으로 render
를 실행 시켰을 때 반환 되는 컴포넌트에서는 google
로딩 되어 전역적으로 접근이 가능함을 보장할 수 있으므로 이때부터 google
에 접근이 가능해진다. → 따라서 Wrapper
를 통해 반환되는 컴포넌트의 하위 컴포넌트에서 google.maps.Map
생성자 함수를 사용해 지도를 생성할 수 있게 된다.
infoWindow
를 생성하기 위해 만든 새로운 모듈은 첫 import
시기에 평가될 것이기 때문에 Wrapper
의 하위 컴포넌트에서 import
를 수행한다면 로드가 완료된 이후 시점일 것이므로 window.google
이 등록되어 google
에 접근이 가능할 것으로 예상했다.
하지만 웹팩을 통한 번들링 과정에서 모듈이 뒤섞여 파일의 평가 시기를 보장할 수 없어져 새로 만든 모듈에서는 google
에 대한 접근이 불가능해지게 되었다. 웹팩을 좀 더 공부해본다면 이 문제를 해결할 수 있을 것 같았지만, 너무 지엽적인 부분에서 많은 시간을 들이기 보단 기존에 개발하던 방식을 통해 문제를 해결해보기로 결정했다.
최종적으로 문제를 해결한 방식은 다음과 같다.
InfoWindow
생성자 함수를 호출할CarFfeineInfoWindowInitializer
컴포넌트를 만든다.Wrapper
로 감싸진 컴포넌트 하위에CarFfeineInfoWindowInitializer
컴포넌트를 추가한다.google
에 접근이 가능한 상태를 보장받은CarFfeineInfoWindowInitializer
내부에서infoWindow
인스턴스를 생성한다.store
에infoWindow
인스턴스를set
해주어 전역적으로infoWindow
를 사용 가능하도록 한다.
2. marker
인스턴스를 생성하는 주체가 StationMarkersContainer
가 되어서는 안된다.
이번 팀 프로젝트에서 지도를 구현하기 위해 google maps api를 사용하게 되었다. 뜬금없이 이 이야기를 한 이유는 다음과 같다.
- google maps api는 바닐라 자바스크립트를 기반으로 동작한다.
- 이번 팀 프로젝트는 리액트를 기반으로 개발을 진행할 것이다.
- 지도를 그리기 위해서 바닐라 자바스크립트와 리액트의 적절한 조화가 필요하다.
- 다소 혼란스러울 수 있는 지도의 조작 방식을 리액트와 조화롭게 사용하기 위해서 컴포넌트 설계시 컴포넌트의 책임을 확실하게 구분해야겠다는 생각을 하게 되었다.
이 컴포넌트의 책임에 대한 문제로 인해 marker
인스턴스를 생성하는 주체에 대해 많은 고민을 하게 되었다.
일단 원래 코드 구조에서 마커를 그리기 위해 컴포넌트를 다음과 같이 추상화 했다.
StationMarkersContainer
컴포넌트- 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로
StationMarker
를 호출한다.
- 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로
StationMarker
컴포넌트- 상위에서 내려받은 충전소 정보 props를 통해
marker
인스턴스를 생성한다. (google maps api에서는 인스턴스 생성이 곧 렌더링을 의미한다) - 생성한
marker
인스턴스에infoWindow
인스턴스의open
메서드를 트리거 하는 클릭 이벤트 리스너를 추가해준다. useEffect
의 클린업 함수를 이용해 충전소 정보가 최신화 되었을 때 마커가 더이상 화면에 보이지 않는다면marker
인스턴스의setMap(null)
메서드를 호출해 google maps api에서 마커를 지우도록 한다. (마커 렌더링 최적화)
- 상위에서 내려받은 충전소 정보 props를 통해
간략히 설명하자면 StationMarkersContainer
컴포넌트는 충전소 정보를 서버에서 받아 StationMarker
를 호출하는 역할만을 수행하고, 마커에 대한 모든 세부 로직은 StationMarker
가 수행하도록 컴포넌트를 추상화 해보았다.
이름에서도 드러나듯 StationMarker
컴포넌트가 marker
인스턴스를 생성하는 주체가 되어야 바닐라 자바스크립트와 리액트의 혼종인 이 프로젝트의 코드를 추후 유지보수 할 때 문제가 없으리라 판단했다.
하지만 이렇게 추상화 된 컴포넌트들은 marker
인스턴스를 배열 형식의 전역 상태에 담아 관리하고자 할 때 문제가 되었다.
일단 먼저 서버에서 내려 받은 충전소 정보를 station
이라고 하자, 우리는 이 station
을 통해 marker
인스턴스를 생성하고자 한다.
이때 생각 할 수 있는 가장 간단한 방법은 station
에서 map
메서드를 통해 marker
인스턴스를 생성하여 이 marker
인스턴스를 하위 컴포넌트인 StationMarker
에 넘겨주는 방식일 것이다.
하지만 이 방식은 인스턴스를 생성하는 것이 곧 화면에 렌더링을 발생시키는 것을 의미하는 google maps api의 특성상 우리가 처음 설계한 컴포넌트의 책임을 반하는 구조를 만들어내게 된다.
자세히 설명해보자면 마커의 렌더링은 StationMarkersContainer
가 수행하고 있는데 화면에 보이지 않는 마커를 지우는 역할은 StationMarker
컴포넌트가 수행하고 있고, 이벤트 핸들러의 추가 역시 마커가 생성된 이후에 하위 컴포넌트에서 이를 수행하는 괴상한 코드가 만들어지게 된다.
추후 코드의 유지보수성을 위해선 피해야 할 방식임이 명확했다.
해결 방식을 고민해보다가 다음과 같은 해결 방안을 생각하게 되었다.
StationMarker
컴포넌트의 역할
marker
인스턴스를 생성한다.marker
인스턴스의 이벤트 핸들러를 추가한다.- 생성된
marker
인스턴스를 배열 형식의 전역 상태에 추가한다. - 충전소 정보가 최신화 되었을 때 마커가 화면에 보이지 않는 상태가 되었다면
marker
인스턴스를 전역 상태에서 삭제한다.
위와 같이 StationMarker
의 역할을 잡게 되면 기존의 컴포넌트 설계 구조를 해치지 않으면서 전역 상태에 marker
인스턴스를 잘 추가할 수 있게 된다. 하지만 이렇게 되면 StationMarker
컴포넌트는 다음의 큰 문제들을 가지게 된다.
marker
들을 가지는 전역 상태를 구독하고 있는 컴포넌트가 새로 생성되는 마커의 개수만큼 리렌더링 된다.- 현재 사용하고 있는 전역 상태 관리 도구의 특성상 이전 상태를 참조해와야
marker
를 추가할 수 있게 되는데, 이 때 이전 상태가 최신의 상태임을 보장하지 못할 수 있다.
이 두 문제를 해결할 방식을 고민해보았을 때 다음과 같은 결론에 도달하게 되었다.
- 현재 사용하고 있는 전역 상태 관리 도구는 React 18에 새로 추가된
useSyncExternalState
훅을 기반으로recoil
과 비슷하게 사용할 수 있도록 계층을 분리하여 만든 도구이다. - 기존에 사용하던 전역 상태 관리 도구의 메서드
useExternalState
,useExternalValue
,useSetExternalState
이외에store
인스턴스에 직접 접근하여 최신의 상태를 참조하는getStoreSnapShot
메서드를 추가한다. store
에 직접 접근해 받아온 최신의 상태는 바닐라 자바스크립트 객체 이므로 리액트의 리렌더링을 발생 시키지 않는다.- 리렌더링으로 인한 문제점들을
getStoreSnapShot
메서드를 추가함으로써 해결할 수 있다.
새로운 기능 추가를 위해 마주했던 앞선 두 가지의 문제와 해결 방식을 살펴 보았다. 그래서 최종적으로 이전까지 계속해서 고민해왔던 문제를 해결한 과정을 간추려보자면 다음과 같다.
- 충전소 정보를 서버에서 받아와 렌더링 하는
StationList
컴포넌트에서marker
인스턴스 배열을 저장하고 있는store
인스턴스에 직접 접근해 최신의marker
인스턴스들을 가져온다. - 충전소 목록에서 사용자가 충전소를 클릭했을 때 전역으로 관리되는
infoWindow
인스턴스의open
메서드에marker
인스턴스들 중 선택된marker
를 전달해 간단 정보 모달을 띄워준다.