[프로젝트3] 1. Spring Data MongoDB 시작하기 + MongoTemplate

rin·2020년 5월 28일
8
post-thumbnail

목표
1. Spring 환경에서 MongoDB를 연동한다. (Spring Data MongoDB)
2. MongoTemplate을 이용한 간단한 CRUD를 테스트한다.

ref. https://docs.spring.io/spring-data/mongodb/docs/current/reference/html/#reference

Introduction

해당 파트는 Spring Data MongoDB가 제공하는 핵심 기능에 대해서 설명한다.
MongoDB support : MongoDB 모듈 기능 셋을 설명한다.
MongoDB Repositories : MongoDB를 지원하는 레파지토리에 대해 소개한다.

MongoDB support

  • 스프링 configuration은 Mongo 드라이버 인스턴스와 복사본 셋(replica set)을 위한 자바 기반의 @Configuration 클래스나 XML namespace를 지원한다.
  • 공통적인 Mongo 연산을 수행할 때 생산성을 증가시키는 MongoTemplate 헬퍼 클레스는 도큐먼트와 POJO 간의 통합적인 객체 매핑을 포함한다.
  • 예외 발생시 Spring의 이식 가능한 데이터 엑세스 예외 계층으로 변환한다.
  • Spring의 변환 서비스를 비롯한 통합된 다양한 기능의 객체 매핑
  • 다른 메타 데이터 형식을 지원하기 위해 확장 가능한 어노테이션 기반의 메타 데이터
  • persistence와 lifecycle 이벤트 매핑
  • 자바 기반의 쿼리, 기준, 업데이트 DSL
  • 커스텀 조회 메소드 지원을 포함하는 레포지토리 인터페이스의 자동 구현
  • 안전한 형식의 쿼리를 지원하기 위한 QueryDSL 통합
  • 지리적 통합

대부분의 작업에는 풍부한 매핑 기능을 활용하는 MongoTemplate이나 Repository 지원이 필요하다. MongoTemplate는 카운터 증가나 ad-hoc CRUD 연산같은 기능에 엑세스 할 수 있는 곳이다. MongoTemplate은 또한 콜백 메소드를 제공하므로 com.mongodb.client.MongoDatabase와 같은 로우 레벨의 API 아티팩트를 사용해 MongoDB와 직접 통신 할 수 있다. 다양한 API 아티팩트에 대한 네이밍 규정의 목표는 기본 MongoDB Java 드라이버의 네이밍 규정을 복사하여 기존의 지식들을 Spring API에 쉽게 매핑하는 것이다.


프로젝트 준비하기

이전 프로젝트 복제하기

[프로젝트2] 1. myBatis 설정, CRUD 테스트에서 레포지토리를 복제하고 intelliJ에서 설정하는 것을 똑같이 하도록하자. (freeboard01을 freeboard03으로 복제하였다.)

만약 톰캣을 실행했는데 아래와 같은 404 에러가 나타난다면

Run/Debug configuration에서 Deployment - Application context를 서버 URL과 일치시켜 준다.

아래처럼 메인 페이지가 잘 뜨는 것을 확인했으면 본격적으로 MongoDB와 연동해보자.

MongoDB 설정 추가하기

ref.
https://docs.spring.io/spring-data/mongodb/docs/current/reference/html/#mongodb-getting-started

https://www.baeldung.com/spring-data-mongodb-tutorial

spring-data-mongodb 버전과 mongo-java-driver 버전 맞춰주기
메이븐 레파지토리 종속을 추가한 뒤에 'java.lang.String com.mongodb.connection.ClusterSettings.getDescription()'에러와 java.lang.ClassNotFoundException: com.mongodb.client.MongoClient에러가 발생하였는데 버전 문제라고 생각되었으나 두 종속의 버전을 맞춰줄 방법을 못 찾았다..🤔
결국 구글 검색에서 찾은 예시에 있는 버전으로 다운그레이드하였음

build.gradle

