스프링부트

spring data MongoDB - MongoTemplate 설정하기

알쓸개잡 2024. 1. 1.

Spring data MongoDB의 핵심 역할을 하는 MongoTemplate과 반응형 대응 클래스는 org.springframework.data:spring-data-mongodb 모듈의 org.springframework.data.mongodb.core 패키지에 있다. MongoTemplate는 데이터베이스와 상호 작용하기 위한 풍부한 기능 세트를 제공하며 MongoDB Document를 생성, 업데이트, 삭제 및 쿼리 하기 위한 편리한 작업을 제공한다.

이번 포스팅에서는 MongoTemplate을 구성하는 방법과 MongoTemplate의 여러가지 옵션에 대해서 정리해 보고자 한다.

MongoClient는 MongoClientSettings와 함께 MongoDB 연결에 대한 설정을 하지만 MongoTemplate에 지정하는 여러 가지 설정 옵션들은 MongoDB 운영에 필요한 설정을 하는 것이라고 보면 되겠다.

 

MongoTemplate Configuration

MongoTemplate 빈은 auto configuration에 의해서 생성되기도 하지만 다음과 같이 직접 빈을 생성할 수 있다.

@Configuration
class MongoDBConfiguration {
	@Bean
	public MongoOperations mongoTemplate( MongoDatabaseFactory factory, MongoConverter converter ) {
	    MongoTemplate mongoTemplate = new MongoTemplate( factory, converter );
	    mongoTemplate.setWriteResultChecking( WriteResultChecking.EXCEPTION );
	    mongoTemplate.setReadPreference( ReadPreference.secondaryPreferred() );
	    mongoTemplate.setWriteConcern( WriteConcern.ACKNOWLEDGED );
	    return mongoTemplate;
	}
}
  • 인자로 전달되는 MongoDatabaseFactory, MongoConverter는 직접 빈을 생성하지 않았다면 auto configuration에 의해서 생성된 빈들이 전달된다.

 

MongoTemplate 옵션

  • MongoTemplate 생성시 설정할 수 있는 옵션은 WriteResultCheckingPolicy, WriteConcern, ReadPreference, WriteConcernResolver, EntityLifecycleEvent, EntityCallback 이 있다.

WriteResultCheckingPolicy

  • MongoDB 쓰기 작업의 결과를 어떻게 확인하고 처리할지 구성하는 옵션이다. 옵션은 다음과 같다.
    • NONE (defualt): 아무런 확인을 수행하지 않음. MongoDB 쓰기 작업의 결과를 확인하지 않는다.
    • EXCEPTION: 쓰기 작업이 실패한 경우 예외를 발생시킨다.

 

WriteConcern

  • WriteConcern 옵션은 데이터베이스에 데이터를 쓸 때 데이터의 일관성과 안정성을 제어하기 위한 설정이다.
  • ReplicaSet 구성에서 의미를 가지며 어느 수준에서 복제 노드의 쓰기 작업이 완료될 때 응답하도록 할지에 대한 옵션이다.
  • WriteConcern 옵션의 설정 레벨이 높아질수록 응답 속도에 대한 성능은 떨어지지만 데이터의 안정성은 높아진다.
  • WriteConcern 클래스의 생성자는 다음과 같다.
public WriteConcern(final int w, final int wTimeoutMS) {
        this(w, wTimeoutMS, null);
}

private WriteConcern(
	@Nullable final Object w, 
	@Nullable final Integer wTimeoutMS, 
	@Nullable final Boolean journal) { ... }

첫 번째 인자인 w는 쓰기 완료를 확인할 노드 개수를 의미한다.

두 번째 인자인 wTimeoutMS는 노드에 쓰기를 할 때 지정되는 timeout을 의미한다.

사전에 미리 정의된 WriteConcern이 제공되며 다음과 같다.

/**
 * Write operations that use this write concern will wait for acknowledgement, using the default write concern configured on the server.
 *
 * @since 2.10.0
 * @mongodb.driver.manual core/write-concern/#write-concern-acknowledged Acknowledged
 */
