새소식

Database

Spring Data MongoDB

  • -
공부한 내용을 정히라는 블로그와 관련 코드를 공유하는 Github이 있습니다.

 

MongoDB란

 

MongoDB는 오픈소스 비관계형 데이터베이스 관리 시스템(DMBS)으로, 테이블과 행 대신 유연한 문서를 활용해 다양한 데이터 형식을 처리하고 저장합니다. NoSQL 데이터베이스 솔루션인 MongoDB는 관계형 데이터베이스 관리 시스템(RDBMS)을 필요로 하지 않으므로, 사용자가 다변량 데이터 유형을 손쉽게 저장하고 쿼리할 수 있는 탄력적인 데이터 저장 모델을 제공합니다.

 

MongoDB 문서 또는 문서 컬렉션은 데이터의 기본 단위입니다. 해당 문서들은 이진 JSON(JavaScript 객체 표기법) 형식으로 지정되어 다양한 유형의 데이터를 저장할뿐 아니라, 여러 시스템 전반에 분산 처리될 수 있습니다. MongoDB는 동적 스키마 설계를 활용하므로 사용자는 독보적인 유연성을 확보해 데이터 레코드를 생성하고, MongoDB 집계를 통해 문서 컬렉션을 쿼리하며, 대량의 정보를 분석합니다.

MongoDB는 4.0버전 이후부터 multi-document transaction을 지원합니다. Spring Data MongoDB를 사용할 경우, 연동하는 mongoDB는 replica로 구성되어야만 @Transactional을 사용할 수 있으므로 MonogoDB에 replica 구성이 필요합니다.

 

readPreference

 

Read Preference 란 MongoDB의 Replica Set 설정 시 Primary와 Secondary 노드에 대한 작업 처리 분산에 대한 설정입니다.

먼저 Replica Set 구조를 살펴보면, MySQL의 Replication 과 동일한 방식입니다. 모든 명령은 기본적으로 Primary에서 처리하며 Secondary는 Primary에 기록된 데이터를 Sync 하며 유지하게 됩니다. 하지만, 이러한 구조는 Client가 Primary 하고만 데이터를 주고받기 때문에 MongoDB 서버로 총 3대를 사용함에도 작업 분산 효과를 누리지 못하며, 오로지 Secondary 노드는 데이터 백업 용도로만 사용할 수밖에 없게 됩니다.

 

 

  • primary : default 값으로 모든 작업을 primary에서 수행합니다.
  • primaryPreferred : primary를 default로 하되, primary가 불가 상태가 될 경우 secondary에서 read 처리
  • secondary : secondary에서 모든 작업 수행
  • secondaryPreferred : secondary를 default로 하되, secondary가 불가 상태 혹은 복제 지연이 발생하면 primary에서 수행
  • nearest : 네트워크 레이턴시를 기준으로 가장 가까운 멤버에서 read 처리

 

primary든 secondary든 preferred 옵션을 사용하는 것이 권장되나, MongoDB에서 트랜잭션을 사용하는 경우, 트랜잭션 내의 모든 작업은 일관성을 보장하기 위해 primary 노드에서 실행되어야 합니다. 따라서 트랜잭션을 사용하는 경우 해당 옵션은 primary로 사용해야 합니다. mongo community

 

write concern

 

Write concern은 MongoDB가 Client의 요청으로 데이터를 기록할 때, 해당 요청에 대한 Response를 어느 시점에 주느냐에 대한 동작 방식을 지정하는 옵션입니다.

MongoDB는 Client가 보낸 데이터를 Primary에 기록하고, 이에 대한 Response를 Client에게 보내게 됩니다. 이때, MongoDB를 레플리카 셋을 위 그림처럼 Primary 1대와 Secondary 2대로 구성하였을 경우, Client가 보낸 데이터의 Write 처리는 Primary에서만 먼저 처리하게 되며, 이후 Secondary로 변경된 데이터를 동기화 시키는 단계를 거칩니다. 이때 주의해야 할 점은 Primary와 Secondary 간 동기화 되는데 시간차가 있다는 점입니다. 만약 Client가 보낸 데이터를 Primary가 처리한 직후 Client 쪽으로 Response를 보내고 이후, Primary와 Secondary 간 동기화가 진행된다고 가정하면 Client가 Response를 받는 시점과 Primary에서 Secondary로 Sync 되는 타이밍 사이에는 데이터 일관성이 보장되지 않는 위험 구간이 존재하게 되는 것입니다.

 

