
Project - FitFinder
0. 들어가며
FitFinder 프로젝트의 핵심 기능 중 하나는 사용자의 현재 위치 또는 지정한 위치의 위도, 경도를 기반으로 반경 10km 이내의 체육 프로그램을 조회하는 기능입니다. 이 기능을 구현하는 과정에서 자연스럽게 고민하게 된 것은 “거리 기반 탐색을 어떻게 효율적으로 처리할 것인가?”였습니다. 그 결과, 여러 데이터베이스 중 PostgreSQL을 선택하게 되었습니다. PostgreSQL에는 "PostGIS"라는 확장 모듈이 존재하는데, 이는 공간 데이터를 저장하고, 조회하며, 분석할 수 있도록 지원해 줍니다. 기존에는 두 지점 간의 거리를 계산하기 위해 Haversine formula 공식을 활용했습니다. 하지만 이 방식은 다음과 같은 문제점이 있었습니다.
- 매 요청마다 복잡한 수학적 계산
- 인덱스 활용 불가능
- 다각형 영역과 같은 복잡한 공간 연산 처리의 한계
반면, PostGIS는 이러한 문제를 해결할 수 있는 다양한 공간 연산 기능과 인덱싱을 제공하며 위치 기반 데이터를 효율적으로 처리할 수 있는 기반을 제공합니다. 그래서 이번 시간에는 Haversine 공식을 사용하는 환경에서 PostGIS를 도입하는 그 여정을 기록해보려고 합니다. 그럼 시작해 보도록 하겠습니다.
1. 프로젝트 소개

FitFinder 프로젝트는 KSPO 공공데이터 활용 경진대회를 준비하면서 개발한 프로젝트로, 성별, 연령, 위치, 선호 종목을 분석하여 최적의 운동 장소와 맞춤형 주간 루틴을 추천해 주는 서비스입니다. 해당 프로젝트에 사용된 데이터 셋은 국민체육진흥공단에서 제공하는 "공공체육시설 프로그램 및 시설 데이터(프로그램명, 종목, 대상, 요일, 시간대, 이용요금, 위치정보, 시설명, 주소, 좌표 등)"입니다. 해당 원본 데이터 셋에서 결측치, 이상치를 정제 및 보완 작업을 진행하여 서비스에 바로 사용할 수 있도록 했습니다.
2. PostGIS 도입기
차근차근 PostGIS 도입하는 과정을 살펴보도록 하겠습니다 :>
2.1 Docker 기반 PostGIS 설치
공식 문서를 보면 여러가지 방법이 있지만, 저는 간단하게 Docker를 활용하는 방법으로 진행해 보도록 하겠습니다. 기존 로컬에 있는 데이터들을 덤프 파일로 만든 다음에 PostGIS가 있는 컨테이너를 새 볼륨으로 띄우고, 덤프 파일을 적용하는 순서로 환경을 구성했습니다.
# 1. 데이터 덤프
docker exec -it postgres-spots pg_dumpall -U <유저명> > /tmp/spots_backup.sql
# 2. 컨테이너 제거
docker stop postgres-spots
docker rm postgres-spots
# 3. 새 볼륨으로 PostGIS 컨테이너 생성
docker run -d \
--name postgres-spots \
-e POSTGRES_USER=<유저명> \
-e POSTGRES_PASSWORD=<패스워드> \
-e POSTGRES_DB=<DB명> \
-v spots-data:/var/lib/postgresql/data \
-p 5432:5432 \
postgis/postgis:15-3.4
# 4. PostGIS 확장 활성화
docker exec -it postgres-spots psql -U <유저명> -d <DB명> -c "CREATE EXTENSION IF NOT EXISTS postgis;"
# 5. 백업 데이터 복원
cat /tmp/spots_backup.sql | docker exec -i postgres-spots psql -U <유저명> -d <DB명>
# 6. 버전 및 데이터 확인
docker exec -it postgres-spots psql -U <유저명> -d <DB명> -c "SELECT PostGIS_Version();"
docker exec -it postgres-spots psql -U <유저명> -d <DB명> -c "SELECT count(*) FROM facility;"

