-
Mutex와 Coroutine을 이용한 동시 트래픽 시뮬레이션 1 - 애플리케이션 구현Kotlin 2024. 3. 15. 14:43
목차
- 개요
- 애플리케이션 요구사항
- 실제 구현 소스 코드 및 설명
- 동시에 트래픽이 몰릴 때 정합성이 깨지는 이유
- 입출고 요청 자체에 락을 걸어버릴 시의 문제점
- 인형 Id 별로 락 인스턴스(Mutex)를 제어하여 문제 해결
- 한계
- 해당 애플리케이션의 테스트 관련 글 링크
개요
최근에 코틀린 코루틴의 정석(https://www.yes24.com/Product/Goods/125014350)을 읽으며 코루틴을 학습하였습니다. 이에 학습한 내용을 다지고자 모의로 요구사항을 정의하여 애플리케이션을 직접 구현하여보았습니다. 해당 요구사항은 서버개발자 면접이나 과제에서 종종 질문 받은 내용을 바탕으로 구성하였습니다.
애플리케이션 요구사항
- 인형 재고관리 어플리케이션입니다. 각 인형은 코틀린 Data 클래스로 id와 stock(재고) 두개의 필드만 갖습니다. 실제라면 아래와 같이 아이디별로 실제 매칭되는 인형이 있을 것입니다. 다만 여기서는 간단히 하기 위해 id와 stock만 필드로 고려합니다. 따라서 서로 다른 두 아이디는 서로 다른 두 인형을 나타낸다고 생각해 주시면 됩니다.
id 이름 재고 1 도라에몽인형 7000 2 피카츄인형 14000 3 헬로키티인형 2500 - 애플리케이션의 기능은 재고 조회와 입고 출고 이렇게 3가지 뿐입니다. 다만 동시에 여러 트래픽이 입 출고를 요구하는 상황에서 아래와 같은 세부적 요구사항이 요구됩니다.
- 여러개의 트래픽이 입출고를 동시에 요청하는 상황 하에서 재고는 정합성을 유지해야 합니다.
- 한 인형의 입고가 진행 되는 중에 해당 인형에 다른 입고 요청이 들어오면 해당 입고 요청은 무시하고 예외를 반환합니다. 진행중인 입고 요청은 반영합니다.
- 한 인형의 출고가 진행 되는 중에 해당 인형에 다른 출고 요청이 들어오면 먼저 들어온 순서대로 처리합니다.
- 한 인형의 출고가 진행 되는 중에 해당 인형에 다른 입고 요청이 들어오면 먼저 들어온 순서대로 처리합니다.
- 한 인형의 입고가 진행 되는 중에 해당 인형에 다른 출고 요청이 들어오면 먼저 들어온 순서대로 처리합니다.
- 서로 다른 인형에게 입출고 요청이 동시에 요청되면 각 인형별로 위의 규칙을 만족하면서 입출고를 처리합니다. 가령 서로 다른 인형에게 입고요청이 동시에 들어오면 둘다 반영합니다.
실제 구현 소스 코드 및 설명
우선 실제 DB를 사용하지 않고 메모리 컬렉션에 집어넣는 것으로 모의로 DB에 넣는 상황을 재현하였습니다. 다만 이 과정에서 약간의 스레드 블로킹 지연시간을 주어 실제 DB에 요청하고 처리되는 동안 약간의 시간이 소요되며 그 동안 입출고 요청이 블로킹 되는 상황을 재연하였습니다. 참고로 소스코드 전문은 (https://github.com/sseung00921/MutexAndCoroutinePractice)에서 확인하실 수 있습니다.
DB 부분의 소스코드
@Component class Database { private val db: HashMap<Long, Doll> = HashMap() fun setStock(id: Long, amount: Long): Doll { Thread.sleep(random().toLong() * 300L + 100) val doll = Doll(stock = amount, updateMilli = System.currentTimeMillis(), updateNano = System.nanoTime()) db[id] = doll return doll } fun getStock(id: Long): Doll { Thread.sleep(random().toLong() * 100L + 100) return db[id] ?: Doll(stock = 0, updateMilli = System.currentTimeMillis(), updateNano = System.nanoTime()) } }setStock은 doll Data 클래스를 그냥 엎어치는 방식으로 동작합니다. 아래는 doll Data 클래스 입니다.
data class Doll(val stock: Long, val updateMilli: Long, val updateNano: Long)원래라면 입출고 API 요청과 DB작업 사이에 서비스 레이어가 있어서 복잡한 비즈니스 로직을 처리하겠지만 여기서는 컨트롤러 단의 API가 직접 DB를 요청하는 형태로 단순화하고 요구사항을 구현하는 비즈니스 로직적 코드들을 모두 컨트롤러 단에 넣었습니다. (원래 실무라면 이런 코드들은 컨트롤러 단이 아닌 서비스 레이어에 있는 것이 맞겠습니다.)
어떻게 위의 요구사항을 구현하였는지 코드를 보여드리면서 설명드리겠습니다.
@RestController @RequestMapping("/doll") class StockController(val db: Database) { private val lockMap: MutableMap<String, Mutex> = ConcurrentHashMap() @GetMapping("{id}/inquire") fun inquire(@PathVariable id: Long): Doll { return db.getStock(id) } @PostMapping("{id}/store") suspend fun store(@PathVariable id: Long, @RequestBody amount: Long): Doll { val storeLock: Mutex = lockMap.computeIfAbsent("store-$id") { k -> Mutex() } val unStoreLock: Mutex = lockMap.computeIfAbsent("unStore-$id") { k -> Mutex() } check(!(!unStoreLock.isLocked && storeLock.isLocked)) { throw CancellationException("입고가 불가능합니다.") } try { storeLock.lock(); val doll = db.getStock(id) val targetStock = doll.stock + amount db.setStock(id, targetStock) return db.getStock(id) } finally { storeLock.unlock() } } @PostMapping("{id}/unStore") suspend fun unStore(@PathVariable id: Long, @RequestBody amount: Long): Doll { val storeLock: Mutex = lockMap.computeIfAbsent("store-$id") { k -> Mutex() } val unStoreLock: Mutex = lockMap.computeIfAbsent("unStore-$id") { k -> Mutex() } try { storeLock.lock() unStoreLock.lock() val doll = db.getStock(id) val targetStock = doll.stock - amount db.setStock(id, targetStock) return db.getStock(id) } finally { unStoreLock.unlock() storeLock.unlock() } } }위 비즈니스 로직 구현 코드를 구현하기 전에 먼저 왜 한 인형에 트래픽이 몰릴 때 데이터 정합성이 깨지는 지를 생각해보아야 합니다. 생각해 봅시다.
- 동시에 트래픽이 몰릴 때 정합성이 깨지는 이유
해당 앱에서 입출고 요청은 아래와 같이 진행됩니다.
해당 아이디의 인형 DB에서 조회(100ms 소요) -> 해당 아이디의 인형 Data클래스를 재고를 적절히 플러스마이너스 해서 새로 생성 -> 해당 아이디의 인형 DB에 새로 생성된 Data클래스를 저장(데이터 클래스 엎어침)
여기서 아래와 같은 경쟁상황은 충분히 발생됩니다.
ThreadA가 도라에몽 인형의 재고를 읽어옴 -> 이 작업 중 ThreadB도 도라에몽 인형의 재고를 읽어옴 -> ThreadA는 읽어온 재고에 500개를 추가 입고해서 DB에 반영 요청함 -> ThreadB는 읽어온 재고에 300개를 출고해서 DB에 반영요청함
위의 경쟁상황데로 CPU가 처리를 수행하면
도라에몽의 재고는 원래 200개가 증가해야 하는데 첫번째 ThreadA의 갱신이 손실되어서 300개가 줄게 됩니다. 그래서 데이터 정합성이 깨지게 됩니다.
그래서 여기서 입출고 요청 메서드 자체에 Sychronized 락을 걸어버리면 어떨 까 하고 단순한 생각으로 접근해보게 됩니다. 그런데 이렇게 하면 문제가 생깁니다.
- 입출고 요청 자체에 락을 걸어버릴 시의 문제점
도라에몽 인형(id 1) 입고가 진행 중일 때 피카츄 인형(id 2)을 입고하는 상황을 생각해 봅시다. 두 인형은 서로 다른인형이기 때문에 요청을 둘다 각각의 인형에 반영하여야 합니다. 그런데 위와 같이 입출고 요청 자체에 락을 걸어버리면 도라에몽을 입고하는 중에 피카츄는 입고를 대기해야 하는 이슈가 발생합니다. 이때 요구사항을 지키려면 입고요청이 피카츄 인형에 대한 것이면 대기하였다가 피카츄 입고를 수행해야 하고 만약 입고요청이 도라에몽인형에 대한 것이면 도라에몽 입고요청을 무시하고 예외를 반환해야 합니다. 그런데 이 과정에서 두가지 문제가 있습니다.
첫째, 해당 스레드가 피카츄인형이나 도라에몽인형이냐에 따라 대기하냐 예외를 뱉냐를 각각 다르게 반응해야 하는데 그 구현 방법이 입출고 메서드 자체에 락을 걸어서는 도저히 떠오르지 않습니다.
둘째, 성능 이슈도 있습니다. 가령 어떻게 어떻게 요청별로 인형을 구분한다 해도 도라에몽 인형을 처리하는데 피카츄 인형이 도라에몽 인형 처리가 끝날때까지 대기할 이유가 전혀 없습니다. 이유는 각 인형별로 스레드 공유자원이기 때문에 도라에몽 인형을 지지고 복고 하는 작업은 피카츄 인형에게는 아무런 영향을 주지 않기 떄문입니다. 즉 서로 다른 인형에 대해서는 여러 스레드가 병렬적으로 처리되어도 정합성 이슈가 없는데도 순차적으로 처리되는 꼴이니 성능에 악영향을 줍니다.
그럼 어떻게 위 코드는 어떻게 이런 문제를 해결하였을까요?
- 인형 Id 별로 락 인스턴스(Mutex)를 제어하여 문제 해결
각 인형 별로 락 인스턴스를 생성하면 위 문제를 깔끔하게 해결할 수 있습니다. 그러기 위해서는 Sychronized 키워드 보다 좀 더 저수준에서 세밀하게 락을 제어해야 합니다.
자바에서는 이와 같은 구체적 락 제어를 위해 Reentrantlock을 제공합니다. 그런데 Reentrantlock락은 락 대기시 대기하는 스레드가 점유되어 버리는 문제가 있습니다. 그래서 코루틴은 락 대기 시 대기에 걸린 스레드를 양보해 버릴 수 있게끔 Reentrantlock을 한층 더 진보시킨 Mutex를 제공합니다. (Mutex는 일반적인 이론적 용어로도 쓰이나 이 글에서는 이와 같이 코루틴이 구체적으로 제공하는 클래스로서의 Mutex를 의미합니다.) (작업 대기 시 스레드를 양보하는 것은 코루틴의 핵심 강점이자 코루틴의 등장 배경 그 자체라고도 할 수 있습니다. 그러나 이 글은 코루틴의 원리와 관련된 글은 아니므로 Mutex를 언급하기 위해 간단히 이러한 점을 언급만 하고자 합니다.)
Mutex를 쓰시려면 gradle에 아래와 같은 의존성이 들어와야 합니다. 비단 Mutex뿐만 아니라 모든 코루틴 관련 기능들을 쓰시려면 아래 의존성이 필요합니다. 이 글의 이후 글에서 다룰 코루틴을 활용한 테스트 역시 아래 의존성을 기본 전제로 합니다.
참고로 위 로직에서 던지는 CancellationException도 해당 라이브러리가 제공하는 Exception입니다. 동시성 테스트를 할 때 코루틴을 사용해서 멀티쓰레드 경쟁 상황을 재현하려면 반드시 이 예외를 던져야 합니다. 그 이유에 대해서는 이 글 다음에 다룰 해당 애플리케이션의 테스트 관련 글에서 상세히 설명드리겠습니다.
dependencies { ... implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2") }Mutex(락)를 이용한 개별 인형별 락제어는 아래와 같이 동작합니다.
- 우선 각 인형 id 별로 입고 Mutex와 출고 Mutex를 생성하고 관리하는 lockMap을 구현합니다. 이 lockMap도 기본적으로 스레드 세이프하여야 할 것이므로 ConcurrentHashMap으로 구현하였습니다.
- 즉 인형이 3개면 결과적으로 총 6개의 락 인스턴스가 생성됩니다.
- 한 인형의 출고 작업 시는 그 인형의 입고 락과 출고 락을 모두 겁니다. 반면 한 인형의 입고 작업 시는 우선 그 인형에 대해 입고 락만 걸려있는지 체크합니다. 그 경우는 그 인형의 입고 작업이 진행 중이라는 의미이므로 에러를 반환합니다. 그 외에는 아무 작업이 진행중이지 않거나 출고 작업 중인 상황이므로 대기합니다.
위 로직이 선뜻 이해가 되지 않으실 수 있습니다. 해당 코드를 보시면서 약간 사고하는 시간이 필요합니다. 혹시 이해가 안되신 분들을 위해 아래에 부연설명을 하겠습니다.
- 우선 가장 중요한건 위와 같이 개별락을 만들면 서로 다른 인형의 입출고 작업은 전혀 다른 작업을 락킹하거나 하지 않습니다. 즉 피카츄 인형 입출고 작업과 도라에몽 인형 입출고 작업은 아예 별개로 동기화 됩니다.
- 여기서 한 인형만 고려하면 그 인형이 출고 중일때 출고 요청이 들어오면 그 인형의 출고락이 점유중이므로 자동 대기합니다. 이는 요구사항에 맞습니다. 만약 출고 중일 때 입고 요청이 들어오면 출고 락이 점유중이므로 예외상황은 아니게 되고 그렇지만 입고락은 점유중이므로 대기하게 됩니다. 이 역시 요구사항에 맞습니다!
- 한 인형이 입고 중일 때 출고 요청이 들어오면 출고 요청은 두 락이 다 필요하므로 자동으로 대기합니다. 이는 요구사항에 맞습니다. 만약 입고 중일 때 입고 요청이 들어오면 입고 처리 중에는 입고락만 락킹되어 있을 테니 해당 입고 요청은 check문에 걸려서 예외를 반환합니다. 즉 이 역시 요구사항에 맞습니다!
이렇게 하여 요구사항의 구현을 완료하였습니다.
한계
- 해당 글은 WAS단에서 코틀린 코드 수준의 비즈니스 로직 레벨에서의 동시성 제어만 다루었습니다. 이 글의 목적 자체가 코루틴을 학습한 것을 적용해보는 것이기 때문입니다. 하지만 실제 실무에서는 DB단에서 동시성 제어가 이루어 질 수도 있고 WAS모듈을 넘어선 시스템 설계에 어느 부분에서 동시성 제어가 이루어 질 수도 있을 것입니다. (가령, Redis 같은) 따라서 해당 비즈니스적 요구사항을 처리하기 위해 위의 해결책이 Best Practice는 아님을 말씀드리며 다른 더 좋은 해결책이 있을 수 있음을 말씀드립니다.
- 위의 해결책은 단일 서버에서만 동작합니다. 다중 서버에서는 위와 같은 해결책을 넘어서서 클러스터 레벨의 동기화를 갈무리 해주는 더 높은 수준의 동기화 처리가 필요할 것임을 말씀드립니다.
해당 애플리케이션의 테스트 관련 글 링크
정작 코루틴 학습을 적용하는 글이면서 코루틴을 많이 다루지는 않은 것 같습니다. 코루틴은 이어지는 해당 애플리케이션의 테스트 관련 글에서 많이 다룰 예정입니다. 테스트 관련 글의 링크(https://sseung00921.tistory.com/11)
'Kotlin' 카테고리의 다른 글
Mutex와 Coroutine을 이용한 동시 트래픽 시뮬레이션 2 - 테스트 (0) 2024.03.15