💥 Redisson Pub/Sub을 이용한 분산락을 프로젝트에 적용하면서, 분산락이 필요한 기능마다 Facade 객체를 만들거나 부가적인 기능을 제공하는 코드를 계속해서 작성해야 하는 문제가 발생했다.
@Component
class LockPostLikeFacade(
private val redissonClient: RedissonClient,
private val postLikeService: PostLikeService,
) {
fun increase(postId: Long) {
val lock = redissonClient.getLock(postId.toString())
try {
val available = lock.tryLock(5, 3, TimeUnit.SECONDS)
if (!available) {
println("Lock 획득 실패")
return
}
postLikeService.increase(postId)
} catch (e: InterruptedException) {
print(e)
} finally {
lock.unlock()
}
}
}
위 코드는 게시글에 좋아요를 누를 때, 동시성을 보장하기 위해 분산락을 사용했다. 만일 다른 동시성 제어가 필요한 기능을 추가해야 한다면, 락을 얻어오고 getLock(), 락을 점유하고 tryLock(), 락을 반환하고 unlock(), 예외 처리 등의 부가적인 코드를 반복해서 작성해야 한다. 이 코드는 가독성이 떨어진다고 판단했고, 해당 문제점을 개선하는 방법을 다음과 같이 생각했다.
1. 템플릿 콜백 패턴 적용
템플릿 콜백 패턴에 관한 글이 아니기 때문에, 템플릿 콜백 패턴을 간략하게 설명하면 다음과 같다.
템플릿 콜백 패턴은 정해진 틀 안에서, 변화되는 부분만 유동적으로 받아 수행하는 패턴이다.
// 콜백
interface Callback {
void execute();
}
// 틀을 가진 객체
class Something {
void work(Callback cb) {
log.info("start logging");
cb.execute();
log.info("end logging");
}
}
// 프로세스
public class Process {
public static void main(String[] args) {
Something s = new Something();
s.work(new Callback() {
@Override
public void execute() {
System.out.println("Something Work...");
}
});
}
}
콜백이라는 Functional Interface를 정의하고 작업 시작 로깅과 작업 종료 로깅을 담당하는 Something에서 콜백 함수를 호출한다. 해당 콜백 함수는 프로세스에서 사용하는 시점에 직접 익명 클래스로 정의해서 사용할 수 있다.
📝 템플릿 콜백 패턴을 적용하기 전에, 코틀린에서는 Trailing Lambdas 라는 문법을 제공한다.
// 함수 파라미터 마지막으로 함수 정의
fun tlExample(name: String, function: () -> Unit) {
println(name)
function()
}
fun main() {
// 매개변수 괄호 안에 모두 작성
tlExample("test", { println("테스트") })
// 매개변수 괄호 밖에 함수부분 작성
tlExample("test") { println("테스트") }
}
Trailing Lambdas 문법은 마지막 매개변수로 함수를 넘기는 경우, 매개변수 소괄호 안에 작성하는 것이 아닌 별도의 중괄호로 작성할 수 있다.
이제, Radisson 분산락 구현에 적용해 보면 다음과 같이 구현할 수 있다.
@Service
class DistributedLockService(
private val redissonClient: RedissonClient,
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun <T> doDistributedLock(
lockName: String,
waitTime: Long = 5,
leaseTime: Long = 3,
timeUnit: TimeUnit = TimeUnit.SECONDS,
function: () -> T
) {
val lock = redissonClient.getLock(lockName)
try {
val available = lock.tryLock(waitTime, leaseTime, timeUnit)
if (!available) {
println("Lock 획득 실패")
return
}
function()
} catch (e: InterruptedException) {
print(e)
} finally {
lock.unlock()
}
}
}
락을 얻어오고 getLock(), 락을 점유하고 tryLock(), 락을 반환하고 unlock(), 예외 처리하는 부가적인 코드를 분산락 서비스로 구현하고 function을 통해 사용하는 시점에 해당 부분을 구현해서 사용하도록 코드를 작성할 수 있다.
@Service
class PostLikeService(
private val postLikeRepository: PostLikeRepository,
private val distributedLockService: DistributedLockService,
) {
@Transactional
fun increase(postLikeId: Long) {
distributedLockService.doDistributedLock("PostLikeService:increase:$postLikeId") {
val postLike = postLikeRepository.findById(postLikeId).orElseThrow {
NoSuchElementException("PostLike not found")
}
postLike.increase()
}
}
}
DistributedLockService를 주입받아 분산락을 부가적인 코드의 중복 없이 구현할 수 있다.
@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class PostLikeServiceTest(
private val postLikeService: PostLikeService,
private val postLikeRepository: PostLikeRepository,
) {
@AfterEach
fun tearDown() {
postLikeRepository.deleteAllInBatch()
}
@Test
fun `멀티 스레드 환경에서 동시에 총 100개의 좋아요 증가 요청이 들어오면, 좋아요 수는 100이다`() {
// given
val postLike = postLikeRepository.save(PostLike(postId = 1L, likeCount = 0L))
val threadCount = 100
val executorService = Executors.newFixedThreadPool(20)
val countDownLatch = CountDownLatch(threadCount)
// when
repeat(threadCount) {
executorService.submit {
try {
postLikeService.increase(postLike.id)
} finally {
countDownLatch.countDown()
}
}
}
countDownLatch.await()
val findPostView = postLikeRepository.getReferenceById(postLike.id)
// then
Assertions.assertThat(findPostView.likeCount).isEqualTo(100)
}
}
동시에 요청이 들어와도 정상적으로 100개의 좋아요가 처리된 것을 확인할 수 있다.
💣 테스트 문제 발생
DistributedLockService를 호출하는 메서드의 트랜잭션 결과와 분산락 메서드의 트랜잭션 결과가 독립적으로 수행되도록 하기 위해서 doDistributedLock메서드에 @Transactional(propagation == Propagation.REQUIRES_NEW)를 적용하였는데, 테스트 시 타임아웃 문제가 발생하였다.
doDistributedLock()에서 Propagation.REQUIRES_NEW 옵션을 사용했기 때문에, 좋아요 증가 요청이 doDistributedLock()에 진입하는 시점에 2개의 데이터베이스 커넥션을 점유하게 된다. 테스트에서는 동시에 좋아요 증가 요청을 하게 되고, 하나의 요청 당 2개의 데이터베이스 커넥션을 점유해 데이터베이스 커넥션 풀의 모든 커넥션들이 고갈되어 새로운 커넥션을 대기하는 데드락이 발생했다. 그 결과로 타임아웃이 발생한 것이다.
비동기나 Facade 클래스를 활용해서 하나의 작업이 끝나면 바로 데이터베이스 커넥션을 반환하도록 하는 방법을 사용해서 이 문제를 해결할 수 있지만 이 글의 취지와 달라지는 것 같아 Propagation.REQUIRES_NEW 옵션을 제거해서 테스트를 수행하였다.
2. AOP 적용
추후 추가 예정