티스토리 뷰

반응형

문제 상황

회사에서 분산환경에서 하루에 한 번만 요청이 가능한 기능이었지만 한 사람이 3번 이상 요청을 보낸 기록이 있어 원인을 찾아보았습니다.

 

우선 샘플 코드는 다음과 같습니다.

fun save(requestDto: BankAccountSaveRequestDto): SimpleBankAccountIdResponseDto {

        check(!validateDuplicationByAccountNumber(requestDto.number)) {
            throw BankAccountDuplicationException(requestDto.number)
        }

        check(!validationDuplicationByName(requestDto.name)) {
            throw BankAccountNicknameDuplicationException(requestDto.name)
        }

        val member = memberQueryService.getDetail(requestDto.memberId)

        val finAccountRequestDto = PublishFinAccountRequestDto.of(
            true,
            member.birth.value,
            requestDto.bankName.code,
            requestDto.number
        )

        val agencyDealCode = NhHeaderValueUtils.createAgencyDealCode()

        val finAccount = bankAccountApiClient.getFinAccountConnection(agencyDealCode.toString(), finAccountRequestDto)
            .flux()
            .toStream()
            .findFirst()
            .orElseThrow { throw FinAccountNotFoundException() }

        val bankAccount = requestDto.toEntity(finAccount).encodePassword(encoder)

        return SimpleBankAccountIdResponseDto(bankAccountRepository.save(bankAccount).id)
    }

다음은 테스트 코드입니다.

class BankAccountConcurrencyTest

@Autowired
constructor(
    private val bankAccountCommandService: BankAccountCommandService,
    private val bankAccountRepository: BankAccountRepository,
    private val memberRepository: MemberRepository
): IntegrationTest() {

    lateinit var executorService: ExecutorService
    lateinit var member: Member

    @BeforeEach
    fun init() {
        executorService = Executors.newFixedThreadPool(5)
        member = memberRepository.save(GivenMember.toMember())
    }

    @Test
    fun save() {
        val requestDto  = BankAccountSaveRequestDto.testInitializer(TestBankAccountUtils.mockBankAccount(), TestBankAccountUtils.memberId)

        val latch = CountDownLatch(2)

        // when
        for (i in 1 .. 2) {
            executorService.execute {
                try {
                    bankAccountCommandService.save(requestDto)
                    latch.countDown()
                } catch (e: Exception) {
                    e.printStackTrace()
                    latch.countDown()
                }
            }
        }

        latch.await()

        // then
        val count =
            bankAccountRepository.countByNumberAndName(TestBankAccountUtils.number, TestBankAccountUtils.name)

        assertThat(count).isEqualTo(1)
    }
}

분명 하나만 들어가야 하지만 테스트 결과는 다음과 같습니다.

시나리오는 다음과 같습니다. (더블 클릭 이슈로 가정)

스레드 - 1 스레드 - 2 결과
계좌 번호 및 계좌 별칭 중복확인   false
  계좌 번호 및 계좌 별칭 중복 확인 false
finAccount 발급   발급 성공
  finAccount 발급 발급 성공
계좌 비밀번호 인코딩   발급 성공
  계좌 비밀번호 인코딩 발급 성공
계좌 등록   성공
  계좌 등록 성공

이런 시나리오대로 흐를 수 있다면 충분히 unique key 제약만 없다면 발생할 수 있는 이슈입니다. 하지만 실제로는 일주일에 한 번 같은 회원이 신청하면 안되지만 그 중 특정 요일엔 하루에 두 번 신청이 가능하단 조건 때문에 unique key로는 해결할 수 없었습니다.

 

그렇다면 차근 차근 해결해보겠습니다.

해결 step. 1

우선 자바 동시성 제어에 대해 살펴봅시다.  synchronized 키워드를 제공해줍니다. synchronized block을 활용해 보겠습니다. method에도 synchronized 키워드를 붙일 순 있지만 그렇게 되면 class 자체가 동기화 대상이 되기 때문에 클래스 접근하는 모든 스레드가 Lock이 걸려 대기해야합니다. 이는 성능 저하로 이어지고 자칫하면 많은 스레드들이 점유를 하기 위해 대기하고 다른 스레드는 또 다른 자원을 점유하려고 대기하는 데드락으로도 이어질 수 있습니다.

fun save(requestDto: BankAccountSaveRequestDto): SimpleBankAccountIdResponseDto {

        synchronized(requestDto.name) {
            check(!validateDuplicationByAccountNumber(requestDto.number)) {
                throw BankAccountDuplicationException(requestDto.number)
            }

            check(!validationDuplicationByName(requestDto.name)) {
                throw BankAccountNicknameDuplicationException(requestDto.name)
            }

            val member = memberQueryService.getDetail(requestDto.memberId)

            val finAccountRequestDto = PublishFinAccountRequestDto.of(
                true,
                member.birth.value,
                requestDto.bankName.code,
                requestDto.number
            )

            val agencyDealCode = NhHeaderValueUtils.createAgencyDealCode()

            val finAccount = bankAccountApiClient.getFinAccountConnection(agencyDealCode.toString(), finAccountRequestDto)
                .flux()
                .toStream()
                .findFirst()
                .orElseThrow { throw FinAccountNotFoundException() }

            val bankAccount = requestDto.toEntity(finAccount).encodePassword(encoder)

            return SimpleBankAccountIdResponseDto(bankAccountRepository.save(bankAccount).id)
        }
    }

그러면 테스트를 통해 실제로 결과를 확인해보겠습니다.

성공한걸 확인할 수 있습니다. JVM에서 synchronized 블록을 만나면 작업 중에 다른 스레드가 임계영역에 침범할 수 없기 때문에 Thread-safe하게 연산을 이어나갈 수 있습니다.

