TypeORM Spatial 쿼리란?
TypeORM의 Spatial 컬럼은 PostgreSQL PostGIS, MySQL Spatial을 활용하여 위치 기반 데이터를 저장하고 쿼리하는 기능입니다. 좌표 저장, 반경 검색, 거리 계산, 영역 포함 여부 등 위치 기반 서비스(LBS)의 핵심 기능을 ORM 레벨에서 구현할 수 있습니다. 배달 앱의 가까운 가게 찾기, 부동산 지역 검색, IoT 디바이스 위치 추적에 필수적인 기술입니다.
PostGIS 설치와 엔티티 설정
-- PostgreSQL에 PostGIS 확장 설치
CREATE EXTENSION IF NOT EXISTS postgis;
-- 설치 확인
SELECT PostGIS_Version();
-- 3.4 USE_GEOS=1 USE_PROJ=1 USE_STATS=1
// TypeORM 엔티티: Point 컬럼 정의
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
import { Point, Geometry } from 'geojson';
@Entity('stores')
export class Store {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
address: string;
@Column()
category: string;
// Point 타입 컬럼 — 위도/경도 저장
@Index({ spatial: true }) // 공간 인덱스 (GiST)
@Column({
type: 'geography', // geography = 지구 곡면 (미터 단위)
spatialFeatureType: 'Point',
srid: 4326, // WGS84 좌표계
})
location: Point;
// Polygon 타입 — 배달 가능 영역
@Column({
type: 'geography',
spatialFeatureType: 'Polygon',
srid: 4326,
nullable: true,
})
deliveryArea: Geometry | null;
@Column({ type: 'decimal', precision: 2, scale: 1, default: 0 })
rating: number;
@Column({ default: true })
isActive: boolean;
}
GeoJSON 형식으로 데이터 삽입
TypeORM Spatial은 GeoJSON 형식을 사용합니다. 주의할 점은 GeoJSON의 좌표 순서가 [경도, 위도]라는 것입니다:
// 가게 데이터 삽입
const store = storeRepository.create({
name: '맛있는 치킨',
address: '서울시 강남구 역삼동 123',
category: 'chicken',
rating: 4.5,
location: {
type: 'Point',
coordinates: [127.0276, 37.4979], // [경도, 위도] — 순서 주의!
},
deliveryArea: {
type: 'Polygon',
coordinates: [[
[127.020, 37.495], // 배달 가능 영역의 꼭짓점들
[127.035, 37.495],
[127.035, 37.502],
[127.020, 37.502],
[127.020, 37.495], // 첫 점과 마지막 점 동일 (닫힌 다각형)
]],
},
});
await storeRepository.save(store);
// 여러 가게 한번에 삽입
const stores = [
{
name: '피자파라다이스',
address: '서울시 강남구 삼성동 456',
category: 'pizza',
location: { type: 'Point' as const, coordinates: [127.0590, 37.5088] },
},
{
name: '스시오마카세',
address: '서울시 서초구 서초동 789',
category: 'japanese',
location: { type: 'Point' as const, coordinates: [127.0086, 37.4837] },
},
];
await storeRepository.save(stores);
반경 검색: 가까운 가게 찾기
가장 많이 사용하는 패턴인 “현재 위치에서 N km 이내 가게 찾기”입니다:
// QueryBuilder로 반경 검색
async findNearbyStores(
lat: number,
lng: number,
radiusKm: number,
category?: string,
): Promise<(Store & { distance: number })[]> {
const origin = `SRID=4326;POINT(${lng} ${lat})`; // WKT 형식
const qb = this.storeRepository
.createQueryBuilder('store')
.addSelect(
`ST_Distance(store.location, ST_GeomFromText(:origin, 4326))`,
'distance'
)
.where(
`ST_DWithin(store.location, ST_GeomFromText(:origin, 4326), :radius)`
)
.andWhere('store.isActive = :active')
.setParameters({
origin,
radius: radiusKm * 1000, // geography 타입은 미터 단위
active: true,
})
.orderBy('distance', 'ASC');
if (category) {
qb.andWhere('store.category = :category', { category });
}
const { entities, raw } = await qb.getRawAndEntities();
// distance를 엔티티에 매핑
return entities.map((entity, i) => ({
...entity,
distance: Math.round(raw[i].distance), // 미터 단위
}));
}
// 사용 예: 강남역 기준 2km 이내 치킨집
const nearby = await findNearbyStores(37.4979, 127.0276, 2, 'chicken');
// [
// { name: '맛있는 치킨', distance: 0, ... },
// { name: '황금치킨', distance: 850, ... },
// { name: '네네치킨 역삼점', distance: 1200, ... }
// ]
ST_DWithin vs ST_Distance: 성능 차이
| 함수 | 동작 | 인덱스 사용 |
|---|---|---|
ST_DWithin(a, b, d) |
거리가 d 이내인지 boolean 반환 | ✅ GiST 인덱스 활용 |
ST_Distance(a, b) < d |
거리 계산 후 비교 | ❌ Full Scan 후 필터 |
-- ❌ 느림: ST_Distance로 필터링 → 인덱스 무시
SELECT * FROM stores
WHERE ST_Distance(location, ST_MakePoint(127.0276, 37.4979)::geography) < 2000;
-- ✅ 빠름: ST_DWithin → GiST 인덱스 활용
SELECT * FROM stores
WHERE ST_DWithin(location, ST_MakePoint(127.0276, 37.4979)::geography, 2000);
-- EXPLAIN으로 인덱스 사용 확인
EXPLAIN ANALYZE
SELECT * FROM stores
WHERE ST_DWithin(location, ST_MakePoint(127.0276, 37.4979)::geography, 2000);
-- → Index Scan using IDX_stores_location on stores
영역 검색: Polygon 내부 포인트 찾기
// 특정 영역 내 가게 검색
async findStoresInArea(polygon: number[][]): Promise<Store[]> {
const wkt = `POLYGON((${polygon.map(p => `${p[0]} ${p[1]}`).join(',')}))`;
return this.storeRepository
.createQueryBuilder('store')
.where(
`ST_Within(
store.location::geometry,
ST_GeomFromText(:polygon, 4326)
)`
)
.setParameter('polygon', wkt)
.getMany();
}
// 사용: 강남구 영역 내 가게 검색
const gangnam = [
[127.017, 37.495],
[127.070, 37.495],
[127.070, 37.520],
[127.017, 37.520],
[127.017, 37.495], // 닫힌 다각형
];
const stores = await findStoresInArea(gangnam);
배달 가능 여부 확인: ST_Contains
// 주문 주소가 배달 가능 영역 내인지 확인
async isDeliverable(storeId: number, lat: number, lng: number): Promise<boolean> {
const result = await this.storeRepository
.createQueryBuilder('store')
.select('ST_Contains(store.deliveryArea::geometry, ST_MakePoint(:lng, :lat))', 'contains')
.where('store.id = :storeId')
.setParameters({ storeId, lat, lng })
.getRawOne();
return result?.contains === true;
}
// K-최근접 이웃 검색 (KNN): 가장 가까운 N개
async findKNearest(lat: number, lng: number, limit: number): Promise<Store[]> {
// PostgreSQL KNN 연산자 <-> 사용 (GiST 인덱스 최적화)
return this.storeRepository.query(`
SELECT *,
location <-> ST_MakePoint($1, $2)::geography AS distance
FROM stores
WHERE is_active = true
ORDER BY location <-> ST_MakePoint($1, $2)::geography
LIMIT $3
`, [lng, lat, limit]);
}
NestJS 서비스 통합: 위치 기반 API
@Controller('stores')
export class StoreController {
constructor(private storeService: StoreService) {}
// GET /stores/nearby?lat=37.4979&lng=127.0276&radius=2&category=chicken
@Get('nearby')
async findNearby(
@Query('lat', ParseFloatPipe) lat: number,
@Query('lng', ParseFloatPipe) lng: number,
@Query('radius', new DefaultValuePipe(3), ParseFloatPipe) radiusKm: number,
@Query('category') category?: string,
) {
// 반경 제한: 최대 50km
if (radiusKm > 50) {
throw new BadRequestException('Maximum radius is 50km');
}
const stores = await this.storeService.findNearby(lat, lng, radiusKm, category);
return stores.map(store => ({
id: store.id,
name: store.name,
address: store.address,
category: store.category,
rating: store.rating,
distance: store.distance,
distanceText: store.distance < 1000
? `${store.distance}m`
: `${(store.distance / 1000).toFixed(1)}km`,
location: {
lat: store.location.coordinates[1],
lng: store.location.coordinates[0],
},
}));
}
// POST /stores/:id/check-delivery
@Post(':id/check-delivery')
async checkDelivery(
@Param('id', ParseIntPipe) storeId: number,
@Body() body: { lat: number; lng: number },
) {
const deliverable = await this.storeService.isDeliverable(
storeId, body.lat, body.lng
);
return { storeId, deliverable };
}
}
Geometry vs Geography 타입 선택
| 특성 | geometry | geography |
|---|---|---|
| 좌표계 | 평면 (유클리드) | 구면 (WGS84) |
| 거리 단위 | 좌표 단위 (도) | 미터 |
| 정확도 | 좁은 영역에서만 정확 | 전 지구적으로 정확 |
| 성능 | 빠름 | 약간 느림 (구면 계산) |
| 권장 용도 | 건물 내부 좌표, CAD | GPS 좌표, 배달/택시 앱 |
공간 인덱스 최적화
-- GiST 인덱스 생성 (TypeORM @Index({ spatial: true })와 동일)
CREATE INDEX idx_stores_location ON stores USING GIST (location);
-- 복합 조건 인덱스: 활성 가게만 인덱싱
CREATE INDEX idx_active_stores_location ON stores USING GIST (location)
WHERE is_active = true;
-- 인덱스 크기 확인
SELECT pg_size_pretty(pg_relation_size('idx_stores_location'));
-- 클러스터링: 공간적으로 가까운 행을 물리적으로 인접 저장
CLUSTER stores USING idx_stores_location;
-- → 반경 검색 시 디스크 I/O 감소
-- 통계 업데이트
ANALYZE stores;
마이그레이션: PostGIS 컬럼 추가
// TypeORM 마이그레이션
export class AddLocationColumn1711000000000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
// PostGIS 확장 활성화
await queryRunner.query('CREATE EXTENSION IF NOT EXISTS postgis');
// geography 컬럼 추가
await queryRunner.query(`
ALTER TABLE stores
ADD COLUMN location geography(Point, 4326)
`);
// 공간 인덱스 생성
await queryRunner.query(`
CREATE INDEX idx_stores_location
ON stores USING GIST (location)
`);
// 기존 lat/lng 컬럼에서 마이그레이션
await queryRunner.query(`
UPDATE stores
SET location = ST_MakePoint(longitude, latitude)::geography
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
`);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX idx_stores_location');
await queryRunner.query('ALTER TABLE stores DROP COLUMN location');
}
}
핵심 정리
| 함수 | 용도 | 인덱스 |
|---|---|---|
ST_DWithin |
반경 검색 (N km 이내) | ✅ |
ST_Distance |
정확한 거리 계산 | 정렬용 |
ST_Contains |
영역 내 포함 여부 | ✅ |
ST_Within |
Polygon 내 Point 검색 | ✅ |
<-> (KNN) |
K-최근접 이웃 | ✅ |
TypeORM Spatial + PostGIS 조합은 위치 기반 서비스의 핵심입니다. 반경 검색에는 반드시 ST_DWithin을 사용하고 geography 타입으로 미터 단위 정확한 거리를 계산하세요. PostgreSQL Partial Index 심화와 TypeORM QueryBuilder 심화도 함께 참고하세요.