MongoDB의 Geo-Spatial query를 Spring Data mongoDB로 작성하는 법

728x90

 

서론


배달 어플리케이션 제작 중에 소비자의 위치 근처에 있는 음식점만 보여줘야 하는 기능이 필요했다. 이를 구현하기 위해 특정한 알고리즘을 Service layer에서 구현할 수도 있었으나, 개발 소요 시간을 줄이기 위해 MongoDB에서 제공하는 Geo-Spatial query를 활용하기로 했다. Geo-Spatial query는 특정 위치 조건에 해당하는 데이터만 가져오도록 해주는 MongoDB에서 제공하는 기능이다. Geo-spatial query에는 현재 크게 네 가지의 쿼리가 제공되고 있다. 

$geoIntersects

주어진 geometry 공간과 교차하는 geometry 영역의 데이터를 반환해주는 쿼리. $geometry 쿼리를 함께 써줘야 한다. 조금이라도 교차하는 polygon이 있다면 모두 반환해준다.

$geoWithin

주어진 geometry 공간 내부에 들어오는 geometry polygon만 반환해주는 쿼리. polygon이 서로 조금 겹치더라도 기준이 되는 geometry 밖으로 벗어난 영역이 조금이라도 존재하면 반환하지 않음.

$near


주어진 좌표점에 가까운 객체들을 반환해준다. 반환하면서 가까운 곳부터 먼 곳 순으로 정렬하여 반환한다. 

$nearSphere


주어진 좌표점에 가까운 객체들을 반환해준다. 반환하면서 가까운 곳부터 먼 곳 순으로 정렬하여 반환한다. $near와 차이점은 $nearSphere는 구 좌표를 기준으로 책정하여 가까운 곳과 먼 곳을 3차원적으로 구분하여 반환해준다는 것이다.

이처럼 다양한 query를 MongoDB에서 제공하니, 현재 개발 중인 프로젝트에 알맞은 쿼리를 선택하여 사용하면 되겠다. 택시 매칭 어플리케이션이라던가, 지도 어플리케이션 등등 위치 기반으로 데이터를 다뤄야하는 모든 어플리케이션에서 활용해볼만하다. 본 글에서는 $near 쿼리를 사용하여 특정 좌표에 가까이 있는 음식점 데이터를 가져오는 것을 실습할 것이다. 

프로젝트 환경

MongoDB Atlas에 database 만들기

database는 local 환경이 아닌 mongodb atlas를 사용할 것이다. atlas는 cloud 상에서 scale-out, sharding, security 등 DB와 관련된 작업들을 자동으로 수행해준다. 

1. MongoDB atlas 계정이 없다면 가입해준다. google이나 github 아이디가 있다면 손쉽게 가입할 수 있다.

2. Organizations에서 organization을 만든다. 해당 프로젝트를 관리할 팀명을 작성한다고 생각하면된다.



3. Organization에 들어가서 프로젝트를 생성한다. 프로젝트 이름은 임의로 생성한다.

 


4. 생성한 프로젝트로 들어가서 database를 생성한다.

 

 

 




DB 타입은 M0을 사용해야 무료로 사용할 수 있다. 다른 타입의 DB를 선택하면 비용을 지불해야한다.



provider는 aws를 선택하고 region은 seoul을 선택하면 된다. create을 누르면 DB가 생성된다. 클러스터와 샤드도 자동으로 생성된다. 

5. DB의 network access를 설정한다. 본 작업은 해당 DB에 접속 가능한 IP를 등록하는 것이다. 아래처럼 0.0.0.0/0이면 모든 IP에서 접속할 수 있다는 것을 의미한다. 현재는 일단 이렇게 진행하도록 한다. 어짜피 Database access 설정을 통해 접근을 제한할 수 있다.




6. Database access를 설정한다. 사용자 id를 만들고 autogenerate secure password를 통해 암호를 생성한다.  Built-in Role에서 "read and write to any database"를 선택한 후 "Add User"를 눌러서 사용자 생성을 완료한다. 해당 ID와 Password는 어플리케이션 코드에서 사용되어 해당 DB에 접속할 수 있게 된다. 본 계정 정보가 없으면 현재 DB에 접속할 수 없다. Network Access와 함께 이중 보안인셈.

 

 

 

 

Spring Boot 셋업

 

dependency


Spring boot 프로젝트의 dependency에 다음을 추가해준다.

 

implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'



