본문 바로가기
학습/DB

[redis] redisson을 통한 분산 락

by KKambi 2021. 1. 24.

저는 입사 후 광고 플랫폼의 어드민 쪽을 담당하여 개발하고 있는데요.
트래픽은 적지만, 다수의 심사자가 동시에 동일한 요청을 할 경우 동시성 문제가 발생할 여지를 안고 있었습니다.

심사의 상태값을 변경할 수 있는 기능을 예로 들어보겠습니다.
해당 기능은 심사자의 잘못된 심사와 같은 휴먼 에러를 해결하기 위해 개발되었습니다.
승인된 심사를 거절로 바꾸거나, 거절된 심사를 승인으로 바꿀 수 있는데요. 이 때 심사 이력을 남기기 위해 기존 심사 데이터의 상태를 변경하지 않고, 원하는 상태의 새로운 심사를 생성하여 기록합니다.

이 때 어떤 심사자가 심사 완료 버튼을 순식간에 여러 번 클릭하면서 동일한 요청을 여럿 보내게 되면 "원하는 상태의 새로운 심사"가 다수 생성되는 동시성 문제가 발생했습니다.

결과적으로는 redisson을 통한 분산 락을 이용하여 동시성 문제를 해결했는데요.
이번 포스트에선 분산 락을 사용하기까지 도달하게 된 문제 해결 과정을 공유하려 합니다.

 

 

프론트의 디바운스 및 쓰로틀링

처음으로 든 생각은 프론트 서버에서 디바운스나 쓰로틀링을 적용하여 더블 클릭을 막으면 되지 않느냐? 였습니다.
디바운스는 연이은 호출들을 그룹화하고, 특정 시간이 지난 후 가장 마지막의 호출만을 보내는 것입니다. 쓰로틀링은 마지막 함수가 호출된 후 일정 시간이 지나기 전에 다시 호출되지 않도록 하는 것입니다.

디바운스는 계속 호출하게 되면 특정 시간이 지나서야 API서버에 요청합니다. 반면 쓰로틀링은 호출이 들어오면 바로 요청을 보내되, 일정 시간이 지난 후에야 새로운 요청을 보낼 수 있습니다. 따라서 쓰로틀링은 정해진 시간마다 정기적인 기능 실행을 보장합니다.

다만 프론트에서의 문제 해결은 허점이 있습니다.
심사자의 더블 클릭은 막을 수 있으나, 동시성 문제는 해결할 수 없는 것이죠.
어떤 심사자가 브라우저를 2개 켜서, 동시에 동일한 요청을 보낸다면? 다수의 심사자가 동일한 심사의 상태값을 동시에 변경한다면?

저는 이를 해결하기 위해 데이터베이스 시간에 배운 지식을 떠올리며 lock을 걸어보자고 생각했습니다.

 

디바운스(Debounce)와 스로틀(Throttle ) 그리고 차이점

