TypeORM Spatial PostGIS 위치 쿼리

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 심화도 함께 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux