1. PostGIS란?

  • PostGIS는 오픈소스 RDBMS인 PostgreSQL공간(지리) 데이터 처리 기능을 추가해주는 Extension임.
PostgreSQL + PostGIS = 위치/지도 데이터를 다루는 공간 데이터베이스.
  • GIS(Geographic Information System)에서 필요한 거리 계산, 영역 검색, 도형 연산 등을 SQL로 처리할 수 있으며, OGC(Open Geospatial Consortium)의 Simple Features for SQL 표준을 준수함.

2. 주요 특징


2-1. 다양한 공간 데이터 타입

타입설명
Point단일 좌표 (위도/경도) — 건물 위치, GPS 좌표
LineString연결된 좌표의 선 — 도로, 강, 경로
Polygon닫힌 다각형 — 건물 외곽, 행정구역 경계
MultiPoint복수의 점 집합
MultiPolygon복수의 다각형 집합 — 섬이 여러 개인 지역
GeometryCollection여러 타입을 혼합한 집합

2-2. 200개 이상의 공간 함수

  • 거리 계산, 면적 측정, 도형 교차 여부 등 GIS 연산을 SQL로 처리.

2-3. 좌표계(SRID) 지원

  • SRID(Spatial Reference ID)로 다양한 좌표계를 지원함.
    • SRID 4326: WGS84 (GPS 기본 좌표계, 전 세계 표준)
    • SRID 5179: GRS80 (국내 지도 서비스에서 자주 사용)

2-4. GIST 공간 인덱스

  • B-Tree 인덱스로는 처리할 수 없는 2D 공간 데이터에 특화된 인덱스로, 대규모 공간 쿼리도 빠르게 처리함.

2-5. 풍부한 생태계 연동

  • QGIS, GeoServer, Mapbox, Leaflet 등 GIS 툴과 바로 연동할 수 있음.

3. 설치 및 초기 설정


3-1. 확장 활성화.

-- PostGIS 확장 설치
CREATE EXTENSION postgis;

-- 설치 확인
SELECT PostGIS_Version();

Ex) 공간 컬럼이 있는 테이블 생성

CREATE TABLE cafes (
    id       SERIAL PRIMARY KEY,
    name     VARCHAR(100),
    location GEOMETRY(Point, 4326)  -- 공간 컬럼 (Point 타입, WGS84 좌표계)
);
  • SRID 4326: WGS84(World Geodetic System 1984)라는 좌표계를 의미.
    • SRID(Spatial Reference Identifier), 즉 좌표계의 고유 번호.

3-1-1. 오류 발생시.

CREATE EXTENSION postgis;

오류: "postgis" 이름의 확장 모듈을 사용할 수 없습니다
SQL state: 0A000 Detail: "C:/Program Files/PostgreSQL/17/share/extension/postgis.control"
확장 모듈 제어 파일 열기 실패: No such file or directory.
Hint: 해당 확장 모듈은 PostgreSQL 시작 전에 먼저 설치 되어 있어야합니다.

  • 해당 프로그램을 통해 PostGIS를 설치해야됨.

4. 사용법.


4-1. 데이터 삽입

  • ST_GeomFromText()WKT(Well-Known Text, 공간 데이터를 텍스트로 표현하는 표준 형식) 문자열을 공간 데이터로 변환.
  • 좌표 순서 주의
    • PostGIS는 POINT(경도 위도) 순서임.
INSERT INTO cafes (name, location) VALUES
    ('스타벅스 강남점', ST_GeomFromText('POINT(127.0276 37.4979)', 4326)),
    ('블루보틀 성수점', ST_GeomFromText('POINT(127.0553 37.5445)', 4326)),
    ('메가커피 종로점', ST_GeomFromText('POINT(126.9894 37.5700)', 4326));
  • ST_SetSRID(ST_MakePoint(경도, 위도), 4326) 방식에서도 순서가 동일.
INSERT INTO cafes (name, location) VALUES
    ('투썸 홍대점', ST_SetSRID(ST_MakePoint(126.9234, 37.5563), 4326));
  • ST_GeomFromText는 문자열을 파싱해서 변환하는 과정이 있어서 상대적으로 느림.
    • WKT 문자열을 직접 다루거나 가독성이 중요할 때 사용.
  • ST_MakePoint는 숫자를 바로 받아서 도형을 생성하기 때문에 더 효율적
    • 애플리케이션에서 좌표값을 변수로 넘길 때 사용.

4-2. 거리 계산

