분산서비스에서 mongodb의 document를 동시에 다루는 상황에서 발생한 문제와, 이를 해결한 내용을 정리한다.
사용중인 mongodb는 replica set 설정이 되어있다.
다루고 있는 도메인 객체는 양방향 연결리스트 구조로 되어있다.
직접 참조하지는 않고 참조할 아이디를 저장한다.
예를들면 아래와같은 구조다.
data class Node(
@Id
val id: ObjectId,
val prevNodeId: String?,
val nextNodeId: String?,
)
처음에는 "트랜잭션 처리하니까 문제없지않나?" 라고 생각하며 MongoTransactionalManager를 사용했다.
정말 안일한 생각이었고 mongodb와 분산환경에대한 이해력이 너무나도 부족했다.
Command failed with error 112 (WriteConflict): 'WriteConflict error: this operation conflicted with another operation. Please retry your operation or multi-document transaction.
...
테스트 중 mongodb를 사용하는 부분에서 위 에러가 발생했고 이게 어떤 상황에서 발생하는 에러인지 찾아봤다.
요약하면, 트랜잭션 A가 시작되어 commit 되지 않은상태에서, 다른 트랜잭션을 시작해 같은 document에 대해 update를 시도하면 write conflict가 발생한다고 한다.
rdb와 달리 공유자원에 대해 접근하고 update 시도 시 예외가 발생했다.
Node를 할당받는 경우, Node를 저장하는 경우, Node를 연결하거나, 연결을 끊는경우 등
이 서비스에서 같은 document에 대해 동시작업이 가능한 경우는 꽤 많았다.
물론 위와같은 상황이 발생하지않게 로직을 잘 작성하는게 가장 중요하지만, 어쩔 수 없는상황이었고 이를 감수해야했다.
코드를 확인하던 중 save, saveAll 를 사용해 도메인객체를 저장하는 경우도 있었지만, findAndModify 등의 메소드를 사용해 직접 update하는 경우도 있었다.
findAndModify를 사용한 이유는,
문서에 대해 원자성을 보장한다고 했기 때문인데, 이 메소드만 사용해서 데이터를 update하는게 아니니 별 의미가 없었다.
가능한 save, saveAll 등의 메소드를 사용해 도메인객체를 저장하도록 수정하는것이 좋을 것 같다. 어디서 에러가 발생한지 추적하는게 쉽지 않았다.
분산락을 사용해야함을 깨닫고 적용시켰다.
이 과정에서도 문제가 있었는데
@Component
class DistributedMongoLockHandler(
private val redissonClient: RedissonClient,
) {
@Transactional(transactionManager = TransactionManager.MONGO_TX_MANAGER, propagation = Propagation.REQUIRES_NEW)
fun <T, R> execute(
key: String,
params: T,
func: Function<T, R>,
timeUnit: TimeUnit = TimeUnit.SECONDS,
waitTime: Long = 5L,
leaseTime: Long = 3L,
): R {
...
}
}
위와같이 분산락 핸들러를 구현하고 이에 사용되는 로직은 파라미터로 주입했다.
distributedMongoLockHandler.execute(
key = key,
params = params,
func = {
...
},
)
어느정도 범위로 락을 걸어야하는지 특정하기는 쉬웠으나, 테스트코드를 작성하며 문제가 발생했다.
every {
distributedMongoLockHandler.execute<LinkCommand.In, Unit>(
key = any(),
params = linkCommand,
func = any(),
timeUnit = any(),
waitTime = any(),
leaseTime = any(),
)
} returns Unit
위 핸들러의 execute 메소드를 mocking하면 func 함수의 검증이 제대로 이뤄지지 않았다.
즉, func 함수의 테스트코드를 별도로 작성해야하는 상황이었는데 제대로 관리되지 않을 것 같아 보류했다. 다른 방법이 있을 것 같은데, 추후에 수정하려고한다.
위와같이 수정하고 다시 테스트해봤는데 해결되지 않았다. 원인은 lock 범위를 제대로 지정하지 않았기 때문이다.
메소드 레벨에서 동작을 잠그려고 했는데 잘못된 생각이었다.
즉, Link 메소드에서는 "LOCK:NODE:LINK", Unlink 메소드에서는 "LOCK:NODE:UNLINK" 로 잠금범위를 지정했던것이다. 이는 메소드에 대한 접근을 막는것이지 자원에 대한 잠금이 아니다.
A 라는 LinkedList에 있는 Node들의 동시접근을 막고싶다면, 적절하게 Node들에 lock을 걸 수도 있겠지만 현재 구조상 이 Node들을 특정하기 어려웠고 A LinkedList 자체에 Lock을 거는것으로 결정했다.
사용되지 않는 Node들까지 lock을 걸게되는거라 효율적이지 못한 방법이라 생각하지만, 정합성을 지키는것이 더 우선이기에 위와같이 마무리했다.