이번 포스팅에서는 MongoDB 다중 업데이트 로직을 구현하면서 마주한 성능 개선의 필요성과 이를 풀어나간 방법들을 공유하고자 한다.
사용 환경은 다음과 같다.
- Spring 2.3.2
- MongoDB 5.0
- JDK 1.8.0
- Kotlin
# 최초 기능 개발 - saveAll()
해당 업무에서는 상태를 기반으로 Entity를 관리하며 비즈니스 로직을 태우는 프로세스를 구축하는 것이 필요했다.신규로 생성된 데이터들을 PENDING으로, 처리를 진행할 때는 PROGRESS, 처리 다 되면 SUCCESS & FAILED 등으로 관리한다.
그중 처리를 진행할 때 대상들을 일괄적으로 PENDING -> PROGRESS로 변경하는 작업이 필요했는데, 해당 과정에서 여러개의 데이터에 대한 업데이트를 진행했다.
최초 로직은 가장 단순하게 MongoRepository에서 제공하는 saveAll()을 사용하였다. 예시 코드는 다음과 같다.
Service
override fun updateToProgress(targets: List<Target>){
targets.forEach {
it.status = "PROGRESS"
}
targetRepository.saveAll(targets)
}
Repository
interface TargetRepository : MongoRepository<Target, String>
Repository의 구조는 MongoRepository를 상속 받기만 하면 되서 매우 간단했다.
다음과 같이 로직을 작성하면서, "정말 편리하네"라고 생각하며 다른 기능 개발로 눈을 돌렸었다.
이후 로컬 환경에서 기능 테스트를 진행하는 과정에서 해당 기능의 API를 호출하니 37000ms가 찍히는 것을 확인할 수 있었다.
처음에는 왜 이렇게 오래걸리나 하고 Postman 요청을 Cancel했다 다시보냈다를 반복하며 애꿋은 서버 탓이지 않을까 생각했지만, 30000ms 아래로 떨어지지 않는 것을 확인할 수 있었다.
데이터 자체가 많지 않다 생각하고, 500개 단위로 업데이트를 진행할거니 괜찮을거라 생각했던 자신의 탓이라는 것을 깨닫고 관련 성능을 올리기 위한 작업을 진행했다. (서버의 잘못은 없다)
# BulkOperations 적용
saveAll() 기반 코드로 테스트를 진행하면 MongoDB에 업데이트가 실시간으로 진행된다는 점을 확인할 수 있었다.
이게 정말 단건으로 처리되는게 맞는지 판단하기 위해 구현체 코드를 확인했고, Entity에 대해서 개별적으로 save()를 통해 저장되는 로직임을 파악할 수 있었다.

해당 로직에서 모두 saveAll() 메소드에 전달된 entities가 모두 새 문서가 아닐 경우, stream -> map을 거쳐 단건으로 save 연산을 수행하게 된다.
따라서, 단건으로 처리하는 로직을 한번에 처리하도록 변경함으로써 성능을 향상시킬 수 있을 것이라 판단하였다.
우선 MongoDB에서 Bulk 연산을 수행하기 위해선, MongoTemplate의 의존성 주입이 필요하였고, 이를 위해 별도의 CustomRepository를 작성하였다.
이후 mongoTemplate에서 제공하는 bulkOps 메소드를 사용해 bulkOperations 객체를 생성해야하는데, 해당 인자에 BulkMode와 엔티티 클래스의 클래스를 할당해주면 된다.
BulkMode에는 2가지가 존재하는데, Enum Class로 ORDERED와 UNORDERED가 존재한다.