아래의 두 종속성 추가

    // https://mvnrepository.com/artifact/org.springframework.data/spring-data-mongodb
    compile group: 'org.springframework.data', name: 'spring-data-mongodb', version: '2.1.5.RELEASE'
    // https://mvnrepository.com/artifact/org.mongodb/mongo-java-driver
    compile group: 'org.mongodb', name: 'mongo-java-driver', version: '3.10.1'

applicationContext.xml

이전에 @Repository 어노테이션이 붙은 클래스는 자동으로 빈으로 등록해주는 설정을 넣어주었으므로, 아래 부분만 추가되었다.

일단 beans의 xmlns 네임스페이스와 schemaLocation을 추가해준다.

xmlns:mongo="http://www.springframework.org/schema/data/mongo"

xsi:schemaLocation=" (생략)
                    http://www.springframework.org/schema/data/mongo
                    http://www.springframework.org/schema/data/mongo/spring-mongo.xsd" ...

그 다음 아래 코드를 추가하면된다.

 <mongo:mongo-client id="mongoClient" host="localhost" port="27017" />
    <mongo:db-factory id="mongoDbFactory" dbname="example" mongo-ref="mongoClient" />
    <bean id="mongoTemplate" class="org.springframework.data.mongodb.core.MongoTemplate">
        <constructor-arg name="mongoDbFactory" ref="mongoDbFactory"/>
    </bean>
    <bean class="org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor" />

    <mongo:repositories base-package="com.freeboard03.domain" />
  • mongo-client : 클라이언트로 접근할 호스트와 포트를 지정한다.
  • repositories : MongoTemplate를 커스텀 마이징하여 사용하기 원한다면 추가한다. MongoRepository를 상속받는 모든 인터페이스를 스캔하여 빈으로 등록한다.

❗️NOTE
ref. https://docs.spring.io/spring-data/mongodb/docs/current/reference/html/#mongo-template

MongoTemplate는 MongoDB 도큐먼트를 작성, 업데이트, 삭제 및 조회할 수 있는 편리한 작업을 제공하며, 도메인 개체와 MongoDB 문서 간의 매핑을 제공한다.

MongoTemplate 클래스는 MongoOperations 인터페이스의 구현체이다. 가능한한 MongoOperations의 메소드는 MongoDB 드라이버 Collection 오브젝트에서 사용할 수 있는 메소드를 따라 명명되었기 때문에 드라이버 API를 사용했던 MongoDB 개발자에게 API를 친숙하게 만들어준다. 예를 들어 find, findAndModify, findAndReplace, findOne, insert, remove, save, update, updataMulti와 같은 메소드들이 있다. 설계 목표는 기본 MongoDB 드라이버의 사용과 MongoOperations를 쉽게 전환할 수 있도록 하는 것이었다. 두 API의 주요 차이점은 MongoOperationsDocument 대신에 도메인 오브젝트를 사용할 수 있게 해준다는 것이다. 또한 MongoOperations는 매개 변수를 지정하기 위해 도큐먼트를 채우는 대신에 Query, Criteria, Update 작업을 위한 뛰어난 API를 제공한다.

테스트 진행하기

PetEntity

MongoDB에 잘 연결되었는지 확인하기 위해 몇가지 테스트를 진행할 것이다. 테스트에 사용할 PetEntity를 domain/pet 패키지 하위에 생성해주자.

@Document(collection = "pets")
@Getter
public class PetEntity {

    @Id
    private long id;

    private String kind;

    private String name;

    private int age;

    @Builder
    public PetEntity(String kind, String name, int age){
        this.kind = kind;
        this.name = name;
        this.age = age;
    }

}

@Document : 설정하지 않을시 자동으로 클래스 이름(첫글자 소문자로 변경)이 collection으로 생성된다.
@Id : 설정하지 않을시 id 혹은 _id라는 멤버 변수가 자동으로 _id 필드로 매핑된다.

PetTest

테스트 코드를 간단하게 작성한다.

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml"})
@Transactional
@Rollback(value = false)
public class PetTest {

    @Autowired
    MongoTemplate mongoTemplate;