만약 이 사이에 Primary에 장애가 발생 했다고 가정해 보면, 아직 최신 데이터를 Sync 하지 못한 Secondary 멤버가 Primary로 승격되고 Client는 이를 알아차리지 못한 채 이미 작업이 완료된 Response를 받았기 때문에 Client가 알고 있는 데이터와 DB의 데이터가 unmatch 되는 상횡이 발생되게 됩니다. 이러한 문제를 해결하기 위해 Client 쪽에 보내는 response 시점을 Primary와 Secondary가 동기화된 이후로 설정이 가능한데 이것이 바로 Write concern 설정의 핵심입니다.

 

 

Write Concern을 설정하게 되면 Primary가 데이터 쓰기를 처리한 이후 바로 Client에게 Response를 보내는 것이 아니라 Secondary 쪽으로 데이터를 동기화 작업을 완료한 이후에 Client에게 Response를 보내게 되므로 Client와 Primary, Secondary 간에 데이터 일관성을 유지할 수 있게 됩니다.

 

w: <value>, j: <boolean>, wtimeout: <number>

 

  • w : 레플리카 셋에 속한 멤버 중 지정된 수 만큼 멤버에게 데이터 쓰기가 완료되었는지 확인합니다.
    • 기본값은 1로 primary에만 확인하고 response합니다. 해당 값을 높이면 해당 숫자만큼의 멤버에서 write ack가 오면 client에 ack 응답을 보냅니다.
    • majority : 과반수 이상의 멤버에서 write ack가 오면 client에 ack 응답을 보냅니다.
  • j : 데이터 쓰기 작업이 디스크 상의 journal(디스크에 변경된 데이터, 인덱스를 적용하기 전 어떤 작업이 수행되었는지 로깅)에 기록된 후 완료로 판단하는 옵션입니다.
    • true : journal log까지 쓰고 ack(권장)
    • false : memory에만 전달하고 ack
  • wtimeout : Primary에서 Secondary로 데이터 동기화 시 timeout 값을 설정하는 옵션입니다.
    • default 없음
    • limit을 넘어가게 되면 실제로 데이터가 primary에 기록되었다고 해도 error를 리턴합니다.

 

default 값은 w : 1, j : true 로 특별한 이슈가 없다면 그대로 사용을 권장합니다. 보통 majority 옵션을 사용한다면 read와 write concern 모두 majority를 같이 사용합니다.

 

Read Concern

 

Write Concern과 마찬가지로 어디까지 반영된 데이터를 읽을지 결정하는 옵션입니다.

 

  • local(권장)
    • 쿼리가 수행되는 서버의 최신 데이터를 반환(메타 데이터 확인)합니다.
    • primary read 시 default이며, 5.0부터 secondary read 시에서 default입니다.
  • snapshot
    • multi document transaction 사용 시 default
    • 트랜잭션 시작 전의 상태를 snapshot으로 찍어서 사용하므로 현재 트랜잭션 시작 후 다른 트랜잭션에서 수정한 데이터는 현재 트랜잭션에서 실행되는 결과에 표시되지 않습니다.
  • linearizable
    • 읽기 연산이 시작하는 시점 전에 과반수의 레플리카 멤버에게서 write 연산이 성공했다는 응답을 받은 데이터만을 읽습니다.
    • 읽기 연산이 write 연산과 동시에 발생할 경우, write 연산이 과반수의 레플리카 맴버에게 전파될 때까지 대기하고 난 후에 write 연산이 반영된 이후의 데이터를 응답합니다.
    • multi document 조회에서는 사용 불가능하고 일부 aggregation pipeline을 사용 불가능($out 등)
    • readPreference : primary와 함께 사용
    • maxTimeMs 설정 필요 : 하나의 멤버라도 문제가 있으면 무한정 대기가 발생할 수 있으므로 지정해야 합니다.
  • majority
    • 과반수 이상의 멤버가 가진 최신 데이터 반환
    • primary-secondary-arbiter 구성에서 장애 유발 가능성이 있습니다. mongo server는 다른 member의 commit 상태 정보를 memory cache에 저장하고 반영되면 cache를 비우는데 이와 같은 구조에서, secondary가 장애 시 primary의 캐시가 비워지지 않고 이력을 계속 저장하게 되면서 primary cache pressure 발생 가능성이 있습니다.
  • available
    • 4.4 버전까지 secondary read 시 default
    • 메타 데이터(config)를 확인하지 않아 샤딩의 경우 잘못된 데이터를 반환할 수 있으므로 샤딩에서 사용해선 안됩니다.
    • 사실상 어떤 서비스의 경우에도 이 옵션 사용을 하지 않는 것을 권장

 