위와 같이 정상적으로 데이터를 옮겼고, PostGIS 설치 옵션에 대해서도 사용 가능(1)하다는 것을 확인할 수 있습니다. 설치 옵션에 대해서 살펴보면 다음과 같습니다.
- USE_GEOS = 1 -> 도형 연산 라이브러리 사용 가능
- USE_PROJ = 1 -> 좌표계 변환 라이브러리 사용 가능
- USE_STATS = 1 -> 공간 통계 및 인덱스 최적화 사용 가능
2.2 의존성 추가 및 Column 추가
위에서 말한 GEOS, PROJ, STATS 옵션들은 PostGIS 내부에서 자동으로 사용되는 것이고, Java 애플리케이션 단에서 PostGIS 공간 타입과 공간 함수를 JPA로 다루기 위해서는 Hibernate spatial 의존성을 설치해야 합니다. 의존성을 build.gradle 파일에 다음과 같이 추가해 줍니다.
# build.gradle
...
// Hibernate Spatial
implementation 'org.hibernate.orm:hibernate-spatial'
...
그다음 application.yml 파일에서 Postgresql 방언 즉, dialect 옵션을 설정도 해야 하지만, Hibernate 6.x 버전에서는 의존성만 있으면 자동으로 공간 함수를 등록해 줍니다. 이제 환경 구축은 완료됐습니다! 공간 타입을 정해야 하는데, 크게 geometry 타입과 geography 타입이 있습니다. 공식 문서를 살펴보면 다음과 같이 적혀있는 것을 확인했습니다.


정리해보면 geometry 타입의 결과는 "degree(도)"를 반환하고, geography 타입의 결과는 "meter"를 반환한다고 명시되어 있습니다. 현재 프로젝트에서는 특정 거리 내에 위치한 프로그램의 수를 구해야 하고, 현재 위치로부터의 몇 km 떨어졌는지 그 값이 필요하기 때문에 geography 공간 타입을 활용해 보도록 하겠습니다. 또 다른 이유로는 geometry 타입은 2D 직교좌표계로 지구 곡률을 무시한 상태로 계산하고, geography 타입은 타원체(지구 표면) 위 곡면좌표계로 계산을 한다고 합니다. 곡면좌표계 위에서 계산하기 때문에 속도는 조금 느리지만, 선택하지 않을 이유는 없다고 생각했습니다.
geography 컬럼을 추가해보도록 하겠습니다. ALTER 문은 공식 문서에도 친절하게 설명이 되어있어서 그대로 사용했습니다.
ALTER TABLE facility ADD COLUMN location geography(Point, 4326);
"location"이라는 이름으로 새로운 컬럼을 추가하고, PostGIS에서 제공하는 공간 데이터 타입인 Point형을 WGS84 좌표계 위에서 사용할 수 있도록 한다는 의미입니다. WGS84 좌표계는 GPS 등 위성 항법 시스템에서 표준으로 사용하는 지구 중심 기반의 3차원 측지 좌표계입니다. 숫자 4326은 WGS84 좌표계를 의미합니다. 결과적으로 현재 세계적으로 사용하고 있는 위도, 경도값을 기반이라는 것이죠.
새로운 컬럼만 추가해서 데이터가 비어있을 것입니다. 이제 시설의 위도, 경도값을 토대로 location 컬럼에 데이터를 채워보도록 하겠습니다.
UPDATE facility
SET location = ST_SetSRID(ST_MakePoint(fclty_lo, fclty_la), 4326)::geography
WHERE fclty_la IS NOT NULL AND fclty_lo IS NOT NULL;
ST_MakePoint(경도, 위도) 값으로 Point 타입 객체를 만들고, 해당 Point가 WGS84 좌표계 위의 점이라는 것을 정해줍니다. ST_SetSRID 함수에서 SRID는 "Spatial Reference Identifier"로 좌표계를 식별하는 고유 번호입니다. 같은 좌표값이라도 어떤 좌표계를 쓰느냐에 따라 전혀 다른 위치를 가리키기 때문에 좌표계를 올바르게 설정하는 것이 중요합니다. "::geography"를 붙인 이유는 ST_MakePoint()와 ST_SetSRID() 함수가 기본적으로 geometry 타입을 반환합니다. 따라서, geography 타입으로 변환하라는 Postgresql의 캐스팅 문법입니다. 또한, Point 객체를 만들 때, (위도, 경도)가 아니라, (경도, 위도) 순서라는 것을 명심하셔야 합니다.