public static final WriteConcern ACKNOWLEDGED = new WriteConcern(null, null, null);

/**
 * Write operations that use this write concern will wait for acknowledgement from a single member.
 *
 * @since 3.2
 * @mongodb.driver.manual reference/write-concern/#w-option w option
 */
public static final WriteConcern W1 = new WriteConcern(1);

/**
 * Write operations that use this write concern will wait for acknowledgement from two members.
 *
 * @since 3.2
 * @mongodb.driver.manual reference/write-concern/#w-option w option
 */
public static final WriteConcern W2 = new WriteConcern(2);

/**
 * Write operations that use this write concern will wait for acknowledgement from three members.
 *
 * @since 3.2
 * @mongodb.driver.manual reference/write-concern/#w-option w option
 */
public static final WriteConcern W3 = new WriteConcern(3);


/**
 * Write operations that use this write concern will return as soon as the message is written to the socket. Exceptions are raised for
 * network issues, but not server errors.
 *
 * @since 2.10.0
 * @mongodb.driver.manual core/write-concern/#unacknowledged Unacknowledged
 */
public static final WriteConcern UNACKNOWLEDGED = new WriteConcern(0);

/**
 * Write operations wait for the server to group commit to the journal file on disk.
 *
 * @mongodb.driver.manual core/write-concern/#journaled Journaled
 */
public static final WriteConcern JOURNALED = ACKNOWLEDGED.withJournal(true);

/**
 * Exceptions are raised for network issues, and server errors; waits on a majority of servers for the write operation.
 */
public static final WriteConcern MAJORITY = new WriteConcern("majority");
ACKNOWLEDGED (defualt) 서버에 적용된 write concern 옵션을 적용한다.
W1 단일 노드의 데이터 쓰기 작업이 완료될 때 응답한다.
W2 두 노드의 데이터 쓰기 작업이 완료될 때 응답한다.
W3 세 노드의 데이터 쓰기 작업이 완료될 때 응답한다.
JOURNALED 서버가 디스크의 저널 파일에 데이터를 기록 후 응답한다. 
ACKNOWLEDGED WriteConcern에 journaled 옵션이 추가된 구성이다.
UNACKNOWLEDGED 쓰기 요청 작업은 소켓에 기록되자마자 결과가 반환된다.
네트워크 문제에 대해서는 예외가 발생하지만 서버 오류에는 예외가 발생하지 않는다.
응답이 가장 빠르지만 데이터의 안정성을 보장하기 어렵다.
MAJORITY 과반수 이상의 노드에서 쓰기 작업이 완료되면 응답한다.
네트워크 문제 및 서버 오류에 대해서 예외가 발생한다.
성능보다 안정성이 중요한 경우에 사용된다.
  • WriteConcern에 Journaled 옵션을 추가하려면. withJournal(true)를 호출한다.
mongoTemplate.setWriteConcern(WriteConcern.W1.withJournal(true));
  • WriteConcern에 timeout 옵션을 추가하려면 .withWTimeout(wTimeout, timeUnit)을 호출한다.
mongoTemplate.setWriteConcern(WriteConcern.W1.withWTimeout(30, TimeUnit.SECONDS));
  • WriteConcern에 Journaled, timeout 옵션을 모두 설정할 수 있다.
mongoTemplate.setWriteConcern(
	WriteConcern.W1
		.withJournal(true)
		.withWTimeout(30, TimeUnit.SECONDS)
);

 

 

ReadPreference

  • ReplicaSet 구성에서 어떤 노드에서 데이터를 읽을지를 결정하는데 사용된다.
primary (default) primary 노드에서 데이터를 읽는다.
secondary 복제 (secondary) 노드 중에서 데이터를 읽는다.
primaryPreferred 가능하면 primary 노드에서 읽지만, primary 노드가 다운된 경우 secondary 노드에서 읽는다.
secondaryPreferred 가능하면 secondary 노드에서 읽지만, secondary 노드가 다운된 경우 primary 노드에서 읽는다.
nearest 가장 낮은 network latency를 갖는 노드에서 데이터를 읽는다.
  • ReadPreference 옵션은 MongoTemplate 사용시 Query 객체를 호출 시점에 다음과 같이 직접 지정할 수도 있다.
