6장 동시성, 데이터가 꼬이기 전에 잡아야 한다.

JUN·2025년 12월 1일

6장 동시성, 데이터가 꼬이기 전에 잡아야 한다.

서버와 동시실행

서버가 동시에 여러 클라이언트의 요청을 처리하는 방식에는 크게 두가지가 있다.

  1. 클라이언트 요청마다 스레드를 할당해서 처리
    • 이 경우에는 경쟁 상태 등 여러 동시성 문제가 발생 할 수 있다.
  2. 비동기 IO ( 또는 논블로킹 IO) 를 사용해서 처리
    • 해당 방법을 사용하더라도 단일 스레드 하나만을 사용하는 경우는 드물다.

→ 어떤 방식이던 서버는 동시 실행이 기본이므로 동시성 문제를 염두해두고 설계하자

경쟁상태

경쟁 상태는 여러 스레드가 동시에 공유 자원에 접근 할 때 접근 순서에 따라 결과가 달라지는 상황을 말한다.

→ 동시성 문제는 프로세스 수준, DB 수준에서 동시에 발생한다. 두 가지의 경우 어떻게 접근해야하는지 알아보자.

프로세스 수준에서의 동시 접근 제어

Lock 을 이용한 접근 제어

Lock 을 사용하면 공유 자원에 접근하는 스레드를 하나로 제한할 수 있다.

로직은 다음과 같다.

  1. 잠금을 획득

  2. 공유 자원에 접근 ( 임계 영역 )

    임계영역이란?
    임계영역은 동시에 둘 이상의 프로세스나 스레드가 접근하면 안되는 공유자원에 접근하는 코드 영역

  3. 잠금을 해제함.

  4. JAVA 의 HashMap 은 다중 스레드 환경에서 안전하지 않다. ( 동시에 여러 스레드가 put() 과 같은 메서드를 호출하면 데이터의 유실이 일어날 수 있으므로)

  5. 이런 문제를 방지하려면 ReentrantLock 을 사용해서 임계 영역에 한번에 한 스레드만 접근할 수 있도록 제한하여야 한다.

  6. synchronized 또한 고려할 수 있다. @synchronized 하면 해당 어노테이션이 있는 코드 블록을 제어하고 코드 블록이 끝나면 자동으로 잠금을 풀어주기 때문이다.

  7. 하지만 ReentrantLock 은 잠금 획득 대기 시간을 지정하는 기능 등의 기능이 있따.

  8. 또한 JAVA 21 버전에 가상 스레드가 ReentrantLock 만 지원하고 synchronized는 자바 24 버전부터 지원한다.

  9. 다만 두 방식을 섞어 쓰지는 말자. 가능하면 하나만 사용하자

동시 접근 제어를 위한 구성 요소

  1. ReentrantLock 은 한 번에 하나의 스레드만 잠금을 할 수 있다.
  2. 잠금 외에도 동시 접근을 제어하는 수다능로 세마포어와 읽기 쓰기 잠금이 있다.

세마포어

  1. 세마포어는 동시에 실행할 수 있는 스레드 수를 잠금한다.
    1. 이진 세마포어와 counting 세마포어가 있는데 이진 세마포어는 동시 접근할 수 있는 스레드가 1개, 계수 세마포어는 지정한 수만큼 동시에 접근 가능
  2. 자바 세마포어 구현체는 퍼밋이라고 표현한다.
  3. 로직은 다음과 같다
    1. 세마포어에서 퍼밋 획득 ( 허용 가능 숫자 1 감소 )
    2. 코드 실행
    3. 세마포어에 퍼밋 반환 ( 허용 가능 숫자 1 증가 )
  4. 세마포어의 남아있는 퍼밋 개수가 0인 상태에서 퍼밋을 획득하려는 스레드는 다른 스레드가 퍼밋을 반환할 때 까지 대기한다.

읽기 쓰기 잠금

읽기 쓰기 잠금 (Read-Write Lock)이란?

일반적인 잠금(Lock이나 synchronized)은 공유 자원에 접근하는 스레드를 한 번에 단 하나로 제한합니다. 이 방식은 데이터의 무결성(integrity)을 보장하지만, 데이터를 변경하지 않는 단순한 읽기 작업마저도 동시에 수행될 수 없게 합니다.

문제점: 만약 데이터에 대한 쓰기(변경) 빈도보다 읽기(조회) 빈도가 훨씬 높다면, 한 번에 하나의 스레드만 읽기를 수행할 수 있으므로 전체적인 읽기 성능이 떨어지는 문제가 발생합니다.

해결책: 읽기 쓰기 잠금은 이러한 성능상의 단점을 없애기 위해 고안된 동시 접근 제어 방식입니다. 이 잠금은 쓰기(데이터 변경)읽기(데이터 조회) 작업을 분리하여 동시성을 관리합니다.

읽기 쓰기 잠금의 핵심 특징 (성능 개선 원리)

읽기 쓰기 잠금은 다음과 같은 규칙을 가집니다:

  1. 쓰기 잠금 (Write Lock):

배타적 접근: 한 번에 하나의 스레드만 쓰기 잠금을 획득할 수 있습니다. (데이터를 변경할 때는 오직 혼자서만 작업해야 데이터가 꼬이지 않기 때문입니다.)

  1. 읽기 잠금 (Read Lock):

공유 접근: 한 번에 여러 스레드가 읽기 잠금을 획득할 수 있습니다. (데이터를 조회만 하는 경우 여러 명이 동시에 봐도 문제가 되지 않습니다.)

  1. 상호 배제 (쓰기/읽기 충돌 규칙):

◦ 어떤 스레드가 쓰기 잠금을 획득한 상태라면, 이 잠금이 해제될 때까지 다른 스레드는 읽기 잠금을 구할 수 없습니다. (쓰는 동안에는 읽을 수 없음).

