이론 정리/Spring boot

동시성 처리에 관한 짧은 고민

철매존 2025. 6. 1. 18:10
728x90
반응형

동시성 처리에 관한 짧은 고민

병렬성과 동시성

먼저 병렬성과 동시성 두 키워드에 대해서 알아두는 것이 좋다.

  • 병렬성
    • 여러 작업이 실제로 동시에 여러 CPU 코어에서 실행
    • 즉, 작업을 분할하여 동시에 처리하여 시간 단축과 처리량 상승
    • 참고로 이거는 여러 CPU 코어가 필요하고 각 코어가 독립적으로 작업을 수행한다.
  • 동시성
    • 여러 작업이 번갈아 가며 처리되는 것처럼 보이는 것. 실제로 동시에 처리되는건 아니고 여러 작업을 번갈아 수행해서 동시 처리되는 것처럼 보이게 함
    • 즉, 작업을 나누어 관리하고 한 작업이 I/O 대기 등으로 멈춰있을 떄 다른 작업을 수행해서 응답성을 높인다.
    • 이거는 단일 코어 CPU에서 가능하며 각 작업 시간을 짧게 나누어 수행한다.

그러면 여기서 동시성 문제가 무엇일까?

동시성 문제

동시성 문제란, "공유 자원에 대해 동시에 접근할 때에 발생할 수 있는 문제" 이다.
예를 들어 하나의 DB 데이터를 두 개의 스레드가 동시에 변경 요청 하는 등 상황이다.

사실.. 동시성 문제랑 병렬성 환경은 연관이 되어 있기도 하고, 요즘은 분산 서버로 구축되어 있기 때문에 따로 경계가 있지는 않다.

  • 동시성 문제 : 공유 자원에 대한 안전하지 않은 동시 접근
  • 병렬성 : 여러 작업이 물리적으로 동시에 수행되는 것

즉, 결국은 공유 자원에 동시에 접근하는 것이 문제이기 때문에 동시성과 병렬성 모두가 원인이 되며, 둘 다 동시성 문제를 야기하는 원인이 된다.

동시성 문제의 대표 사례

기본적으로 동시성 문제는 결국 공유 자원에 동시 접근할 때 발생한다.
이를 Race Condition 이라고 부르며, 가장 대표적인 사례를 은행에 돈을 넣거나 빼는 것이 있을 수 있다.

은행 계좌 입출금 사례

예를 들어 은행에 돈을 넣고 빼는 상황을 들 수 있다.

  1. 현재 10만원이 있음
  2. 3만원 입금 (A요청)
  3. 2만원 출금 (B요청)

이렇게 하면 본래는

10 + 3 - 2 = 11
요렇게 돼야 한다.

근데 입금과 출금이 비슷한 때에 수행된다면 어떻게 될까?

  1. A요청에서 현재 계좌 잔액 확인 (10만원)
  2. B요청에서 현재 계좌 잔액 확인 (10만원)
  3. A요청에서 입금 처리 진행 (10만원 + 3만원 = 13만원)
  4. B요청에서 출금 처리 진행 (10만원 - 2만원 = 8만원)
  5. 최종 처리 이후 금액 : 8만원

이런 식으로 공유 자원에 대해서 한꺼번에 접근을 하고 처리하면 문제가 발생할 수 있다.

해결 방법

기본적으로

  • DB 락(비관적 락, 낙관적 락)
  • Redis 분산락
  • Message Queue 활용한 순차 처리

정도가 있다.

DB락 - 비관적 락

간단히 말하자면 누군가가 특정 데이터를 선점중이면 다른 곳에서 접근 자체를 못하도록 막는 방법이다.

select ... for update

요런 식으로 트랜잭션을 걸 때에 exclusive lock을 걸어 다른 곳에서 접근을 아예 막는 것
이렇게 한다면

  1. A요청에서 현재 계좌 잔액 확인 후 lock
  2. B요청에서 현재 계좌 잔액 확인을 하려 하면 접근 불가
  3. A요청에서 입금 처리 진행 (10만원 + 3만원 = 13만원)

이런 식으로 처리가 가능하다.
어쨌든 B요청과 A요청이 같은 데이터를 변경할 수 있는 여지를 아예 막아 버릴 수 있음.

  • 장점
    • 딱 보면 알겠지만, 선점하고 있는 데이터에 대해 다른 곳에서의 접근 자체를 막기 때문에 정합성을 매우 강력하게 보장할 수 있다.
    • 같은 데이터에 여러 요청이 동시에 많이 발생하는 경우(충돌이 자주 발생하는 상황) 에 적합하다.
      • 뭔가 고려할 점이 적기 때문
  • 단점
    • exclusive lock 을 걸기 때문에 락 선점 시 다른 트랜잭션 성능 저하가 발생할 수 있다.
    • Deaklock 발생 가능성이 높다.

