내 주위 병원 정보는 어떻게 찾을까?


서비스 요구사항


<aside>

서비스를 설계하던 중 내 주위 병원 또는 약국에 대한 정보를 가져와야 했다.

따라서 아래와 같은 자세한 요구사항을 고려할 필요가 있다.

1️⃣ 거리 계산


내가 현재 있는 위치와 그 위치를 기반으로 가장 가까운 병원 또는 약국 정보가 필요했다. 먼저 병원 정보는 공공 API를 통해서 전국 병원 정보를 가지고 왔다. 이 정보에는 좌표 정보가 있었다. 따라서 좌표 정보를 통해서 내 현재 좌표와 거리가 가까운 병원 또는 약국 정보를 가지고 온다.

2️⃣ 상세 정보


병원 또는 약국에 대한 상세 정보를 가지고 오는 것 뿐만 아니라, 추후에 리뷰, 추천해요 등과 같은 추가 정보를 조인 연산으로 가지고 와야할 수 있다. 따라서 이 과정에서 조인 등을 활용해야할 수 있으므로 이를 고려해야 한다.

</aside>

MongoDB


2dsphere


<aside>

2dsphere란


MongoDB의 2dsphere 인덱스는 지구 표면과 같은 구면 좌표계상에서 지리 데이터를 효율적으로 조회하기 위한 인덱스이다.

이 인덱스는 위치 정보를 저장하여 구면 기하 연산을 수행하는데, 이를 통해 특정 위치를 기준으로 일정 반경 내에 위치한 지점을 효율적으로 찾을 수 있다.

S2 지오메트리


2dsphere 인덱스는 S2 지오메트리 라이브러리를 활용한다.

S2 라이브러리는 지구 표면을 격자 형태의 셀로 분할하며, 각 지리 데이터의 좌표나 도형은 이 셀들의 집합으로 표현된다.

MongoDB는 이러한 셀들을 B-트리 구조의 인덱스로 저장함으로써 공간적으로 인접한 데이터들을 빠르게 탐색할 수 있도록 지원한다.

S2가 지구 → 큐브 → 셀로 쪼개지는 과정


지구를 정육면체로 만들고 이후 각 면을 Level 0라 하고 이후 부터 Level 30까지 계속 4등분한다.

즉, Level 0는 큐브의 6면이 루트 셀이며, Level 1은 Level 0 셀을 4 등분해 얻는 자식 셀인 것이다.

이와 같이, LEVEL 30까지 나눠지며모든 셀은 64-bit 정수 Cell ID 하나로 표현된다.

앞쪽 비트는 어느 면 어느 레벨인지, 뒷쪽 비트는 면 안에서의 위치 정보를 담는다. 덕분에 숫자가 비슷하면 지리적으로도 가깝다는 성질이 생기는 것이다.

커버링 셀 집합


서울 광화문을 Level 15에서 보면, 딱 1개 셀 ID에 담긴다. 만약 반경 1 km 원이라면 원을 완전히 덮는 다수의 셀을 골라 커버링이라는 셀 집합으로 기록한다.

<aside>

  1. 먼저 후보 셀을 초기화 하는 과정을 거친다. 이는 6 개의 Level 0 셀 중, 영역과 겹치는 셀만 후보 리스트에 넣는다.
  2. 후보 셀 하나 꺼내서 셀이 영역 전체를 포함하면 그대로 확정하고, 일부만 겹치면 4등분해 자식 셀을 후보에 재삽입한다.
  3. 확정 셀 수가 maxCells를 넘지 않을 때까지 2번째 과정을 반복한다. </aside>

프로세스


  1. 서울 광화문(37.575981, 126.976973)을 MongoDB에 저장하면, S2 라이브러리가 이 점이 속한 레벨 15 셀을 계산한다.
  2. 셀 ID는 그냥 정수이므로, 일반 인덱스 키처럼 B-트리에 올라가는데 키 값 자체가 지리적 근접성을 담고 있어서 인덱스로 활용하기 좋다
  3. 사용자가 현 위치에서 1 km 안의 장소 정보를 조회하면 이 원 반경을 최소한으로 덮는 S2 셀 집합을 만들어낸다.
  4. 각 셀마다 범위를 계산해 B-트리에서 순차 범위 스캔을 수행한다
  5. 최종적으로 조건에 만족하는 데이터를 반환한다.

서비스 아키텍처에 적합할까?


사용자의 현재 좌표를 기준으로 가장 가까운 병원이나 약국을 검색해야 할 경우, 2dsphere 인덱스를 활용하면 거리에 따라 정렬된 결과를 빠르게 얻을 수 있다. 또한, maxDistance를 지정하면 불필요하게 먼 곳까지 조회하지 않고 일정 반경 내의 결과로 제한할 수 있어 효율적이다.

