1. 동시성 문제란?
📒 개념
- 여러 작업이 동시에 실행될 때 발생할 수 있는 예상치 못한 오류나 데이터 충돌 현상
- 공유 자원에 여러 스레드, 혹은 사용자 요청이 동시에 접근하면서 문제가 생기는 경우가 많음
📒 예시
- 재고 감소 문제
- 동시에 재고 감소 요청이 2개가 들어오면 두 요청 모두 quantity = 1을 읽고 0으로 만들고 저장
- 결과적으로 재고가 1이 줄어야 하는데 실제로는 2개가 줄어들 수 있는 상황
val stock = stockRepository.findById(productId)
if (stock.quantity > 0) {
stock.quantity -= 1
stockRepository.save(stock)
}
- 게시글 좋아요 수 증가
- 여러 사용자가 동시에 요청을 하면 값이 덮어씌워져서 좋아요 수가 실제보다 적게 올라가는 문제 발생 가능
post.likes += 1
postRepository.save(post)
- 중복 결제가 발생한 경우
- 사용자가 결제 버튼을 2번 누르거나 네트워크 지연으로 다시 요청이 들어올 때 이미 결제된 건을 또 처리하는 문제 발생 가능
2. 동시성 문제 대표적 증상
📒 Lost Update (업데이트 손실)
- 여러 개의 요청이 동시에 같은 데이터를 읽고 수정할 때 한 쪽의 수정 결과가 다른 쪽에 의해 덮어씌워져 사라지는 현상
- EX) 게시글 좋아요 수 증가
- 아래 서비스 코드로 테스트 코드를 실행하면 기댓값은 3이지만 실제 값은 1이 나온다.
// Service 코드
@Service
class PostService(
private val postRepository: PostRepository
) {
@Transactional
fun like(
id: Long
) {
val post = postRepository.findById(id).orElseThrow()
post.likeCount += 1
}
}
// 테스트 코드
@SpringBootTest
class PostServiceTest(
private val postService: PostService,
private val postRepository: PostRepository,
): BehaviorSpec({
Given("같은 게시글에 좋아요 요청이 3개가 동시에 들어올 때") {
val threadCount = 3
val executorService = Executors.newFixedThreadPool(32)
val latch = CountDownLatch(threadCount)
When("게시글의 좋아요 수를 1씩 증가시킨다면") {
repeat(threadCount) {
executorService.submit {
try {
postService.like(2L)
} finally {
latch.countDown()
}
}
}
// 모든 스레드 작업 완료 대기
latch.await()
Then("좋아요 수는 3이 되어야 한다") {
val post = postRepository.findById(2L).orElseThrow()
post.likeCount shouldBe 3
}
}
}
})
📒 Race Condition
- 둘 이상의 작업이 동시에 실행되면서 실행 순서에 따라 결과가 달라지는 현상 의미
- 공유 자원에 대해 동기화 없이 동시에 접근했을 때 작업 순서나 타이밍에 따라 잘못된 결과 발생
- EX) 닉네임 중복 체크
- 테스트 코드 기댓값은 1이나 실제 값은 2가 나오고, DB를 직접 확인해보니 '유찌' 닉네임의 유저가 2명이 존재했다.
// Service 코드
@Service
class UserService(
private val userRepository: UserRepository
) {
@Transactional
fun register(
registerDto: RegisterDto
) {
// 1. 닉네임 중복 확인
if (!userRepository.existsByNickname(registerDto.nickname)) {
// 2. 중복 없으므로 회원 저장
val user = UserEntity(
email = registerDto.email,
nickname = registerDto.nickname
)
userRepository.save(user)
} else {
throw IllegalArgumentException("이미 존재하는 닉네임입니다")
}
}
}
// 테스트 코드
@SpringBootTest
class UserServiceTest(
private val userService: UserService,
private val userRepository: UserRepository,
private val dataSource: DataSource
): BehaviorSpec({
Given("2명이 동시에 회원 가입 요청이 들어오고") {
val threadCount = 2
val executorService = Executors.newFixedThreadPool(32)
val latch = CountDownLatch(threadCount)
val registerDto = RegisterDto(
email = "yujin123.kim@gmail.com",
nickname = "유찌"
)
When("닉네임 중복에서 걸리면") {
repeat(threadCount) {
executorService.submit {
try {
userService.register(registerDto)
} finally {
latch.countDown()
}
}
}
latch.await()
Then("중복되는 닉네임은 없어야 한다") {
val list = userRepository.findByNickname(registerDto.nickname)
list.size shouldBe 1
}
}
}
})
3. 동시성 문제 해결 전략
🔎 Synchronized
- 하나의 스레드가 특정 코드 블록 또는 메서드를 실행할 때, 다른 스레드의 진입을 막아주는 동기화 수단
- 장점
- 구현이 간단
- JVM 기반 동기화 (Java/Kotlin 기본 제공, 외부 라이브러리 필요 X)
- 동시에 작업이 수행되지 않도록 보장
- 단점
- 멀티 서버 환경에서는 적용 불가능
- 락 경쟁이 많아지면 병목 발생 가능
- 비즈니스 로직이 무거워 장시간 락 보유 시 데드락 위험 존재
💡 Transactional 어노테이션은 프록시 기반이기 때문에 트랜잭션이 synchronized 블록 안에서 시작되도록 구성해야 안전
- EX) 게시글 좋아요 수 증가
- 주의 사항에 맞추어 Service를 분리하여 Executor에서 synchronized 블록을 구성하고 안에서 트랜잭션이 실행되도록 작성
- 테스트 코드는 위와 동일하고 결과는 통과
// Service 코드
@Component
class PostLikeExecutor(
private val postService: PostService
) {
private val lock = Any()
fun safeLike(
id: Long
) {
synchronized(lock) {
postService.like(id)
}
}
}
@Service
class PostService(
private val postRepository: PostRepository
) {
@Transactional
fun like(
id: Long
) {
val post = postRepository.findById(id).orElseThrow()
post.likeCount += 1
}
}
🔎 ReentrantLock
- Java의 java.util.concurrent.locks 패키지에 포함된 명시적인 락 객체
- 재진입 가능한 락으로 같은 스레드가 여러 번 락을 획득해도 문제 없이 작동하며, synchronized보다 더 많은 제어 권한 제공
- 장점
- 유연한 락 제어 가능 (tryLock(), tryLock(timeout))
- 정교한 제어 가능
- 단점
- 락 해제를 명시적으로 해야하므로 실수하면 데드락 위험 존재
- 코드가 synchronized에 비해 복잡
- JVM 내부에서만 유효 (멀티 서버 의미 X)
- EX) 게시글 좋아요 수 증가
- synchronized와 동일하게 Executor에서 lock을 걸고 트랜잭션을 호출하도록 구성
- 테스트 코드는 위와 동일하고 결과는 통과
// Service 코드
@Component
class PostLikeExecutor(
private val postService: PostService
) {
private val locks = ConcurrentHashMap<Long, ReentrantLock>()
fun safeLike(
id: Long
) {
val lock = locks.computeIfAbsent(id) { ReentrantLock() }
lock.withLock {
postService.like(id)
}
}
}
@Service
class PostService(
private val postRepository: PostRepository
) {
@Transactional
fun like(
id: Long
) {
val post = postRepository.findById(id).orElseThrow()
post.likeCount += 1
}
}
🔎 비관적 락 (Pessimistic Lock)
- 데이터에 접근할 때 동시 수정을 막기 위해 미리 락을 거는 방식
- 특징
- 데이터 조회 시 DB 수준의 락을 먼저 걸어둠
- 동시에 두 트랜잭션이 데이터를 수정할 수 없음
- 락을 잡고 있는 트랜잭션이 끝나야 다음 트랜잭션 진행 가능
- 장점
- Race Condition 완벽 방지
- 데이터 무결성 보장
- 코드로 직접 동기화 구현 필요 X
- 단점
- 일반 조회도 락이 걸려 동시 처리 성능 저하 (락 대기 시간 발생)
- 데드락 위험 존재
- EX) 게시글 좋아요 수 증가
- 테스트 코드에서 동일 게시글에 100개의 좋아요 요청이 들어왔다고 변경해보았다.
- 아래 테스트도 통과
// Repository 코드
@Repository
interface PostRepository : JpaRepository<PostEntity, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM PostEntity p WHERE p.id = :id")
fun findByIdForUpdate(id: Long): PostEntity?
}
// Service 코드
@Service
class PostService(
private val postRepository: PostRepository
) {
@Transactional
fun like(
id: Long
) {
val post = postRepository.findByIdForUpdate(id)
?: throw IllegalArgumentException("존재하지 않는 게시글입니다")
post.likeCount += 1
}
}
// 테스트 코드
@SpringBootTest
class PostServiceTest(
private val postService: PostService,
private val postListExecutor: PostLikeExecutor,
private val postRepository: PostRepository,
): BehaviorSpec({
Given("같은 게시글에 좋아요 요청이 100개가 동시에 들어올 때") {
val threadCount = 100
val executorService = Executors.newFixedThreadPool(32)
val latch = CountDownLatch(threadCount)
When("게시글의 좋아요 수를 1씩 증가시키면") {
repeat(threadCount) {
executorService.submit {
try {
postService.like(2L)
} finally {
latch.countDown()
}
}
}
latch.await()
Then("좋아요 수는 100개가 되어야 한다") {
val post = postRepository.findById(2L).orElseThrow()
post.likeCount shouldBe 100
}
}
}
})
🔎 낙관적 락 (Optimistic Lock)
- 데이터 충돌이 자주 발생하지 않을거라 생각하고 별도의 락을 걸지 않고 데이터를 수정하되, 수정 시점에 버전 번호(Version)를 비교하여 충돌 여부 감지
- 특징
- DB 락 없이 자유롭게 읽고 씀
- @Version 필드를 통해 JPA가 자동으로 버전 체크
- 트랜잭션 충돌 감지 시 OptimisticLockingFailureException 발생
- 락을 걸지 않기 때문에 동시 처리 성능에 유리
- 장점
- 락 대기가 없어 성능 좋음
- 읽기 위주 트래픽에 적합
- Scale-out 구조에서도 잘 작동
- 단점
- 충돌 발생 시 예외 발생 + 재시도 필요
- 충돌이 자주 일어나는 구조에서는 오히려 비효율적
- 처리 로직이 복잡해질 수 있음 (재시도 로직)
- EX) 게시글 좋아요 수 증가
- 간헐적으로 실패할 때가 있다.
- @Retryable은 재시도를 도와주는 어노테이션인데 backoff에서 random 값을 주니 대부분 성공하였다.
- 재처리를 하는데 고정값으로 backoff를 주면 같은 시점에 재처리를 하면서 충돌이 또 일어나고 다시 실패할 확률이 높아진다.
// Entity
@Entity
@Table(name = "post")
class PostEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long,
val title: String,
val content: String,
@Column(name = "like_count")
var likeCount: Int,
@Version
val version: Long
)
// Service 코드
@Service
class PostService(
private val postRepository: PostRepository,
private val redissonClient: RedissonClient
) {
@Retryable(
value = [OptimisticLockingFailureException::class],
maxAttempts = 5,
backoff = Backoff(delay = 100, multiplier = 2.0, random = true)
)
fun like(
id: Long
) {
doLike(id)
}
@Transactional
fun doLike(
id: Long
) {
val post = postRepository.findById(id).orElseThrow()
post.likeCount += 1
postRepository.save(post)
}
@Recover
fun recover(
e: OptimisticLockingFailureException,
id: Long
) {
throw IllegalStateException("좋아요 실패", e)
}
}
💡 비관적 락, 낙관적 락 각 테스트 코드에 실행 시간을 측정해보기도 한 결과 '같은 게시글에 동시에 100개의 좋아요 요청이 들어온다' 즉, 많은 충돌이 일어난다고 가정을 하니 실행 시간이 낙관적 락이 굉장히 오래 걸리는 것을 확인했다.
락을 적용하고자 하는 로직 상황에 잘 맞추어 선택하는 것이 중요하다.
🔎 Redis 분산락
- Redis를 이용해 여러 인스턴스에서 공유 자원에 대한 접근을 하나로 제한하는 동시성 제어 방식
- 특징
- 여러 서버 간 자원 접근 동기화 가능
- 빠르고 가볍게 동작
- 락을 자동 해제할 수 있는 TTL 기능을 활용해 데드락 방지
- 장점
- 멀티 인스턴스 환경에서도 안전한 락 제어
- TTL 기반 자동 만료로 데드락 위험 감소
- 구현이 비교적 간단하거나, Redisson으로 쉽게 사용 가능
- 단점
- 락 획득/해제 로직을 잘못 만들면 데이터 손실 위험
- Redis가 죽거나 복제 지연이 생기면 잠재적 문제 가능
- 단일 Redis에 의존하면 SPOF(Single Point Of Failure) 발생
- EX) 게시글 좋아요 수 증가
// Service 코드
@Service
class PostService(
private val postRepository: PostRepository,
private val redissonClient: RedissonClient
) {
fun like(
id: Long
) {
val lockKey = "lock:post:$id"
val lock = redissonClient.getLock(lockKey)
// 3초 대기, 5초 동안 락 유지
val available = lock.tryLock(3, 5, TimeUnit.SECONDS)
if (!available) {
throw IllegalStateException("다른 요청이 처리 중입니다. 잠시 후 다시 시도해주세요")
}
try {
val post = postRepository.findById(id).orElseThrow()
post.likeCount += 1
postRepository.save(post)
} finally {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}
}
실무에서 사용하던 내용들의 이름이 뭔지도 모르고 있었다.
여러가지로 많이 알아간다..ㅠ