    @Test
    public void insertTest(){
        PetEntity pet = PetEntity.builder().kind("CAT").name("나비").age(2).build();
        mongoTemplate.insert(pet);

        Criteria criteria = new Criteria("name");
        criteria.is("나비");
        Query query = new Query(criteria);
        // 위의 세 줄 짜리 코드는 아래 한 줄 코드와 동일하다.
        // Query query = new Query(Criteria.where("name").is("나비"));

        PetEntity findPet = mongoTemplate.findOne(query, PetEntity.class, "pets");

        assertThat(pet.getId(), equalTo(findPet.getId()));
        assertThat(pet.getName(), equalTo(findPet.getName()));
        assertThat(pet.getKind(), equalTo(findPet.getKind()));
        assertThat(pet.getAge(), equalTo(findPet.getAge()));
    }
}

"Template"도 상속받아서 사용해야하는 줄 알았는데 굳이 그럴 필요없이 MongoTemplate을 이용하여 CRUD를 진행할 수 있었다.

Criteria는 JPA의 Specification과 비슷한 쿼리를 생성해주는 클래스이다. 위 예제에서 "name"은 key이며 해당 키의 value를 "나비"로 설정한 것이다. 테스트는 성공한다.

터미널을 이용해 mongoDB에 직접 접근하여 데이터가 어떻게 저장되었는지 확인하자.
@id로 설정했던 id가 Long type으로 저장되었고, 추가적으로 _class라는 필드가 들어가 있는 것을 확인 할 수 있다. value는 문자열 타입의 PetEntity 참조 경로로 저장되어있다. 사실 이 필드가 매핑에 직접적으로 사용되는 것은 아니다.
mongoTemplate의 findOne 메소드를 보면(오버로딩된 두 개 뿐이다.) 반드시 반환 클래스 타입을 전달해주어야한다. 즉, 해당 필드를 이용하여 자동 매핑해주는 것은 아니란 뜻이다. MappingMongoConverter에서 이 필드가 자동생성되는 것을 막을 수 있지만 추후에 어떻게 쓰일지 모르니 일단 남겨두자 🤔

한 번 더 테스트를 수행해보려고 했더니 다음같은 에러가 뜬다.
E11000 duplicate key error collection: example.pets index: _id_ dup key: { _id: 0 } pets 콜렉션에 _id 필드의 값이 0인 데이터가 이미 존재한다는 뜻인데, 자동으로 _id 증분이 되지 않고 있었다.

그럼, MongoDB에서 권고했던 ObjectId 타입을 사용하면 어떨까?

터미널에서 db.${collection}.remove({}) (해당 컬렉션의 데이터 모두 삭제) 혹은 db.${collection}.remove({name='나비'}) (조건에 해당하는 데이터만 삭제)를 사용하여 데이터를 삭제한다.

PetEntity의 id 타입을 long에서 ObjectId로 변경해준뒤 테스트를 다시 수행해보자.

테스트를 한 번 더 수행하면 테스트는 실패할 것이다.

findOne으로 name 필드의 value가 "나비"인 가장 먼저 검색되는 도큐먼트를 가져오기 때문에 id를 비교하는 assert문에서 실패하는 것이다. 아마 데이터 베이스에는 id값이 위의 Excpeted에 해당하는 도큐먼트와 Actual에 해당하는 도큐먼트가 저장되어 있을 것이다. 확인해보자! 🙋🏻

테스트 코드는 다음처럼 변경해주자!

@Test
    public void insertTest(){
        PetEntity pet = PetEntity.builder().kind("CAT").name("나비").age(2).build();
        mongoTemplate.insert(pet);
        
        // 변경된 부분 -> name이 아닌 _id로 질의한다.
        Query query = new Query(Criteria.where("_id").is(pet.getId()));

        PetEntity findPet = mongoTemplate.findOne(query, PetEntity.class, "pets");

        assertThat(pet.getId(), equalTo(findPet.getId()));
        assertThat(pet.getName(), equalTo(findPet.getName()));
        assertThat(pet.getKind(), equalTo(findPet.getKind()));
        assertThat(pet.getAge(), equalTo(findPet.getAge()));
    }

❗️NOTE
pet의 Id는 언제 생성되는 것일까? 🤔

Entity 생성직후db에 저장직후