하지만 병원 또는 약국과 관련된 운영 시간, 리뷰, 평점 등 다양한 부가 정보를 조회할 때는 MongoDB에 자동 조인 기능이 없어 추가적인 처리가 필요하다. 또한 MongoDB의 조인은 제한된 범위 내에서 동작하며 복잡한 다단계 조인이나 대용량 데이터 간의 조인은 성능 저하가 발생할 가능성이 존재한다.

</aside>

PostGIS


Geometry vs Geography


<aside>

PostGIS는 Geometry와 Geography 두 가지 주요 공간 데이터 타입을 제공한다.

Geometry 타입은 평면 좌표계를 기반으로, 모든 좌표 연산을 평면상에서 수행하지만, Geography 타입은 위도와 경도를 통해 지리 좌표계를 사용하여 지구 표면의 곡률을 고려한 연산을 수행한다.

즉, Geometry는 투영된 평면 좌표계를 가정(위도와 경도를 평면으로 투영)하고, Geography는 지구를 타원체로 간주한 둥근 지구 모델을 사용한다.

어떤 경우에 사용할까?


Geometry는 평면 좌표 단위의 연산을 수행한다. 반면 Geography는 위도와 경도 좌표계를 사용하고 거리를 미터 단위로 계산하는 등 지표면상의 실제 거리를 바로 다루지만 지원되는 함수의 종류가 적다.

예를 들어, Geometry 타입은 PostGIS의 거의 모든 공간 함수를 지원하는 반면, Geography 타입은 ST_Distance, ST_DWithin, ST_Intersects 등 일부 함수만 지원된다.

이런한 구조적 차이로 인해 성능상의 차이도 존재한다. Geometry는 계산이 단순한 평면 기하 연산이므로 처리 속도가 빠르고 대부분의 공간 함수를 지원하지만, Geography는 복잡한 삼각함수 연산을 포함하여 계산 비용이 높아 대용량 연산 시 Geometry보다 상대적으로 느릴 수 있다.

실제로 동일한 거리를 계산할 때 평면상의 피타고라스 공식을 쓰는 경우보다 Geography는 여러 번의 삼각함수 연산이 추가되어 비용이 높아지는 것이다.

따라서 데이터 범위가 비교적 좁거나 지역적인 경우에는 적절한 투영 좌표계를 설정한 Geometry 타입을 사용하는 것이 유리하고, 지구 전역에 걸친 광범위한 데이터를 다루거나 여러 좌표계 변환이 번거로운 경우 Geography 타입을 사용하는 것이 좋다.

간단히 말하면 Geometry를 사용했을때 발생하는 투영 오차가 발생할 수 있지만 이는 적은 거리에서 오차가 크지 않기에 가까운 거리일 수 성능을 위해 Geometry를 사용하는 것이 더 좋은 선택지가 된다.

</aside>

R-Tree와 GiST


<aside>

PostGIS는 PostgreSQL에 공간 기능을 추가하는 확장으로, GiST(Generic Search Tree) 기반의 R-트리(Rectangle-tree) 인덱스를 사용한다

GiST란


GiST는 인덱스 프레임워크로 어떤 자료형이든 전용 인덱스를 만들 수 있게 해주는 것이다.

즉, 하나의 공통 코드로 R-tree, B-tree 등 다양한 인덱스를 플러그인처럼 꽂아 쓸 수 있는것이다.

따라서 필요에 따라 인덱스 자료구조를 선택하는 것이고, 기본적으로 B-tree를 인덱스 자료구조로 사용하지만 PostGIS는 R-tree를 인덱스 자료구조로 사용한다.

R-트리란


N차원 최소 경계 사각형(MBR) 계층으로 각 노드는 자식의 MBR을 담고, 리프 노드는 실제 객체(점, 라인, 폴리곤)의 MBR을 담는 것이다. 이는 GiST 노드는 MBR만 갖고 있고, 실제 geometry / geography 데이터는 본문 테이블에 저장된다는 것을 의미한다.

즉 각 노드는 자식 노드에 대한 포인터를 가지고 있고, 실제 리프 노드에 점 라인 폴리곤이 담기는 것이다.

리프 노드의 MBR는 2-차원 공간에서 한 개의 점 선 폴리곤을 완전히 감싸는 가장 작은 직사각형이며 영역이라고 보면 된다.

MBR 생성 & 트리 삽입


37.5700, 126.9830인 데이터를 삽입할때 리프 노드에는 MBR=(126.9830, 37.5700, 126.9830, 37.5700)와 같이 저장된다. 해당 위 경도를 포함하는 가장 작은 직사각형 영역이다.

만약 페이지 초과 시 두 그룹으로 분할하고 부모 MBR 갱신하며 필요하면 루트까지 전파된다.

결국 상위 노드는 자식 MBR을 품은 더 큰 MBR을 가지게 되는 것이다.

</aside>