1. 무엇을 개발하려고 했을까
배달 서비스는 날씨나 운영 상황에 따라
유연하게 정책을 조정해야 하는 특성을 가집니다.
예를 들어, 기상 악화로 특정 지역의 접근성이 급격히 낮아지거나
명절 등 특정 상권에 주문 수요가 일시적으로 집중되는 상황이 발생할 수 있기 때문입니다.

이러한 상황에 대응하기 위해
주문이 생성될 때 해당 주문의 도착 좌표가
지도 상에 정의된 ‘도착지 중단 구역’(다각형)에 포함되는지를 판단하는 기능이 필요해 졌습니다.
이 판단은 다각형 영역 기준으로 수행되어야 했으며,
주문 생성 흐름의 핵심 경로에 포함되는 로직인 만큼 지연 없는 응답 성능이 중요했습니다.
2-1. 기존 방식의 한계
기존 시스템에도 오라클 DB 기반으로, 특정 구역을 설정하는 기능이 있기는 했습니다.
그러나 기존 로직은 배송 구역을 하나의 공간 객체(면)로 다루지 못하고,
좌표 기반의 수치 데이터로만 관리하는 방식이었습니다.
실제 데이터 모델은 다음과 같은 구조를 가지고 있었습니다. (단순 예시 이미지)

create table AREA
(
...
AREA_SEQ NUMBER not null,
XY_SEQ NUMBER not null,
S_MAP_Y VARCHAR2(20),
S_MAP_X VARCHAR2(20),
E_MAP_Y VARCHAR2(20),
E_MAP_X VARCHAR2(20),
AREA_NAME VARCHAR2(50),
...
)
이 테이블은 배송 구역을 하나의 도형으로 통째로 저장하지 않고,
도형을 이루는 선 하나하나를 잘라 각각 개별 레코드로 저장한 구조였습니다.
하나의 배송 구역은 여러 개의 선분 레코드로 구성되며
각 선분은 시작 좌표(S_MAP_X, S_MAP_Y)와
종료 좌표(E_MAP_X, E_MAP_Y)를
각각의 컬럼으로 저장하고 있었습니다.
이러한 구조로 인해 ‘배송 구역’이라는 도메인 개념이
데이터베이스 상에서 하나의 의미 있는 단위로 표현되지 못하고
여러 개의 좌표 데이터로 분산되어 존재하는 한계를 가지고 있었습니다.
2.2 SQL 기반 좌표 계산 로직의 복잡성
기존 시스템에서는 특정 좌표가 배송 구역 내부에 포함되는지를 판단하기 위해
선분 데이터를 기반으로 SQL에서 직접 좌표 계산을 수행했습니다.
🔴 AS-IS
생략....
WHERE 생략....
AND (
(
A.S_MAP_Y <= :latitude
AND A.E_MAP_Y > :latitude
)
OR (
A.S_MAP_Y > :latitude
AND A.E_MAP_Y <= :latitude
)
)
AND (
A.S_MAP_X
+ (
(:latitude - A.S_MAP_Y)
/ (A.E_MAP_Y - A.S_MAP_Y)
* (A.E_MAP_X - A.S_MAP_X)
)
) > :longitude
해당 쿼리는 선분의 시작점과 종료점을 이용해
도착 좌표와의 교차 여부를 계산하고,
이를 통해 영역 내부 포함 여부를 판단하는 방식입니다.
이 과정에서 하나의 정책 판단을 위해
복잡한 좌표 계산 로직이 SQL 내부에 직접 포함되어 쿼리 구조가 다소 복잡해졌습니다.
3. 계산이 아닌 공간 개념으로 문제를 다시 정의하다
기존 시스템은 오라클 기반 환경에서 운영되고 있었으나,
이번에 개발한 기능은 신규 서비스 흐름에 포함된 기능으로
PostgreSQL 기반 환경에서 구현할 수 있었습니다.
이에 따라 기존 좌표 계산 중심의 구현 방식을 확장하기보다는,
배송 구역과 도착 좌표를 공간 개념으로 명확히 표현할 수 있는 방식을 생각해 보게 되었습니다.
❓ PostGIS
어떤 방식이 적합할지 검토하던 중
PostgreSQL에서 제공하는 PostGIS를 활용하는 방안을 고려하게 되었습니다.
PostGIS는 공간 데이터를 데이터베이스 수준에서 직접 다룰 수 있도록 지원하는 확장 기능입니다.
점(Point), 선(Line), 면(Polygon)과 같은 공간 객체를 하나의 도메인 단위로 표현하고
이들 간의 관계를 공간 연산을 통해 직관적으로 처리할 수 있도록 해줍니다.
🔵 TO-BE
-- 도착 좌표(Point)가 배송 구역(Polygon)에 포함되는지 판단
WHERE ST_Contains(
delivery_area,
ST_SetSRID(
ST_Point(:longitude, :latitude),
4326
)
)
위에서 살펴봤던 복잡하고 길었던 🔴 AS-IS 코드가
PostGIS를 적용한 이후에는 의도가 명확한 한 줄의 조건식으로 단순화되었습니다.
테이블의 area 필드에는 아래와 같이 저장됩니다. (단순 예시 이미지)