위 이미지에서 확인 할 수 있듯이 mongoTemplate에 의해서 데이터베이스에 저장된 직후에 id값이 갱신된다. 영속성 관리가 되고 있음을 알 수 있다.

본격적으로 MongoTemplate를 이용하여 CRUD를 테스트해보자!

CRUD

전체 코드는 다음과 같다.

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml"})
@Transactional
@Rollback
public class PetTest {

    @Autowired
    MongoTemplate mongoTemplate;

    @Test
    public void insertTest(){
        PetEntity pet = PetEntity.builder().kind("CAT").name("나비").age(2).build();
        mongoTemplate.insert(pet);

        Query query = new Query(Criteria.where("_id").is(pet.getId()));

        PetEntity findPet = mongoTemplate.findOne(query, PetEntity.class, "pets");

        assertThat(pet.getId(), equalTo(findPet.getId()));
        assertThat(pet.getName(), equalTo(findPet.getName()));
        assertThat(pet.getKind(), equalTo(findPet.getKind()));
        assertThat(pet.getAge(), equalTo(findPet.getAge()));
    }

    @Test
    public void findTest(){
        final String KIND = "CAT in findTest("+randomString()+")";
        final int INSERT_SIZE = 10;
        insertFindAllTestData(KIND, INSERT_SIZE);

        Query query = new Query(Criteria.where("kind").is(KIND));
        List<PetEntity> findPets = mongoTemplate.find(query, PetEntity.class);

        assertThat(findPets.size(), equalTo(INSERT_SIZE));
    }

    void insertFindAllTestData(String KIND, int INSERT_SIZE) {
        for (int i=0 ; i<INSERT_SIZE; ++i){
            PetEntity pet = PetEntity.builder().age(2).kind(KIND).name("Test Name").build();
            mongoTemplate.insert(pet);
        }
    }

    @Test
    @DisplayName("pet을 저장한 뒤 해당 도큐먼트의 name을 변경하고 age를 5 더한다.")
    public void updateTest(){
        PetEntity pet = PetEntity.builder().kind("CAT").name("나비").age(0).build();
        mongoTemplate.insert(pet);

        Query query = new Query(Criteria.where("_id").is(pet.getId()));

        String updatedName = "노랑이";
        int increaseAge = 5;
        Update update = Update.update("name", updatedName).inc("age", increaseAge);

        mongoTemplate.updateFirst(query, update, PetEntity.class);

        PetEntity findPet = mongoTemplate.findOne(query, PetEntity.class);
        assertThat(findPet.getName(), equalTo(updatedName));
        assertThat(findPet.getAge(), equalTo(increaseAge));
    }

    @Test
    @DisplayName("pet을 저장한 뒤 해당 도큐먼트의 name을 변경하고 inc를 이용해 age를 0으로 만든다.")
    public void updateTest2(){
        int age = 2;
        PetEntity pet = PetEntity.builder().kind("CAT").name("나비").age(age).build();
        mongoTemplate.insert(pet);

        Query query = new Query(Criteria.where("_id").is(pet.getId()));

        String updatedName = "노랑이";
        int decreaseAge = -1 * age;
        Update update = Update.update("name", updatedName).inc("age", decreaseAge);

        mongoTemplate.updateFirst(query, update, PetEntity.class);

        PetEntity findPet = mongoTemplate.findOne(query, PetEntity.class);
        assertThat(findPet.getName(), equalTo(updatedName));
        assertThat(findPet.getAge(), equalTo(0));
    }

    @Test
    @DisplayName("pet을 저장한 뒤 remove를 이용해 모두 삭제한다.")
    public void deleteTest(){
        final String KIND = "CAT in findTest("+randomString()+")";
        final int INSERT_SIZE = 10;
        insertFindAllTestData(KIND, INSERT_SIZE);

        Query query = new Query(Criteria.where("kind").is(KIND));

        DeleteResult result = mongoTemplate.remove(query, PetEntity.class);
        assertThat(String.valueOf(result.getDeletedCount()), equalTo(String.valueOf(INSERT_SIZE)));
    }