Transaction Isolation

 

몽고디비는 SQL 표준에서 제시한 4가지 격리 수준과는 다른, 스냅샷 격리 수준(Snapshot isolation level)을 사용합니다. WiredTiger 스토리지 엔진은 Read Uncommitted, Read Committed, Snapshot 격리 수준을 지원하고, 몽고디비는 트랜잭션 격리 수준을 스냅샷으로 고정하여 스냅샷 격리 수준만 지원합니다.

 

WiredTiger(와 몽고디비) 트랜잭션에서의 스냅샷이란, 스냅샷 생성 시점에서 트랜잭션들의 상태를 캡처한 것입니다. 즉, 스냅샷에는 어떤 트랜잭션이 완료(커밋)되었고, 어떤 트랜잭션이 진행중인지, 해당 스냅샷을 가진 트랜잭션이 뭔지에 대한 정보를 담고 있습니다. WiredTiger는 트랜잭션이 시작할 때 스냅샷을 설정합니다. 이후에 모든 읽기 연산은 스냅샷에 있는 정보를 참고하여 문서의 어떤 버전이 읽을 수 있는 버전인지 확인하여 읽어옵니다.

 

트랜잭션을 사용하지 않는 단일 문서 작업이나 특정 읽기 작업에서는 필요에 따라 다양한 readConcern 옵션을 활용하여 일관성을 유지할 수 있습니다. default값은 local은 커밋되지 않은 데이터도 읽을 수 있으므로 Read Uncommitted와 유사합니다. 공식 문서

 

compound index 참고사항

 

Equal, Sort, Range 순으로 compound key를 구성을 권장합니다. 다른 순서로도 가능하지만 Sort의 부하를 줄이는 것이 중요하므로 Sort 할 때 인덱스가 살아있다면 괜찮습니다.

 

프로젝트 설정

docker-compose를 활용한 mongoDB replica 설정

key file은 replica set에 참여하는 mongod 인스턴스 간의 인증, 클라이언트 접속 시 access control에 사용됩니다.

 

key file 생성

# mongodb 키 생성
sudo openssl rand -base64 756 > ~/.ssh/replica-mongodb-test.key

# 권한 설정
sudo chmod 400 ~/.ssh/replica-mongodb-test.key

# 제대로 key가 생성되었는지 확인
cat ~/.ssh/replica-mongodb-test.key

 

 

docker-compose.yml

version: "3.1"
services:
  mongodb1:
    image: mongo
    container_name: mongo1
    hostname: mongo1
    restart: always
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: root
    command: mongod --replSet rs0 --keyFile /etc/mongodb.key --bind_ip_all
    volumes:
      - ./db1:/data/db
      - ~/.ssh/replica-mongodb-test.key:/etc/mongodb.key

  mongodb2:
    image: mongo
    container_name: mongo2
    hostname: mongo2
    restart: always
    ports:
      - "27018:27018"
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: root
    command: mongod --replSet rs0 --keyFile /etc/mongodb.key --bind_ip_all
    volumes:
      - ./db2:/data/db
      - ~/.ssh/replica-mongodb-test.key:/etc/mongodb.key

 

 

레플리카셋 설정

# 실행
docker-compose up -d

# 정상적으로 올라왔는지 확인
docker ps -a

# container 접속
docker exec -it mongo1 /bin/bash

# root 계정 몽고 쉘 접속
mongosh -u root -p root

# admin 데이터베이스 사용
use admin

# replication 초기화
rs.initiate()

# mongo2 복제세트 추가
rs.add({_id: 1, host: "mongo2:27017"})

# 리플리카 셋 설정 정보 확인
rs.config()

# 리플리카 셋 상태정보 확인
rs.status()

 

spring config 설정

 

build.gradle.kts

implementation("org.springframework.boot:spring-boot-starter-data-mongodb")

 

mongoDB 의존성을 추가해 줍니다.

 

 

application.yml

spring:
  data:
    mongodb:
      uri : mongodb://root:root@localhost:27017/test?authSource=admin&connectTimeoutMS=10000
      database: test-mongo

