java 멀티 스레드 동시성 문제 알아보기 (feat.Redis)

김진욱·2024년 2월 24일
post-thumbnail

멀티 스레드란

하나의 프로세스에 있는 자원을 여러개의 스레드가 공유하며 작업을 나누어 처리

테스트 시나리오

간단한 입금 처리를 위해 공유 자원을 여러개의 스레드가 사용 할 때의 문제점을 알아봅시다.
1.공유 자원인 계좌 잔액이 10만원 이라고 가정한다
2.10개의 쓰레드에서 동시에 5만원씩 입금한다
예상 되는 최종 잔액은 60만원인데 실제 멀티 스레드에서는 다른 결과값이 나오게 됩니다. 위 시나리오 대로 코드를 작성해봅시다.

1.동시성을 고려하지 않은 코드

@SpringBootTest
class AccountApplicationTests {
    private val log = LoggerFactory.getLogger(this.javaClass)!!

    var amount = 100000


    @Test
    fun test() {
        // ThreadPool 구성
        val executor = Executors.newFixedThreadPool(10)
        //  작업이 완료될 때 가지 기다릴 수 있도록 설정
        val countDownLatch = CountDownLatch(10)


        for (i in 1..10) {
            executor.submit { execute(countDownLatch) }
        }

        countDownLatch.await()
        log.info("result : ${this.amount}")
        assertEquals(600000, this.amount)
    }

    fun execute(countDownLatch: CountDownLatch) {
        try {
            this.amount += 50000
            log.info("amount : ${this.amount}")
        } finally {
            countDownLatch.countDown()
        }
    }
}

결과

2.Synchronized 사용으로 동기화 처리

@SpringBootTest
class AccountApplicationTests {
    private val log = LoggerFactory.getLogger(this.javaClass)!!

    var amount = 100000


    @Test
    fun test() {
        // ThreadPool 구성
        val executor = Executors.newFixedThreadPool(10)
        //  작업이 완료될 때 가지 기다릴 수 있도록 설정
        val countDownLatch = CountDownLatch(10)


        for (i in 1..10) {
            executor.submit { execute(countDownLatch) }
        }

        countDownLatch.await()
        log.info("result : ${this.amount}")
        assertEquals(600000, this.amount)
    }

    fun execute(countDownLatch: CountDownLatch) {
        synchronized(this) {
            try {
                this.amount += 50000
                log.info("amount : ${this.amount}")
            } finally {
                countDownLatch.countDown()
            }
        }
    }
}

결과

JAVA Sychronized의 문제점

java의 Sychronized는 하나의 프로세스 안에서만 보장이 되어서 여러개의 서버가 존재할 때는 동시성 보장이 안됩니다.

3.Redis 활용

Redis란 key-value 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비 관계형 인메모리 DBMS

java 의 redisClient 는 아래 3가지가 존재하며

  • Jedis
  • Lettuce
  • Redisson

해당 테스트는 Redisson 방식을 사용합니다.

Redisson 방식

채널을 만들고, 락을 점유 중인 스레드가, Lock을 해제했음을, 대기 중인 스레드에게 알려주면 대기 중인 스레드가 Lock 점유를 시도하는 방식

테스트에 필요한 Redis를 실행해줍니다.
docker-compose.yml Redis 파일 작성 후 실행(docker-compose up -d --build)

version: '3.7'
services:
    redis:
      image: redis:alpine
      command: redis-server --port 6379
      container_name: redis
      hostname: redis_boot
      labels:
        - "name=redis"
        - "mode=standalone"
      ports:
        - 6379:6379

gradle 의존성 추가

dependencies {
	implementation("org.redisson:redisson:3.17.6")
}

Redis Config

@Configuration
class RedisConfig {

    @Bean(destroyMethod = "shutdown")
    fun redissonClient(): RedissonClient {
        val config = Config()
        config.useSingleServer().address = "redis://localhost:6379"
        return Redisson.create(config)
    }

}

Redis Test Service

interface RedissonTestService {
    fun diposit(id: Long, amount: Long)
}

@Service
class RedissonTestServiceImpl(
    private val redissonClient: RedissonClient,
    private val accountService: AccountService
): RedissonTestService {
    override fun diposit(id: Long, amount: Long) {
        // 식별자를 키로 갖는 lock 정의
        val lock = redissonClient.getLock(id.toString())
        try {
            // 락 획득 시도 3초동안 획득 시도, 3초간 락상태
            val available = lock.tryLock(3, 3, TimeUnit.SECONDS)

            if (!available) {
                println("lock 획득 실패")
                return
            }
            val account = accountService.deposit(id, amount)
            println("update amount : ${account.amount}")
        } catch (e: Exception) {
            e.printStackTrace()
        } finally {
            // 작업이 끝나면 락 해제
            lock.unlock()
        }
    }
}

Account Service

interface AccountService {
    fun deposit(id: Long, amount: Long): Account
}

@Service
class AccountServiceImpl(
    private val accountRepository: AccountRepository
): AccountService {
    override fun deposit(id: Long, amount: Long): Account {
        val account = accountRepository.findById(id).orElseThrow()
		account.update(amount)
        accountRepository.saveAndFlush(account)
        return account
    }
}

Test Code

@SpringBootTest
class AccountApplicationTests {
    private val log = LoggerFactory.getLogger(this.javaClass)!!

    @Autowired
    lateinit var accountRepository: AccountRepository

    @Autowired
    lateinit var redissonTestService: RedissonTestService

    @Test
    fun redisTest() {
        // ThreadPool 구성
        val executor = Executors.newFixedThreadPool(10)
        //  작업이 완료될 때 가지 기다릴 수 있도록 설정
        val countDownLatch = CountDownLatch(10)
        val testAccount = Account(amount = 100000)
        val entity = accountRepository.saveAndFlush(testAccount)

        log.info("init amount : ${entity.amount}")


        for (i in 1..10) {
            executor.submit { entity.id?.let { execute(it, countDownLatch) } }
        }

        countDownLatch.await()
        val result = entity.id?.let { accountRepository.findById(it).orElseThrow() }
        log.info("result : ${result?.amount}")
        assertEquals(600000, result?.amount)

    }

    fun execute(id: Long, countDownLatch: CountDownLatch) {
        try {
            redissonTestService.diposit(id, 50000)
        } finally {
            countDownLatch.countDown()
        }
    }

}

결과

간단한 테스트로 RedisClient 락을 생성하고 해당 대기시간, 유지 시간 설정으로 동시성 문제를 해결해보았습니다.

profile
2021.12~ 공부의 기록

0개의 댓글