    @Test
    @DisplayName("pet을 저장한 뒤 findAllAndRemove를 이용해 모두 삭제한다.")
    public void deleteTest2(){
        final String KIND = "CAT in findTest("+randomString()+")";
        final int INSERT_SIZE = 10;
        insertFindAllTestData(KIND, INSERT_SIZE);

        Query query = new Query(Criteria.where("kind").is(KIND));

        List<PetEntity> deletedDocuments = mongoTemplate.findAllAndRemove(query, PetEntity.class);
        assertThat(deletedDocuments.size(), equalTo(INSERT_SIZE));
        assertThat(deletedDocuments.stream().map(document -> document.getKind()).distinct().collect(Collectors.joining()), equalTo(KIND));
    }

    private String randomString(){
        String id = "";
        for(int i = 0; i < 10; i++) {
            double dValue = Math.random();
            if(i%2 == 0) {
                id += (char) ((dValue * 26) + 65);   // 대문자
                continue;
            }
            id += (char)((dValue * 26) + 97); // 소문자
        }
        return id;
    }
}

어려운 코드는 없는 아주 기본적인 CRUD 테스트이다. remove 메소드를 보다가 findAllAndRemove가 있길래 테스트를 두개로 나누어 보았다.
보다시피 remove의 반환 타입은 DeleteResult이고, findAllAndRemove의 반환타입은 제네릭 리스트이다. DeleteResult와 유사한 UpdateResult도 존재하며 몇 개의 도큐먼트가 질의 대상이 되었는지 등의 정보를 얻을 수 있는 메소드를 가지고 있다. (UpdateResult가 더 다양한 메소드를 가지고 있음.)

MongoTemplate을 사용해 본 소감은 공식 도큐먼트에서 직접 이야기 했듯이 정말 "다양한" 기능을 가지고 있다는 것이다.

Spring-data-mongodb : MongoTemplate Class에서 메소드에 대한 종합적인 내용을 확인 할 수 있는데 한 번 전체적으로 훑어보는게 꽤 도움이 될 것 같다. 굳이 내가 만들어서 쓰기 전에 다 만들어둔 느낌..🤔? (링크는 3.0.0 릴리즈 버전이라서 조금 차이가 있을 수도 있겠다.)

임베디드 도큐먼트

PetEntity

그럼 Pet 클래스가 멤버 변수로 클래스를 가지고 있으면 어떻게 되는 걸까?
형제가 있는 경우에는 sibling에 리스트로 들어가게 된다. RDB였으면 형제 또한 PetEntity로 추가된 뒤, M:M 매핑을 위한 매핑테이블에 각각 추가해줘야 했을 것이다. (모든 형제는 자신을 제외한 형제들을 sibling으로 가져야하기 때문에 상호 참조하는 M:M 관계가 맺어진다.)

PetTest

간단한 테스트 코드를 이용하여 sibling을 가지고 있는 pet을 insert해보자!

    @Test
    @DisplayName("pet 컬렉션을 멤버변수로 가지고 있는 pet 객체를 insert한다.")
    public void insertTest2() {
        final int SIBLING_SIZE = 5;
        PetEntity pet = PetEntity.builder().kind("DOG").age(7).name("바둑이").sibling(getPets(SIBLING_SIZE)).build();
        mongoTemplate.insert(pet);

        Query query = new Query(Criteria.where("_id").is(pet.getId()));

        PetEntity findPet = mongoTemplate.findOne(query, PetEntity.class);
        assertThat(pet.getSibling().size(), equalTo(findPet.getSibling().size()));
    }

    private List<PetEntity> getPets(int size) {
        List<PetEntity> petEntities = new ArrayList<>();
        for (int i = 1; i <= size; ++i) {
            PetEntity pet = PetEntity.builder().name("sibling"+i).age(7).kind("DOG").build();
            petEntities.add(pet);
        }
        return petEntities;
    }

다섯 마리의 형제를 가지는 바둑이를 삽입해주었다. 테스트는 통과한다. 실제로 데이터 베이스에는 어떻게 저장되어 있을까?

