티스토리 뷰
최근 프로젝트에서 에러 로깅을 진행하면서 동시성 이슈를 겪었다.
이전에 취준 스터디를 할 때 동시성 공부를 하면서 접했던 내용인데 잊고 살았던 것 같아서 기록으로 남기려고 한다.
단순히 기록을 위한 내용이고, 사내 이슈를 명확히 쓸 수 없다보니 정확한 내용과 예시가 아닐 수도 있다.
트랜잭션 커밋 시점과 락 해제 시점에 따른 동시성 이슈 해결기
팀 내에서는 레디스 분산 락을 적용해서 일반적으로 동시성을 제어하고 있고, 해당 이슈가 발생한 부분 역시도 동일한 흐름으로 진행되고 있다고 생각했다.
그래서 처음 이슈를 접했을 때 분산락으로 잘 제어하고 있는데 동시성 이슈가 발생했다고? 라고 생각을 하면서 다른 부분을 집중적으로 찾아보고 있었다.(redis lock 구현체, 이벤트 리스너, 비즈니스 코드 흐름 등등...)
결론적으로는 트랜잭션 커밋 시점과 락 해제 시기 간의 문제였다. (공부했었던 내용인데 먼저 못 떠올렸던게 아쉬웠다)
그래서 이 글에서는 스프링의 트랜잭션과 Redis를 이용한 분산 락 간의 관계, 그리고 이를 해결한 방법을 정리하고자 한다.
1. 사건
서비스에서 특정 자원은 반드시 한 사용자만이 처리할 수 있게끔 비즈니스 로직이 진행되고 있다.
주문이 완료된 데이터를 통해서 새로운 확정서 생성이 필요했다.
그래서 redis를 활용한 분산 락을 사용하고 있는데, 굉장히 짧은 시간 내에 한 자원에 대해서 락을 동시에 획득한 로그를 발견했다.
원하는 동작은 이러했다.
- 요청이 들어오면 Redis를 통해 분산 락을 획득한다.
- 완료된 주문 자원을 조회하고, 요청을한 사용자와 조회한 데이터를 통해 새로운 데이터를 만들어서 DB에 저장한다.
- 트랜잭션이 커밋된 이후 락을 해제한다.
하지만 실제로 기대와 다른 사용자의 데이터가 최신 값으로 저장되어 있었다.
2. 원인
문제의 원인은 바로 트랜잭션 커밋 시점과 락 해제 시점 간의 상관관계였다.
실제로는 이벤트 기반으로 동작하고 있고, 자주 사용하는 프로젝트가 아니어서 한 눈에 원인을 찾기에는 시간이 다소 걸렸다.
로직을 1차원적으로 펴서 생각하면 문제가 발생한 코드 구조는 다음과 같았다.
@Transactional
fun 자원등록() {
distributedLock.lock("LOCK_KEY")
try {
val resource = resourceRepository.find()
val newResource = resource.createFrom()
newResourceRepository.save(newResource)
} finally {
distributedLock.unlock("LOCK_KEY")
}
}
이 코드에서 문제는 다음과 같다.
- Redis 분산 락은 트랜잭션과 무관하게 동작한다.
- 스프링의
@Transactional
어노테이션은 메서드가 끝난 이후 트랜잭션을 커밋하거나 롤백한다. - 위 코드에서는 트랜잭션이 실제로 DB에 반영되기 전에 락을 먼저 해제하고 있었다.
다음 그림을 통해 이 현상을 쉽게 이해할 수 있다.
| 단계 | 요청 A | 요청 B |
|-------------------|----------------------|-----------------------------|
| 1. 락 획득 | O | 대기 |
| 2. 자원 조회 | O | 대기 |
| 3. 자원 차감 | O | 대기 |
| 4. 락 해제 | O (트랜잭션 미커밋) | 락 획득 후 자원 조회(미반영 상태) |
| 5. 트랜잭션 커밋 | O (DB 반영) | 자원 차감 후 락 해제 |
| 6. 트랜잭션 커밋 | - | O (잘못된 데이터로 반영) |
위 그림과 같이, 요청 A가 락을 해제한 시점에는 아직 트랜잭션이 커밋되지 않았기 때문에, 요청 B가 아직 DB에 반영되지 않은 상태의 자원을 조회하게 되어 잘못된 결과가 발생하게 된다.
단 하나의 데이터만 존재해야 된다의 조건을 지키기 위해 DB 제약조건을 걸면 안되냐? 라고 할 수 있지만 이런 저러한 이유로 그럴 수 없는 테이블이다.
3. 해결 방법
이 문제를 해결하려면 반드시 트랜잭션이 커밋된 이후에 락을 해제해야 한다.
이를 AOP(Aspect-Oriented Programming)를 통해 처리했다. 스프링의 AOP를 활용하면 트랜잭션과 락의 순서를 깔끔하게 제어할 수 있다.
간단한 해결 방법은 다음과 같다.
- 분산 락을 처리하는 어노테이션(
@DistributedLock
)을 별도로 정의했다. - AOP에서 이 어노테이션을 캐치하고, 트랜잭션 내부에서 락을 잡고 트랜잭션이 완전히 끝난 이후에 락을 풀어주도록 했다.
- 특히 이 과정에서
@Transactional
과 락을 처리하는 어드바이스의 실행 순서를 명확하게 조절하기 위해 스프링의@Order
를 이용하여 락 어드바이스가 항상 트랜잭션보다 먼저 실행되도록 했다.
구현 예시는 다음과 같다.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class DistributedLock(
val key: String,
val timeUnit: TimeUnit = TimeUnit.SECONDS,
val waitTime: Long = 10,
val leaseTime: Long = 30
)
이때, 반드시 스프링 트랜잭션의 타임아웃 시간과 분산 락의 waitTime
및 leaseTime
을 함께 고려해야 한다.
분산 락이 트랜잭션 타임아웃보다 짧게 설정되면 락이 먼저 풀릴 수 있고, 반대로 너무 길면 불필요하게 락 점유가 길어져 성능 저하가 발생할 수 있다.
@Aspect
@Component
@Order(Ordered.LOWEST_PRECEDENCE - 1) // 트랜잭션보다 우선 실행
class DistributedLockAspect(private val distributedLock: DistributedLockClient) {
@Around("@annotation(distributedLockAnno)")
fun lock(joinPoint: ProceedingJoinPoint, distributedLockAnno: DistributedLock): Any? {
distributedLock.lock(
distributedLockAnno.key,
distributedLockAnno.timeUnit,
distributedLockAnno.waitTime,
distributedLockAnno.leaseTime
)
try {
return joinPoint.proceed()
} finally {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
distributedLock.unlock(distributedLockAnno.key)
} else {
TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization {
override fun afterCompletion(status: Int) {
distributedLock.unlock(distributedLockAnno.key)
}
})
}
}
}
}
이 방법으로 락의 해제 시점이 트랜잭션 커밋 이후로 미뤄졌고, 동시성 문제가 해결되었다.
하지만 모든 동시성 문제를 이런 간단한 방법으로 해결할 수는 없다.
예를 들어 스프링 트랜잭션의 동작 특성상 사용하는 메서드보다 상위 메서드나 클래스 레벨에 @Transactional이 적용된 경우에는 해당 트랜잭션에 참여한다.
트랜잭션이 이미 진행 중인 상태로 락을 해제할 수 있게 되어 데이터 정합성 문제와 커넥션 관리 문제가 동시에 발생할 수 있으므로 반드시 주의해야 한다.
물론 REQUIRED_NEW
의 전파 속성을 사용한다고 할 수 있지만, 불필요한 커넥션 자원을 사용하게 되고 이 과정에서 데드락이 발생할 수도 있다.
결론
트랜잭션과 분산 락은 함께 사용할 때 반드시 트랜잭션의 커밋 시점과 락 해제 시점을 명확하게 관리해야 한다.
스프링 AOP와 @Order
를 적절히 활용하면 복잡한 동시성 이슈도 깔끔하게 해결할 수 있다.
'Spring' 카테고리의 다른 글
[400분이 걸리는 10만 개의 알림 요청 시간을 줄여보자] 4. 비동기를 더 우아하게 써볼 순 없을까? (4) | 2024.02.21 |
---|---|
[400분이 걸리는 10만 개의 알림 요청 시간을 줄여보자] 3. 왜 CPU 스파이크가 발생할까? JVM WarmUP으로 해결해보자 (0) | 2024.02.20 |
[400분이 걸리는 10만 개의 알림 요청 시간을 줄여보자] 2. @Async와 Executor를 사용해 비동기로 이사가기 (0) | 2024.02.20 |
[400분이 걸리는 10만 개의 알림 요청 시간을 줄여보자] 1. 우리의 알림 서비스는 왜 문제였을까? (5) | 2024.02.20 |
[SQL] 서비스 내에서 발생하는 쿼리를 분석하고 개선하기 (4) | 2023.09.21 |
- Total
- Today
- Yesterday
- 피움 6주차 회고
- 스프링 부트
- 알림기능개선기
- 우테코 회고
- 5주차 회고
- 백준
- java
- 런칭 페스티벌
- 3차 데모데이
- jpa
- ZNS SSD
- Spring
- 스프링MVC
- 네트워크
- 팀프로젝트
- dm-zoned
- 피움
- dm-zoned 코드분석
- ZNS
- 8주차 회고
- 회고
- 알림개선기
- 우테코
- 프로젝트
- 스프링 Logback
- 파이썬
- CI/CD
- 스프링 프레임워크
- 환경 별 로깅 전략 분리
- 2차 데모데이
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |