[Backend] 동시성 이슈와 해결 방법 알아보기

2025. 3. 31. 15:34·Backend

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()
            }
        }
    }

}

 

 


 

 

실무에서 사용하던 내용들의 이름이 뭔지도 모르고 있었다.

여러가지로 많이 알아간다..ㅠ

'Backend' 카테고리의 다른 글

[Backend] Domain-Driven Design(DDD)와 Hexagonal Architecture  (0) 2025.05.03
'Backend' 카테고리의 다른 글
  • [Backend] Domain-Driven Design(DDD)와 Hexagonal Architecture
나는 유찌
나는 유찌
쩌리쨩
  • 나는 유찌
    유찌 개발 일기
    나는 유찌
  • 전체
    오늘
    어제
    • 분류 전체보기
      • 사이드 프로젝트
        • 게시판
        • 블로그(Spring boot + React.js ..
      • 데이터베이스
        • SQLD
      • 이슈 해결
      • Front
        • Javascript
        • Vue.js
        • HTML+CSS
      • Backend
        • Spring
        • ORM
        • JAVA
      • 공부
        • HTTP
        • OOP
        • 이것저것
        • 코딩테스트 | 알고리즘
      • Computer Science
        • Computer architecture
        • 데이터베이스
        • 운영체제
      • 일상
        • 독서
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    LeetCode
    mysql 격리수준
    jwt
    DIRTY READ
    AccessDecisionVoter
    spring
    pessimisticlock
    mssql
    권한 scope 처리
    refresh token
    phantom read
    Spring Boot
    access token
    Spring Security AccessDecisionManager
    role scope
    Spring boot에서 JWT 구현
    spring 격리수준
    Access Token Refresh Token
    한국소설
    Kotlin AntPathMatcher
    히가시노 게이고
    redis 분산락
    AntPathMatcher
    Access token 재발급
    독서
    jwt 로그인 구현
    추리소설
    Kotlin AccessDecisionManager
    웹 개발
    JWT이란?
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
나는 유찌
[Backend] 동시성 이슈와 해결 방법 알아보기
상단으로

티스토리툴바