logging:
  level:
    org.springframework.data.mongodb.core.MongoTemplate: DEBUG

 

readConern과 writeConcern은 큰 문제가 없다면 기본값인 localw : 1, j : true 사용이 권장됩니다.

 

https://www.mongodb.com/community/forums/t/why-can-t-read-preference-be-secondary-in-a-transaction/204432

 

replicaset의 활용성을 높이기 위해 readPreference을 secondaryPreferred로 지정해 줄 수 있지만 만약 프로젝트에서 Trasnactional을 사용하는 경우 해당 옵션은 기본값은 primary를 사용해야만 합니다. MongoDB의 경우 트랜잭션은 기본적으로 모든 읽기, 쓰기 작업은 일관성을 유지하기 위해 primary노드에서 수행되어야 하기 때문입니다.

앞서 언급했듯이 WiredTiger는 트랜잭션을 사용 시, isoliation이 snapshot으로 고정됩니다. 따라서 @Transactional(readOnly = true)를 사용하면 트랜잭션 시작 시점에 생성한 snapshot을 기반으로 일관된 읽기가 가능해집니다. 반면에 @Transactional(readOnly = true)을 사용하지 않으면 readConcern에 따른 읽기 일관성이 제공됩니다.

 

 

MongoClientConfig

/**
 * https://www.baeldung.com/spring-data-mongodb-transactions
 * https://docs.spring.io/spring-data/mongodb/docs/current-SNAPSHOT/reference/html/#mongo.transactions.tx-manager
 */
@Configuration
@EnableMongoAuditing
@EnableMongoRepositories(basePackages = ["com.example.mongo.repository"])
class MongoClientConfig(
    @Value("\${spring.data.mongodb.database}") private val database: String,
    @Value("\${spring.data.mongodb.uri}") private val uri: String,
) : AbstractMongoClientConfiguration() {

    @Bean("mongoTransactionManager")
    fun mongoTransactionManager(dbFactory: MongoDatabaseFactory): MongoTransactionManager {
        return MongoTransactionManager(dbFactory)
    }

    override fun getDatabaseName(): String {
        return database
    }

    override fun mongoClient(): MongoClient {
        return MongoClients.create(
            MongoClientSettings.builder()
                .applyConnectionString(ConnectionString(uri))
                .build(),
        )
    }

    override fun mappingMongoConverter(
        databaseFactory: MongoDatabaseFactory,
        customConversions: MongoCustomConversions,
        mappingContext: MongoMappingContext,
    ): MappingMongoConverter {
        super.mappingMongoConverter(databaseFactory, customConversions, mappingContext)
        val dbRefResolver: DbRefResolver = DefaultDbRefResolver(databaseFactory)
        val mappingConverter = MappingMongoConverter(dbRefResolver, mappingContext)

        mappingConverter.customConversions = customConversions

        // db에 _class 컬럼을 남기지 않는 설정
        mappingConverter.setTypeMapper(DefaultMongoTypeMapper(null))

        return mappingConverter
    }
}

 

기본적으로 spring boot는 txManager를 자동으로 빈으로 등록해 주지만 mongoDB는 tx사용이 선택적이라 자동으로 빈을 등록해주지 않습니다. 따라서 mongoDB tx 사용을 위해서는 별도의 빈을 등록해야 합니다. mongoTypeMapper를 설정해 주는 이유는 아무런 설정이 없다면 db에 저장될 때 _class값이 메타데이터로 저장되는데 이 값을 없애기 위함입니다.

 

사용법

document

@Document(DOCUMENT_NAME)
class PipelineDocument(
    @Field(ID) @Id val id: ObjectId? = null,
    @Field(STEPS) val steps: List<StepDocument>,
    @Field(STATUS) val status: String,
    @Field(REGISTERED_BY) val registeredBy: String,
    @Field(REGISTERED_DATE) val registeredDate: LocalDateTime,
) {

    @Document(StepDocument.DOCUMENT_NAME)
    data class StepDocument(
        @Field(TYPE) val type: String,
        @Field(STATUS) val status: String,
        @Field(REGISTERED_BY) val registeredBy: String,
        @Field(REGISTERED_DATE) val registeredDate: LocalDateTime,
    ) {

        companion object {
            const val DOCUMENT_NAME = "step"
            const val TYPE = "type"
            const val STATUS = "status"
            const val REGISTERED_BY = "registeredBy"
            const val REGISTERED_DATE = "registeredDate"
        }
    }

    companion object {
        const val DOCUMENT_NAME = "pipeline"
        const val ID = "_id"
        const val STEPS = "steps"
        const val STATUS = "status"
        const val REGISTERED_BY = "registeredBy"
        const val REGISTERED_DATE = "registeredDate"
    }
}

 