충돌이 많이 발생할 수 있고, 정합성이 중요한 경우 추천되는 방식이다.

DB락 - 낙관적 락

위의 비관적 락과는 다르게, 락을 걸지 않은 상태로 작업을 한 후에 데이터 업데이트 시점에 혹시 다른 트랜잭션이 이 데이터를 변경했는지 확인하는 방식이다.
이름이 낙관적 락인 이유는 "충돌 별로 없을거임" 이라고 가정하고 시작하기 때문

그래서 어떤 식으로 다른 트랜잭션에서 변경했는지 확인할 수 있을까?

  • 버전 번호 확인
  • 마지막 수정 타임스탬프 확인

결국은 그 공유 데이터에서 변경 여부를 확인할 수 있는 뭔가를 보면 된다.

  1. A요청에서 현재 계좌 잔액 확인 (10만원 - v1)
  2. B요청에서 현재 계좌 잔액 확인 (10만원 - v1)
  3. A요청에서 입금 처리 진행 (10만원 + 3만원 = 13만원 - v2로 변경)
  4. B요청에서 출금 처리 진행하려 했더니 버전이 다름(읽은건 v1인데 지금 버전이 v2)
  5. B요청에 대해 재시도하거나 실패 처리

즉, Java Atomic class 의 CAS 방식과 유사하다고 생각하면 이해가 쉬울 것이다.
이거 바꿔도 되나? 확인 후 되면 변경같은 느낌

  • 장점
    • exclusive lock이 걸리지 않기 때문에 비관적 락에 비해 동시 처리 시 성능 저하가 적다.
  • 단점
    • 근데 같은 공유데이터에 접근이 많다면(충돌이 많다면) 재시도가 자주 발생하고, 이 때에 성능이 저하될 여지가 크다.

요 방식은 읽기가 많고 충돌 가능성이 낮은 경우에 조금 더 적합하다 볼 수 있다.

Redis 분산 락

여러 서버가 동시에 하나의 DB에 접근하려 할 때, 그 DB 앞에 Redis 를 두고 키를 선점한 친구가 접근할 수 있도록 하는 방식
요 방식은 기본적으로 Redis 가 싱글 스레드 기반이기 때문에 SETNX같은 연산이 연산의 원자성을 보장한다.
즉 한번에 하나의 요청만 처리하기 때문에 활용 가능

즉, 접근하고자 하는 공유 자원의 key가 같다면 앞의 처리가 완료되지 않았다면 뒤에 요청은 접근이 불가능한 것을 이용한 방법이다.

  1. A요청에서 현재 계좌 잔액 확인 (10만원 - key1)
  2. B요청에서 현재 계좌 잔액 확인을 위해 접근하려 했으나 선점으로 접근 불가 (key1 사용중)
  3. A요청에서 입금 처리 진행 (10만원 + 3만원 = 13만원 -> key1 해제)
  4. B요청에서 현재 계좌 잔액 확인 (13만원 - key1)
  5. 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 설정이 필요함
    • 네트워크 지연 발생 시 락의 유효성이 깨질 가능성도 있음

Message Queue 활용한 순차 처리

다른 방식과 달리, 동시 요청을 그냥 메시지 큐에 넣어 얘가 순차적으로 처리하도록 유도한다.
Kafka나 RabbitMQ 등을 활용해서 요청을 메시지로 저장한 후 워커 스레드가 하나씩 순차적으로 처리하는 방식

  1. A요청으로 3만원 입금해 달라고 메시지 전송
  2. B요청으로 2만원 출금해 달라고 메시지 전송
  3. 워커가 A요청 처리(10만원에서 3만원 입금 완료)
  4. 워커가 B요청 처리(13만원에서 2만원 출금 완료)

결국은 요청에 대한 처리를 워커가 하는 것이다.

  • 장점
    • 애플리케이션 서버와 실제 작업 처리 로직을 분리할 수 있다(시스템 결합도가 낮아짐)
    • 공유 자원에 대한 동시 접근 자체를 아예 피할 수 있다.
    • 요청이 급증해도 시스템이 안정적으로 동작 가능하다.
    • 메세지 큐를 사용하고 시스템 결합도가 낮기 때문에 작업이 실패해도 재시도가 용이하다.
      • Kafka나 RebbitMQ 등은 보통 메시지를 메모리뿐 아니라 디스크에도 저장하기 때문에... 처리되지 않은 메시지가 큐에 남아있으므로
  • 단점
    • 어쨌든 큐를 거치고 읽어와야 하므로 지연이 발생한다.
    • 시스템 큐를 하나 더 운영해야 한다.

이 방식은 실시간성이 엄청 잘 보장되어야 하지는 않지만, 안정적인 처리가 필요하고 트래픽 변동이 큰 작업에 용이하다.

반응형