하지만 분산환경에서 synchronized는 Application Server에서의 Lock이기 때문에 여러 서버가 존재하는 분산환경에서는 동시에 다른 서버에 요청이 들어와 작업을 할 때에는 synchronized가 동기화 여부를 장담해줄 수 없습니다.

해결. step. 2

본 글에서는 INCR 명령어를 이용한 Lock구현을 다룰 예정입니다. 선택하게 된 이유는 다음과 같습니다.

Redis INCR을 선택한 이유

물론 처음부터 INCR을 선택한건 아니었습니다. 고려해본 방법은 여러가지가 있습니다. 

1. MySQL Named Lock 
2. Redisson pub/sub을 활용한 분산락
3. INCR을 이용한 요청 count 전략

Named Lock은 MySQL에서 제공해주는 Lock방법 중 하나로 고유한 이름으로 잠금을 획득하여 동시성 제어를 할 수 있습니다. 하지만 본 기능은 B2C 서비스기 때문에 요청량 자체가 많았고 높은 처리량이 요구되는 상황이었습니다. 하지만 JPA와 JDBC를 사용중이기 때문에 Blocking I/O 때매 성능이 Redis에 비해 비교적 떨어집니다. 또한 Lettuce 클라이언트를 사용하기 때문에 비동기 기반의 빠른 처리가 가능한 Redis가 서비스 운영에 더 유리합니다.

Redisson은 pub/sub을 이용해 서버간 잠금 유무를 공유하게 하여 동시성을 제어합니다. 이 방식은 Redis에서 채널을 통해 분산 서버에 Lock획득 유무를 전달해주고 notify 해주기 때문에 이후 재시도가 필요할 때 spinLock 방식이 아니라 Redis 부하를 줄일 수 있습니다. 하지만 요구사항에 재시도는 없었기 때문에 사용하지 않았습니다. 

레디스가 Single Thread로 동작하기 때문에 동일한 키로 여러 스레드가 increament요청 하더라도 스레드는 기다려야하기 때문에 여러 서버에서 작업하더라도 데이터 일관성이 지켜집니다. 또한 Spring에서는 increamentAndGet 메서드로 카운팅 후 바로 값을 가져오고 이를 내부적으로 synchronized 블럭으로 동기화 중이기 때문에 요청 갯수에 동시성 이슈가 발생하지 않습니다. 만약 이후 프로세스를 타야한다면 spinLock으로 Redis에 재시도를 구현해야했지만 요구사항이 이후 요청을 튕겨내도 되었기 때문에 이 방식을 채택했습니다.

 

INCR을 선택한 이유를 설명드렸습니다.  자 그러면 코드를 확인해 봅시다.

class BankAccountLockService(
    private val redisTemplate: RedisTemplate<String, Int>
) {

    companion object {
        const val BANK_ACCOUNT_LOCK_KEY = "BANK_LOCK::"
    }

    fun tryLock(name: String): Boolean {

        val key = BANK_ACCOUNT_LOCK_KEY + name
        redisTemplate.expire(key, 2, TimeUnit.MINUTES)
        return redisTemplate.opsForValue().increment(key, 1)!! == 1L
    }

    fun unlock(name: String) {
        redisTemplate.delete(BANK_ACCOUNT_LOCK_KEY + name)
    }
}

tryLock요청 시 2분의 ttl을 갖는 name을 기준으로 생성된 key와 value가 increment가 됩니다. 그리고 숫자가 1이 아닌 다른 숫자가 들어온다면 false를 return하여 Lock 획득에 실패합니다. (참고로 increment 메소드를 사용하면 Get도 같이 해옵니다.)
또한 2분의 ttl은 작업이 길어져 한 자원이 Lock을 길게 잡고 있어 데드락이 걸리는 경우를 회피하기 위해 추가해줬습니다.

그러면 구현부를 살펴봅시다.

fun save(requestDto: BankAccountSaveRequestDto): SimpleBankAccountIdResponseDto {

    try {
        if (!bankAccountLockService.tryLock(requestDto.name)) {
            throw AlreadyLockException()
        }

        this.validateDuplicationByAccountNumber(requestDto.number)
        this.validationDuplicationByName(requestDto.name)

        val member = memberQueryService.getDetail(requestDto.memberId)

        val finAccountRequestDto = PublishFinAccountRequestDto
            .of(true, member.birth.value, requestDto.bankName.code, requestDto.number)

        val agencyDealCode = NhHeaderValueUtils.createAgencyDealCode()
        val finAccount = getFinAccount(agencyDealCode, finAccountRequestDto)
        val bankAccount = requestDto.toEntity(finAccount).encodePassword(encoder)

        return SimpleBankAccountIdResponseDto(bankAccountRepository.save(bankAccount).id)
    } finally {
        bankAccountLockService.unlock(requestDto.name)
    }
}

try 안에서 작업을 하고 finally를 이용하여 개발자가 의도하지 못한경우에도 무조건 Lock을 해제해줄 수 있게 해주었습니다.

그러면 테스트를 다시 한 번 실행시켜 보겠습니다.

정리

Redis Lettuce 클라이언트를 이용하여 Lock을 구현해보았습니다. 물론 단점이 존재할 수 도 있어 제 스스로 추후 R&D 후 개인프로젝트에는 더 나은 Redis 분산락 방식을 이용하여 상호 배제를 구현할 예정입니다. 여기서 핵심은 분산 환경에서 상호 배제를 하기 위해선 애플리케이션이 아닌 Database를 고려하자 입니다. 다들 도움이 됐기를 바랍니다. 

 

마침.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
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
글 보관함