캔버스와 네이버 지도 API를 연동하기 위한 과정
🎯 이 문서를 읽고 난 후의 상태
- 프로젝트와 문제에 대한 컨텍스트를 이해했다.
- 지도와 캔버스를 연동하기 위해 어떤 설계 과정을 거쳤는지 이해했다.
- 네이버 지도와 캔버스를 연동하는 과정 속에서 겪은 어려움과 해결을 위한 시도를 알았다.
- 최종적으로 어떻게 네이버 지도와 캔버스를 연동했는지를 이해했다.
🤔 배경
현재 네이버 부스트캠프 9기에 참여해서 배우고 있다.
어느덧 마지막인 그룹 프로젝트를 하게 되었는데, "위치 기반 서비스"를 주제로 선정해서 진행하게 되었다.
"위치 기반 서비스"라는 큰 카테고리 내에서, 세부 주제를 정해서 진행해야만 했었다.
사업성이나, 아이디어의 독창성보다는 진짜 "우리가 쓸 법한, 우리 주변에 있는 문제를 해결해보자." 라는 취지에서 시작한 주제이다.
주제는 "중장년층을 위한 접근성을 바탕으로 한 위치 기반 서비스"로, 핵심은 지도 위에 캔버스를 띄우고, 거기에 자녀가 경로를 표시해서 부모님에게 전달하는 것이다.
이해를 돕기 위해서 먼저, 우리 프로젝트에 대한 깃허브 링크를 남긴다.
🤔 어떻게 시작할 것인가?
내가 팀에서 맡게 된 일은 지도와 캔버스 사이의 연동이었다.
네이버 지도와 캔버스 모두 처음이었기에, 어떻게 연동을 시킬 지에 대한 고민이 많았다.
그래서 제일 먼저 한 일은 자료조사였다.
일단, 이 기술들이 무엇인지 알고, 어떻게 돌아가는 지에 대해서 알아야 설계를 할 수 있었기 때문이다.
찾은 자료의 일부분인데, 한번 정리하고 남은 자료들이다.
구글링 하면 나오는 온갖 사용 자료와, 네이버 지도 팀의 QnA, 타입 관련해서는 타입 깃허브 등 많은 자료를 찾았던 것 같다.
관련해서 혹시라도 도움이 될까 싶어 몇 가지 참고자료를 남긴다.
- 줌레벨과 관련해서는 네이버 지도는 1~21단게를 사용중이며, 1~6단계는 모두 같은 단계로 취급한다. 추상적으로는 16단계가 맞으나, 엄격히 말하면 21단계이다.
🧑💻 함께 찾은 방향성
해당 자료 조사를 바탕으로 동료들과 함께 회의를 진행했다.
어떤 관점에서 접근해야할 지, 어떤 기능이 필요한 지 등을 함께 논의하였다.
특히, 지도와 캔버스를 각기 다른 레이어로 두고 다루기로 했어서, 레이어 사이에 어떤 인터페이스를 바탕으로 다룰 것인지를 함께 논의하였다.
처음에는 다음과 같은 형태를 생각했었다.
interface 주고받을데이터 {
위도: number,
경도: number,
x: number,
y: number,
}
이를 바탕으로 생각하고 있었는데, 동료가 기가막힌 해결책을 주었다.
interface 주고받을데이터 {
위도: number,
경도: number,
}
위도와 경도만을 갖고 데이터를 만들자.
그리고 이를 바탕으로 캔버스에서는 좌표로 바꿔서 출력을 하자는 의미였다.
배경이 되는 지도가 캔버스 위에 그려질 그림들의 기준이 되고 있고, 둘 사이를 연동하려면 어떤 기준점이 필요한데, 그걸 위도와 경도로 하면 어떻겠냐는 의견에서 였다.
처음에는 이해가 잘 되지 않았기에 논의 과정에서 정말 많은 의문과 질문을 던졌다.
단순 말로 표현하자니, 서로 잘못 이해하는 부분도 많았고, 오해하는 부분도 있었다.
이에, 그림을 그려가면서 구조를 논하였고, 이에 생각이 동기화되어 명확하게 이해 할 수 있었다.
- 회의 내용 1
- 회의 내용 2
이렇게 시각적으로 함께 맞추어가다보니, 단순 인터페이스를 넘어서 어떻게 구현을 해야겠다가 보이기 시작했다.
그렇게 뽑아낸 내용은 다음과 같다.
- 지도와 캔버스는 각기 다른 레이어로 두고, 레이어 사이에는 위도와 경도를 주고 받는 인터페이스를 만들자.
- 캔버스에서는 지도의 위도와 경도를 받아서, 캔버스의 좌표로 바꾸어서 출력하자.
- 데이터는 (위도, 경도) 만을 다루자.
- 드래그 이벤트 시에는 각자 동일한 변화값 만큼 움직이면 될 것이다.
- 확대 축소시에는 동일한 비율로 확대 축소가 되면 될 것이다.
이렇게 함께 논의하고, 방향성을 잡았다.
🖼️ 작업 프로세스 추출
방향성이 잡히고, 본격적으로 작업에 착수하게 되었다.
그리고, 제일 먼저 한 일은, 내가 이걸 구현하기 위해서 어떤 과정을 거쳐야하는 지를 파악하는 것이었다.
아직 어떤 기능이 필요한지, 특정한 문제를 해결하기 위해서는 얼마만큼의 시간이 필요한지 견적이 잡히지 않는 상황이었다.
그래서, 기존 개발 경험을 바탕으로 빠르게 예상되는 작업을 나열하고, 순서를 정리하였다. 그리고 그 과정 속에서 필요하다고 생각되는 것들을 적어두었다.
🚀 내가 등록해둔 이슈 살펴보기
📌 요청 기능 설명
캔버스와 지도를 연동시켜서 같이 동작시킨다.
📝 기능 세부 사항
- 지도와 캔버스 연동 과정 설계
- 지도 위에 캔버스 레이어 출 력
- 캔버스 컴포넌트 구현
- 지도 위에 함께 배치하는 레이아웃 구현 (하나의 Container 위에 여러 레이아웃 겹쳐서 구현)
- 지도의 끝 점과 캔버스 끝 점을 연계해서, 캔버스 좌표와 매핑
- 지도의 꼭짓점 좌표를 알아내는 함수가 필요
- 캔버스의 끝점의 크기를 알아내는 함수가 필요 (캔버스는 CSS와, 캔버스 내부 좌표가 일치해야함.)
- 위도/경도 좌표 -> X, Y로 바꾸는 함수 구현
- 드래그 이벤트 핸들러 구현
- 드래그 시 캔버스가 이동되게 구현
- 드래그 시 지도가 이동되게 구현
- 드래그 시 캔버스의 좌표와 지도가 정확히 매핑되게 구현
- 줌인 줌아웃 이벤트 핸들러 구현
- 줌인/줌아웃 시 캔버스가 확대/축소 되게 구현
- 줌인/줌아웃 시 지도가 확대/축소 되게 구현
- 줌인/줌아웃 시 좌표와 지도가 정확히 매핑되게 구현
🤔 기능 추가 배경 및 목적
MVP기능으로, 캔버스 위에 그리는 그림이 지도에 그대로 매핑될 수 있도록 기능을 제공해야 한다.
🚩 완료 조건 (Acceptance Criteria)
- 설계에 대한 기술적인 근거가 명확해야 한다.
- 지도 위에 캔버스 레이어가 겹쳐서 보여진다.
- 화면을 드래그 하였을 때 지도와 캔버스가 같이 움직인다.
- 캔버스 위의 마커나 선이 지도의 위도, 경도 상에 정확하게 배치된다.
- 화면을 줌인/줌아웃 하였을 때 지도와 캔버스가 같이 확대 축소가 된다.
- 캔버스 위의 마커와 선의 확대 축소 비율이 지도와 동일하게 유지된다.
- 각 과정에 대한 테스트코드가 작성되어 있다.
💡 참고 자료 (선택)
📝 설계에 대한 계획 수립
"길을 먼저 보고 개발에 착수하자." 라는 개발 철학이자 습관이 있다.
완벽하기 보다는 진짜 순수하게 길을 보는 용도로써의 설계. 그렇기에 설계는 최대한 간단하게, 그리고 빠르게 진행할 필요가 있었다.
이 역시도 대략적인 그림을 그리지 않고 설계에 들어갈 경우 시간이 많이 걸릴 위험이 있었다.
그래서 빠르게 무엇에 대해서 고려를 할 지 나열하고, 이에 대해서 순서를 정하였다.
🚀 내가 등록해둔 이슈 살펴보기
📌 작업 제목
지도와 캔버스 연동 과정 설계
🎯 목표
지도와 캔버스 연동 과정을 설계하고, 문서로 상세하게 작성한다.
📝 작업 세부 사항
- 지도와 캔버스 간의 좌표 변환 과정 설계
- 어떻게 (위도, 경도) <-&rt; (x, y)로 변 환할 건지 함수 설계
- 컴포넌트 구조 설계
- z-index 기반으로 해서 어떻게 겹쳐서 보여줄 지 설계
- 레이어에 대해서 그림으로 그려서 표현하기
- 컴포넌트 계층도에 대해서 도식도로 그려서 표현하기
- 로딩부터 동작까지 흐름 설계
- 초기 로딩 시에, 어떤 조건이 필요한지, 순서는 어떻게 되어야 하는 지 설계
- 예) 네이버 지도 로딩과, 서버에서 초기값 로딩이 필요하다. 네이버의 중심점 좌표를 잡기 위해서는 서버에서 받아와야 하기에 서버와의 통신이 먼저 이루어지고, 네이버 지도 로딩이 이루어져야 한다. 즉, 로딩은 먼저 하되 화면 출력은 이후에 한다. 와 같이 구체적으로 시나리오가 나와야 함.
- 이전 화면에서 데이터를 받는 지 여부, 받는다면 서버와의 통신에서도 어떻게 공통으로 뽑아낼 수 있을지에 대한 설계
- 이벤트 발생 시 지도와 캔버스 위치를 어떻게 동기화시킬 지에 대해 설계
- 각 과정에 따라 대충 그림이 그려지면, 프로토타입 구현을 통해 실현 가능 여부 판단
🚩 완료 조건 (Acceptance Criteria)
- 지도와 캔버스 간의 좌표 변환 과정이 식으로 뚜렷하게 표현이 된다.
- 컴포넌트 구조가 도식도로 한 눈에 보이게 설계가 되었다. 그리고, 도식도만 보고도 어떻게 구현이 될 지 이해가 된다.
- 로딩부터 동작까지의 일련의 과정이 한 눈에 들어온다.
- 이벤트 발생 시 위치의 동기화를 어떻게 이룰 것인지 이해할 수 있다.
- 프로토타입을 통해서 각 과정이 실현 가능함을 보장할 수 있다.
📅 예상 소요 시간 및 일정
2일 소요 예정입니다. (2MD -> 16시간 예정)
📝 본격적인 설계의 시작 :: 내용 분석하기
설계에 들어가면서, 제일 먼저 한 일은 프로젝트 자체에서의 지도와 캔버스의 좀 더 자세한 구조를 파악하는 것이었다.
이를 파악하기 위해서, 동료들과 많은 이야기를 나누었다. 내가 생각하는 게 맞는지, 동료가 생각하는 바는 무엇인지 등 잦은 상호 동기화 시간을 가졌다.
그렇게 프로젝트에 대해서 서로가 생각하는 것을 일치 시키고, 이 내용을 바탕으로 다음과 같은 그림을 그렸다.
이렇게 작성하면서 관련된 요소의 파악도 진행하였다.
- 지도 테스트 이미지
- 캔버스 테스트 이미지
위와 같은 과정을 거쳐서 지도와 캔버스의 구조를 파악하였다.
캔버스의 경우는 노마드 코더님의 캔버스 강의를 필요한 부분만 빠르게 들으면서 파악하고자 했고, 네이버 지도는 예제를 참고하였다.
이를 통해서 각각이 동작하기 위해서는 어떤 데이터가 필요하고, 무엇이 요구되는지를 빠르게 파악할 수 있었다.
이 뿐만 아니라 직접 네이버 API를 뜯어보면서 어떻게 동 작하는지 파악도 했는데 이렇게 파악한 내용은 아래와 같다.
⚙️ 캔버스 관련 요소 분석
- 캔버스는 왼쪽 위가 (0, 0) 이며, 오른쪽으로 가면 x가 증가하고, 아래로 가면 y가 증가한다.
- 경도(Longitude)는 캔버스상의 X좌표에 대응된다.
- 위도(Latitude)는 캔버스상의 Y좌표에 대응된다.
- 캔버스의 크기는 고정이 되어 있으며, 그 내부에 그려지는 요소만 변화한다.
- 캔버스와 관련된 이벤트는 캔버스 내부에서만 발생한다.
- 캔버스는 기본 HTML 관련 이벤트가 매핑되어 있으나, 그래픽에 대한 동작은 JS로 구현되어야 한다.
⚙️ 지도 관련 요소 분석
- 지도는 네이버 지도 API를 사용한다.
- 지도는 위도와 경도를 기반으로 한다.
- 각 요소는 타일 형태로 구현이 되어 있다. (타일은 지도의 한 부분을 의미한다.)
- 지도는 캔버스와는 다르게, 지도 자체적으로 이벤트를 가지고 있으며, 이벤트가 발생하면 지도 자체적으로 이벤트 핸들러가 동작한다.
- 지도는
Wrapper
가 되는HTML 태그
요소보다 약간 큰 사이즈가 랜더링된다. (지도의 크기는 지도의 크기를 의미하며, Wrapper는 지도를 감싸는 HTML 태그를 의미한다.) - 드래그나 줌인 줌아웃 이벤트로 사전에 랜더링 된 범위를 넘어서면 다시 랜더링이 된다.
- 지도 타일 이미지2
- 지도 타일 이미지2
위와 같이, 지도는 타일 형태로 구성되어 있으며 각 타일은 img
태그로 작성이 되어 있었다.
이벤트나 여타 동작을 처리함에 있어서 기존에 지도가 갖고 있는 이벤트를 덮어 씌우는 방식도 생각해보았으나, 각 이미지 태그를 하나하나 조작하거나, 관련해서 처리하는 로직까지 고려하는 것은 너무 과한 행위라는 생각이 들었다. 또한, 프로젝트의 목표와도 맞지 않는 문제가 있었다.
이에 따라서, 지도와 캔버스를 각기 다른 레이어로 두고, 레이어 사이에는 위도와 경도를 주고 받는 인터페이스를 만들자는 초기 기획 방향으로, 설계를 굳히고, 진행하였다.
📝 지도와 캔버스의 연동 구조 설계
팀의 궁극적인 목표는 모듈화였다.
확장까지 고려했을 때 우리팀이 궁극적으로 지향하는 바는 지도에 연동되는 캔버스를 다른 곳에서도 사용할 수 있도록 하는 것이었다.
지도와는 별개로 지도와 연동되는 로직과, 캔버스를 오픈소스로 만들어서 배포하는 게 목표였다고 볼 수 있다.
이를 위해서는 지도와 나머지 구조간의 의존성을 최대한 끊을 필요가 있었다.
고민 끝에 설계한 구조는 위와 같다.
캔버스와 지도가 연동된 요소를 하나의 컴포넌트로 수립한다.
사용자(다른 개발자)는 캔버스와 지도가 어떻게 연동되었는지 알 필요가 없다.
캔버스에 그림을 그리고, 움직이거나 줌인 줌아웃이 지도와 연동된, 이 기능만 사용할 수 있으면 된다.
그래서, 캔버스와 지도를 하나로 묶어서 <CanvasWithMap>
이라는 컴포넌트로 퍼사드 패턴
으로 묶었고, 필요한 동작은 외부에서 props
나 이벤트 등으로 제공한다.
또한, 지도의 경우도 어디까지나 배경의 역할만을 수행한다. 어떤 지도로 갈아끼워지든 간에, <CanvasWithMap>
은 몰라도 되며, 지도에 대해서 동일한 인 터페이스로 기능하면 된다.
이에 따라서, 지도와 그 나머지 세부 요소를 전략 패턴
으로 갈아끼울 수 있게 설계 하였다.
이를 통해, 지도와 캔버스의 연동 과정에서 각각의 책임을 더욱 명확하게 할 수 있었다.
📝 캔버스와 지도의 좌표 변환 과정 설계
지도와 캔버스에 대해서 이미 본격적인 설계의 시작 :: 내용 분석하기에서 1차적으로 분석한 바가 있다.
좌표 변환은 추가적인 분석을 요구했다.
우선 당장의 완성을 위해서 네이버 지도를 기준으로 삼았기에, 네이버 지도의 좌표 변환을 먼저 생각해보았다.
이와 관련해서 네이버지도 API를 정말 깊게 찾아보았고, 그 내용은 다음과 같다.
🚀 네이버 지도 API를 통한 좌표 변환 과정 탐구
- 네이버 지도 API에서는 다음과 같은 기능을 제공한다.
- MapSystemProjection은 현재 설정된 지도 유형의 Projection을 가공해 지도 내부에서 사용하는 투영 객체이다. 이 객체를 이용하면 지도 좌표와 세계 좌표, 화면 픽셀 좌표를 서로 변환할 수 있다.
- 단, 이는 추후 확장 방안에서 카카오 지도, 구글 지도 등과 연동하기로 했으므로 고려하지 않는다.
LatLngBounds
클래스는 남서쪽과 북동쪽의 위/경도 좌표가 설정돼 있는 직사각형의 지리적 영역(이하 좌표 경계)을 정의한다.- 이는 왼쪽 아래 꼭짓점과 오른쪽 위 꼭짓점의 위/경도 좌표를 주는 메서드이다.
- 이런 기능은 대부분의 지도 API가 제공하고 있으며, 앞서 보여준 매핑도에도 부합한다.
- 지도 좌표 경계 확인하기
- 관련 예제는 위와 같다.
다음과 같은 이유로 2번으로 진행하기로 했다.
- 추후 확장 방안으로 여러 지도 API에 대한 대응을 고려하고 있다.
- 지도 API의 기본 기능을 최소한으로 사용하고자 한다.
- 2번의 방법으로 보다 더 많은 기술적인 성장을 이루고자 한다.
그러면 이제 다음과 같은 고민이 생겼다.
- 지도와 캔버스 각각의 꼭지점에 대해서 어떤 인터페이스로 매핑할 것인가?
- 꼭짓점을 기준으로 마커의 위치를 계산할 텐데, 그러면 위도 경도의 소숫점은 어떤 의미를 갖고 있는가?
- 네이버 지도에서 확대 축소시에 위도 경도의 소숫점 자리수는 어느 정도까지 의미있게 다루는가?
사전에 회의한 내용에 의거, 지도와 캔버스는 [위도, 경도]의 데이터만 다룬다.
이유는 다음과 같다.
- 사용자의 인터렉션이 잦은 지도 조작의 특성 상 캔버스상의 (x, y)를 (경도, 위도)에 매핑하고 (경도, 위도, x, y) 이렇게 데이터를 갖고 있으면 매번 (x, y) 값을 갱신해서 저장해야하는 문제가 생긴다.
- 반응형 UI/UX도 고려해야 한다. 이런 이유로 (x, y)에 대한 변경이 너무 잦고 경우의 수가 많은 것을 고려할 수 밖에 없다.
- 지도에서 연산을 하든, 캔버스 위에서 연산을 하든 캔버스에 출력할 (x, y) 값에 대한 연산은 필요하다.
- 지도 API에서 꼭짓점의 (위도, 경도)를 제공해준다면 캔버스 위의 특정 마커나 점에 대한 (위도, 경도)를 갖고 있으면 꼭짓점의 (위도, 경도)로 부터 우리가 가진 마커의 (위도, 경도) 정보를 계산해서 화면에 보여줄 수 있다. 이벤트 발생 시마다 계산을 해야한다는 문제가 있지만, 앞선 이유로 어떻게 하든 연산은 필요하다. 그리고, 이에 대한 부분은 드래그 이벤트의 최적화 기법 중 하나인 RequestAnimationFrame 등을 이용해서 특정 주기때에만 계산하게 하는 방법 등으로 최적화할 수 있다.
🤔 꼭짓점을 기준으로 마커의 위치를 계산할 텐데, 그러면 위도 경도의 소숫점은 어떤 의미를 갖고 있는가?
네이버 지도에서 사용하는 위도와 경도는 일반적으로 소수점 6~7자리까지 다룬다.
이에 대한 세부적인 내용은 다음과 같다.
위도/경도의 소수점 자릿수와 정밀도
- 소수점 5자리: 약 1미터의 정밀도를 제공한다.
- 소수점 6자리: 약 10센티미터의 정밀도를 제공한다.
- 소수점 7자리: 약 1센티미터의 정밀도를 제공한다.
위의 가이드 뿐 아니라 다양한 가이드에서 소수점 7째 자리까지 다루고 있다. 이에 따라서, 최소 6자리, 최대 7자리 수준의 정밀도를 다룬다고 생각하면 좋을 것 같다.
조금 더 찾아보니, 네이버지도 뿐 아니라, 구글 지도와 카카오 지도에서 사용하는 위도와 경도는 기본적으로 동일한 체계를 따른다고 한다.
위도와 경도는 전 세계적으로 공통된 지리적 좌표계를 기반으로 하며, 주로 **WGS84(World Geodetic System 1984)**라는 표준 좌표계를 사용한다. 이 좌표계는 GPS와 대부분의 온라인 지도 서비스에서 사용되기 때문에, 지도 서비스 간 위도와 경도의 의미는 동일하다고 한다.
즉, 위의 지표를 기준 삼아서 소수점 7번째 자리까지만 고려해서 다루면 된다는 것을 확인할 수 있었다.
🤔 네이버 지도에서 확대 축소시에 위도 경도의 소숫점 자리수는 어느 정도까지 의미있게 다루는가?
네이버 지도의 줌 단계는 다양한 확대/축소 레벨을 지원하며, 각 레벨은 특정 위도와 경도 범위에 대응된다. 일반적으로 줌 레벨이 낮을수록 지도가 더 축소되며, 줌 레벨이 높을수록 지도가 확대된다.
네이버 지도에서 제공하는 줌 레벨 범위
네이버 지도는 줌 레벨 1단계부터 21단계까지 총 21단계의 줌 레벨을 제공한다. 각 단계는 지도의 확대 및 축소 수준을 나타내며, 사용자가 지도를 얼마나 상세하게 볼지를 조절할 수 있다.
다만, 1~6단계까지는 모두 동일하게 고정을 해 두었는데, 원래는 지도 레벨로 확대가 되어야하나, 대한민국 전체가 보이는 수준이 최대 확대 수준으로 고정되어 있었다.
이에 따라서 실질적으로 6단계~21단계의 총 15단계 수준을 제공한다고 볼 수 있었다.
이에 대해서 하나하나 살펴보면서 줌 레벨을 설정했다.
이렇게 잡은 설정 값은 다음과 같다.
maxDiff | Zoom 레벨 |
---|---|
0.0005 | 19 |
0.001 | 18 |
0.004 | 17 |
0.01 | 15 |
0.03 | 14 |
0.05 | 13 |
0.1 | 12 |
0.2 | 11 |
0.5 | 10 |
1 | 9 |
2 | 8 |
5 | 7 |
10 | 6 |
20 | 5 |
🚀 추가 설명
줌 레벨이 낮을수록: 지도의 범위가 넓어져 큰 지역을 한눈에 파악할 수 있다. 예를 들어, 나라 전체나 대륙을 볼 때 유용하다. 줌 레벨이 높을수록: 지도의 범위가 좁아지면서 상세한 정보를 확인할 수 있다. 도로, 건물, 공공 시설 등 세부적인 지형을 탐색할 때 적합하다.
줌 레벨(scale)별 차이 : 다음 지도 & 네이버 지도(5)
위 글은 네이버지도가 v3로 업데이트되면서 조금 다른 부분이 있지만, 큰 맥락에서는 참고하기 좋을 듯 하여 가져왔다.
위의 자료를 찾고 보니 한 가지 궁금한 점이 생겼다.
네이버 지도 API Zoom Level 설명 표를 보니 줌 레벨과 위도/경도 수준 사이에 어떤 식이 있어서 이를 기반으로 지도 상의 좌표 기준이 달라지는 게 아닌가 싶었다. (줌 확대에 따른 위도/경도의 소수점을 어디까지 다룰 것인가 하는 그런 기준)
그리고 이걸 이용해서 식을 도출해내면, 줌에 따라서 위도 경도를 계산하는 코드를 짤 수 있을 것 같았다.
이와 관련해서는 GPT
의 도움을 받았고 내용은 다음과 같다.
- 계산식1
- 계산식2
- 계산식3
식이 꽤나 복잡하게 나왔는데, 이를 통해서 찾아내는 방법이 있을 것 같다.
🤔최종적으로 어떻게 좌표를 계산해야하는가?
위의 탐구 과정 속에서 더욱 단순한 방법이 하나 떠올랐다.
- 캔버스가 표시되는 영역의 꼭짓점을 (0,0), (1,0), (0,1), (1,1)로 추상화한다.
- 그리고 각각의 좌표에 대해 지도의 꼭짓점 좌표를 매핑한다.
이를 의사코드로 옮기면 다음과 같다.
- 줌 이벤트가 발생한다.
- 지도에 줌 이벤트를 적용시키고, 꼭짓점 좌표를 받아온다.
- 꼭짓점 좌표와 내가 갖고 있는 점의 좌표를 계산해서, canvas 상의 어디에 배치되어야 하는지 추출한다.
- 캔버스 화면 위에 좌표에 맞는 점을 찍는다.
연산 과정 속에서 약간의 딜레이가 있을 수 있지만, 네이버 지도 역시도 줌인 줌아웃이 될 떄는 약간의 딜레이가 생긴다.
이 때에 연산을 해서 사용자가 불편함을 느끼지 못하게 한다.
또한, 드래그시에도 유사하게 적용할 수 있도록 한다.
다만, 한 가지 우려되는 점이 화면 상에 정말 많은 점이 찍혔을 때 이를 다 배열에 넣는다고 하면, 각각에 대해서 연산이 굉장히 오래 걸릴 것 같다.
추후, 이에 대한 최적화로 WebAPI를 사용하거나(비동기 멀티 스레드 이용) 다른 최적화 방법을 모색해야할 필요가 있을 듯 하다.
위와 같은 발상을 통해서 생각을 했었고, 결과적으로는 좌표 연산은 캔버스에서 매번 진행을 하게 하되, 매핑 과정에 있어서는 재 랜더링
을 이용하기로 했다.
이에 대해서는 뒤에서 다시 다루고자 한다.
📝 컴포넌트 z-index 설계
초기에 설명했던 것처럼, 캔버스와 지도는 각각의 레이어로 구성되어 있으며, 각 레이어는 z-index
를 통해서 겹치게 된다.
이에 대해서 좀 더 세부적으로 z-index를 어떻게 둘 것인지 설정을 진행하였다.
📝 로딩부터 동작까지 흐름 설계
설계를 하는 과정 속에서 계속 구현 방안을 모색하면서 단순히 연동만 고려할게 아니라 로딩 등도 고려해야 함을 깨달았다.
네이버 지도 객체는 비동기로 받아오는데, 이를 다루는 것은 로딩 된 직후에 보여져야 하기에 이런 순서도 고려가 필요했었다.
그래서 앞에서 언급했던 직접 예제를 통해 구현하면서 각 요소가 이렇게 되겠구나를 파악함과 동시에, 멘토님과의 멘토링을 통해서 부족한 점을 보완해서 다음과 같은 설계를 할 수 있었다.
로딩 단계에서는 데이터 처리의 흐름이 고려될 필요가 있어서, 비동기로 진행되더라도, 일련의 흐름을 갖도록 구성했다. (async, await)
세팅의 경우는 로딩된 자료를 바탕으로 지도를 출력하고, 캔버스를 출력한다.
이 과정에서 로딩이 오래 걸릴 경우 로딩 화면을 출력한다.
사용자 동작의 경우는 권한에 따라 다르게 동작하도록 하며, 이는 추후 고려하도록 한다.
📝 이벤트 발생 시 지도와 캔버스 위치를 어떻게 동기화 시킬 지에 대해
네이버 지도의 경우 급격한 위치변화 혹은 줌인 줌아웃 시 로딩이 요구되는 것을 파악했다.
미리 필요한 부분만 다운받아두고, 사용자의 인터렉션에 따라 바로 다음 영역을 받아오는 형식이다.
위 처럼 테스트를 하면서 동작을 파악해보았다.
그리고 이를 바탕으로, 로딩이 진행될 때 꼭짓점의 좌표를 받아서 이때 지도와 캔버스간의 동기화를 이루도록 하면 사용자의 사용성도 개선하고, 미묘하게 지도와 캔버스가 동기화가 안되는 문제가 생기더라도 대응이 될 수 있지 않을까 싶었다.
혹시 몰라 천천히 움직이는 경우도 확인한 결과, 이와 똑같은 로직이 적용되는 것을 발견하였다.
이에 기반해서 생각을 해 보았을 때, 이벤트를 감지해서 수시 연산을 하는 것 보다는 해당 로딩시간에 맞추어서 재연산을 하는 과정도 괜찮은 옵션인 듯 싶었다. 다만 이에 대한 부분은 직접 구현을 하면서 더 적합한 방법으로 진행하고자 했다.
기본적으로 꼭짓점의 위도 경도를 기준으로 좌표 계산하는 것을 핵심으로 두되 다음과 같은 순서를 고려했다.
- 줌인/줌아웃 이벤트 발생 혹은 이동에 대한 이벤트 발생
- 이벤트가 끝난 시점을 기준으로 지도에 변화
- 지도로부터 꼭짓점 좌표 받아오기
- 받아온 좌표를 바탕으로 캔버스 재연산
- 화면에 출력
위의 순서로 이벤트 발생 시 화면과 캔버스를 동기화 시키기로 했다.
📝구현 순서에 대한 설계
마지막으로 구현 순서를 정해서 구현에 들어가 기로 했다. 이때 고려한 순서는 아래와 같았다.
- 지도와 캔버스를 겹친 레이어로 출력
- 특정 위치(현재 위치 혹은 임의의 위치 기준)를 중점으로 삼아서 지도 출력
- 네이버 지도의 꼭짓점 좌표를 받아오는 로직 구현
- 캔버스의 꼭짓점과 네이버 지도의 꼭짓점의 위도 경도를 매핑하는 로직 구현
- 위도 경도를 바탕으로 캔버스에 점을 표시할 수 있도록 연산하는 함수 구현
- 연산된 값을 바탕으로 화면에 좌표를 출력하는 로직 구현
- 이벤트가 발생했을 때 4번부터 다시 계산하는 로직 구현
🧑💻 Z-index 설정
설계가 끝나고 제일 먼저 한 일은 z-index 설정이었다.
zIndex: {
0: '0', // 메인 화면
1000: '1000', // 캔바스
1001: '1001', // 캔바스
1002: '1002', // 캔바스
1003: '1003', // 캔바스
1004: '1004', // 캔바스
4000: '4000', // 레이아웃
6000: '6000', // 모달, 알림 등 오버레이 요소
// 필요에 따라 추가적인 z-index 값을 더 추가할 수 있습니다.
},
tailwind.config.js
이며, 우선은 기능 테스트를 위해 하드코드 형태로 빠르게 Z-index를 설정할 수 있게 하였다.
🧑💻 캔버스와 지도를 겹쳐서 보여주기
그 다음으로 한 것은 지도를 구현한 것이었다. 아래는 지도의 코드이다.
- 코드1
- 코드2
- 코드3
import {NaverMap} from '@/component/maps/NaverMap.tsx';
import {ReactNode, useEffect, useState} from 'react';
import classNames from 'classnames';
interface IMapProps {
lat: number;
lng: number;
className?: string;
type: string;
}
const validateKindOfMap = (type: string) => ['naver'].includes(type);
export const Map = (props: IMapProps) => {
if (!validateKindOfMap(props.type)) throw new Error('Invalid map type');
const [MapComponent, setMapComponent] = useState<ReactNode>();
useEffect(() => {
if (props.type === 'naver') {
setMapComponent(<NaverMap lat={props.lat} lng={props.lng}/>);
}
}, []);
return (
<article className={classNames({'h-screen': !props.className}, props.className)}>
{MapComponent}
</article>
);
};
// NaverMap.tsx
import { useEffect, useRef } from 'react';
import { INaverMapOptions, setNaverMapObj } from '@/utils/maps/naverMap/setNaverMapObj.ts';
export const NaverMap = (props: INaverMapOptions) => {
const mapRef = useRef < HTMLDivElement > (null);
const mapOptions: INaverMapOptions = {
lat: props.lat,
lng: props.lng,
zoom: props.zoom,
};
useEffect(() => {
if (mapRef.current) {
setNaverMapObj(mapRef.current, mapOptions);
}
}, []);
return <section ref={mapRef} className="w-full h-full"/>;
};
// setNaverMapObj.ts
export interface INaverMapOptions {
lat: number;
lng: number;
zoom ? : number;
}
export const setNaverMapOption = (mapOptions: INaverMapOptions): INaverMapOptions => {
return {
...mapOptions,
lat: mapOptions.lat ? mapOptions.lat : 37.42829747263545,
lng: mapOptions.lng ? mapOptions.lng : 126.76620435615891,
zoom: mapOptions.zoom ? mapOptions.zoom : 20,
};
};
export const setNaverMapObj = (
htmlElement: HTMLElement,
mapOptions: INaverMapOptions,
): naver.maps.Map => {
const {lat, lng, ...restProps} = setNaverMapOption(mapOptions);
return new naver.maps.Map(htmlElement, {
center: new naver.maps.LatLng(lat, lng),
...restProps,
});
};
그리고 레이어의 기본이 될 캔버스를 구현하였다.
- 코드1
- 코드2
- 코드3
// Canvas.tsx
import classNames from 'classnames';
import { useEffect, useRef } from 'react';
interface ICanvasProps {
className?: string;
onClick?: () => void;
onMouseDown?: () => void;
onMouseUp?: () => void;
onMouseMove?: () => void;
}
export const Canvas = (props: ICanvasProps) => {
const { className, ...rest } = props;
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
context.fillRect(100, 100, 200, 200);
}, []);
return (
<canvas
ref={ref}
className={classNames('z-1000 absolute h-full w-full bg-transparent', className)}
{...rest}
/>
);
};
// CanvasWithMap.tsx
import { Canvas } from '@/component/canvas/Canvas.tsx';
import { Map } from '@/component/maps/Map.tsx';
import classNames from 'classnames';
interface ICanvasWithMapProps {
className?: string;
lat: number;
lng: number;
zoom: number;
mapType: string;
}
export const CanvasWithMap = (props: ICanvasWithMapProps) => {
return (
<div className={classNames('relative h-screen', props.className)}>
<Canvas />
<Map lat={props.lat} lng={props.lng} type={props.mapType} zoom={props.zoom} />
</div>
);
};
// GuestView.tsx
import { HeaderContext } from '@/component/layout/header/LayoutHeaderProvider';
import { useContext, useEffect } from 'react';
import { CanvasWithMap } from '@/component/canvas/CanvasWithMap.tsx';
export const GuestView = () => {
const headerContext = useContext(HeaderContext);
// TODO: geoCoding API를 이용해서 현재 위치나 시작위치를 기반으로 자동 좌표 설정 구현 (현재: 하드코딩)
return <CanvasWithMap lat={37.3595704} lng={127.105399} zoom={14} mapType="naver" />;
};
캔버스에는 의도적으로 사각형을 배치하였다. 이를 통해서 캔버스가 제대로 보이는지 확인하고자 했다.캔버스의 배경색을 투명하게 만들고 화면을 꽉 채우게 만들어서 지도와 겹쳐보일 수 있게 만들었다.
그리고 위와 같이 캔버스를 구현하고, CanvasWithMap
이라는 컨테이너로 지도와 함께 감싸서 보여줄 수 있게 하였다.
🧑💻 기술적 어려움 : 어떻게 겹쳐있는 레이어에 이벤트를 발생시킬 것인가?
앞서서 지도와 캔버스를 겹치는 과정까지는 순조롭게 진행되었다.
그러나, 생각지도 못한 곳에서 큰 어려움이 찾아왔다.
<div class='container relative w-[300px] h-[300px]'>
<div class='top-child absolute z-30 w-full h-full' />
<div class='bottom-child absolute z-0 w-full h-full' />
</div>
위의 top-child
와 bottom-child
처럼 겹쳐있는 레이어에 대해서 어떻게 동시에 같은 이벤트를 발생시킬 것인가에 대한 어려움이 있었다.
- 설계1
- 설계2
우리의 서비스에서 지도는 위와 같이 동작을 해야했다.
지도와 캔버스가 겹쳐져 있을 때, 지도와 캔버스 모두에 이벤트가 발생해야 했다.
그리고, 우리가 처리해야 하는 이벤트는 다음과 같았다.
- 클릭
- 드래그
- 줌인/줌아웃
이 세가지 이벤트였다.
사실 처음에는 대수롭지 않게 아주 간단하게 생각했었다.
당연하게 top-child
를 클릭하면 바로 아래 레이어인 bottom-child
에게도 그 이벤트가 적용될거라고 생각했기 때문이다.
그러나, 이는 의도대로 동작하지 않았다. top-child
에는 정상적으로 이벤트가 적용이 되나, bottom-child
에는 적용이 되지 않았기 때문이다.
🤔 원인 분석 : 쌓임 맥락(Stacking Context)
원인은 스택 컨텍스트(Stacking Context
)에 의한 문제였다.
스택컨텍스트 자체에 관련해선 에전에 작성한 위의 글을 참고하면 좋다.
CSS에서 요소가 화면에 보여지는 스택 컨텍스트
라는게 존재한다. z-index
를 걸게 되면, 높은 z-index
값을 가진 요소는 낮은 값을 가진 요소보다 앞에 위치하게 된다.
그리고, 브라우저는 클릭 이벤트가 발생했을 때 가장 위에 있는 요소부터 이벤트를 처리한다.
top-child
가 위에 있으므로, 여기에 이벤트를 제일 먼저 전달한다. 그리고 top-child
가 이 이벤트를 가로채고 해결한 상태로, stopPropgation
같은 요소로 이벤트 전달을 막아서 생기는 문제였다.
실제로, 우리가 헤더 등을 absolute
로 구현하면 여기에만 이벤트가 적용되고 다른 곳에는 적용이 안되는데.. 이를 간과했던 것이다.
🧑💻 1차 시도 : pointer-event
속성을 끄기
첫 번째 시도로는 위에 있는 요소인 top-chlid
의 pointer-event
속성을 끄는 것이었다.
pointer-events - CSS: Cascading Style Sheets | MDN
pointer-event
라고 함은 CSS의 속성 중 하나로, 요소가 마우스 클릭, 터치, 커서 이동과 같은 포인터 이벤트를 받을 수 있는지 여부를 제어하는 속성이다.
단순하게 생각해서, 제일 위에 있는 요소가 이벤트를 다 잡아먹고 있다면, 그걸 끄면 되는게 아닌가? 하는 생각에서 였다.
우리는 tailwindcss
를 사용하고 있었기에, 다음과 같이 pointer-event-none
클래스를 주는 것만으로도 끌 수 있었다.
<div class='container relative w-[300px] h-[300px]'>
<div class='top-child absolute z-30 w-full h-full pointer-event-none' /> // 여기에서 `pointer-event-none` 속성에 주목한다.
<div class='bottom-child absolute z-0 w-full h-full' />
</div>
이렇게 하면 top-child
의 모든 포인터 이벤트를 끌 수 있다.
pointer-event-none
을 주게 되면, top-child
는 이벤트를 받지 않게 되고, bottom-child
는 이벤트를 받게 된다.
그러나, 이는 우리가 원하는 바가 아니었다. 우리는 top-child
, bottom-child
모두 같은 이벤트가 동시에 발생하길 원했기 때문이다.