함수반환 타입설명
ST_Distance(a, b)float두 도형 사이의 거리
-- 서울시청(126.9784, 37.5665) 기준 각 카페까지의 거리 (미터)
SELECT
    name,
    ROUND(
        ST_Distance(
            location::geography,
            ST_GeomFromText('POINT(126.9784 37.5665)', 4326)::geography
            -- ST_SetSRID(ST_MakePoint(126.9784, 37.5665), 4326)::geography
        )::numeric
    ) AS distance_m
FROM cafes
ORDER BY distance_m;
  • ::: 형변환(type casting) 연산자.
  • ::geography: geometry 타입 -> geography 타입으로 변환.
    • 변환하는 이유 -> 지구 곡률을 반영한 실제 거리(미터)를 계산함.
  • 기본 geometry 타입에서 ST_Distance를 쓰면 단위가 도(degree)로 나와서 거리를 알기 어려움.
    • geography로 변환하면 PostGIS가 지구가 둥글다는 것을 계산에 반영하여 단위를 미터(m)로 출력해줌.

4-3. 반경 내 검색

함수반환 타입설명
ST_DWithin(a, b, r)boolean반경 r 이내 여부
-- 현재 위치 반경 3km 이내 카페 검색
SELECT name
FROM cafes
WHERE ST_DWithin(
    location::geography,
    ST_GeomFromText('POINT(126.9784 37.5665)', 4326)::geography,
    3000   -- 단위: 미터
);

4-4. 특정 지역 내 포함 여부

  • 특정 점(Point)이 지정한 다각형(Polygon, 영역) 안에 완전히 들어가 있는지 확인.
    • 좌표의 마지막은 반드시 첫 번째 좌표와 같아야 합니다(도형을 닫아야 함).
-- 해당 좌표가 폴리곤(강남구) 안에 있는지 확인.
SELECT ST_Contains(
    ST_GeomFromText('POLYGON((
        127.01 37.49, 127.07 37.49,
        127.07 37.52, 127.01 37.52,
        127.01 37.49
    ))', 4326),
    ST_GeomFromText('POINT(127.0276 37.4979)', 4326)
) AS is_in_gangnam;
-- 결과: true

4-5. 자주 쓰는 공간 함수

함수반환 타입설명
ST_Distance(a, b)float두 도형 사이의 거리
ST_DWithin(a, b, r)boolean반경 r 이내 여부
ST_Contains(a, b)booleana가 b를 완전히 포함하는지
ST_Intersects(a, b)boolean두 도형이 겹치는지
ST_Area(geom)float면적 계산 (geography → m²)
ST_Length(geom)float선의 길이
ST_Centroid(geom)geometry도형의 중심점
ST_Buffer(geom, r)geometry반경 r의 버퍼 도형 생성
ST_Intersection(a, b)geometry두 도형의 교집합
ST_Union(a, b)geometry두 도형의 합집합
ST_AsText(geom)textWKT 형식 문자열로 변환
ST_AsGeoJSON(geom)textGeoJSON 형식으로 변환

6. 공간 인덱스 (GIST Index)


6-1. 왜 공간 인덱스가 필요한가?

  • 일반 B-Tree 인덱스는 숫자/문자열 비교에 최적화되어 있음.
  • 2D 공간 데이터의 겹침·포함 연산에는 사용할 수 없음.
    • PostGIS는 이를 위해 GIST(Generalized Search Tree)라는 인덱스를 사용함.
      • GiST는 내부적으로 R-Tree(Rectangle Tree) 구조를 사용하여 공간 데이터를 관리.

6-2. 인덱스 생성.

기본 GIST 공간 인덱스

CREATE INDEX idx_cafes_location
ON cafes USING GIST(location);
  • location 컬럼이 GEOMETRY 타입이므로 평면 좌표 기준으로 인덱스를 만듦.
-- geography 타입 쿼리가 많다면 별도 인덱스 생성
CREATE INDEX idx_cafes_location_geo
ON cafes USING GIST(location::geography);
  • location 컬럼을 geography형변환한 상태로 인덱스를 만듦.

6-2-1. 인덱스 사용.

  • PostgreSQL은 쿼리의 타입과 인덱스의 타입이 일치할 때만 인덱스를 사용함.
    • 형변환을 하면 PostgreSQL 입장에서는 다른 타입으로 인식하기 때문.
-- geometry 인덱스만 있을 때
WHERE ST_DWithin(location, '...', 5000)             -- ✅ 인덱스 사용
WHERE ST_DWithin(location::geography, '...', 5000)  -- ❌ 인덱스 못 씀 (풀스캔)

-- geography 인덱스만 있을 때
WHERE ST_DWithin(location, '...', 5000)             -- ❌ 인덱스 못 씀 (풀스캔)
WHERE ST_DWithin(location::geography, '...', 5000)  -- ✅ 인덱스 사용
상황권장
서비스범위가 좁음 (특정 도시 내)geometry 인덱스
전국/글로벌 서비스, 실거리 계산 필요geography 인덱스
두 타입 쿼리가 혼재둘 다 생성
  • 대부분의 위치 기반 서비스는 ::geography실거리를 계산하니까 geography 인덱스 하나만 만드는 경우가 많음.

6-3. 인덱스 팁

  • 대용량 데이터 삽입 후에는 VACUUM ANALYZE로 통계 정보를 갱신.
  • ::geography 형변환 쿼리가 많다면 geography 기반 인덱스를 별도로 생성.
  • 일반적인 공간 검색에는 GIST 인덱스를 사용하는 것이 표준.

7. geometry vs geography

구분geometrygeography
기준평면 좌표계 (2차원 평면(가로, 세로) 위에서 취급)지구 구면체 (둥근 지구(구면체) 위에 있는 위치로 취급)
거리 단위도 (degree)미터 (m)
정확도좁은 지역은 정확, 넓으면 오차 큼실제 거리 정확
연산 속도빠름상대적으로 느림
권장 상황소규모 영역 (도시 내)전국/글로벌 서비스

8. Ex) 배달앱 주변 가게 검색

-- 테이블 정의
CREATE TABLE restaurants (
    id       SERIAL PRIMARY KEY,
    name     VARCHAR(100),
    category VARCHAR(50),
    location GEOMETRY(Point, 4326)
);