임베디드 도큐먼트 배열의 형태로 들어간 것을 확인 할 수 있다. 중요한 것은 sibling의 도큐먼트들은 _id_class가 자동으로 생성되지 않았다는 것이다. 여기서 유추할 수 있는 점은 임베디드 도큐먼트는 join이 완료된 결과값이 아니라 그저 필요한 "정보"만 담고 있을 뿐이라는 것이다. 당연히 저 도큐먼트들은 따로 저장되거나 하지는 않았다.

그렇다면 이번엔 각각의 형제가 _id를 정확하게 가지고, 검색 될 수 있도록 쿼리를 작성해보자.

위 테스트 코드에서 DB 접근은 총 세 차례 이뤄진다.

  1. mongoTemplate.insertAll(..) : _id를 부여받기 위한 삽입 → sibling 리스트의 각각의 오브젝트의 _id 변수가 채워진다.
  2. updateSibing(..)메소드의 mongoTemplate.updateFirst(..) : _id를 포함하여 자신을 제외한 나머지 형제의 정보를 리스트업하여 Update 객체로 생성-"sibling" 필드의 값으로 추가함, 자신의 _id로 검색하여 업데이트한다.
  3. getUpdatePetEntities(..)메소드의 mongoTemplate.find(..) : Criteriain()메소드를 사용하여 id 리스트를 이용해 검색

🤔 updateFirst후에 sibling 리스트의 각 entity가 가지고 있는 sibling 멤버 변수가 업데이트 될 것이라고 생각했는데 null이였다.

Id는 자동으로 set되는 것을 보고 update된 필드도 자동으로 set되지 않을까..? 하고 생각했지만 안됐음.

도큐먼트에서 persistence를 지원한다고 하여 jpa의 persistence context의 기능을 기대했던 건데.. update를 수동으로, db에 바로 접근해서 바꿔주는 것이니 오브젝트를 영속성 컨텍스트에서 관리하면서 dirty cache를 통해 업데이트를 진행하는 jpa와 맥락이 다른가보다.

당연하게도 spring-data-mongodb는 dirty cache는 지원하지 않는다.

테스트가 잘 통과하는지 돌려본 뒤 직접 저장된 데이터도 확인해보자.
마치 조인한 것처럼 (_class는 없지만 👀💦) 동일한 아이디를 가진 도큐먼트가 임베디드 도큐먼트로 포함되어있음을 확인 할 수 있다.

위에서 사용한 테스트 코드는 다음과 같다.

    @Test
    @DisplayName("pet 컬렉션을 멤버변수로 가지고 있는 pet 객체와 멤버 변수의 M:M 관계를 유자하여 insert한다.")
    public void insertTest3() {
        final int SIBLING_SIZE = 3;
        List<PetEntity> sibling = getPets(SIBLING_SIZE);
        mongoTemplate.insertAll(sibling);

        updateSibling(SIBLING_SIZE, sibling);

        List<PetEntity> updatedSibling = getUpdatedPetEntities(sibling);

        for (int i = 0; i < SIBLING_SIZE; ++i) {
            List<PetEntity> thisSibling = updatedSibling.get(i).getSibling();
            List<ObjectId> thisSiblingIds = thisSibling.stream().map(pet -> pet.getId()).collect(Collectors.toList());
            assertThat(thisSiblingIds, not(contains(updatedSibling.get(i).getId())));
        }
    }

    void updateSibling(int SIBLING_SIZE, List<PetEntity> sibling) {
        for (int i = 0; i < SIBLING_SIZE; ++i) {
            ObjectId nowPetId = sibling.get(i).getId();
            Query query = new Query(Criteria.where("_id").is(nowPetId));
            mongoTemplate.updateFirst(query,
                    Update.update("sibling", sibling.stream().filter(pet -> pet.getId().equals(nowPetId) == false).collect(Collectors.toList())),
                    PetEntity.class);
        }
    }

    List<PetEntity> getUpdatedPetEntities(List<PetEntity> sibling) {
        Query query = new Query(Criteria.where("_id").in(sibling.stream().map(pet -> pet.getId()).collect(Collectors.toList())));
        return mongoTemplate.find(query, PetEntity.class);
    }

내일은 repository를 사용해보도록 하겠음!

모든 코드는 github에서 확인 할 수 있습니다.

profile
🌱 😈💻 🌱

0개의 댓글