dependency는 이게 끝이다. Spring에서는 공식적으로 Spring data mongodb를 제공하고 있으므로 이것만 가져오면 된다. mongoDB에서 제공하는 sdk를 사용하여 쿼리하는 방법도 있지만, 간단한 쿼리의 경우 Spring data mongodb를 사용하는 것이 훨씬 편리하므로 이를 사용하여 쿼리할 것이다. 

application.properties


atlas 클러스터에 connect를 누르고 Drivers를 선택하여 DB 연결에 url을 가져온다.

 

 

 



가져온 url을 applications.properties에 끼워넣는다. username과 password는 아까 database access에서 만든 id와 password를 각각 채워넣는다. 본 글쓴이는 해당 코드를 github에 올릴 것으므로 DB 보안을 위해서 .env파일에 username와 password를 입력해놓고 아래와 같이 연결하여 사용했다.

spring.data.mongodb.uri=mongodb+srv://${MONGODB_USER}:${MONGODB_PW}@cluster0.nvkumv9.mongodb.net/?retryWrites=true&w=majority

 


.env파일에는 아래 등호 오른쪽에 해당 username과 password를 채워 넣으면 된다.

MONGODB_USER=
MONGODB_PW=

 

mongoDB Configuration


Configuration을 담당할 클래스를 하나 생성한다. 그리고 @Configuration 애노테이션을 붙여주어 component로 등록하여 스프링이 관리하도록 해준다. 또한 @EnableMongoRepositories 애노테이션을 사용하여 Spring data mongoDB가 사용될 위치를 알려준다. 

 

@Configuration
@EnableMongoRepositories(basePackages = "msa.customer.repository")
public class MongoDBConfig {
}

 


basePackages는 해당 spring data 라이브러리가 적용될 클래스들을 모두 포함하는 상단 패키지 명을 지정해주면 된다. 현재 글쓴이의 패키지는 아래와 같이 되어있다. repository에 spring data 상속받는 interface를 생성할 것이므로 해당 위치를 basePackages로 지정하면 된다.



이렇게까지 완료하면 해당 프로젝트를 진행하기 위한 기본 셋업이 완료된다.

쿼리 대상 Entity 생성


쿼리를 작성하기 전에 쿼리 대상이 되는 클래스를 먼저 생성한다. 현 프로젝트는 소비자가 근처 음식점을 찾는 쿼리를 가정하고 있으므로 이름을 restaurant라고 정하고 아래와 같이 클래스를 생성할 것이다.

@Setter
@Getter
@NoArgsConstructor
@Document("restaurant")
public class Restaurant {
    @MongoId
    private String id;
    private String name;
    private String phoneNumber;
    private String address;
    private String addressDetail;
    
    @GeoSpatialIndexed(type=GeoSpatialIndexType.GEO_2DSPHERE)
    private Point location;
}



여기서 중요한 점은 Geo-spatial query의 기준점이 되는 property에는  @GeoSpatialIndexed(type=GeoSpatialIndexType.GEO_2DSPHERE) 를 반드시 붙여줘야 한다는 것이다. 
@MongoId는 붙여주지 않아도 MongoDB가 자동적으로 id를 생성해서 이를 대체할 수 있지만 @GeoSpatialIndexed 를 붙여주지 않으면 쿼리를 날렸을 때 런타임 에러가 발생하니, 잊지말고 인덱싱을 해주자.


FindByLocationNear - 주어진 좌표 근방 4km 이내 지점의 데이터 가져오기

Spring data 상속 interface 생성


이제 geospatial query를 작성해본다. 인터페이스를 하나 생성하여 spring data 기능을 사용하도록 MongoRepository를 extend한다. 제네릭에는 방금 생성한 Restaurant 클래스와 String을 넣는다.

public interface SpringDataMongoRestaurantRepository extends MongoRepository<Restaurant, String> {
    List<Restaurant> findByLocationNear(Point location, Distance distance);
}



findByLocationNear 쿼리 메소드의 첫번째 인자로는 위치가 들어가고, 두번째 인자로는 distance가 들어간다. Point와 Distance 클래스는 spring에서 제공하는 클래스를 사용해야 한다.

 

import org.springframework.data.geo.Distance;
import org.springframework.data.geo.Point;

 

Repository class 생성


spring data mongodb는 기본적으로 save()와 findById() 메소드를 제공하므로 해당 메소드는 별도로 spring data interface에서 정의하지 않아도 사용할 수 있다. save()는 데이터를 DB에 저장하는 역할을 하고, findById()는 primary key에 해당하는 데이터를 읽어오는 역할을 한다. 