mongoTemplate.find(Person.class)
	.matching(query(where(...)).withReadPreference(ReadPreference.secondary()))
	.all();

 

 

WriteConcernResolver

  • MongoDB operation 별(delete, update, insert, save) 혹은 특정 조건의 작업에 대해서 WriteConcern 옵션을 달리 적용하고 싶을 때 WriteConcernResolver등록을 통해서 해결할 수 있다.
public interface WriteConcernResolver {
	WriteConcern resolve(MongoAction action);
}

위와 같이 functional interface로 정의되어 있으므로 lambda 표현식으로 MongoTemplate에 등록할 수 있다.

@Bean
public MongoTemplate mongoTemplate( MongoDatabaseFactory factory, MongoConverter converter ) {
    MongoTemplate mongoTemplate = new MongoTemplate( factory, converter );
    mongoTemplate.setWriteResultChecking( WriteResultChecking.EXCEPTION );
    mongoTemplate.setReadPreference( ReadPreference.secondaryPreferred() );
    mongoTemplate.setWriteConcern( WriteConcern.W1.withJournal( true ).withWTimeout( 30, TimeUnit.SECONDS ) );
    mongoTemplate.setWriteConcernResolver( action -> {
        if ( action.getEntityType().getSimpleName().contains( "Person" ) ) {
            return WriteConcern.W1;
        } else if ( action.getDocument().containsKey( "personName" )) {
            return WriteConcern.JOURNALED;
        } else if ( action.getCollectionName().equals( "user" ) ) {
            return WriteConcern.MAJORITY;
        } else {
            return action.getDefaultWriteConcern();
        }
    } );
    return mongoTemplate;
}
  • 위 MongoTemplate 에 지정되는 WriteConcernResolver는 다음과 같은 역할을 한다.
    • entity 타입의 클래스명에 Person이 포함된 경우 W1 레벨의 WriteConcern을 적용한다.
    • operation 대상 document에 'personName' 필드가 존재하는 경우 JOURNALED WriteConcern을 적용한다.
    • collection 이름이 'user'인 경우 MAJORITY 레벨의 WriteConcern을 적용한다.
    • 그 외에는 MongoTemplate 빈 생성 시에 setWriteConcern 메서드를 통해서 지정된 WriteConcern을 적용한다.
  • MongoTemplate에 의한 작업이 수행될 때 먼저 WriteConcernResolver의 resolve 메서드가 호출되는데 이때 인자로 전달되는 MongoAction 인스턴스에는 다음과 같은 정보가 담겨 있다. (Repository 클래스를 통한 write 작업 수행 시에도 내부적으로 MongoTemplate을 통해서 operation 동작이 이루어진다.)
    • collection name
    • mongo action operation (REMOVE, UPDATE, INSERT, INSERT_LIST, SAVE, BULK, REPLACE)
    • default write concern (MongoTemplate 빈 생성시 setWriteConcern으로 지정된 WriteConcern 인스턴스)
    • entity type (entity 역할을 하는 Class)
    • query 인스턴스
    • document (operation 대상이 되는 document)
  • 예시로 디버거를 통해서 살펴본 MongoAction 내용은 다음과 같다.

MongoAction 인스턴스 내용
MongoAction 인스턴스 예

 

 

Entity Lifecycle Event

  • entity lifecycle event는 Spring의 ApplicationContext 이벤트 인프라를 기반으로 한다.
  • MongoTemplate은 lifecycle event 기능에 대해서 활성/비활성 설정을 할 수 있다. (default: 활성)
mongotTemplate.setEntityLifecycleEventsEnabled(false);
  • lifecycle event listener는 AbstractMongoEventListener 추상클래스를 상속하여 구현하며 callback 메서드는 다음과 같다.
