본문 바로가기
개발/대규모 시스템 설계

[대규모 시스템 설계] 동시성 처리

by 경험의 가치 2024. 7. 4.

동시성 처리, 왜 중요할까?

 

동시성 처리는 대규모 시스템 설계에서 중요한 부분이다. 왜냐하면 대규모 시스템에서는 엄청나게 많은 사용자가 동시에 접근할 것이고, 그러면 여러 스레드가 동시에 한 값에 접근하는 상황이 많이 발생할 것이다.

 

예시로, 외국민 프로젝트에서 게시글의 조회수/추천수를 증가시키는 기능이 있다고 해보자. 이 부분은 단지 “DB로 부터 값 읽기 → 값 증가” 이 과정이다보니 Race Condition이 발생할 가능성이 매우 크다. 100명이 추천을 동시에 눌렀는데, 실제 값은 82명 정도로 100명에 못미치는 값이 될 가능성이 매우 높다. 

 

혹시 해당 현상이 발생하는 이유는 잘 모르겠다면, 아래 운영체제 동시성에 관련된 개념을 한번 읽어보길 바란다.

 

[OS/OSTEP] 26.threads-intro - 쓰레드 개념 정리와 필요성, atomic, 전역변수

지난 게시글. [OS/OSTEP] 22.vm-beyondphys-policy : 메모리 교체 정책 - LRU,FIFO,OPTIMAL,RANDOM,CLOCK #16 22.vm-beyondphys-policy # 시작하며 교체 정책의 핵심은 내보내도 될만한 페이지를 어떤 방식으로 선택하는 것인

devforyou.tistory.com

 

참고로, 해당 글은 Spring을 기준으로 작성한게 좀 많으니 양해바란다.

 

동시성 처리 방안

 

1. synchronized를 이용하기

 

 

2. DB Lock

 

 

3. 트랜잭션 Isolation 레벨 조정

 

앞서 말한 Lock은 쓰기 단계에서 정합성을 지키기 위한 방법이었다. 그렇다면, 읽기 단계에서 정합성을 어떻게 지킬 수 있을까? 바로 DB의 Isolation Level을 엄격하게 하는 것이다. 동시에 여러 트랜잭션이 실행될 때 트랜잭션 간의 간섭을 제어하고 데이터 일관성을 보장하는 방식을 결정한다.

 

예를 들어, Read Uncommitted 격리 수준은 커밋되지 않은 다른 트랜잭션에서 변경된 데이터를 읽을 수 있지만, Serializable 격리 수준은 트랜잭션이 완전히 완료될 때까지 다른 트랜잭션에서 해당 데이터를 읽을 수 없다. 따라서 격리 수준은 주로 읽기 작업에 대한 데이터의 일관성을 보장하기 위해 사용다.

 

트랜잭션 격리 수준에 대해서는 아래 블로그에 잘 나와있으니 참고바란다.

 

 

[MySQL] 트랜잭션의 격리 수준(Isolation Level)에 대해 쉽고 완벽하게 이해하기

이번에는 트랜잭션 격리 수준(Isolation Level)에 대해 알아보도록 하겠습니다. 아래의 내용은 RealMySQL과 MySQL 공식 문서 등을 참고하여 작성하였으며, 모든 내용은 InnoDB를 기준으로 설명합니다. 해

mangkyu.tistory.com

 

4. Redis 분산락

 

비관적 락의 문제는 분산 DB 환경에서, lock이 단일 DB에 잡히기 때문에 발생한 문제이다. 그러면 락을 잡는 것을 다른 DB에 분산하면 되지 않을까?라고 생각할 수 있다. 그래서 나온 것이 Redis를 이용한 분산 락이다. 앞서 말했다 싶이, Redis는 단일 스레드에서 실행된다. 따라서, 이러한 특성을 이용하여 Redis에 lock 여부를 저장해두는 것이다. 이렇게 하면 여러 Redis 인스턴스에 분산 락을 구현하고, 단일 장애 지점 (SPOF)을 방지할 수 있을 것이다.

 

Spring에서 Redis 활용을 위한 라이브러리로 대표적인 것이 Lettuce와 Redisiion이 있다. 이 둘의 Lock 작동 과정이 다르다.

 

  • Lettuce 기반 : Lettuce는 Spin Lock 방식으로 Lock을 구현한다. 따라서, lock 휙득을 실패했을때 재시도하는 로직을 개발자가 작성해줘야 된다. 또한, 지속적으로 계속 락을 얻을 수 있는지 확인해야되다 보니 비효율적일 수 있다. 하지만, 비교적 구현이 매우 간단하다.
  • Redission 기반 : Redission은 pub/sub 기반으로 락이 구현되어 있다. 따라서, 별도로 재시도하는 로직을 작성하지 않고, 훨씬 효율적이다. (Redis에 부하를 덜 줌) 하지만, Lettuce에 비해서 설정이 매우 까다롭다.

 

실제로 내가 짰던 코드의 예시로 봐보자. 이건 Lettuce 기반으로 작성한 Redis 분산락이다. 해당 코드는 조회수에서 발생하는 Race Condition을 예방하기 위해서 구성했다. 맨 위에 Thread.sleep을 통해서 재시도 로직을 작성했다. 그리고 while 루프를 돌면서 기다리다가, Lock을 휙득하면 그제서야 이제 밑에 있는 try finally 로직을 실행하는 것이다. 모든 로직을 완료하면 최종적으로 Redis Lock을 Release 해준다.

 

