동시성 처리에 관한 짧은 고민
동시성 처리에 관한 짧은 고민
병렬성과 동시성
먼저 병렬성과 동시성 두 키워드에 대해서 알아두는 것이 좋다.
- 병렬성
- 여러 작업이 실제로 동시에 여러 CPU 코어에서 실행
- 즉, 작업을 분할하여 동시에 처리하여 시간 단축과 처리량 상승
- 참고로 이거는 여러 CPU 코어가 필요하고 각 코어가 독립적으로 작업을 수행한다.
- 동시성
- 여러 작업이 번갈아 가며 처리되는 것처럼 보이는 것. 실제로 동시에 처리되는건 아니고 여러 작업을 번갈아 수행해서 동시 처리되는 것처럼 보이게 함
- 즉, 작업을 나누어 관리하고 한 작업이 I/O 대기 등으로 멈춰있을 떄 다른 작업을 수행해서 응답성을 높인다.
- 이거는 단일 코어 CPU에서 가능하며 각 작업 시간을 짧게 나누어 수행한다.
그러면 여기서 동시성 문제가 무엇일까?
동시성 문제
동시성 문제란, "공유 자원에 대해 동시에 접근할 때에 발생할 수 있는 문제" 이다.
예를 들어 하나의 DB 데이터를 두 개의 스레드가 동시에 변경 요청 하는 등 상황이다.
사실.. 동시성 문제랑 병렬성 환경은 연관이 되어 있기도 하고, 요즘은 분산 서버로 구축되어 있기 때문에 따로 경계가 있지는 않다.
- 동시성 문제 : 공유 자원에 대한 안전하지 않은 동시 접근
- 병렬성 : 여러 작업이 물리적으로 동시에 수행되는 것
즉, 결국은 공유 자원에 동시에 접근하는 것이 문제이기 때문에 동시성과 병렬성 모두가 원인이 되며, 둘 다 동시성 문제를 야기하는 원인이 된다.
동시성 문제의 대표 사례
기본적으로 동시성 문제는 결국 공유 자원에 동시 접근할 때 발생한다.
이를 Race Condition 이라고 부르며, 가장 대표적인 사례를 은행에 돈을 넣거나 빼는 것이 있을 수 있다.
은행 계좌 입출금 사례
예를 들어 은행에 돈을 넣고 빼는 상황을 들 수 있다.
- 현재 10만원이 있음
- 3만원 입금 (A요청)
- 2만원 출금 (B요청)
이렇게 하면 본래는
10 + 3 - 2 = 11
요렇게 돼야 한다.
근데 입금과 출금이 비슷한 때에 수행된다면 어떻게 될까?
- A요청에서 현재 계좌 잔액 확인 (10만원)
- B요청에서 현재 계좌 잔액 확인 (10만원)
- A요청에서 입금 처리 진행 (10만원 + 3만원 = 13만원)
- B요청에서 출금 처리 진행 (10만원 - 2만원 = 8만원)
- 최종 처리 이후 금액 : 8만원
이런 식으로 공유 자원에 대해서 한꺼번에 접근을 하고 처리하면 문제가 발생할 수 있다.
해결 방법
기본적으로
- DB 락(비관적 락, 낙관적 락)
- Redis 분산락
- Message Queue 활용한 순차 처리
정도가 있다.
DB락 - 비관적 락
간단히 말하자면 누군가가 특정 데이터를 선점중이면 다른 곳에서 접근 자체를 못하도록 막는 방법이다.
select ... for update
요런 식으로 트랜잭션을 걸 때에 exclusive lock을 걸어 다른 곳에서 접근을 아예 막는 것
이렇게 한다면
- A요청에서 현재 계좌 잔액 확인 후 lock
- B요청에서 현재 계좌 잔액 확인을 하려 하면 접근 불가
- A요청에서 입금 처리 진행 (10만원 + 3만원 = 13만원)
이런 식으로 처리가 가능하다.
어쨌든 B요청과 A요청이 같은 데이터를 변경할 수 있는 여지를 아예 막아 버릴 수 있음.
- 장점
- 딱 보면 알겠지만, 선점하고 있는 데이터에 대해 다른 곳에서의 접근 자체를 막기 때문에 정합성을 매우 강력하게 보장할 수 있다.
- 같은 데이터에 여러 요청이 동시에 많이 발생하는 경우(충돌이 자주 발생하는 상황) 에 적합하다.
- 뭔가 고려할 점이 적기 때문
- 단점
- exclusive lock 을 걸기 때문에 락 선점 시 다른 트랜잭션 성능 저하가 발생할 수 있다.
- Deaklock 발생 가능성이 높다.
충돌이 많이 발생할 수 있고, 정합성이 중요한 경우 추천되는 방식이다.
DB락 - 낙관적 락
위의 비관적 락과는 다르게, 락을 걸지 않은 상태로 작업을 한 후에 데이터 업데이트 시점에 혹시 다른 트랜잭션이 이 데이터를 변경했는지 확인하는 방식이다.
이름이 낙관적 락인 이유는 "충돌 별로 없을거임" 이라고 가정하고 시작하기 때문
그래서 어떤 식으로 다른 트랜잭션에서 변경했는지 확인할 수 있을까?
- 버전 번호 확인
- 마지막 수정 타임스탬프 확인
결국은 그 공유 데이터에서 변경 여부를 확인할 수 있는 뭔가를 보면 된다.
- A요청에서 현재 계좌 잔액 확인 (10만원 - v1)
- B요청에서 현재 계좌 잔액 확인 (10만원 - v1)
- A요청에서 입금 처리 진행 (10만원 + 3만원 = 13만원 - v2로 변경)
- B요청에서 출금 처리 진행하려 했더니 버전이 다름(읽은건 v1인데 지금 버전이 v2)
- B요청에 대해 재시도하거나 실패 처리
즉, Java Atomic class 의 CAS 방식과 유사하다고 생각하면 이해가 쉬울 것이다.
이거 바꿔도 되나? 확인 후 되면 변경같은 느낌
- 장점
- exclusive lock이 걸리지 않기 때문에 비관적 락에 비해 동시 처리 시 성능 저하가 적다.
- 단점
- 근데 같은 공유데이터에 접근이 많다면(충돌이 많다면) 재시도가 자주 발생하고, 이 때에 성능이 저하될 여지가 크다.
요 방식은 읽기가 많고 충돌 가능성이 낮은 경우에 조금 더 적합하다 볼 수 있다.
Redis 분산 락
여러 서버가 동시에 하나의 DB에 접근하려 할 때, 그 DB 앞에 Redis 를 두고 키를 선점한 친구가 접근할 수 있도록 하는 방식
요 방식은 기본적으로 Redis 가 싱글 스레드 기반이기 때문에 SETNX같은 연산이 연산의 원자성을 보장한다.
즉 한번에 하나의 요청만 처리하기 때문에 활용 가능
즉, 접근하고자 하는 공유 자원의 key가 같다면 앞의 처리가 완료되지 않았다면 뒤에 요청은 접근이 불가능한 것을 이용한 방법이다.
- A요청에서 현재 계좌 잔액 확인 (10만원 - key1)
- B요청에서 현재 계좌 잔액 확인을 위해 접근하려 했으나 선점으로 접근 불가 (key1 사용중)
- A요청에서 입금 처리 진행 (10만원 + 3만원 = 13만원 -> key1 해제)
- B요청에서 현재 계좌 잔액 확인 (13만원 - key1)
- B요청에서 출금 처리 진행 (13만원 - 2만원 = 11만원 -> key1 해제)
여기서 key1의 사용중 처리와 해제, 그리고 이를 파악하는 방법은 spin-lock 이나 pub-sub 구조 두가지가 있는데 이에 대해서는 여기 에서 간단히 확인 가능하다.
참고로... 락을 해제하기 전에 그 락이 자신이(그 요청이) 획득한 락인지 확인하고 해제해야 한다. (예를 들어 락 획득 시 랜덤한 값을 저장하고 해제 시 그 값을 확인한다 등등...)
- 장점
- 다양한 언어나 환경에서 쉽게 구현 가능
- 성능이 좋다. (In-momery 기반인 Redis 를 활용하므로)
- 단점
- Redis 가 장애가 난다면 SpoF 가능(이거를 해결하기 위해 여러 Redis 마스터 노드 활용 등 방법이 있는데 이거는 구현 복잡성이 높기도 하고 위의 싱글 스레드 관련한 논쟁이 존재한다.)
- 이 방식은 동시성 문제가 발생할 여지가 있다(TTL이나 해제 관련 문제 등등)
- 예를 들어 TTL이 30초로 되어 있는데 작업이 35초가 걸리면 lock 이 해제되어 다른 작업이 수행되게 되고, 35초가 된 이전 작업이 지금 작업의 lock을 해제
- 이를 해결하기 위해 Lock 해제 시 본인이 만든 락인지 확인해야 한다.
- 또 작업 시간이 TTL 초과할 것 같으면 락을 연장하는 메커니즘도 있어야 한다.
- 혹은 SETNX 후 expire 설정 전에 장애가 발생하면 lock 이 해제되지 않을 수 있다.
- SETNX 시 동시에 TTL 설정이 필요함
- 예를 들어 TTL이 30초로 되어 있는데 작업이 35초가 걸리면 lock 이 해제되어 다른 작업이 수행되게 되고, 35초가 된 이전 작업이 지금 작업의 lock을 해제
- 네트워크 지연 발생 시 락의 유효성이 깨질 가능성도 있음
Message Queue 활용한 순차 처리
다른 방식과 달리, 동시 요청을 그냥 메시지 큐에 넣어 얘가 순차적으로 처리하도록 유도한다.
Kafka나 RabbitMQ 등을 활용해서 요청을 메시지로 저장한 후 워커 스레드가 하나씩 순차적으로 처리하는 방식
- A요청으로 3만원 입금해 달라고 메시지 전송
- B요청으로 2만원 출금해 달라고 메시지 전송
- 워커가 A요청 처리(10만원에서 3만원 입금 완료)
- 워커가 B요청 처리(13만원에서 2만원 출금 완료)
결국은 요청에 대한 처리를 워커가 하는 것이다.
- 장점
- 애플리케이션 서버와 실제 작업 처리 로직을 분리할 수 있다(시스템 결합도가 낮아짐)
- 공유 자원에 대한 동시 접근 자체를 아예 피할 수 있다.
- 요청이 급증해도 시스템이 안정적으로 동작 가능하다.
- 메세지 큐를 사용하고 시스템 결합도가 낮기 때문에 작업이 실패해도 재시도가 용이하다.
- Kafka나 RebbitMQ 등은 보통 메시지를 메모리뿐 아니라 디스크에도 저장하기 때문에... 처리되지 않은 메시지가 큐에 남아있으므로
- 단점
- 어쨌든 큐를 거치고 읽어와야 하므로 지연이 발생한다.
- 시스템 큐를 하나 더 운영해야 한다.
이 방식은 실시간성이 엄청 잘 보장되어야 하지는 않지만, 안정적인 처리가 필요하고 트래픽 변동이 큰 작업에 용이하다.