onBeforeConvert insert, insertList, save operation에서 MongoConverter에 의해서 POJO entity에서 Document로 변환되기 전에 호출된다.
onBeforeSave insert, insertList, save operation에서 데이터베이스에 Document를 저장하기 전에 호출된다.
onAfterSave insert, insertList, save operation에서 데이터베이스에 Document를 저장한 후에 호출된다.
onAfterLoad find, findAndRemove, findOne, getCollection operation에서 Document가 검색된 후에 호출된다.
onAfterConvert find, findAndRemove, findOne, getCollection operation에서 Document가 검색된 후에 POJO entity로 변환이 완료된 후에 호출된다.
lifecycle event는 root 레벨의 entity 타입에 대해서만 발생한다. Document 내에서 속성으로 사용되는 복합 유형은 @DBRef로 참조 document로 지정된 문서가 아닌 한 이벤트 게시 대상이 아니다.
lifecycle event는 이벤트가 언제 처리되는지 보장할 수 없는데 이로 인해 listener 내에서 값을 변경하여 처리하는 경우 올바르게 동작하지 않을 수 있다.
MongoDBlifecycle 내에서 값을 변경하여 처리하고자 한다면 다음에 소개할 EntityCallback을 통해서 수정하는 것을 권장한다.
  • 다음은 entity lifecycle event listener 샘플코드이다.
@Component
@Slf4j
public class MongoDBLifeCycleEvents extends AbstractMongoEventListener<Person> {
    @Override
    public void onBeforeConvert( BeforeConvertEvent<Person> event ) {
        Person source = event.getSource();
        String collection = event.getCollectionName();
        log.info( "before convert person object: {}, collection: {}", source, collection );
    }

    @Override
    public void onBeforeSave( BeforeSaveEvent<Person> event ) {
        Person source = event.getSource();
        String collection = event.getCollectionName();
        log.info( "before save person object: {}, collection: {}", source, collection );
    }

    @Override
    public void onAfterSave( AfterSaveEvent<Person> event ) {
        Person source = event.getSource();
        String collection = event.getCollectionName();
        log.info( "after save person object: {}, collection: {}", source, collection );
    }

    @Override
    public void onAfterLoad( AfterLoadEvent<Person> event ) {
        Document source = event.getSource();
        String collection = event.getCollectionName();
        log.info( "after load document: {}, collection: {}", source, collection );
    }

    @Override
    public void onAfterConvert( AfterConvertEvent<Person> event ) {
        Person source = event.getSource();
        String collection = event.getCollectionName();
        log.info( "after convert person object: {}, collection: {}", source, collection );
    }

    @Override
    public void onAfterDelete( AfterDeleteEvent<Person> event ) {
        Document source = event.getSource();
        String collection = event.getCollectionName();
        log.info( "after delete document: {}, collection: {}", source, collection );
    }

    @Override
    public void onBeforeDelete( BeforeDeleteEvent<Person> event ) {
        Document source = event.getSource();
        String collection = event.getCollectionName();
        log.info( "before delete document: {}, collection: {}", source, collection );
    }
}

위와 같은 entity lifecycle event를 빈으로 등록 후에 아래와 같은 코드를 수행한 뒤에 로그를 확인해 보면 다음과 같다.

//mongoTemplate 빈
private final MongoTemplate mongoTemplate;
private final static String COLLECTION_NAME = "person";

