Spring

Java, Database, Synchronized 등 다양한 동시성 제어 방법 in Java (1)

snape 2023. 3. 21. 14:35

동시성 문제는 하나의 자원에 2개 이상의 스레드 혹은 세션에서 동시에 데이터를 제어할 때 나타난다. 

이 문제에 대한 가장 근본적인 해결 방법은 데이터에 순차적으로 접근할 수 있도록 하는 것이라 생각한다.

 

그렇다면 그 방법에는 무엇이 있을까?

  1. Synchronized
  2. 데이터 베이스에서 Lock
  3. Java에서 Lock 
  4. Atomic 변수
  5. Thread-safe collections
  6. Thread-local variables

위의 5가지 정도로 정리가 되는 것 같은데, 간단하게 설명 해보면 다음과같다.

 

1. Synchronized


Synchronized를 활용한 동시성 제어는 가장 기본적인 동시성 제어 방법이라 생각한다. Java에서 기본적으로 제공해주는 기능으로 사용법이 쉽다. 멀티 스레드 환경에서 자원(데이터)에 대한 접근을 싱글 스레드만 허용함으로써 동시성으로 인한 문제를 방지한다.

하지만 서버가 1개가 아닌 2개 이상일 경우, Synchronized는 각 프로세스의 동시 접근 제어만을 해주기 때문에 다른 서버에서 데이터에 대한 접근을 막을 수 없다. 따라서 데이터에 대한 수정이 의도치 않게 일어날 가능성이 존재한다.

 

 

2. Database의 Lock 이용


1번의 방법보다는 좀 더 유연하고 강력한 동시성 제어를 지원한다. 

데이터베이스에서 지원되는 Lock을 이용하는데, 이것은 각 데이터베이스마다 기능이 다르다.

 공통적으로 해당되는 개념으로는 Shared Lock(공유락), Exclusive Lock(베타락)이 있다.

공유락은 Read Lock으로도 불리며, 데이터를 읽을 때 사용된다. 또한 여러 사용자가 하나의 데이터를 읽을 수있는데 이것은 공유락끼리만 가능하다.

 

베타락은 Write Lock으로도 불리며, 데이터를 변경하고자 할 때 사용되며, 트랜잭션이완료될 떄까지 유지된다.

베타락은 락이 해제될 때까지 다른 트랜잭션(읽기 포함)이 해당 리소스에 접근할 수 없다.   

 

 

3. Java에서 Lock 구현


Java에서 Lock은 멀티스레드 환경에서 동기화를 달성하기 위해 사용되는 객체다. 

  1. ReentrantLock
  2. ReadWriteLock

크게 위의 두 가지 유형으로 구성되어 있다.

ReentrantLock은 내부적으로 동기화 매커니즘을 사용하여 임계 영역을 보호한다. ReentrantLock 객체는 lock()과 unlock() 메서드를 통해 사용된다. lock() 메서드를 호출하면 스레드는 해당 임계 영역에 대한 Lock을 획득하고 다른 스레드가 이를 가져올 수 없다. unlock() 메소드를 호출하면 스레드는 임계 영역의 Lock을 해제한다. ReentrantLock은 ReentrantReadWriteLock과 마찬가지로 공정 잠금 및 비공정 잠금을 제공한다.

 

ReadWriteLock은 읽기 작업과 쓰기 작업을 분리하는 동시성 제어 매커니즘을 제공한다. 읽기 작업은 상호 배제되지 않으며 여러 스레드가 동시에 읽을 수 있다. 쓰기작업은 임계 영역에 대한 배타적 액세스를 보장한다. ReadWriteLock은 ReentrantLock과 마찬가지로 lock()과 unlock() 메서드를 제공한다.

 

Lock 인터페이스는 synchronized보다 더 세밀한 동시성 제어를 제공하며, 예외 처리 및 인터럽트 처리가 더 유연하다. 다만 잘못 사용하면 데드락 같은 동기화 문제가 발생할 수 있으므로 조심해야 한다. 

 

 

4. Atomic 변수


Java에서 Atomic 변수는 멀티스레드 환경에서 안전하게 공유 변수에 접근하기 위한 동시성 제어 매커니즘이다. 

Atomic 변수는 원자적(atomic) 연산을 지원하므로, 여러 스레드가 동시에 값을 변경하더라도 올바른 결과를 보장한다. 일반적으로 int, long, boolean, reference 등의 기본 자료형에 대한 원자적 연산을 지원한다.

장점

  • 코드의 가독성이 좋다
    • Atomic 변수를 사용하면 synchronized 혹은 Lock과 같은 명시적인 동기화 코드 없이도 안전하게 공유 변수에 접근 가능하다.   
  • 성능이 빠르다.
    • 동기화를 위한 Lock을 사용하지 않으므로 Lock-free 프로그래밍이 가능하며, 동기화 오버헤드가 없어서 성능이 더 빠르다. 
      하지만 Lock-free 프로그래밍은 코드의 복잡도가 높아지고 디버깅이 어렵다.

단점

  • 멀티스레드 환경에서 발생할 수 있는 경쟁 상태에 대해 고려해야 한다. 또한 원자적 연산을 제공하는 기본자료형에 대해서만 사용할 수 있으므로, 다른 자료형에 대해서는 synchronized나 Lock을 사용해야 한다. 

 

Java에서 제공하는 Atomic 변수

  • AtomicInteger, AtomicLong: int, long 값을 저장하는 Atomic 변수
  • AtomicBoolean: boolean 값을 저장하는 Atomic 변수
  • AtomicReference: 객체 참조 값을 저장하는 Atomic 변수

 

 

5. Thread-safe collections


Java에서는 java.util.concurrent 패키지에 여러 동시성 제어용 컬렉션 클래스들이 포함되어 있다. 이러한 클래스들은 thread-safe 한 컬렉션을 제공하며, 멀티스레드 환경에서 동시성 제어를 보장한다. 대표적으로 다음과 같은 방법이 있다.

1. Synchronized Collentions

Java에서는 기본적인 컬렉션 클래스들을 synchronized 버전으로 제공한다. 이를 통해 여러 스레드에서 동시에 접근하더라도 하나의 스레드에서만 해당 컬렉션에 접근할 수 있도록 동기화를 제공한다.

List<String> synchronizedList = Collections.synchronizedList(new ArrayList<>());

 

2. ConcurrentHashMap

ConcurrentHashMap은 해시 테이블 구조를 가진 Map 컬렉션 클래스로, 여러 스레드에서 동시에 접근해도 안전하게데이터를 수정할수 있도록 구현되어있다.

ConcurrentMap<String, Integer> concurrentMap = new ConcurrentHashMap<>();

   

3. CopyOnWriteArrayList

CopyOnWriteArrayList는 ArrayList와비슷한 구조를 가진 list 컬렉션 클래스로, 쓰기 작업이 발생할 때마다 copy된 새로운 list를 생성한다. 따라서, 여러 스레드에서 동시에 접근하더라도 안전하게 데이터를 수정할 수 있다.

List<String> copyOnWriteList = new CopyOnWriteArrayList<>();

 

Thread-safe collections을 사용하면 멀티스레드 환경에서 안전하게 데이터를 공유할 수 있다. 하지만, 이러한 컬렉션 클래스들은 데이터를 동시에 수정하는 작업이 많을수록 성능이 떨어질 수있다. 따라서, 실제로는 데이터를 동시에 수정하는 작업이 적을 때에만 Thread-safe collections를 사용하는 것이 좋다.  

 

 

6. Thread-local variables


Thread-local 변수는 각각의 스레드에 대해 별도로 저장되는 변수로, 해당 스레드에서만 접근 가능한 변수를 말한다. Thread-local 변수는 멀티스레드 환경에서 동시성 제어를 위해 사용된다.

Thread-local 변수를 사용하면 동시에 여러 스레드에서 공유하는 객체를 각각의 스레드에서 별도로 관리할 수 있다. 이를 통해 여러 스레드에서 동시에 접근하더라도, 스레드 간의 데이터 불일치나 경쟁 상태와 같은 문제를 방지할 수 있다.

Thread-local 변수를 사용하기 위해서는 아래와 같은 단계를 거친다.

1. ThreadLocal 변수를 사용하여 Thread-local 변수를 정의한다.

ThreadLocal<String> threadLocal = new ThreadLocal<>();

 

2. ThreadLocal 클래스의 set() 메서드를 사용하여 Thread-local 변수에 값을 저장한다.

threadLocal.set("Hello, world!");

 

3. ThreadLocal 클래스의 get() 메서드를 사용하여 Thread-local 변수에서 값을 가져온다.

String value = threadLocal.get();

 

Thread-local 변수를 사용하여 동시성 제어를할 때는 주의할 점이 있다.

  • 스레드 간의 데이터 공유 불가능
    • Thread-local 변수는 각각의 스레드에서 별도로 저장되기 때문에, 스레드 간의 데이터 공유가 불가능하다. 따라서 여러 스레드에서 공유해야 하는 정보에 대해서는 Thread-local 변수를 사용할 수 없다.  
  • 메모리 누수에 대한 주의 필요
    • Thread-local 변수는 각각의 스레드에서 별도로 관리되기 때문에, 사용이 끝난 후에는 반드시 clear() 메서드를 호출하여 값을 제거해야 한다. 이를 하지 않으면, thread-local 변수가 계속해서 메모리에 쌓이면서 메모리 누수가 발생할 수 있다.

  

 

 

여기서 궁금한 점이 생긴다.

  1. Java의 Lock과 synchronized는 어떤 것이 다를까?
  2. Java의 Lock과 Database의 Lock은 어떻게 다를까?
  3. Java의 Lock과 Database의 Lock 중에서 어떤 것이 더 효과적일까? 

 

이러한 궁금증은 다음 글에서 더 공부해 보자.