그 외 spring data interface에서 정의한 findByLocationNear 쿼리 메소드를 활용하여 Restaurant 객체들의 리스트를 반환하는 repository 메소드를 작성한다. 

 

@Repository
@Primary
public class MongoRestaurantRepository {

private final SpringDataMongoRestaurantRepository repository;

    public MongoRestaurantRepository(SpringDataMongoRestaurantRepository repository) {
        this.repository = repository;
    }

    public String create(Restaurant restaurant) {
        Restaurant savedRestaurant = repository.save(restaurant);
        return savedRestaurant.getId();
    }
    
    public Optional<Restaurant> findById(String id) {
        return repository.findById(id);
    }
    
    public List<Restaurant> findRestaurantNear(Point location) {
        Distance distance = new Distance(4, Metrics.KILOMETERS);
        return repository.findByLocationNear(location, distance);
    }
}



여기서 주의할 점은 Distance 객체를 만들 때 생성자의 두 번째 인자로 Metrics.KILOMETERS를 반드시 끼워넣어줘야 한다는 것이다. 이 단위를 넣어주지 않으면 실물 단위로 인지되지 않으므로 mongoDB geospatial query가 정상적으로 작동되지 않을 수 있다.

Repository Test


방금 생성한 repository가 정상적으로 작동하는지 테스트한다.
@SpringBootTest 애노테이션을 붙여줘야 spring이 띄워지고 configuration이 동작하여 mongoDB atlas에 연결되어 정상적으로 동작하게 된다.

@SpringBootTest
class RestaurantRepositoryTest {

    private final RestaurantRepository restaurantRepository;

    @Autowired
    RestaurantRepositoryTest(RestaurantRepository restaurantRepository) {
        this.restaurantRepository = restaurantRepository;
    }
    
    @DisplayName("주어진 좌표 근방 음식점 조회한다.")
    @Test
    void getNearRestaurantTest(){
        // given
        Restaurant restaurant = new Restaurant();
        Point pizzaCoordinate = new Point(127.080, 37.251);
        restaurant.setName("착한피자");
        restaurant.setLocation(pizzaCoordinate);
        restaurantRepository.create(restaurant);
        // when
        Point orderCoordinate = new Point(127.074, 37.252);
        List<Restaurant> restaurantNear = restaurantRepository.findRestaurantNear(orderCoordinate);
        // then
        assertThat(restaurantNear.get(0).getName()).isEqualTo("착한피자");
    }



Test가 통과하면 정상적으로 MongoDB의 쿼리가 작동한 것이다. 


FindByLocationNearAnd - 주어진 좌표 근방에 해당하는 데이터 중 특정 속성이 조건에 만족하는 데이터만 가져오기


FindByLocationNear만 쓰고 끝내기엔 뭔가 찝찝하고 아쉬울 것이다. 실사용에선 주어진 좌표 근방에 해당하는 모든 데이터만 가지고 올 것이 아니라, 조건을 만족하는 특정한 데이터만 가지고 오는 경우가 많기 때문이다.

예를 들면 택시 잡는 어플리케이션의 경우에는 현재 대기 중인 택시만 호출하고 싶을 수도 있고, 지도 정보 어플리케이션에는 근처의 음식점만 검색하고 싶을 수 있다. 본 프로젝트처럼 배달 어플리케이션이라면 중식이면 중식, 한식이면 한식, 치킨이면 치킨 등 특정한 메뉴를 제공하는 가게만 검색하고 싶을 수 있다. 

이렇게 특정한 조건을 끼워넣은 채로 geospatial query를 사용하는 것은 그렇게 어려운 일이 아니다. spring data는 특정 조건을 중첩하여 적용할 수 있도록 And 쿼리 메소드를 제공해준다. 이 쿼리 메소드는 SQL의 WHERE와 동일한 역할을 한다. 

아래 처럼 특정 종류의 음식점만 쿼리할 수 있도록 코드를 추가해보자.

FoodKindType.enum


열거형을 사용하여 음식 타입에 사용할 상수를 작성한다.

public enum FoodKindType {
    CHINESE, KOREAN, JAPANESE, CHICKEN, PIZZA, SOUTH_EAST, STAKE, SIMPLE, DESSERT
}



Restaurant Entity


Entity에 방금 생성한 열거형을 추가한다. 

@Setter
@Getter
@NoArgsConstructor
@Document("restaurant")
public class Restaurant {
    @MongoId
    private String id;
    private String name;
    private String phoneNumber;
    private String address;
    private String addressDetail;
   