주석을 보면 ORDERED는 에러 발생 시 캔슬나는 옵션이고, UNORDERED는 에러나도 처리를 이어가는 옵션이다.
비즈니스 목적에 맞게 적절하게 사용할 수 있도록 두가지 옵션이 존재한다.
본인은 업데이트 로직이 완전하게 끝나도록 보장되어야했기 때문에 해당 처리가 실패하면 Exception 처리를 하여, Client 측에서 다시 요청을 보내게하는 것이 적절하다 판단하여 ORDERED를 사용했다.
이후 BulkOperations 객체를 사용하면 최종적으로 excute() 메소드를 통해 MongoDB에 연산들을 실행하게 되는데, 해당 객체에 excute()에서 실행할 연산들을 추가해주어야한다.
2.3.2 버전에서 벌크 업데이트를 위한 연산은 updateMulti(Query var1, Update var2)임을 확인하였고, 이를 위해 Query(), Update() 인스턴스를 생성했다.
최신 버전에선 updateMany()를 사용한다 확인되어, 자신의 버전에 지원하는 메소드 확인이 필요하다.
Query()에는 Criteria 객체를 사용해 업데이트할 대상들을 선택하는 로직을 작성하면 되고, Update 객체에는 어떤 값으로 지정할지를 결정하여 넣어주면 된다. 관련 코드는 한번에 작성할 예정이다.
Query와 Update 인스턴스가 준비되면, BulkOperations 객체의 updateMulti() 메소드로 해당 벌크 연산을 진행할 것이라고 지정해준다.
이후 excute() 메소드를 실행하면 Bulk 연산이 실행된다.
작성된 코드는 다음과 같다.
class TargetRepositoryCustomImpl(
private mongoTemplate: MongoTemplat // DI
){
override fun updateTargetStatusBulk(
ids: List<String>
){
val ops = mongoTemplate.bulkOps(ORDERED, Target::class.java) // BulkOperations 객체 생성
val query = Query()
query.addCriteria(Criteria.where("id").`in`(ids))
val update = Update()
update["status"] = "PROGRESS"
ops.updateMulti(query, update)
ops.excute()
}
}
이를 통해서 기존 saveAll()의 단건 업데이트 방식에서 벌크 업데이트 로직으로 변경을 진행했다.
이후 테스트를 돌려보니 9000ms ~ 10000ms의 성능으로 개선된 점을 확인할 수 있었다.
하지만, 이런 단순한 기능에서 9000ms ~ 10000ms의 속도는 성능 개션이 필요한 영역이였고, 최소한 3000ms ~ 5000ms 내로 향상시키는 것이 필요하여 인덱스 추가 작업을 진행하였다.
# 인덱싱 적용
엔지니어 분들께 테크리뷰를 받을 때 사전 인덱싱에 대해 고려해볼 필요성에 대해 말씀해주셔서, 적절한 인덱싱 조건에 대해서 사전에 리서치를 해두어 바로 인덱스를 생성하였다.
다른 기능에서 조회하는 조건에 대해 리서치를 했을 때, 복잡한 조건의 사용은 없다는 점과 주로 id값을 통해 조회 및 업데이트가 일어난다는 점을 파악했다.
추가적으로 id 칼럼의 업데이트는 변경되지 않음을 보장할 수 있었기 때문에, 간단하게 id 칼럼으로 단일 인덱싱을 진행하는 것으로 결정했다.
추가적인 로직이 필요해지면, 복합 인덱스를 고려할 예정이다.
MongoDB에서 인덱스를 생성하는 방법은 간단하다. mongoDB Shell 환경에 접근하여, 다음 코드로 인덱스 생성이 가능하다.
인덱스 조회, 삭제 명령어도 같이 기재하였다.
db.{collection_name}.createIndex({"{column_name}" : 1 or -1 }) // 인덱스 생성
db.{collection_name}.getIndexes() // 인덱스 여러개 조회
db.{collection_name}.getIndexKeys() // 인덱스 여러개에서 키만 조회
db.{collection_name}.dropIndex("{index_name}") // 특정 인덱스 삭제
해당 인덱스를 생성하고 saveAll()과 BulkOps 적용 기능의 성능을 확인해보니 다음과 같았다.
saveAll() : 35000ms ~ 37000ms
BulkOps 적용 : 1000ms ~ 1500ms
목표했던 수치를 달성하고 빠르게 결과가 반환되는 것을 보고, 데이터가 많아질수록 인덱싱은 정말로 중요하다는 점을 확인할 수 있었다.
saveAll()할때는 인덱싱 유무에 따라 성능 향상이 존재하지 않는다는 점을 확인할 수 있었다.
MongoDB에서는 문서 생성 시 _id 값이 생성되었는데, 사실 컬렉션에 기본적으로 _id 칼럼으로 인덱싱이 생성되어 있는 것을 확인할 수 있었다.
따라서, saveAll()은 인덱싱 여부의 문제가 아니라, 단건 처리를 진행하면서 발생하는 Network I/O 등의 부분에서 성능을 상당히 까먹고 있다는 점을 알 수 있었다.
# 정리
단건 처리 -> 벌크 처리 -> 인덱싱 추가 과정을 거치며 결과적으로 37000ms ~ 40000ms 걸리던 기능을 1000ms ~ 1500ms 으로 줄일 수 있었다.
이를 정리해보면 다음과 같다.
Index 적용 전
- saveAll() : 37000ms ~ 40000ms
- BulkOps() : 9000ms ~ 10000ms
Index 적용 후
- saveAll() : 35000ms ~ 37000ms
- BulkOps : 1000ms ~ 1500ms
최종적으로 1000ms ~ 1500ms 로 떨어지는 것을 확인했을 때 성취감을 느끼면서, 기술적으로 이런 부분들을 해결하는 과정이 앞으로 이런 부분들을 해결해나갈 수 있는 역량을 더욱 갖춰야겠다 생각하였다.
# 추가적인 고민 과정
해당 작업을 진행하면서 Transactional 기능을 적용하고자 노력했었다.
Spring이 제공하는 @Transactional 어노테이션을 적용할 수 있을거라 생각했는데, MongoDB에서는 기본적으로 제공되진 않고, 추가적인 설정을 진행해야 한다는 것을 알 수 있었다.
- MongoTransactionManager
그 외에도 ClientSession을 직접 받아, commit을 직접 처리해보려고 했지만 사용하는 MongoDB 버전과 spring 버전 호환성과 앞서 말한 구성이 필요하였다.
운영 환경에서는 3.6버전을 사용하는데, MongoDB에서 다중 문서 작업에 대한 트랜잭션 기능은 4.0 이상부터 지원하고 이를 위해선 앞서말한 TransactionManager를 Configuration에서 Bean으로 직접 등록해줘야한다.
https://www.baeldung.com/spring-data-mongodb-transactions
Sessions & Transactions :: Spring Data MongoDB
As of version 3.6, MongoDB supports the concept of sessions. The use of sessions enables MongoDB’s Causal Consistency model, which guarantees running operations in an order that respects their causal relationships. Those are split into ServerSession inst
docs.spring.io
val session: ClientSession = mongoClient.startSession()
try {
// 로직들
sessions.commitTransaction()
} catch (e: Exception) {
session.abortTransaction()
throw e
} finally {
session.close()
}
위와 같이 try-catch문 걸어서 처리할 수 있겠구나 싶어서 코드까지 다 짜뒀는데, 막상 테스트해보니 동작하지 않아 나중에 이 부분을 확인했고, 이후 걷어내는 작업을 진행해야한다.
'Dev > Web' 카테고리의 다른 글
CSS Selector & XPath 개념 및 사용법 (7) | 2024.04.12 |
---|---|
네이버 쇼핑 검색 과정 자동화(크롤링) with Selenuim - 2 (3) | 2024.04.10 |
네이버 쇼핑 검색 과정 자동화(크롤링) with Selenuim - 1 (47) | 2024.03.03 |
FastAPI & Nginx 특정 엔드포인트(Health Check) 로그 제거 - ALB Health Check 로그 제외시키기 (2) | 2023.10.29 |
FastAPI 기반 Server Sent Event(SSE) 구현 과정 및 시행착오 - UTF-8 Decoding 및 ANSI Escape Sequence 처리 (4) | 2023.10.28 |