public void savePersonByMongoTemplate( Person person ) {
    log.info( "======================================" );
    Person saved = mongoTemplate.insert( person );
    Query query = new Query();
    query.addCriteria( where( "_id" ).is( saved.getPersonId()) );
    mongoTemplate.findOne( query, Person.class, COLLECTION_NAME );
    log.info( "======================================" );
}
======================================
before convert person object: Person(personId=929797c1-e9c0-460d-be8b-8359cf1c13f8, personName=test-name, personAge=100, personGender=null, personNation=null), collection: person
before save person object: Person(personId=929797c1-e9c0-460d-be8b-8359cf1c13f8, personName=test-name, personAge=100, personGender=MALE, personNation=Korea), collection: person
after save person object: Person(personId=929797c1-e9c0-460d-be8b-8359cf1c13f8, personName=test-name, personAge=100, personGender=MALE, personNation=Korea), collection: person
after load document: Document{{_id=929797c1-e9c0-460d-be8b-8359cf1c13f8, personName=test-name, personAge=100, personGender=MALE, personNation=Korea, _class=com.example.springmongodb.documents.Person}}, collection: person
after convert person object: Person(personId=929797c1-e9c0-460d-be8b-8359cf1c13f8, personName=test-name, personAge=100, personGender=MALE, personNation=Korea), collection: person
======================================

mongoTemplate.insert() 메서드 수행 과정에서 onBeforeConvert, onBeforeSave, onAfterSave event가 발생한다.

mongoTemplate.findOne() 메서드 수행 과정에서 onAfterLoad, onAfterConvert event가 발생한다.

 

Entity Callback 구성

  • Spring Data 인프라는 특정 메서드가 호출되기 전후에 엔티티를 수정하기 위한 훅을 제공한다. entity callback instance라고 불리는 이러한 인스턴스는 콜백 방식으로 엔티티를 확인하고 잠재적으로 수정할 수 있는 편리한 방법을 제공한다.
  • entity callback은 동기식 및 반응형 API와의 통합 지점을 제공하여 처리 체인 내에서 잘 정의된 체크포인트에서 순서대로 실행되도록 보장하고, 수정 가능성이 있는 엔티티 타입을 반환한다.
  • Document 생성 시간, 생성자, 수정 시간, 수정자에 대한 Auditing 역시 내부적으로 이러한 callback에 의해서 동작한다.
  • 각 EntityCallback의 종류와 설명은 다음과 같다.
Callback Description Order
BeforeConvertCallback POJO entity가 Document로 변환되기 전에 호출된다.
POJO entity 객체를 수정할 수 있다.
LOWEST_PRECEDENCE
AfterConvertCallback 검색된 Document가 POJO entity로 변환된 후에 호출된다.
POJO entity 객체를 수정할 수 있다.
LOWEST_PRECEDENCE
AuditingEntityCallback auditable entity를 표시한다. (created or modified) 100
BeforeSaveCallback POJO entity 객체를 저장하기 전에 호출한다.
entity 정보를 포함하고 있는 Document를 수정할 수 있다.
LOWEST_PRECEDENCE
AfterSaveCallback POJO entity 객체를 저장한 뒤에 호출한다. LOWEST_PRECEDENCE

 

  • 다음은 EntityCallback Listener 샘플 코드이다.
@Component
@Slf4j
public class MongoDBEntityCallbacks implements
        BeforeConvertCallback<Person>,
        BeforeSaveCallback<Person> {
    @Override
    public Person onBeforeConvert( Person entity, String collection ) {
        log.info( "before convert callback, entity: {}, collection: {}", entity, collection );
        if ( entity.getPersonGender() == null ) {
            entity.setPersonGender( Gender.MALE );
        }

        if ( !StringUtils.hasText( entity.getPersonNation() ) ) {
            entity.setPersonNation( "Korea" );
        }

        return entity;
    }

    @Override
    public Person onBeforeSave( Person entity, Document document, String collection ) {
        log.info( "before save callback, entity: {}, document: {}, collection: {}", entity, document, collection );
        return entity;
    }
}
  • BeforeConvertCallback, BeforeSaveCallback을 implements 하여 두 개의 callback을 등록하였다.
  • 위 callback 샘플은
    • onBeforeConvert 콜백에서 Person 객체에 성별과 국가 정보가 세팅되지 않은 경우 디폴트 값을 세팅한다.
    • onBeforeSave 콜백에서 저장될 document를 확인한다.
  • 위와 같이 별도의 Callback Listener 클래스를 생성하는 대신에 MongoTemplate 빈을 생성 시에 다음과 같이 callback을 등록할 수 있다.