추가적으로, Redis Lock의 장점이 하나 있다. 앞서 언급한 MySQL Named Lock 같은 경우는 멀티 스레드 기반으로 요청마다 동기적으로 처리한다. 반면에 Redis는 Multiplexing I/O 기술과 Redission의 Netty를 이용한 epoll 매커니즘을 이용하여 비동기적으로 redis 소켓을 Listen하고 있는 모든 서버에게 빠르게 I/O 전달이 가능해진다. 따라서, Named Lock 같은 경우는 스케일 아웃 하더라도 성능이 늘어나지 않는다. 반면에 Redis 분산락은 그렇지 않다는 것이다!

 

Multiplexing I/O에 대해서는 아래 글을 참고하면 좋다.

 

[네이버클라우드 기술&경험] IO Multiplexing (IO 멀티플렉싱) 기본 개념부터 심화까지 -1부-

안녕하세요, 네이버 클라우드 플랫폼입니다. 이번 포스팅을 통해 다양한 Multiplexing 기법을 알려드리려 ...

blog.naver.com

 

5. Redis Transaction

 

앞서 언급한 방법들은 실제 RDB에서 정보를 읽어오고 저장할 때 정합성과 관련된 문제들이었다. 그런데 예를 들어, Cache나 Refresh Token 같은 것들은 RDB가 아니라 Redis에서 읽어올텐데, 이때 정합성을 어떻게 보장할까?

 

이게 무슨 소린가 싶을 수도 있다. 그래서 앞서 언급한 코드를 예시로 가지고 다시 봐보자. 만약 위에 코드에서 Redis Lock 휙득 과정이 없다면 어떻게 될까? Redis는 싱글스레드에서 실행되기 때문에 당연히 Race Condition이 보장되는 것이 맞다. 하지만, 위에 코드를 봐보자. 위 그림과 같이 두 명의 사용자가 동시에 조회수를 증가시키는 메서드에 접근했다 해보자. 이때, t1은 아직 레디스에 값을 저장하지 않은 상태에서, 갑자기 t2가 들어와서 값을 읽었다면, 값이 하나 누락되고 Race condition이 발생하게 된다. 

 

이는, Redis 자체는 싱글 스레드에서 구동되는 것은 맞지만, 값을 읽어오고 증가시키는 저 일련의 로직이 Atomic하게 보장되있지 않기 때문이다. 따라서, 저 묶음을 Transaction 처리해서 Redis 내부에서 실행시키면 Redis는 싱글스레드로 돌아가기 때문에 Atomic이 보장될 것입니다. (참고로 Redis Lock으로 해도 된다.)

 

Redis Transcation에 관해서는 아래 여기어때 기술 블로그에 아주 잘 나와있다.

 

Redis&Kafka를 활용한 선착순 쿠폰 이벤트 개발기 (feat. 네고왕)

안녕하세요. 유저혜택개발팀 쿠폰 백앤드 개발자 페이든입니다.

techblog.gccompany.co.kr

 

6. Redis 내부에서 Lua Script 실행

 

5번 문제의 연장선이다. 일련의 행위를 Atomic하게 실행시키는 또다른 방법이다. Redis 2.6부터는 Redis 내부에서 Lua Script를 실행시킬 수 있도록 Lua 언어 프로세서가 내장되어있다. 따라서, 앞서 언급한 저 일련의 과정을 Lua Script로 짜두고, 이걸 Redis 내부에서 실행시키면 Redis는 싱글 스레드이기 때문에 Atomic이 보장된다!

 

 

spring-cloud-gateway/spring-cloud-gateway-server/src/main/resources/META-INF/scripts/request_rate_limiter.lua at af09c72a9a5dbc2

An API Gateway built on Spring Framework and Spring Boot providing routing and more. - spring-cloud/spring-cloud-gateway

github.com

 

대표적으로 Spring Cloud Gateway에서 API Rate Limiter를 개발할 때 사용되었다. 위에 Spring Cloud Gateway 소스 코드를 까서 보면 Token Bucket Algorithm의 과정을 Lua Script로 짜두어서 Atomic하게 실행되도록 보장하였다. 이에 대해서는 추후 "처리율 제한 장치"를 다룰때 더 자세히 설명할 것이지만, 간단하게 설명하면 유저마다 Request를 보낼 수 있는 횟수가 제한되어있고, 이 횟수를 다 쓰면 429 Error (Too many Requests)를 반환하여 차단한다. 어쨋든 저 Request 보내는 횟수가 RaceCondition이 발생하기 딱 좋은 환경이다. 왜냐하면 Redis에서 값을 읽어보고, 유효한지 확인하고, 값을 줄이고 이런 일련의 과정이 이루어져야 되기 때문이다. 따라서, Lua Script를 이용하여 Atomic을 보장하도록 Spring Cloud Gateway는 구현한 것이다.

 

7. 동시성 처리 포기

 

장난 같지만 나는 동시성 처리 포기도 좋은 방식이라고 생각한다. 진짜 이 데이터를 동시성을 고려해야될 만큼 중요한 데이터인가?라고 고민하는 것이다. 앞서 봤다싶이 동시성 처리는 꽤나 생각을 많이 해야되고, 복잡하고, 관리해야될 포인트를 증가시킬 수 있다. 따라서, 진짜 동시성 처리가 필요한지 고민해보고 구현하는 것도 좋다고 생각한다.

 

 

(작성중...)