CREATE INDEX idx_facility_location ON facility USING GIST (location);
그다음, 위와 같이 공간 인덱스를 생성해 주면 됩니다. 이 공간 인덱스 설정이 PostGIS를 도입하는 가장 핵심적인 이유입니다. 공간 인덱스는 공간 데이터를 "R-Tree 구조로 저장하여 항상 O(lon N) 탐색 속도"를 보장합니다.

전공 수업 때, R-Tree와 MBR을 도대체 어디에 사용할까... 생각했던 적이 있었는데, 여기에서 사용하고 있었군요.. 역시 수업 때 배운 내용들은 모두 쓸모가 있었습니다... 그럼 이제 애플리케이션 단에서 Haversine 공식을 활용하는 로직을 제거하고, PostGIS 함수를 사용하는 구조로 변경하도록 하겠습니다.
2.3 Haversine 공식 관련 로직 제거
import org.locationtech.jts.geom.Point;
...
@Column(columnDefinition = "geography(Point, 4326)")
private Point location;
...
Facility 시설 Entity 필드 데이터로 추가한 컬럼에 맞는 location 필드를 추가해줍니다. columnDefinition을 지정해줘서 Hibernate가 geography 타입임을 인식하고, 그에 맞는 테이블 컬럼 생성 및 매칭을 진행합니다. 이제 컬럼을 추가했으니 Haversine 로직을 제거하고, PostGIS 함수를 활용하는 로직으로 변경하겠습니다.
private BooleanExpression stDWithin(QProgram p, double longitude, double latitude, double meters) {
return Expressions.booleanTemplate(
"ST_DWithin({0}, ST_SetSRID(ST_MakePoint({1}, {2}), 4326), {3}) = true",
p.facility.location, longitude, latitude, meters
);
}
private NumberExpression<Double> stDistanceKm(QProgram p, double longitude, double latitude) {
return Expressions.numberTemplate(
Double.class,
"ST_Distance({0}, ST_SetSRID(ST_MakePoint({1}, {2}), 4326)) / 1000.0",
p.facility.location, longitude, latitude
);
}
QueryDsl을 활용하고 있었기 때문에 Expression을 별도의 함수로 분리해서 유틸성을 높였습니다. "ST_DWithin" 함수는 사용자의 위치를 하나의 Point 객체로 받아서 WGS84 좌표계로 설정해서 두 Point 객체 사이의 거리가 지정한 meters 이내인지 참/거짓을 판단하는 함수입니다. "ST_Distance" 함수는 사용자의 위치와 시설과의 위치를 km 단위로 반환하는 함수입니다.
이제 실행만 하면 성공할 줄 알았는데, "문제가 발생"했습니다.
2.4 st_dwithin 인식 문제 발생 및 해결

"ST_DWithin"함수에 대해서 왼쪽 표현 타입이 Object인데, 오른쪽에는 Boolean(true) 타입의 표현으로 비교하고 있어서 생기는 문제였습니다. 관련해서 찾아보니까 다음과 같은 글을 확인할 수 있었습니다.