mongoTemplate.setEntityCallbacks( EntityCallbacks.create(
        ( BeforeConvertCallback<Person> )( entity, collection ) -> {
            log.info( "before convert callback, entity: {}, collection: {}", entity, collection );
            if ( entity.getPersonGender() == null ) {
                entity.setPersonGender( Gender.MALE );
            }

            if ( !StringUtils.hasText( entity.getPersonNation() ) ) {
                entity.setPersonNation( "Korea" );
            }

            return entity;
        },
        ( BeforeSaveCallback<Person> )( entity, document, collection) -> {
            log.info( "before save callback, entity: {}, document: {}, collection: {}", entity, document, collection );
            return entity;
        }
) );

다음의 코드가 실행될 때 로그를 확인해 보면 다음과 같다.

Person person = Person.builder()
                .personId( UUID.randomUUID() )
                .personName( "test-name" )
                .personAge( 100 )
                .build();
mongoDBService.savePersonByMongoTemplate( person );

....

//mongoTemplate 빈
private final MongoTemplate mongoTemplate;
private final static String COLLECTION_NAME = "person";

public void savePersonByMongoTemplate( Person person ) {
    log.info( "======================================" );
    Person saved = mongoTemplate.insert( person );
    Query query = new Query();
    query.addCriteria( where( "_id" ).is( saved.getPersonId()) );
    mongoTemplate.findOne( query, Person.class, COLLECTION_NAME );
    log.info( "======================================" );
}
======================================
before convert callback, entity: Person(personId=171f6e62-ac13-4b5e-842d-c35e4086974a, personName=test-name, personAge=100, personGender=null, personNation=null), collection: person
before save callback, entity: Person(personId=171f6e62-ac13-4b5e-842d-c35e4086974a, personName=test-name, personAge=100, personGender=MALE, personNation=Korea), document: Document{{_id=171f6e62-ac13-4b5e-842d-c35e4086974a, personName=test-name, personAge=100, personGender=MALE, personNation=Korea, _class=com.example.springmongodb.documents.Person}}, collection: person
======================================

만약 AfterSaveCallback, AfterConvertCallback을 모두 등록하였다면 mongoTemplate.insert()에서 onAfterSave 콜백이, mongoTemplate.findOne()에서 onAfterConvert 콜백이 호출되었을 것이다.

 

Person POJO entity 에는 저장하기 전에 personGender, personNation 값은 null 상태지만, onBeforeConvert 콜백 메서드에서 personGender, personNation 필드를 각각 Genger.MALE, 'Korea'로 세팅하여 POJO entity를 수정하였다.

이후 POJO entity -> Document로 convert 되고 onBeforeSave 콜백에서 변환된 Document를 확인해 보면 

document: Document{{_id=171f6e62-ac13-4b5e-842d-c35e4086974a, personName=test-name, personAge=100, personGender=MALE, personNation=Korea, _class=com.example.springmongodb.documents.Person}}

와 같이 personGender, personNation 필드에 디폴트 값이 세팅되어 저장되는 것을 확인할 수 있다.

다음은 MongoDB 에 저장된 Person Document이다.

{
  "_id": {
    "$binary": {
      "base64": "bDrvfW3dRLCDUEyBGYQ1VA==",
      "subType": "04"
    }
  },
  "personName": "test-name",
  "personAge": 100,
  "personGender": "MALE",
  "personNation": "Korea",
  "_class": "com.example.springmongodb.documents.Person"
}

 

지금까지 MongoTemplate Configuration과 관련된 옵션들 및 lifecycle event, entity callback에 대해서 알아보았다.

 

샘플 코드 gitlab 링크


참고 링크

https://docs.spring.io/spring-data/mongodb/reference/mongodb/template-config.html

https://docs.spring.io/spring-data/mongodb/reference/mongodb/lifecycle-events.html

댓글

💲 추천 글