모종닷컴

Spring MongoDB Transaction Support 본문

Programming/Spring

Spring MongoDB Transaction Support

모종 2022. 10. 23. 21:31
반응형

개요

작업을 하다가 우연히 발견하게 된 것이 있습니다. MongoDB에 일괄로 저장하는 코드를 작성하고 @Transactional을 붙이면 이 작업이 트랜잭션 단위로 동작하기를 기대했는데 실제로는 전혀 동작이 되고 있지 않았기 때문입니다.

문서들을 찾아본 결과 MongoDB는 4.0 버전 이후부터 Transaction을 지원하기 시작했고, 사용하고 있는 Spring Data MongoDB는 2.1 버전 이후부터 MongoDB의 Transaction을 사용할 수 있다고 합니다.

제가 작업하는 환경은 모두 MongoDB의 Transaction을 사용할 수 있는 버전이었음에도 불구하고 동작이 되지 않았습니다. 그래서 이번 포스팅에서는 스프링 프레임워크에서 MongoDB의 Transaction을 사용하기 위한 설정을 하는 법을 쓰려고 합니다. 

 

테스트 : Transaction이 동작하지 않는 코드

설정하기 전에 먼저 트랜젝션이 동작하지 않음을 눈으로 보려고 합니다. 테스트 환경은 Gradle + Spring Boot + Kotlin입니다.

현재 준비된 MongoDB 가 4.0 버전 미만이거나 replication 세팅이 되어 있지 않는 분들은 [MongoDB] Replication을 통해 로컬에 세팅 바랍니다.

빌드 스크립트

buildscript {
    ext {
        springBootVersion = '2.6.13'
        kotlinVersion = '1.7.20'
    }

    repositories {
        maven {
            url("https://plugins.gradle.org/m2/")
        }
    }

    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
        classpath("org.jetbrains.kotlin:kotlin-allopen:${kotlinVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'kotlin-spring'
apply plugin: 'kotlin'

group = 'com.monny'
version = '0.0.1-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
    implementation 'io.github.microutils:kotlin-logging:3.0.2'
    implementation 'org.jetbrains.kotlin:kotlin-reflect'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

소스코드

@Document(collection = "post")
data class Post(
    @Id
    var id: Long,
    var title: String,
    var content: String
)
interface PostRepository : MongoRepository<Post, Long>
private val log = KotlinLogging.logger {  }

interface PostService {
    fun saveAll(posts: List<Post>)
}
@Service
class PostServiceImpl(
    private val postRepository: PostRepository
): PostService {
    @Transactional
    override fun saveAll(posts: List<Post>) {
        posts.forEach {
            log.info { "save $it" }
            if(it.title.length > 10)
                throw IllegalArgumentException("제목은 10자 이하여야합니다.")
            postRepository.save(it)
        }
    }
}
@RestController
@RequestMapping("/api/posts")
class PostApiController(
    private val postService: PostService
) {
    @PostMapping("/saveAll")
    fun saveAll(@RequestBody posts: List<PostDTO>) {
        postService.saveAll(posts.map { it.toEntity() })
    }
}
data class PostDTO(
    var id: Long,
    var title: String,
    var content: String
) {
    fun toEntity() = Post(id, title, content)
}

테스트해보기

PostService 코드를 보면 알겠지만 입력받은 post 제목이 10자리 이상이면 에러를 내뱉도록 하였습니다. 또한 코드 상단에 @Transactional을 붙였기 때문에 기대하기로는 일부가 저장하다가 실패했을 경우 전체 데이터가 들어가지 않는 것입니다. 일부러 실패할만한 데이터를 넣어서 제대로 롤백이 되는지 테스트해보기 위해 /api/posts/saveAll을 호출할 때 아래와 같은 데이터를 넘겨줄 것입니다.

POST http://localhost:8080/api/posts/saveAll
Content-Type: application/json

[
  {
    "id": 1,
    "title": "10자리미만제목",
    "content": "모종닷컴"
  },
  {
    "id": 2,
    "title": "10자리가넘어가는제목",
    "content": "모종닷컴"
  }
]

애플리케이션을 실행시키고 엔드포인트에 위의 데이터를 세팅해서 호출해보고 MongoDB에 어떻게 데이터가 들어가 있는지 확인해보겠습니다. 

save Post(id=1, title=10자리미만제목, content=모종닷컴)
Opened connection [connectionId{localValue:9, serverValue:390}] to mongo-2:30001
save Post(id=2, title=10자리가넘어가는제목, content=모종닷컴)
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.IllegalArgumentException: 제목은 10자 이하여야합니다.] with root cause

호출에 대한 응답으로 500 응답 코드를 받았고, 로그에는 위와 같이 남아있습니다. 원하던 대로 실패가 제대로 되었네요. 이제 몽고 DB에 데이터가 어떻게 저장되어있는지 보겠습니다.

replset:PRIMARY> db.post.find()
{ "_id" : NumberLong(1), "title" : "10자리미만제목", "content" : "모종닷컴", "_class" : "com.monny.mongotx.post.Post" }

역시 Transaction이 제대로 동작하지 않았고, 실패하기 이전에 넣었던 데이터가 들어가 있습니다. 

왜 Transaction이 동작하지 않았을까?

스프링 문서를 읽어보면 아래와 같이 설명하고 있습니다.

저희가 MongoTransactionManager를 구체화시키지 않는 이상 Transaction 지원은 안된다고 설명하고 있습니다. 따라서 MongoDB의 Transaction을 사용하기 위해서는 MongoTransactionManager 등록해주어야 한다는 것입니다.

MongoTransactionManager 등록

@Configuration
class MongoConfig {
    @Bean
    fun transactionManager(dbFactory: MongoDatabaseFactory): MongoTransactionManager {
        return MongoTransactionManager(dbFactory)
    }
}

테스트해보기

이제 다시 테스트를 해보도록 하겠습니다. 이번에도 같은 방식으로 endpoint를 호출할 건데 헷갈릴 수 있으니 데이터를 아래와 같이 변형해서 호출하겠습니다.

[
  {
    "id": 3,
    "title": "10자리미만제목",
    "content": "모종닷컴"
  },
  {
    "id": 4,
    "title": "10자리가넘어가는제목",
    "content": "모종닷컴"
  }
]

이제 호출하고 결과를 보도록 하겠습니다.

이전과 마찬가지로 호출에 대한 결과로 500 응답 코드를 받았습니다. 로그 또한 이전과 같이 남았고 실패하였습니다.

save Post(id=3, title=10자리미만제목, content=모종닷컴)
Opened connection [connectionId{localValue:9, serverValue:395}] to mongo-2:30001
save Post(id=4, title=10자리가넘어가는제목, content=모종닷컴)
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.IllegalArgumentException: 제목은 10자 이하여야합니다.] with root cause

그렇다면 MongoDB에는 어떻게 저장되어 있을까요?

replset:PRIMARY> db.post.find()
{ "_id" : NumberLong(1), "title" : "10자리미만제목", "content" : "모종닷컴", "_class" : "com.monny.mongotx.post.Post" }

이전에 실패해서 들어갔던 데이터를 제외하면 id가 3, 4번인 데이터는 들어가지 않았습니다. 제대로 트랜젝션이 동작하고 있음을 확인할 수 있었습니다.

마무리

스프링에서 몽고 DB의 트랜젝션을 사용하기 위해서는 특정 설정이 필요함을 알 수 있었습니다. 지금까지는 당연하게 트랜잭션이 동작하겠지라는 생각으로 이런 설정 없이 작성했었던 것 같은데 천운인지 해당 코드에서 한 번도 오류가 난적이 없었어요.. 조세호가 "모르는데 어떻게 가요" 하는 것처럼 몽고 트랜잭션이 동작하지 않고 있음을 알리는 로그 하나 없는데 이걸 어떻게 눈치챌까요..?

반응형