   @GeoSpatialIndexed(type=GeoSpatialIndexType.GEO_2DSPHERE)
    private Point location;
    
    private FoodKindType foodKind;
}

 

 

spring data interface


열거형을 인자로 받도록 메소드를 수정한다. FindByLocationNear에서 And를 추가하면 중첩 조건을 추가할 수 있다. FoodKindIs를 사용하면 foodKind가 주어진 인자와 일치하는 데이터만 가지고 오게 된다.

public interface SpringDataMongoRestaurantRepository extends MongoRepository<Restaurant, String> {
    List<Restaurant> findByLocationNearAndFoodKindIs(Point location, Distance distance, FoodKindType foodKind);
}



Repository


repository의 findRestaurantNear에도 인자로 foodKind가 들어가도록 수정해준다.

@Repository
@Primary
public class MongoRestaurantRepository implements RestaurantRepository{

    private final SpringDataMongoRestaurantRepository repository;

    public MongoRestaurantRepository(SpringDataMongoRestaurantRepository repository) {
        this.repository = repository;
    }

    @Override
    public String create(Restaurant restaurant) {
        Restaurant savedRestaurant = repository.save(restaurant);
        return savedRestaurant.getId();
    }

    @Override
    public Optional<Restaurant> findById(String id) {
        return repository.findById(id);
    }

    @Override
    public List<Restaurant> findRestaurantNear(Point location, FoodKindType foodKind) {
        Distance distance = new Distance(4, Metrics.KILOMETERS);
        return repository.findByLocationNearAndFoodKindIs(location, distance, foodKind);
    }
}



Repository Test


repsitory 메소드의 인자에 foodKindType이 추가된 것을 반영해준다. 추가적으로 4km가 넘는 거리의 음식점은 조회가 되지 않는지 보기 위해 테스트 코드를 추가해준다. 모든 테스트가 정상적으로 작동되면 의도된대로 쿼리가 동작한 것이다.

@SpringBootTest
class RestaurantRepositoryTest {

    private final RestaurantRepository restaurantRepository;

    @Autowired
    RestaurantRepositoryTest(RestaurantRepository restaurantRepository) {
        this.restaurantRepository = restaurantRepository;
    }
    
    @DisplayName("주어진 좌표 근방 음식점 조회한다.")
    @Test
    void getNearRestaurantTest(){
        // given
        Restaurant restaurant = new Restaurant();
        Point pizzaCoordinate = new Point(127.080, 37.251);
        restaurant.setName("착한피자");
        restaurant.setLocation(pizzaCoordinate);
        restaurant.setFoodKind(FoodKindType.PIZZA);
        restaurantRepository.create(restaurant);
        // when
        Point orderCoordinate = new Point(127.074, 37.252);
        List<Restaurant> restaurantNear = restaurantRepository.findRestaurantNear(orderCoordinate, FoodKindType.PIZZA);
        // then
        assertThat(restaurantNear.get(0).getName()).isEqualTo("착한피자");
    }

    @DisplayName("4km 이상 떨어진 곳에 위치한 음식점은 조회하지 않는다.")
    @Test
    void notGetRestaurantOver4kmTest(){
        // given
        Restaurant restaurant = new Restaurant();
        Point pizzaCoordinate = new Point(127.018, 37.261);
        restaurant.setName("피자헤븐");
        restaurant.setLocation(pizzaCoordinate);
        restaurant.setFoodKind(FoodKindType.PIZZA);
        restaurantRepository.create(restaurant);
        // when
        Point orderCoordinate = new Point(127.074, 37.253);
        List<Restaurant> restaurantNear = restaurantRepository.findRestaurantNear(orderCoordinate, FoodKindType.PIZZA);
        // then
        assertThat(restaurantNear).isEmpty();
    }

}



글을 마치며


Spring Data mongoDB는 이처럼 한 문장으로 쿼리를 끝낼 수 있는 매우 편리하고 직관적인 도구다. 단순한 쿼리만 필요할 경우에는 Spring Data mongoDB만 사용해도 무관하다. 하지만 복잡한 쿼리가 필요할 경우 쿼리 메소드의 길이가 과도하게 길어지고 가시성이 떨어지는 단점이 있으므로 MongoDB에서 제공하는 Driver(SDK)를 사용하는 방법도 고려해볼 수 있다.