POLYGON ((126.8553151 37.4923487,
126.8567742 37.4892159,
126.8641985 37.4911228,
126.8553151 37.4923487))
4. 테스트 해보다
🔵 테스트 결론 (Conclusion)
기존 Oracle 기반 선분 좌표 + 산술 연산 방식과
PostGIS의 공간 객체(Polygon) + GIST 인덱스 방식을 동일한 데이터 조건에서 비교한 결과
PostGIS 방식이 평균 응답 시간 기준 약 18배 이상의 성능 개선을 보였습니다.
특히 기존 방식은 데이터 수 증가에 따라 선형적으로 성능이 저하되었다.
반면 PostGIS 방식은 공간 인덱스를 활용하여 대규모 데이터에서도 안정적인 응답 시간을 유지했습니다.
이를 통해 배달 가능 영역 판단과 같이
주문 생성 핵심 경로에 포함되는 로직에 대해
PostGIS 도입이 충분한 기술적·성능적 타당성을 가짐을 검증했습니다.
비교 목적 및 조건
1) 비교 목적
- 기존 Oracle 기반 Ray-Casting 산술 연산 로직의 성능 한계 검증
- PostGIS 공간 객체 + 인덱스 적용 시 성능 개선 효과 정량화
- 데이터 증가 상황에서의 확장성(Scalability) 비교
2) 비교 대상 로직
🔴 기존 방식
- 선분 단위 좌표 저장
- 모든 선분에 대해 산술 연산 수행
- SQL 내 Ray-Casting 공식 직접 구현
- Full Scan 기반 처리
(A.s_map_x + ((lat - A.s_map_y) / (A.e_map_y - A.s_map_y)
* (A.e_map_x - A.s_map_x))) > lon
🔵 신규 방식 (PostGIS)
- 영역을 Polygon Geometry로 저장
- 공간 인덱스(GIST) 사용
- PostGIS 표준 공간 연산 함수 활용
ST_Contains(area, ST_SetSRID(ST_Point(lon, lat), 4326))
| 항목 | 기존 방식 | PostGIS 방식 |
| 시간 복잡도 | O(n) | O(log n) |
| 스캔 방식 | Seq Scan | Index Scan |
| 코드 복잡도 | 높음 (수식 다수) | 매우 낮음 |
| 유지보수 | 어려움 | 쉬움 |
| 확장성 | 데이터 증가 시 급격한 저하 | 안정적 |
🧪 테스트 방법
1) 테스트 환경 초기화
- 두 테이블을 모두 제거 후 재생성하여 조건 동일화
- 재실행 시에도 오류가 나지 않도록 DROP TABLE IF EXISTS 사용
-- PostGIS 설치 여부 확인
CREATE EXTENSION IF NOT EXISTS postgis;
SELECT postgis_version();
DROP TABLE IF EXISTS ald_grp_br_xy_area_pg;
DROP TABLE IF EXISTS delivery_block_area;
2) 기존 방식 테이블 생성 (선분 구조)
- Oracle 구조를 그대로 Postgres에 재현
- 선분 단위 좌표 저장
CREATE TABLE ald_grp_br_xy_area_pg (
br_code varchar(5) NOT NULL,
area_seq integer NOT NULL,
xy_seq integer NOT NULL,
s_map_y varchar(20),
s_map_x varchar(20),
e_map_y varchar(20),
e_map_x varchar(20),
area_name varchar(50),
CONSTRAINT ald_grp_br_xy_area_pg_pk
PRIMARY KEY (br_code, area_seq, xy_seq)
);
3) PostGIS 테이블 생성
- Polygon Geometry 저장
- GIST 공간 인덱스 생성
CREATE TABLE delivery_block_area (
id bigserial PRIMARY KEY,
hub_id varchar(10) NOT NULL,
name varchar(100) NOT NULL,
area geometry(Polygon, 4326) NOT NULL
);
CREATE INDEX delivery_block_area_gist_idx
ON delivery_block_area USING gist (area);
4) 테스트 데이터 생성
- 영역 수: 1,000개
- 영역당 선분 수: 8개
- 총 선분 데이터: 8,000 row
- 모든 영역은 랜덤 중심 좌표를 기준으로 한 닫힌 다각형 구조로 생성
DO $$
DECLARE
area_cnt INTEGER := 1000;
edge_cnt INTEGER := 8;
area_seq_i INTEGER;
edge_i INTEGER;
base_lat DOUBLE PRECISION;
base_lon DOUBLE PRECISION;
radius DOUBLE PRECISION;
lat1 DOUBLE PRECISION;
lon1 DOUBLE PRECISION;
lat2 DOUBLE PRECISION;
lon2 DOUBLE PRECISION;
BEGIN
FOR area_seq_i IN 1..area_cnt LOOP
base_lat := 37.40 + random() * 0.30;
base_lon := 126.80 + random() * 0.40;
radius := 0.003 + random() * 0.01;
FOR edge_i IN 1..edge_cnt LOOP
lat1 := base_lat + radius * sin(2 * pi() * (edge_i - 1) / edge_cnt);
lon1 := base_lon + radius * cos(2 * pi() * (edge_i - 1) / edge_cnt);
lat2 := base_lat + radius * sin(2 * pi() * edge_i / edge_cnt);
lon2 := base_lon + radius * cos(2 * pi() * edge_i / edge_cnt);
INSERT INTO ald_grp_br_xy_area_pg (
br_code,
area_seq,
xy_seq,
s_map_y,
s_map_x,
e_map_y,
e_map_x,
area_name
) VALUES (
'B0337',
area_seq_i,
edge_i,
lat1::text,
lon1::text,
lat2::text,
lon2::text,
'TEST_AREA_' || area_seq_i
);
END LOOP;
END LOOP;
END
$$ LANGUAGE plpgsql;
SELECT
COUNT(*) AS total_rows,
COUNT(DISTINCT area_seq) AS area_count
FROM ald_grp_br_xy_area_pg;
5) 선분 → Polygon 변환
- 동일한 데이터로 PostGIS 테이블 구성
- ARRAY_AGG + ST_MakeLine + ST_MakePolygon
- 시작점 재추가로 Polygon 닫힘 보장
INSERT INTO delivery_block_area (
hub_id,
name,
area,
created_by,
created_at,
updated_at
)
SELECT
br_code,
area_name,
ST_SetSRID(
ST_MakePolygon(
ST_AddPoint(
ST_MakeLine(
ARRAY_AGG(
ST_Point(s_map_x::float, s_map_y::float)
ORDER BY xy_seq
)
),
ST_StartPoint(
ST_MakeLine(
ARRAY_AGG(
ST_Point(s_map_x::float, s_map_y::float)
ORDER BY xy_seq
)
)
)
)
),
4326
),
'perf_test',
NOW(),
NOW()
FROM ald_grp_br_xy_area_pg
GROUP BY br_code, area_name;
SELECT
COUNT(*) AS total_polygons,
COUNT(*) FILTER (WHERE ST_IsClosed(area)) AS closed_polygons
FROM delivery_block_area;
6) 성능 비교 쿼리 실행
🔴 기존 방식
EXPLAIN ANALYZE
SELECT COUNT(*)
FROM ald_grp_br_xy_area_pg A
WHERE A.br_code = 'B0337'
AND (
(A.s_map_y::float <= 37.55 AND A.e_map_y::float > 37.55)
OR (A.s_map_y::float > 37.55 AND A.e_map_y::float <= 37.55)
)
AND (
A.s_map_x::float
+ ((37.55 - A.s_map_y::float)
/ (A.e_map_y::float - A.s_map_y::float)
* (A.e_map_x::float - A.s_map_x::float))
) > 126.95;
🔵 PostGIS 방식
EXPLAIN ANALYZE
SELECT COUNT(*)
FROM ald_grp_br_xy_area_pg A
WHERE A.br_code = 'B0337'
AND (
(A.s_map_y::float <= 37.55 AND A.e_map_y::float > 37.55)
OR (A.s_map_y::float > 37.55 AND A.e_map_y::float <= 37.55)
)
AND (
A.s_map_x::float
+ ((37.55 - A.s_map_y::float)
/ (A.e_map_y::float - A.s_map_y::float)
* (A.e_map_x::float - A.s_map_x::float))
) > 126.95;
📈 최종 결과
- 기존 방식: 약 72 ms
- PostGIS 방식: 약 4 ms
👉 약 18배 성능 개선
5. 마무리하며: 계산을 넘어, 문제를 다시 정의하는 힘
이번 작업은 새로운 기술을 적용해 본 경험을 넘어,
기존 구조를 그대로 받아들이지 않고 왜 이런 한계가 발생했는지를 질문한 기록입니다.
처음에는 복잡한 수학 수식으로 점철된 SQL을 어떻게 최적화할지 고민했지만
문제의 본질은 계산 방식이 아니라
공간 데이터를 어떻게 모델링하고 있는가에 있음을 깨달았습니다.
이에 ‘좌표 계산’이 아닌 ‘공간 개념’으로 문제를 다시 정의했고
동일한 조건에서 직접 8,000건의 데이터를 생성해 실행 계획과 성능을 비교하며
추측이 아닌 데이터 기반으로 기술적 타당성을 검증했습니다.
그 결과, 평균 응답 시간은 72ms에서 4ms로 단축되었고
이는 단순한 성능 개선을 넘어
주문 생성이라는 핵심 경로의 안정성을 확보했다는 점에서 의미가 있었습니다.
코드의 가독성과 유지보수성, 그리고 확장성까지 함께 개선할 수 있었습니다.
문제를 올바르게 정의하고, 스스로 가설을 세우며 검증하는 과정에서
저는 개발자로서 지치지 않고 성장할 수 있는 ‘사고의 근력’을 기를 수 있었다고 생각합니다.
이러한 자기주도적인 문제 접근 방식과 기준을 세워가는 태도가
토스 Learner’s High가 지향하는 서버 개발자의 성장 방향성과 맞닿아 있다고 믿습니다.
'Programming > 프로그래밍 내용 정리' 카테고리의 다른 글
| Apache Kafka 톺아보기 (0) | 2025.05.30 |
|---|---|
| [ 보고서 ] Kafka 도입을 통한 선착순 쿠폰 발급 기능 개선 (0) | 2025.05.29 |
| [ 보고서 ] Redis 도입을 통한 선착순 쿠폰 발급 시스템 개선 (0) | 2025.05.23 |
| [ 보고서 ] 동시성 이슈 - 비관적 락 vs 낙관적 락 (0) | 2025.04.22 |
| [ 보고서 ] 정렬 인덱스, 필터 인덱스, 그 조합이 만드는 쿼리의 운명 (0) | 2025.04.17 |