1. 문제
걱정과 기대가 함께 있었던 항해 플러스가 시작되었다. 이전 기수분들이 겁(?)을 많이 줬었던 터라 걱정을 좀 더 많이 품고 그렇게 항해 플러스 1주차가 시작되었다.
1주차의 과제는 크게 다음과 같았다.
* 어떤 사용자의 포인트 조회, 포인트 충전 및 사용, 포인트 충전 및 사용에 대한 목록 내역 조회 기능을 구현한다.
* 각 기능에 대한 단위 테스트를 작성한다.
* 포인트 충전과 사용에 대해 동시에 요청이 오더라도 순서대로 혹은 한 번에 하나의 요청씩만 제어될 수 있도록 구현한다.
* 동시성 제어에 대한 통합 테스트를 작성한다.
처음 발제를 받았을 때는 ‘그래도 첫 주차라 나름 간단하겠는데?’ 하고 생각했었다. 하지만 이는 안일한 생각이었으니… 기능 구현이야 기존 하던 방식대로 구현하면 되는 거였지만, 문제는 동시성 제어였다.
2. 시도
처음 든 생각은 막연하게 Thread-Safe한 Queue와 같은 컬렉션에 요청이 들어오면 순차적으로 적재하고, 적재된 요청에서 가져와 처리하면 되겠거니 생각했다. 하지만 막상 구현하려고 하니 그런 방법으로 구현하게 되면 요청을 한 사용자에게 다른 사용자의 포인트 충전 또는 사용을 했다는 응답을 전달할 것 같았다.
두 번째로 생각한 방법은 synchronized
를 이용하는 것이었다. 포인트 충전과 사용 메서드를 synchronized
메서드로 만들어 동시성을 제어하는 방식으로 말이다. 하지만 이 방법 역시도 문제가 존재했는데 다른 팀의 멘토링 시간에 청강을 하다 발견한 문제로, 다른 유저의 포인트 충전 또는 사용 요청 때문에 나의 포인트 충전 또는 사용 요청이 지연이 된다는 점이었다.
생각해보면 해당 포인트 충전과 사용 요청은 동일한 사용자의 요청에 대해서만 동시성 제어가 이루어져야 하고, 다른 사용자의 요청은 병렬로 수행해도 문제 없어야 하는 기능이었던 것이다.
3. 해결
멘토링 시간때와 다른 동기분들의 힌트로 Thread-Safe한 컬렉션인 ConcurrentHashMap
과 재진입 락인 ReentrantLock
으로 동일한 사용자의 요청에 대해서만 락을 걸어 포인트의 충전 및 사용 로직이 동작하도록 수정했다.
private val userPointLocks = ConcurrentHashMap<Long, ReentrantLock>()
fun chargeUserPoint(pointDto: PointDto): UserPoint {
// userId가 concurrentHashMap 에 없으면 새로운 lock 획득, 있으면 존재하는 lock 가져옴
val lock = userPointLocks.computeIfAbsent(pointDto.userId) {
ReentrantLock(true)
}
return lock.withLock {
val userPoint = getUserPointById(pointDto.userId)
userPoint.charge(pointDto.amount)
pointHistoryRepository.insert(
pointDto,
System.currentTimeMillis()
)
pointRepository.save(userPoint)
}
}
ConcurrentHashMap
은 <Long, ReentrantLock>
구조인 데이터를 적재하도록 하여, key에는 사용자의 ID값을, value에는 ReentrantLock
을 저장한다. 한 사용자의 ID에 대해 Lock을 획득한 상태에서 같은 사용자의 요청이 또 들어온다면, 이미 해당 사용자의 ID에 대해 Lock이 획득한 상태이므로, 해당 Lock이 해제될 때까지 대기하게 되는 방식이다.
그렇게 구현하고 나면 동시성 제어에 대한 테스트인데, 이 때는 CompletableFuture
와 CountDownLatch
를 사용하여 병렬 수행 테스트를 작성했다. 아무래도 해당 기능들에 대해서 익숙하지 않았어서 사용 방법에 대해서도 예제 코드를 찾아보면서 작성하려고 했었다. 아래는 그 중 다중 병렬 수행을 할 때 정상적으로 결과가 적용되었는가 검증하는 테스트 코드이다.
@Test
@DisplayName("포인트 충전 병렬 요청에 대한 동시성 제어 기능 테스트")
fun handleChargePointConcurrentTest() {
// 최대 10개의 스레드를 가진 스레드 풀 생성
val executor = Executors.newFixedThreadPool(10)
val user1Latch = CountDownLatch(10)
val user2Latch = CountDownLatch(10)
try {
// 아이디 1L인 유저의 다중 포인트 충전 요청
repeat(10) {
executor.submit {
try {
sut.chargeUserPoint(PointDto(1L, TransactionType.CHARGE, 100L))
} finally {
user1Latch.countDown()
}
}
}
// 아이디 2L인 유저의 다중 포인트 충전 요청
repeat(10) {
executor.submit {
try {
sut.chargeUserPoint(PointDto(2L, TransactionType.CHARGE, 100L))
} finally {
user2Latch.countDown()
}
}
}
// 모든 작업이 완료될 때까지 대기
user1Latch.await()
user2Latch.await()
val user1Point = pointRepository.findById(1L).point
val user2Point = pointRepository.findById(2L).point
val user1Histories = pointHistoryRepository.findAllByUserId(1L)
val user2Histories = pointHistoryRepository.findAllByUserId(2L)
assertAll(
{ assertThat(user1Point).isEqualTo(1000L) },
{ assertThat(user2Point).isEqualTo(1000L) },
{ assertThat(user1Histories.size).isEqualTo(10) },
{ assertThat(user2Histories.size).isEqualTo(10) }
)
} finally {
executor.shutdown()
}
}
4. 알게된 것
먼저 ReentrantLock
에 대해서 처음으로 알게 되었다. 사실 실무에서 Jvm 애플리케이션의 Lock에 대해 다루는 경우가 잘 없다보니, 이번에 처음 사용하면서 사용 방법이나 Lock의 종류들에 대해 배우게 되었다.
그리고 동시성 제어를 테스트하기 위한 멀티 스레드 프로그래밍인 CompletableFuture
와 CountDownLatch
에 대해서도 알게 되었다. 특히 CountDownLatch
에 대해서는 처음 접해봐서 ‘이런 기능도 있구나!’ 하며 처음으로 사용해본 경험이 되었다. 그러면서 앞으로 멀티스레딩 병렬 수행에 대해 기능을 구현해야 한다거나 테스트를 작성할 때 이 기능들을 활용해서 작성하면 되겠구나하는 부분들도 배우게 되었다.
Keep : 현재 만족하고 계속 유지할 부분
코치님이 피드백을 남기신 것처럼 전반적으로 깔끔한 코드를 작성한 점은 만족하고, 앞으로도 유지해야 할 부분이라고 생각한다. 이 부분은 어떻게 하면 더 깔끔하고, 다른 개발자들이 유지보수하기 쉬운 프로젝트가 만들어질지 고민하면서 작성하도록 해야겠다.
Problem : 개선이 필요하다고 생각하는 문제점
- P1. 피드백을 받은 것처럼 테스트 코드에 대한 설명이 조금 더 명확하게 작성해야겠다고 생각했다.
- P2. 테스트 케이스를 세우는 것에 아직 익숙하지 않다고 느꼈다.
Try : 문제점을 해결하기 위해 시도해야 할 것
- P1. 작성한 테스트 코드의 동작을 보는 사람이 더 잘 이해할 수 있도록 설명을 자세하게 작성해야겠다.
- P2. 요구 사항을 나열하고, 그룹핑하고, 기능으로 정의하는 능력을 더 기르고 연습할 필요를 느꼈다.
그리고 감사하고 뿌듯하게도 한 주의 두 과제 중 한 과제에 우수를 받았다! 퇴근하고 겨우겨우 과제를 해쳐나가면서 이 방법이 맞나, 내가 지금 잘 하고 있는건가 고민을 정말 많이 하면서 갈피를 못 잡고 있었는데, 코치님에게 따봉을 받으니 내가 한 많은 고민과 노력을 보상받는 느낌이 들었다.
이를 추진력 삼아 남은 주차도 파이팅할 수 있겠다!😄
🤩 다음 수료생 추천 할인 혜택!
혹시라도 항해 플러스에 합류하고 싶은데 비싼 수강료 때문에 망설여진다면…? 🤔
수료생 추천 할인 혜택으로 20만 원을 할인받으실 수 있다는 사실! 💡
결제페이지 → 할인 코드 → 수료생 할인 코드에 tJQjYK 입력하면 추가로 20만 원을 할인받는 혜택 꼭 챙겨가시길 바란다🚀🚀🌟
#추천인: tJQjYK #항해플러스 #항해99