◦ 어떤 스레드라도 읽기 잠금을 획득한 상태라면, 모든 읽기 잠금이 해제될 때까지 쓰기 잠금을 구할 수 없습니다. (읽는 동안에는 쓸 수 없음).

이러한 특징 덕분에 읽기 쓰기 잠금을 사용하면 동시에 여러 스레드가 데이터를 읽을 수 있게 되어, 일반 잠금을 사용했을 때 발생하는 읽기 성능 저하 문제를 완화할 수 있습니다

원자적 타입

  1. Lock 을 사용하면 카운팅 변수 와 같은 공유 자원에 동시성 문제를 방지시킬 수 있다.
  2. 하지만 이를 사용하면 단일 스레드가 해당 자원을 점유하는 동안 다른 스레드가 대기하기에 CPU 효율이 낮아진다.
  3. 이를 해결하기 위한 방법으로 AtomicInteger, AtomicLong, AtomicBoolean 과 같은 원자적 타입을 사용하면 동시성 문제 없이 여러 스레드가 공유하는 데이터를 변경할 수 있다.
  4. ㅇ이러한 타입은 내부적으로 CAS( 비교 후 교체 연산) 을 사용한다.
  5. 이를 통해 스레드를 멈추지 않고도 다중 스레드에서 동시성 문제를 해결할 수 있다.

동시성 지원 컬렉션

스레드에 안전한지 않은 컬렉션을 여러 스레드에 공유하면 동시성 문제가 발생한다.

자바에서는 Collections.synchronized 와 같이 동기화된 컬렉션을 생성하는 메서드를 제공한다.

단, 자바 23 또는 이전 버전 기준으로 가상 스레드를 사용한다면 해당 동기화 컬렉션 변환 메서드를 사용하면 안된다. 내부적으로 synchronized 를 사용해서 동시 접근을 동기화 하기 때문이다. → 성능에 문제가 생길 수 있다.

다른 방법으로는 동시성 자체를 지원하는 컬렉션 타입을 사용하는 것이다.

ConcurrentHashMap 과 같은 타입은 잠금 범위를 최소화하므로 키의 해시 분포가 다르고 동시 수정이 많으면 더 나은 성느을 제공한다.

DB 와 동시성

선점 잠금

  • 데이터에 먼저 접근한 트랜잭션이 잠금을 획득하는 방식
    • 분산 잠금? 분산 잠금은 여러 프로세스가 도시에 동일한 자원에 접근하지 못하도록 막는 방법 보통 레디스를 활용해서 구혀낳ㄴ다.

비선점 잠금

  • 명시적으로 잠금을 사용하지 않고 데이터를 조회하려는 시점에서의 값과 수정하려는 시점에서의 기준값(버전 컬럼)이 비교하는 방식으로 동시성 문제를 처리하는 방식
    • 잠금을 획득하기 위한 대기 시간이 없기 때문에 실패할 경우 사용자에게 더 빠르게 결과를 응답할 수 있음

외부 시스템과 연동해야한다면?

  • 선점 잠금을 고려하는게 좋음
  • 만약 비선점 잠금을 사용해야한다면

증분 쿼리

잠금 사용 시 주의사항

1. 잠금 해제하기

잠금을 해제 하지 않으면 잠금을 시도하는 스레드가 무한정 대기하게 되므로 try-finally 형태의 코드를 사용하여 작므을 해제하는 코드를 넣는 습관을 들이자

2. 대기 시간 정하기

잠금 해제 시간을 정하면 사용자에게 잠금 획득 실패 에러를 빠르게 전달할 수 있다.

3. 교착 상태 피하기

대기 시간은 교착 상태를 피하는데도 효과를 볼 수 있다.

또한 지정한 순서대로 잠금을 획득하게 할 수 도 있다. 모든 스레드가 트랜잭션에서 같은 순서로 잠금을 획득한다는 규칙을 만들면 교착상태를 피하는데 도움을 준다.

  • 라이브락 : 아무것도 하지 않고 대기하는 교착 상태와는 다르게 지속적으로 대기상태를 빠져나가기 위해 진행되나 그것이 교착을 만들어내는 것 → 중재자 우선순위를 둬서 해결 가능
  • 기아 상태
    • 우선순위를 두면 우선순위가 낮은 작업이 지속적으로 순위가 밀리므로 기아상태에 빠짐 → 작업의 우선순위를 높이거나 여러 프로세스나 스레드가 공유하는 자원을 독점하는 시간에 제한을 두어 가능한 작업을 실행할 수 있도록 해야함

단일 스레드로 처리하기

동시성이 발생하는 이유는 여러 스레드가 동시에 동일 자원에 접근하기 때문이므로 차라리 한 스레드만 상태를 관리하게 하고 그 스레드를 사용할 수 있는 큐를 만드는 것이 더 나을 수 있음

두 스레드의 데이터 공유가 필요하면 콜백이나 큐와 같은 수단을 이용해서 데이터 복제본을 공유하게 할 수 있음

→ 성능에 문제가 될 수 있으나 임계 영역의 실행 시간이 짧고 동시 접근 스레드 수가 적을수록 잠ㄱ믕르 사용하는 구현 성능이 좋을 가능성이 높음

왜냐하면 큐나 채널을 사용하는데 드는 시간보다 잠금 획득과 해제에 드는 시간이 더 짧기 때문에

하지만 동시에 실행되는 작업이 많고 임계 영역의 실행 시간이 길어지면 큐나 채널을 이용한 방식이 더 비슷하거나 나은 성능을 낼 가능성이 있음

profile
순간은 기록하고 반복은 단순화하자 🚀

0개의 댓글