이번에 회사에서 사용하는 화상 회의툴을 줌으로 교체하는 업무를 맡아서 하던 중에, 튜터와 튜티가 동시에 수업 입장을 시도하는 경우에 각각 다른 방으로 인도하는 현상을 발견했다.

 

수업에 입장을 하는 로직은 대략적으로 다음과 같다.

위의 로직에서 ZoomMeeting이 이미 생성된 경우에는 새로 생성하지 않고 기존에 있던 방의 정보를 응답하도록 하는데, 튜터와 튜터의 입장이 동시에 이루어지면서 다른 쪽에서 생성되고 있는 데이터를 읽어오지 못하고 각각 데이터를 따로 생성해 버린 것이다.

 

이렇게 된 데에는 크게 2가지 원인이 있다.

  1. 줌 회의방을 생성하기 위해 줌과 통신을 해야하고 이 부분에서 꽤나 긴 시간이 소요된다는 것
  2. 원자성을 보존하기 위해 transaction.atomic을 걸어주어 로직이 끝나기 전까지 DB에 데이터가 쓰여지지 않았고, 다른 쪽에서 데이터의 유무를 확인할 수 없었던 것

1의 경우는 어쩔수 없는 부분이었고, 2의 경우에도 transaction을 포기하는 것은 옳은 방법이 아니라고 판단했다.

이로 인해 생각해낸 방법이 요청하는 데이터에 대해 lock을 걸어놓는 것이었다.

 

메커니즘을 요약하면 다음과 같다.

 

수업에 입장하기 위해 클라이언트에서는 수업의 고유한 id 값을 서버에 보내게 되는데, 이 값을 받자마자 transaction 바깥 쪽에서 DB 레코드를 생성하여 lock을 걸어놓는 방식이다.

 

다음과 같이 Lock 모델을 만들어주고, 모델 안에 간단하게 lock을 취득하는 메소드와 반환하는 메소드를 선언했다.

class VideoConferenceLock(models.Model):
    key = models.CharField(max_length=50, default='')
    value = models.CharField(max_length=50, default='')

    @classmethod
    def get_lock(cls, key, value):
        while True:
            if cls.objects.filter(key=key, value=value).count() == 0:
                break
        cls.objects.create(
            key=key,
            value=value
        )

    @classmethod
    def release_lock(cls, key, value):
        cls.objects.filter(key=key, value=value).delete()

 

 

그리고 수업에 입장하는 API를 다음과 같이 설계하였다.

try:
    VideoConferenceLock.get_lock('session_id', session_id)
    with transaction.atomic():
        # 회의방  생성
        ...
	
    VideoConferenceLock.release_lock('session_id', session_id)
    return Response
except:
	VideoConferenceLock.release_lock('session_id', session_id)

여기서 중요한 것은 로직이 돌다가 오류로 인해 except로 빠졌을 때에도 lock을 풀어줘야 한다는 것이다. 이렇게 하지 않으면, 해당 id 는 계속 lock에 잡혀있게 되고 동일한 id에 입장하려는 시도가 생기면 무한루프가 도는 장애가 발생하기 때문이다.

 

급하게 RDB 를 활용하여 위와 같이 임시방편으로 해결하긴 했지만, 추후에 Redis 등의 인메모리 DB 를 활용하여 경량화할 생각이다.

 

추가

기존의 lock을 저장하는 곳을 Redis 로 변경했다. 

익숙하지도 않던 Redis 를 꾸역 꾸역 적용시키려고 한 이유는 크게 2가지이다.

  1. 가볍고 저장과 삭제가 빠르다.
  2. HTTP Connection 이 정상적으로 종료되지 않은 케이스에 자동으로 만료(unlock)되는 옵션을 사용할 수 있다.

특히 2번이 Redis를 사용하는 좀 더 주된 이유였다.

최근에 pod가 죽고 재실행되는 이슈가 있었는데(해당 이슈는 아직 파악중이다), 혹시나 lock이 걸린 채로 pod가 죽어버리면 unlock이 정상적으로 이뤄지지 않을 것을 우려하여 급하게 redis를 공부하고 도입하게 되었다.

 

변경된 로직은 아래와 같다.

@classmethod
    def get_lock(cls, session_id):
        key = f'zoom_session_id:{session_id}'
        while True:
            # 해당 key가 레디스에 없으면 lock  (1초마다 시도)
            if cache.get(key) is None:
                break
            time.sleep(1)
        cache.set(key, 1, 60)  # 60초 간 유효

    @classmethod
    def release_lock(cls, session_id):
        key = f'zoom_session_id:{session_id}'
        cache.delete(key)

위에서 cache라고 가져오는 것이  redis 인스턴스이다.

기존 로직과 변경된 것이 있다면, 만료시간을 준다는 것과 lock 을 획득하기 위해 busy wait 할 때 1초씩 대기 시간을 주며 redis에 조회를 날리는 횟수를 줄인 것이다.

 

끝으로, redis에 대한 정리를 끝으로 포스팅을 마치고자 한다.

 

Redis(Remote Dictionary Server)

  • 주로 Cache의 목적으로 사용
  • In-memory Data Structure Store(Main Memory: DRAM에 저장)
  • Atomic Critical Section에 대한 동기화 제공
  • Single Thread
  • 메모리 파편화, 가상 메모리, Replication Fork
  • 메모리 파편화: 메모리를 할당, 해제하는 과정에서 비어있는 부분이 생기고 사용하지 못하는 physical memory가 생김 -> 실제 사용중인 메모리 보다 큰 메모리를 할당
  • 가상 메모리-스왑: 프로세스를 스왑하는 과정에서 자원 소모
  • Replication-Fork: 인메모리 특성상 휘발성이 있음: 데이터가 유실될 가능성 -> 복제본을 만듦
  • 레디스의 메모리는 제한되어있기 때문에 주기적으로 scale out, back up 해야함 -> redis cluster

 

Redis Cluster

  • master를 여러개 두어 분산 저장이 가능하며(Sharding), scale out 이 가능하다.
    • 서버를 늘릴수록 저장할수 있는 공간이 무한대로 커진다. 
  • master에 하나 이상의 slave 를 둘 수 있다.
  • master 1,2,3 이 있다면 데이터는 3개중에 하나에 저장되며, client 가 데이터 읽기 요청시 저장된 곳이 아닌 다른 마스터에 요청 했다면 저장된 마스터 정보를 알려주며, 클라이언트는 전달받은 마스터 정보에 다시 요청해서 데이터를 받아온다.

 

References

https://co-de.tistory.com/24 

'Django' 카테고리의 다른 글

Django - pytest 환경 구성  (0) 2022.09.06
Django - orm update() 사용 시 주의점  (0) 2022.09.04
Django - time zone  (0) 2022.04.14
Django - simple jwt 적용 (1) XSS, CSRF  (0) 2022.03.16
Django - 동적 쿼리  (0) 2022.03.05

+ Recent posts