Throttle, Debounce & Difference 스로틀(Throttle) 과 디바운스(Debounce) 란 무엇일까? 이 두 가지 방법 모두 DOM 이벤트를 기반으로 실행하는 자바스크립트를 성능상의 이유로 JS의 양적인 측면, 즉 이벤트(ev.

webclub.tistory.com

 

 

Synchronized

제일 먼저 떠올린 락은 Java의 synchronized 키워드를 이용한 것이었습니다.
어드민 서버는 현재 인스턴스 1대로만 운영되고 있으니, 멀티 스레드의 동시 접근을 막으면 되지 않을까?
한 스레드가 심사 상태 변경을 완료하면, 다음 요청을 담당하는 스레드는 예외 처리에 걸려서 괜찮지 않을까?

synchronized는 메소드에 적용하거나, 동기화 블록을 만들어 그 부분만 적용할 수도 있는데요.
저는 메소드에 적용하는 방법을 사용해봤습니다.

@RequiredArgsConstructor
@Service
public class ReviewService {
   ...
	
    @Transactional
    public synchronized void changeStatus(Review.Status desiredStatus) {
    	// desiredStatus를 가진 새로운 심사 생성 로직
    }
}

하지만 이 방법은 큰 문제 2개를 가지고 있었습니다.

첫번째는 스프링의 @Transactional 어노테이션은 메소드의 앞에 트랜잭션 begin, 메소드의 뒤에 트랜잭션 commit을 추가시켜주고, 예외가 발생했을 때 자동으로 rollback시켜줍니다. 이는 스프링 AOP로 우리가 만든 메소드를 스프링이 제공하는 프록시 객체로 감싸주는 기능입니다.

그런데 트랜잭션의 begin과 commit은 synchronized 메소드에 포함되지 않습니다.
그 결과 커밋이 날라가 심사의 수정된 상태가 DB에 반영되기 전에, 그 다음 요청을 담당한 스레드가 synchronized 메소드에 진입하여 아직 수정이 반영되지 않은 심사 값을 읽게 되고 예외 처리에 걸리지 않는 것입니다.
그 결과 의도했던 바와 다르게 원하는 상태를 반영한 새로운 심사가 다수 생성됩니다.

두번째는 인스턴스가 2대 이상으로 늘어날 경우 동시성 문제를 해결할 수 없다는 것입니다.
synchronized 키워드는 특정 어플리케이션 내에서 멀티 스레드가 동시에 진입하는 것을 막을 뿐, 다수의 서버가 작동하고 있을 때, 각 서버의 스레드의 동시 접근은 막을 수 없습니다.

결국 자바의 synchronized는 근본적인 해결책이 될 수 없었습니다. 그래서 DB lock을 사용해보기로 했습니다.

 

 

Database Lock - redisson 이용하기

저희 서비스는 하나의 데이터베이스를 이용하기 때문에, 데이터베이스 락을 사용한다면 여러 서버를 운영하는 분산 환경에서 공유 자원의 동기화 처리를 보장할 수 있습니다.

처음 생각했던 것은 MySQL 데이터베이스에 락을 관리하는 테이블을 만들어 사용하는 것이었는데요.
MySQL Connection까지 관리해주면서 분산 락을 직접 구현하기에는, 분산 락을 처음 구현해보는 저에게 무리였다 판단했습니다. 대신 우아한형제들에서 이를 실현하여 과정을 공유한 글이 있으니 이를 참고해주세요!

 

MySQL을 이용한 분산락으로 여러 서버에 걸친 동시성 관리 - 우아한형제들 기술 블로그

안녕하세요. 비즈인프라개발팀 권순규입니다. 현재 광고시스템에서 사용하고 있는 MySQL을 이용한 분산락에 대해 설명드리고자 합니다.

woowabros.github.io

다른 해결책은 redis를 이용하는 것이었습니다. 회사 동료분께서 아이디어를 주셨고, 마침 redisson을 이용해 분산 락을 사용할 수 있었습니다.

redisson은 Lettuce와 같은 자바 레디스 클라이언트입니다. Lettuce와 비슷하게 Netty를 사용해서, 비동기 논블록킹 I/O를 제공하는데, 특이하게도 레디스의 명령어를 직접 제공하지 않고 Lock과 같은 특정한 구현체의 형태를 제공합니다. 저희 파트에선 Lettuce를 사용하고 있지만, Lettuce는 기본적으로 분산 락을 제공하지 않기 때문에 빠른 구현을 위해 redisson을 사용했습니다.

// application.yml
spring:
  redis:
    host: ~~
    port: 6379
    

// 비즈니스 로직
@RequiredArgsConstructor
@Service
public class ReviewService {

    private final RedissonClient redissonClient;

    @Transactional
    public void changeStatus throws(Status desiredStatus) {
        // 레디스 락 데이터 생성 후, 3초 락
        RLock lock = redissonClient.getLock("key 이름");
        
        try {
            boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
            if (!isLocked) {
                // 락 획득에 실패했으므로 예외 처리
                throw new Error( ... );
            }
        
            // 새로운 심사 생성 로직
            
        } catch (InterruptedException e) {
	    // 쓰레드가 인터럽트 될 경우의 예외 처리        
        } finally {
            // 락 해제
            lock.unlock();
        }
       	
    }
}

사용법은 정말 간단합니다. 스프링 프로파일에 레디스 인스턴스 연결 정보를 설정해놓은 뒤, 원하는 곳에서 락을 얻고, 로직 수행 후 락을 해제하면 됩니다.

 

타임아웃을 설정하여 데드락 방지
tryLock의 첫번째 파라미터로 락 획득을 대기할 시간을, 두번째 파라미터로 락이 만료되는 시간을 설정했습니다.
2초 동안 락을 획득하지 못하면 false를 반환하여 락이 실패됐음을 알려주고, 락을 획득하고 나서 3초가 지나면 자동으로 레디스에 저장된 key:value가 삭제되기 때문에 혹여나 락이 해제되지 않더라도 다른 스레드에서 락을 획득할 수 있죠.

 

스핀 락을 사용하지 않음
스핀 락은 락 획득 실패 시 바로 재진입하여 락 획득을 다시 시도하는 것인데 이는 레디스에 계속해서 요청을 보내므로 부담을 줄 수 있습니다.

redisson은 redis의 pub/sub(발행/구독) 시스템을 이용합니다.
락이 해제될 때마다, subscribe 중인 클라이언트들에게 "락 획득을 시도해도 된다"는 메세지를 보내는 것입니다.
락 획득에 실패할 때마다 일일이 레디스에 요청을 보내는 과정이 사라지는 것이죠.

 

Lua 스크립트 사용

Lua 스크립트는 가볍고 쉬운 스크립트 언어로 개발되었는데, 프로그램에 내장되어 일부 기능을 구현할 수 있다고 합니다. 락을 정상적으로 획득/해제하기 위해선 락에 사용되는 연산이 atomic해야 하는데, 여러 명령어를 하나의 트랜잭션으로 묶는 과정에서 Lua 스크립트로 atomic을 보장한다고 합니다.

 

하이퍼커넥트에서 "레디스와 분산 락"이라는 포스팅을 많이 참고했습니다.
실무에서 redisson을 도입한 2편이 아직 나오지 않아 아쉽지만, 전반적으로 원리를 이해하기 좋은 글입니다.

 

레디스와 분산 락(1/2) - 레디스를 활용한 분산 락과 안전하고 빠른 락의 구현

레디스를 활용한 분산 락에 대해 알아봅니다. 그리고 성능을 높이고 일관성을 보장하는 방법에 대해 알아봅니다.

hyperconnect.github.io

 

 

결론

관계형 데이터베이스만 사용하고 있다면, Redis를 이용한 분산 락은 인프라 구축에 대한 비용 + 유지보수 비용을 감당해야 합니다. 이 경우 우아한형제들에서 구현한 MySQL 분산 락 포스트를 참고하면 좋을 것 같습니다.

하지만 Redis를 이미 사용하고 있다면, In-Memory 캐싱, Pub/Sub 시스템 등 Redis가 가지고 있는 장점을 생각했을 때 이를 활용하는 편이 좋을 것 같습니다.

단점도 있긴 합니다. 다른 Redis Client인 Lettuce와 비교해서 사용하기 쉽지 않습니다.

Lettuce는 레디스 인스턴스에 커넥션만 되면 바로 명령어를 요청할 수 있는데, Redisson은 Bucket, Map, RLock과 같은 구현체만을 제공하므로 DataType에 맞는 메소드를 선택해야 합니다. 예를 들어 레디스에 SET 명령을 요청하려면 getBucket()을 써야 하고, LPUSH 명령을 요청하려면 getDeque()나 getList()를 써야 합니다.

또한 레디스 명령과 Redisson 메소드 명이 대부분 다릅니다. 그래서 사용자가 이를 익혀야 합니다.
Redisson에서 별도의 명령어 매핑 페이지를 제공하기도 합니다.

Redisson은 간단하게 사용하긴 쉽지만, Redis나 기존의 클라이언트들과 다른 특성을 가지고 있어 학습 비용이 커질 수도 있어 보이네요.

마지막으로 Redis Gate에서 제공하는 redisson 교육 자료를 공유하며 글을 마치겠습니다 :>

 

Redisson Introduction

redisson_intro Redisson Introduction Redis Client for Java Redisson(레디슨)은 Java용 Redis Client 입니다. 이 동영상은 2017년 Redis Conference에서 개발자(Founder) 니키타가 Redisson을 소개하는 것입니다. Nikita Koksharov[Ни

redisgate.kr

 

 

댓글