[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
        • 데이터베이스
        • 운영체제
      • 일상
        • 독서
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

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

티스토리툴바