Hibernate가 "st_dwithin 함수"에 대해서 잘 몰라서 어떤 값을 반환해야 하는지 모르기 때문에 발생하는 오류였습니다.("ST_Distance 함수"는 인식합니다.) 이를 해결하는 방법에는 2가지 방법을 찾을 수 있었습니다. 그 해결 방법은 다음과 같습니다.
- FunctionContributor 구현 클래스 생성 및 SPI 등록 -> HQL 파서 함수 인식
- function() 키워드 활용 -> JTS Point 객체 활용하여 DB 함수 호출
FunctionContributor를 구현해서 함수를 인식시키는 방법보다 직접 JTS Point 객체를 만들어서 function() 키워드로 콜 하는 방법이 더 DB를 잘 활용하는 방법 같아서 2번 방법으로 문제를 해결해 보기로 결정했습니다.
2.4.1 JTS Point 객체 생성
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.PrecisionModel;
...
Point center = createPoint(req.longitude(), req.latitude());
,,,
private Point createPoint(double longitude, double latitude) {
return new GeometryFactory(new PrecisionModel(), 4326)
.createPoint(new Coordinate(longitude, latitude));
}
...
코드를 살펴보면 (경도, 위도) 값을 파라미터로 받아서 JTS Point 객체를 생성하는 코드입니다. 경도, 위도 값을 활용해서 Coordinate 좌표값 객체를 만들고, 좌표값의 소수점 정밀도 모델과 SRID 값을 지정해 줍니다. 정밀도 모델은 기본값을 그대로 사용하고, SRID 값은 WGS84 좌표계를 뜻하는 4326을 넣어줍니다. 이를 공간 객체 생성기인 GeometryFactory를 이용해 객체를 Point 객체를 만들어줍니다.
2.4.2 function() 키워드 적용
...
QProgram p = program;
BooleanBuilder where = new BooleanBuilder();
NumberExpression<Double> distance;
if (req.latitude() != null && req.longitude() != null) {
Point center = createPoint(req.longitude(), req.latitude());
where.and(stDWithin(p, center, 10000.0));
distance = stDistanceKm(p, center);
} else {
distance = null;
}
private BooleanBuilder stDWithin(QProgram p, Point center, double meters) {
return new BooleanBuilder(
Expressions.booleanTemplate(
"function('st_dwithin', {0}, {1}, {2}) = true",
p.facility.location, center, meters
)
);
}
private NumberExpression<Double> stDistanceKm(QProgram p, Point center) {
return Expressions.numberTemplate(
Double.class,
"ST_Distance({0}, {1}) / 1000.0",
p.facility.location, center
);
}
...
유저의 위치 정보가 담긴 Point 객체를 center 변수에 담고, 시설 위치 정보인 facility.location Point 객체를 st_dwithin 함수와 ST_Distance 함수에 사용합니다. 의미는 코드를 읽어만 봐도 너무나도 명확합니다.

결과적으로 동일하게 쿼리가 3개 나갔고, 실제 DB로 나간 쿼리에 st_distance, st_dwithin 함수를 활용한 것을 확인할 수 있었습니다 :>
2.5 성능 테스트 및 결과
그럼 이제 Haversine 공식을 활용하던 로직과 PostGIS 함수들을 사용하여 공간 인덱스를 활용하는 로직의 쿼리 성능이 얼마나 나오는지 한번 비교해 보도록 하겠습니다.

Haversine 공식을 활용했을 때의 쿼리 실행 계획 분석 결과입니다. "Seq Scan"은 "Full Scan"을 의미합니다. 즉, 시설 테이블의 모든 데이터를 읽고, 10km 이내에 있는 23개의 시설 데이터를 선택했고, 계획 시간과 실제 실행 시간은 약 "0.13ms"입니다. 데이터가 208개 밖에 없기 때문에 수행 시간이 별로 나오지 않는 것을 확인할 수 있었습니다. 그러면 공간 인덱스를 활용하는 경우도 살펴보겠습니다.

옵티마이저가 인덱스를 활용했기 때문에 "Seq Scan"이 아닌 "Bitmap Heap Scan" 내용을 확인할 수 있습니다. 또한, 인덱스를 활용하지 않았을 때에는 "Rows Removed by Filter: 185"라는 결과가 나왔습니다. 즉, 전체 208개의 데이터 중에서 185개를 걸러내고, 23개의 데이터를 선택했다는 것이죠. 그러나, 인덱스를 활용한 경우에는 "Rows Removed by Filter: 14"라는 결과가 나온 것을 확인할 수 있었습니다. 즉, 인덱스를 활용해서 검사 대상을 축소시켰음을 확인할 수 있었습니다.
검사 대상도 줄였고, 인덱스를 활용했기 때문에 당연히 응답 속도가 더 빠르게 나올 줄 알았는데, 예상외의 결과가 나왔습니다. 테이블 풀스캔을 했을 때보다 약 2배가 넘는 시간인 "0.366ms" 소요됐습니다. 데이터가 적기 때문에 인덱스를 타고, 바운딩 박스에 접근하여 필터링하고, 힙 페이지에 접근해서 계산하는 과정보다 그냥 삼각함수로 모든 데이터를 계산하는 게 훨씬 빠른 것 같았습니다.
그러면 PostGIS는 쓸모가 없는 걸까요..? 데이터의 크기와 데이터의 밀집도가 관련이 있을 것 같아서 한국 전역의 위도, 경도 값을 랜덤으로 생성해서 데이터 수를 [200, 1000, 5000, 10000, 50000, 100000, 500000] 순차적으로 늘려가면서 쿼리 수행 시간을 측정해 보았습니다.

Haversine 공식을 활용해서 쿼리 수행 시간을 측정해 본 결과 데이터가 늘어날수록 비례해서 수행시간이 늘어나는 것을 확인할 수 있었습니다. 즉, "O(n) 선형"으로 늘어나는 것이죠. 그에 비해서 PostGIS 함수를 활용해서 공간 인덱스를 타는 경우에는 비교적 큰 차이가 없는 것을 확인했습니다. 물론, 특정 장소에 데이터가 너무 밀집되어 있어서 Haversine 공식과 ST_Distance 함수를 동일하게 사용할 수 있겠지만, 전국적으로 퍼져있는 데이터의 경우에는 바운딩 박스로 빠르게 데이터를 걸러내기 때문에 PostGIS의 이점이 훨씬 커질 것 같습니다.
3. 정리하며
지금까지 "10 km 이내의 체육 프로그램 찾기"라는 기능을 구현하기 위해서 Haversine 공식을 활용하여 두 위치 간 거리 공식을 구하는 로직을 PostGIS 함수들을 활용하도록 도입하는 과정에 대해서 정리해 보았습니다. Postgresql DB에 내장되어 있는 기능이어서 적용하는데 그렇게 오랜 시간이 걸리진 않았지만, 적용해 가면서 또 많은 것들을 배울 수 있었습니다.
- PostGIS는 R-Tree를 활용하여 공간 인덱스를 생성한다.
- Hibernate는 st_dwithin 함수를 인식하지 못한다.
- 공간 인덱스를 사용하더라도 적은 데이터 환경 속에서는 오버헤드로 인하여 수행 시간이 더 오래 걸릴 수 있다.
물론, 제가 잘 모르는 부분이 있어서 활용을 못하는 부분도 있을 것입니다. 그래도 이렇게 적용해 보면서 PostGIS가 어느 상황에서 더 이점을 얻을 수 있고, 어떤 경우에 활용하면 좋을지 알아가는 정말 소중한 시간이었던 것 같습니다. 이번 경험을 통해서 Postgresql도 활용해 볼 수 있어서 너무 재미있었습니다! 다음에도 좋은 내용들로 찾아올 수 있도록 해보겠습니다! 감기 조심하세요 :>
4. Reference
'Project' 카테고리의 다른 글
| [Stash] AWS Bedrock 활용한 북마크 플러그인 개발기 (0) | 2026.05.01 |
|---|---|
| [FitFinder] Fetch Join과 Batch 조회를 통한 쿼리 최적화 여정기 (0) | 2026.04.04 |
| [ReTrip] 아키텍처 개선 여정기 (0) | 2026.03.30 |