-- 데이터
INSERT INTO restaurants (name, category, location) VALUES
    ('맛있는 한식당', '한식', ST_SetSRID(ST_MakePoint(126.988439, 37.532301), 4326)),
    ('차이나타운', '중식', ST_SetSRID(ST_MakePoint(126.962202, 37.546571), 4326)),
    ('도쿄식당', '일식', ST_SetSRID(ST_MakePoint(126.995426, 37.579222), 4326)),
    ('브런치카페', '양식', ST_SetSRID(ST_MakePoint(127.006637, 37.536760), 4326)),
    ('쫄면천국', '분식', ST_SetSRID(ST_MakePoint(126.972778, 37.532645), 4326)),
    ('할리스', '카페', ST_SetSRID(ST_MakePoint(126.958142, 37.566886), 4326)),
    ('교촌치킨', '치킨', ST_SetSRID(ST_MakePoint(126.944311, 37.544816), 4326)),
    ('미스터피자', '피자', ST_SetSRID(ST_MakePoint(126.989192, 37.569736), 4326)),
    ('쉐이크쉑', '버거', ST_SetSRID(ST_MakePoint(126.958272, 37.572927), 4326)),
    ('명품족발', '족발/보쌈', ST_SetSRID(ST_MakePoint(127.000679, 37.530968), 4326)),
    ('참나물 밥집 본점', '한식', ST_SetSRID(ST_MakePoint(127.000419, 37.580766), 4326)),
    ('차이나타운 본점', '중식', ST_SetSRID(ST_MakePoint(126.966898, 37.541695), 4326)),
    ('도쿄식당 본점', '일식', ST_SetSRID(ST_MakePoint(127.011319, 37.554735), 4326)),
    ('브런치카페 본점', '양식', ST_SetSRID(ST_MakePoint(126.949078, 37.537464), 4326)),
    ('쫄면천국 본점', '분식', ST_SetSRID(ST_MakePoint(127.003420, 37.573968), 4326)),
    ('메가커피 본점', '카페', ST_SetSRID(ST_MakePoint(127.000513, 37.583041), 4326)),
    ('교촌치킨 본점', '치킨', ST_SetSRID(ST_MakePoint(126.981008, 37.600564), 4326)),
    ('미스터피자 본점', '피자', ST_SetSRID(ST_MakePoint(126.969654, 37.570247), 4326)),
    ('쉐이크쉑 본점', '버거', ST_SetSRID(ST_MakePoint(127.002117, 37.575033), 4326)),
    ('명품족발 본점', '족발/보쌈', ST_SetSRID(ST_MakePoint(127.004443, 37.572069), 4326)),
    ('고향 식당 강남점', '한식', ST_SetSRID(ST_MakePoint(126.993129, 37.533799), 4326)),
    ('차이나타운 강남점', '중식', ST_SetSRID(ST_MakePoint(126.958809, 37.551336), 4326)),
    ('도쿄식당 강남점', '일식', ST_SetSRID(ST_MakePoint(126.948145, 37.547261), 4326)),
    ('브런치카페 강남점', '양식', ST_SetSRID(ST_MakePoint(126.949672, 37.550514), 4326)),
    ('쫄면천국 강남점', '분식', ST_SetSRID(ST_MakePoint(126.988169, 37.556768), 4326)),
    ('스타벅스 강남점', '카페', ST_SetSRID(ST_MakePoint(126.969053, 37.545585), 4326)),
    ('교촌치킨 강남점', '치킨', ST_SetSRID(ST_MakePoint(126.961622, 37.597939), 4326)),
    ('미스터피자 강남점', '피자', ST_SetSRID(ST_MakePoint(126.989059, 37.574357), 4326)),
    ('쉐이크쉑 강남점', '버거', ST_SetSRID(ST_MakePoint(126.954722, 37.582997), 4326)),
    ('명품족발 강남점', '족발/보쌈', ST_SetSRID(ST_MakePoint(126.954165, 37.557821), 4326)),
    ('맛있는 한식당 종로점', '한식', ST_SetSRID(ST_MakePoint(127.013646, 37.576580), 4326)),
    ('차이나타운 종로점', '중식', ST_SetSRID(ST_MakePoint(126.982500, 37.579792), 4326)),
    ('도쿄식당 종로점', '일식', ST_SetSRID(ST_MakePoint(127.003085, 37.586372), 4326)),
    ('브런치카페 종로점', '양식', ST_SetSRID(ST_MakePoint(126.958891, 37.532811), 4326)),
    ('쫄면천국 종로점', '분식', ST_SetSRID(ST_MakePoint(126.965113, 37.549777), 4326)),
    ('할리스 종로점', '카페', ST_SetSRID(ST_MakePoint(126.957591, 37.598389), 4326)),
    ('교촌치킨 종로점', '치킨', ST_SetSRID(ST_MakePoint(127.005498, 37.553157), 4326)),
    ('미스터피자 종로점', '피자', ST_SetSRID(ST_MakePoint(126.989592, 37.558985), 4326)),
    ('쉐이크쉑 종로점', '버거', ST_SetSRID(ST_MakePoint(127.008247, 37.563537), 4326)),
    ('명품족발 종로점', '족발/보쌈', ST_SetSRID(ST_MakePoint(126.961471, 37.548257), 4326)),
    ('참나물 밥집 홍대점', '한식', ST_SetSRID(ST_MakePoint(126.982819, 37.549417), 4326)),
    ('차이나타운 홍대점', '중식', ST_SetSRID(ST_MakePoint(126.984490, 37.595143), 4326)),
    ('도쿄식당 홍대점', '일식', ST_SetSRID(ST_MakePoint(126.971157, 37.546291), 4326)),
    ('브런치카페 홍대점', '양식', ST_SetSRID(ST_MakePoint(127.014223, 37.567186), 4326)),
    ('쫄면천국 홍대점', '분식', ST_SetSRID(ST_MakePoint(126.948945, 37.533892), 4326)),
    ('메가커피 홍대점', '카페', ST_SetSRID(ST_MakePoint(126.950295, 37.575676), 4326)),
    ('교촌치킨 홍대점', '치킨', ST_SetSRID(ST_MakePoint(126.999430, 37.560896), 4326)),
    ('미스터피자 홍대점', '피자', ST_SetSRID(ST_MakePoint(126.946974, 37.557977), 4326)),
    ('쉐이크쉑 홍대점', '버거', ST_SetSRID(ST_MakePoint(127.014121, 37.568596), 4326)),
    ('명품족발 홍대점', '족발/보쌈', ST_SetSRID(ST_MakePoint(127.012318, 37.592476), 4326));

-- 공간 인덱스
CREATE INDEX idx_restaurants_location
ON restaurants USING GIST(location);

-- 사용자 위치 반경 2km, 거리순 정렬, 상위 20개
SELECT
    name,
    category,
    ROUND(ST_Distance(
        location::geography,
        ST_GeomFromText('POINT(126.9784 37.5665)', 4326)::geography
    )::numeric) AS distance_m
FROM restaurants
WHERE ST_DWithin(
    location::geography,
    ST_GeomFromText('POINT(126.9784 37.5665)', 4326)::geography,
    2000
)
ORDER BY distance_m
LIMIT 20;

profile
Every cloud has a silver lining.

0개의 댓글