위 document를 기반으로 진행해 보겠습니다. @Document는 Jpa의 @Entity와 유사하며 @Id는 pk를 의미합니다. mongoDB에서는 ObjectId 타입을 사용하고 null값인 경우 db에서 auto-generate를 해줍니다.

 

repository

interface PipelineDocumentRepository : MongoRepository<PipelineDocument, ObjectId>, PipelineDocumentCustomRepository {
    fun findByStepsTypeIn(stepsTypes: List<String>): List<PipelineDocument>
}

interface PipelineDocumentCustomRepository {
    fun findByStepStatusIn(stepsStatuses: List<String>): List<PipelineDocument>
    fun countByStepStatusIn(stepsStatuses: List<String>): Long
    fun findPageByStepStatusIn(stepsStatuses: List<String>, pageable: Pageable): List<PipelineDocument>
}

 

Spring Data Mongodb는 jpa와 유사한 MongoRepository 인터페이스를 제공하여 다양한 CRUD 메서드를 자동으로 만들어줍니다. 이외의 복잡한 쿼리는 QueryDSL과 유사하게 추가 인터페이스를 사용하여 구현할 수 있습니다.

 

@Repository
class PipelineDocumentCustomRepositoryImpl(
  private val mongoTemplate: MongoTemplate,
): PipelineDocumentCustomRepository {

  override fun findByStepStatusIn(stepsStatuses: List<String>): List<PipelineDocument> {
    return mongoTemplate.find(
      PipelineDocumentQueryBuilder.buildQueryToFindByStepStatusIn(stepsStatuses),
      PipelineDocument::class.java
    )
  }

  override fun countByStepStatusIn(stepsStatuses: List<String>): Long {
    return mongoTemplate.count(
      PipelineDocumentQueryBuilder.buildQueryToFindByStepStatusIn(stepsStatuses),
      PipelineDocument::class.java
    )
  }

  override fun findPageByStepStatusIn(stepsStatuses: List<String>, pageable: Pageable): List<PipelineDocument> {
    return mongoTemplate.find(
      PipelineDocumentQueryBuilder.buildQueryToFindPageByStepStatusIn(stepsStatuses, pageable),
      PipelineDocument::class.java
    )
  }
}

 

추가 인터페이스 구현은 mongoTemplate을 활용하여 구현합니다.

 

object PipelineDocumentQueryBuilder {

    fun buildQueryToFindByStepStatusIn(stepStatuses: List<String>): Query {
        return Query().addCriteria(
            Criteria.where("${PipelineDocument.STEPS}.${PipelineDocument.STATUS}").`in`(stepStatuses)
        )
    }

    fun buildQueryToFindPageByStepStatusIn(stepStatuses: List<String>, pageable: Pageable): Query {
        return Query().addCriteria(
            Criteria.where("${PipelineDocument.STEPS}.${PipelineDocument.STATUS}").`in`(stepStatuses)
        )
            .with(pageable)
            .with(Sort.by(Sort.Direction.DESC, "$PipelineDocument.ID"))
    }
}

 

사용되는 쿼리는 재사용성을 위해 추가의 object 클래스로 분리하여 사용합니다.

 

Transaction Write Conflict

 

  1. 트랜잭션 1 시작
  2. 트랜잭션 2 시작
  3. 트랜잭션 1에서 document 1 수정 후 업데이트
  4. 트랜잭션 2에서 document 1 수정 후 업데이트 시도 -> 실패

위와 같은 상황에서 트랜잭션 1은 정상적으로 수행되지만, 트랜잭션 2는 Write Conflict Error를 발생시키며 롤백됩니다. 도큐먼트의 버전을 확인하여 이미 수정되지 않은 경우에만 수정하는 낙관적 동시성 제어 방식을 사용하는 것입니다. 또한 트랜잭션 1이 커밋되기 전에 트랜잭션 3이 시작돼버리면 트랜잭션 3도 Write Conflict Error가 발생하므로 재시도는 트랜잭션 1이 커밋되고 난 후에 진행되어야 합니다. 이는 공식 문서에도 설명되어 있습니다.

 

 

참고

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감/반응 부탁드립니다.