목표
1. Spring 환경에서 MongoDB를 연동한다. (Spring Data MongoDB)
2. MongoTemplate을 이용한 간단한 CRUD를 테스트한다.
ref. https://docs.spring.io/spring-data/mongodb/docs/current/reference/html/#reference
해당 파트는 Spring Data MongoDB가 제공하는 핵심 기능에 대해서 설명한다.
MongoDB support : MongoDB 모듈 기능 셋을 설명한다.
MongoDB Repositories : MongoDB를 지원하는 레파지토리에 대해 소개한다.
@Configuration
클래스나 XML namespace를 지원한다.MongoTemplate
헬퍼 클레스는 도큐먼트와 POJO 간의 통합적인 객체 매핑을 포함한다.대부분의 작업에는 풍부한 매핑 기능을 활용하는 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와 연동해보자.
ref.
https://docs.spring.io/spring-data/mongodb/docs/current/reference/html/#mongodb-getting-started
spring-data-mongodb 버전과 mongo-java-driver 버전 맞춰주기
메이븐 레파지토리 종속을 추가한 뒤에 'java.lang.String com.mongodb.connection.ClusterSettings.getDescription()'
에러와 java.lang.ClassNotFoundException: com.mongodb.client.MongoClient
에러가 발생하였는데 버전 문제라고 생각되었으나 두 종속의 버전을 맞춰줄 방법을 못 찾았다..🤔
결국 구글 검색에서 찾은 예시에 있는 버전으로 다운그레이드하였음
아래의 두 종속성 추가
// 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'
이전에 @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" />
❗️NOTE
ref. https://docs.spring.io/spring-data/mongodb/docs/current/reference/html/#mongo-templateMongoTemplate는 MongoDB 도큐먼트를 작성, 업데이트, 삭제 및 조회할 수 있는 편리한 작업을 제공하며, 도메인 개체와 MongoDB 문서 간의 매핑을 제공한다.
MongoTemplate
클래스는MongoOperations
인터페이스의 구현체이다. 가능한한MongoOperations
의 메소드는 MongoDB 드라이버Collection
오브젝트에서 사용할 수 있는 메소드를 따라 명명되었기 때문에 드라이버 API를 사용했던 MongoDB 개발자에게 API를 친숙하게 만들어준다. 예를 들어find
,findAndModify
,findAndReplace
,findOne
,insert
,remove
,save
,update
,updataMulti
와 같은 메소드들이 있다. 설계 목표는 기본 MongoDB 드라이버의 사용과MongoOperations
를 쉽게 전환할 수 있도록 하는 것이었다. 두 API의 주요 차이점은MongoOperations
가Document
대신에 도메인 오브젝트를 사용할 수 있게 해준다는 것이다. 또한MongoOperations
는 매개 변수를 지정하기 위해 도큐먼트를 채우는 대신에Query
,Criteria
,Update
작업을 위한 뛰어난 API를 제공한다.
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
필드로 매핑된다.
테스트 코드를 간단하게 작성한다.
@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를 테스트해보자!
전체 코드는 다음과 같다.
@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 릴리즈 버전이라서 조금 차이가 있을 수도 있겠다.)
그럼 Pet 클래스가 멤버 변수로 클래스를 가지고 있으면 어떻게 되는 걸까?
형제가 있는 경우에는 sibling에 리스트로 들어가게 된다. RDB였으면 형제 또한 PetEntity로 추가된 뒤, M:M 매핑을 위한 매핑테이블에 각각 추가해줘야 했을 것이다. (모든 형제는 자신을 제외한 형제들을 sibling으로 가져야하기 때문에 상호 참조하는 M:M 관계가 맺어진다.)
간단한 테스트 코드를 이용하여 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 접근은 총 세 차례 이뤄진다.
mongoTemplate.insertAll(..)
: _id
를 부여받기 위한 삽입 → sibling
리스트의 각각의 오브젝트의 _id
변수가 채워진다.updateSibing(..)
메소드의 mongoTemplate.updateFirst(..)
: _id
를 포함하여 자신을 제외한 나머지 형제의 정보를 리스트업하여 Update
객체로 생성-"sibling" 필드의 값으로 추가함, 자신의 _id
로 검색하여 업데이트한다. getUpdatePetEntities(..)
메소드의 mongoTemplate.find(..)
: Criteria
의 in()
메소드를 사용하여 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에서 확인 할 수 있습니다.