동시성 처리, 왜 중요할까?
동시성 처리는 대규모 시스템 설계에서 중요한 부분이다. 왜냐하면 대규모 시스템에서는 엄청나게 많은 사용자가 동시에 접근할 것이고, 그러면 여러 스레드가 동시에 한 값에 접근하는 상황이 많이 발생할 것이다.
예시로, 외국민 프로젝트에서 게시글의 조회수/추천수를 증가시키는 기능이 있다고 해보자. 이 부분은 단지 “DB로 부터 값 읽기 → 값 증가” 이 과정이다보니 Race Condition이 발생할 가능성이 매우 크다. 100명이 추천을 동시에 눌렀는데, 실제 값은 82명 정도로 100명에 못미치는 값이 될 가능성이 매우 높다.
혹시 해당 현상이 발생하는 이유는 잘 모르겠다면, 아래 운영체제 동시성에 관련된 개념을 한번 읽어보길 바란다.
참고로, 해당 글은 Spring을 기준으로 작성한게 좀 많으니 양해바란다.
동시성 처리 방안
1. synchronized를 이용하기
해당 방식은 몇가지 문제가 있습니다.
우선 위 그림처럼, 메서드 자체를 synchroized를 건다고 해봅시다. 그러면, 메서드에 대해서는 lock이 걸립니다. 하지만, 메서드가 종료되는 시점에서 이제 Transaction commit을 날려서 반영을 하는데, 그 사이에 짧은 텀이 있습니다. 저 짧은 구간에서 race condition이 또다시 발생하게 됩니다. 왜냐하면 @Transaction 어노테이션은 AOP로 작동하는데, 그래서 메서드가 실행이 완료 된 후 AOP인 transaction 관리자가 DB에 commit하고 거기애 오는 응답을 보고 롤백을 하던지 합니다. 즉, synchorized를 메서드로 감싸면 AOP로 작동하는 transaction manager까지 당연히 못감싸고 틈이 생기는 것 입니다.
그러면, Transaction 시작 전에 synchronized를 묶어버리면 안될까요? 이럼 예방이 되긴 합니다. 하지만, 우리가 로드 밸런싱을 한다고 서버를 여러개 두면 문제가 생깁니다. 서버가 두 대 이상일 경우, synchroized는 하나의 프로세스 안에서만 Race Condition이 예방되기 때문에 결국에는 Race Condition이 발생합니다. 따라서, 이는 좋은 방법이 아닙니다.
2. DB Lock
DB Lock은 크게 3가지 분류로 나눌 수 있습니다.
우선, 비관적 락입니다. (Pessimistic Locking) 위 사진처럼, 직관적으로 이미 다른 트랜젝션이 DB에서 Lock을 잡고 있으면 접근 못하는 형태입니다. 다만, 위 방식의 문제점은 하나의 트랜잭션의 작업이 완료될 때까지 lock을 걸고 있기 때문에 다른 트랜잭션은 대기해야되서 성능적 문제가 있습니다. 또한 단일 DB가 아닌 환경에서는 문제가 발생할 수 있습니다. (샤딩을 한다던지의 상황 등)
그 다음, 낙관적 락입니다. (Optimistic Locking) 위 사진 처럼, 각 데이터에 버전 정보가 있습니다. t1이 version 정보가지고 업데이트해서 version 2정보로 바뀌었죠. 그런데 이 상태에서 t2가 version1의 정보를 가지고 역시 업데이트를 한다고 가정해봅시다. 그런데 이미 DB에 반영된건 version2 정보이기 때문에 반영되지 않습니다. 그래서 분산 DB 환경에서도 문제없이 사용이 가능합니다. 다만, 실패했을 경우 다시 시도하는 로직을 개발자가 직접 작성해줘야됩니다.
개인적으로 이 낙관적 락이 꽤 쓸모가 있다고 생각합니다. 예를 들어서, 우리가 게시글에 추천 기능을 만든다고 생각해봅시다. 그리고 매번 글 리스트를 요청할때 마다 추천 Table에서 Count 쿼리를 날려서 집계하는 것은 성능적으로 무리가 있다고 생각해서 Post Table에 추천 수를 관리할 수 있는 칼럼을 따로 만들었다고 가정해봅시다. 그러면, 해당 추천수라는 데이터는 Race Condition이 발생하기 딱 좋은 데이터입니다. 하지만, 추천수는 빈번하게 접근되는 데이터는 아니죠. 빈번하게 접근되지는 않지만, Race Condition이 발생할 가능성이 있어서 밑에 소개할 분산락을 건다든지 하는 것은 매우 비효율적입니다. 따라서, 이럴 경우 이 낙관적 락을 쓰면 좋습니다. 실제로 Lock이 걸리는게 아닐 뿐더러, Race Condition까지 예방이 가능하니 이런 상황에서 매우 좋죠.
마지막으로, Named Lock입니다. DB 자체에 Lock이라는 분산락을 만들고, 이걸 이걸로 lock을 제어하는 방법입니다. 그런데 이 방식을 사용하면, Lock을 가져오는 스레드와 트랜잭션을 유지하는 스레드까지 두개를 사용해야되기 때문에 좋은 방법은 아닙니다.
3. 트랜잭션 Isolation 레벨 조정
앞서 말한 Lock은 쓰기 단계에서 정합성을 지키기 위한 방법이었습니다. 그렇다면, 읽기 단계에서 정합성을 어떻게 지킬 수 있을까? 바로 DB의 Isolation Level을 엄격하게 하는 것입니다. 동시에 여러 트랜잭션이 실행될 때 트랜잭션 간의 간섭을 제어하고 데이터 일관성을 보장하는 방식을 결정합니다.
예를 들어, Read Uncommitted 격리 수준은 커밋되지 않은 다른 트랜잭션에서 변경된 데이터를 읽을 수 있지만, Serializable 격리 수준은 트랜잭션이 완전히 완료될 때까지 다른 트랜잭션에서 해당 데이터를 읽을 수 없습니다. 따라서 격리 수준은 주로 읽기 작업에 대한 데이터의 일관성을 보장하기 위해 사용합니다.
트랜잭션 격리 수준에 대해서는 아래 블로그에 잘 나와있으니 참고바랍니다.
4. Redis 분산락
비관적 락의 문제는 분산 DB 환경에서, lock이 단일 DB에 잡히기 때문에 발생한 문제이다. 그러면 락을 잡는 것을 다른 DB에 분산하면 되지 않을까?라고 생각할 수 있다. 그래서 나온 것이 Redis를 이용한 분산 락이다. 앞서 말했다 싶이, Redis는 단일 스레드에서 실행된다. 따라서, 이러한 특성을 이용하여 Redis에 lock 여부를 저장해두는 것이다. 이렇게 하면 여러 Redis 인스턴스에 분산 락을 구현하고, Redis가 단일 장애 지점(SPOF)이 되는 것을 막기 위해 여러 Redis 인스턴스를 활용하여 락을 분산시킬 수 있다.
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에 대해서는 아래 글을 참고하면 좋다.
그러나 Redis의 분산 락에는 한계도 존재한다. Redis 분산락 알고리즘 중에서, RedLock 알고리즘은 Redis를 이용한 분산 락 메커니즘으로, 여러 Redis 인스턴스에 락을 분산시켜 단일 장애 지점(SPOF)을 줄이고자 개발되었다. 이 알고리즘은 과반수 이상의 Redis 노드에서 락을 획득하면 락이 성공한 것으로 간주하는 방식으로 동작한다. 그런데 특히, 이 RedLock 알고리즘은 네트워크 지연, 클럭 드리프트(clock drift), 애플리케이션 중단 등 여러 상황에서 동기화 문제가 발생할 수 있다. RedLock 알고리즘은 여러 Redis 노드에서 과반수 이상의 락을 획득해야 잠금이 성공하는 구조인데, 클럭 드리프트 현상이나 클라이언트의 중단으로 인해 동시에 여러 클라이언트가 잠금을 획득하는 race condition이 발생할 수 있다.
따라서, 완벽한 동기화가 필요하다면 ZooKeeper와 같은 합의 시스템을 사용하는 것이 더 적합할 수 있다. RedLock은 특정 상황에서 안전하지 않을 수 있기 때문에 클럭 동기화 문제를 충분히 이해하고 도입하는 것이 중요하다.
해당 내용은 자세히 설명하면 너무 길어지므로 따로 글을 써서 정리해보고자 한다.
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에 관해서는 아래 여기어때 기술 블로그에 아주 잘 나와있다.
6. Redis 내부에서 Lua Script 실행
5번 문제의 연장선이다. 일련의 행위를 Atomic하게 실행시키는 또다른 방법이다. Redis 2.6부터는 Redis 내부에서 Lua Script를 실행시킬 수 있도록 Lua 언어 프로세서가 내장되어있다. 따라서, 앞서 언급한 저 일련의 과정을 Lua Script로 짜두고, 이걸 Redis 내부에서 실행시키면 Redis는 싱글 스레드이기 때문에 Atomic이 보장된다!
대표적으로 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는 구현한 것이다.
즉, 5번과 6번은 Redis 내에서 값을 읽어오고, 비교하고, 그거에 따라서 처리하고, 값을 증가시키거나 감소시킬 때 사용하면 굉장히 유리하다.
7. 동시성 처리 포기
장난 같지만 나는 동시성 처리 포기도 좋은 방식이라고 생각한다. 진짜 이 데이터를 동시성을 고려해야될 만큼 중요한 데이터인가?라고 고민하는 것이다. 앞서 봤다싶이 동시성 처리는 꽤나 생각을 많이 해야되고, 복잡하고, 관리해야될 포인트를 증가시킬 수 있다. 따라서, 진짜 동시성 처리가 필요한지 고민해보고 구현하는 것도 좋다고 생각한다.
💡참고자료
https://vladmihalcea.com/optimistic-vs-pessimistic-locking/
https://mangkyu.tistory.com/311
https://techblog.woowahan.com/2631/
'BackEnd > 대규모 시스템 설계' 카테고리의 다른 글
[대규모 시스템 설계] Kafka의 기초 (1) | 2024.11.15 |
---|---|
[대규모 시스템 설계] GRPC를 통한 서비스간 통신 (2) | 2024.05.22 |
[대규모 시스템 설계] 캐시 설계 전략 (0) | 2024.05.20 |
[대규모 시스템 설계] MSA의 필요성과 고려할 포인트들 (0) | 2024.05.19 |