[개발지식] 동일 사용자 동일 서비스 호출에 대한 동시성 제어 - 다양한 멀티스레드 제어 방법과 각각의 장단점

Hyo Kyun Lee·2025년 3월 27일
0

개발지식

목록 보기
78/84

1. 개요

동일한 사용자가 동일한 서비스를 여러번 호출하는 멀티스레드 환경에서 동시성 제어를 하기 위한 다양한 방법을 기록하였다.

2. 멀티스레드 동시성 제어 방법

먼저 멀티스레드의 환경을 구성하기 위해

  • ExecutorService를 3000번 호출하면서 static 변수의 결과와 기대값을 비교하였다.
  • 멀티스레드의 동시성 제어 테스트에 집중하여, DB와의 상호작용을 통한 확인이 아닌 단순 static 변수의 누적 증감값을 활용하여 동시성 제어 테스트를 진행하였다.

2-1. synchronized를 적용한 메소드

메소드에 synchronized를 적용하여, 메소드를 호출한 pointService 객체에 동기화를 적용한다.

/*
	 * 동시성 테스트 검증을 위한 서비스
	 * 나-1 : 충전 서비스 전체를 synchronized 적용한다.
	 * */
	public synchronized long syncCharge1(long id, long amount) {	
		//메소드 실행시간 확인을 위함
		long startTime = System.nanoTime();
		
		//static field를 통해 결과 누적
		POINT = POINT + amount;
		
		//메소드 실행시간 확인을 위함
		long endTime = System.nanoTime();
				
		//메소드 실행시간
		log.info("case 1 실행 시간 : {}", String.valueOf((endTime-startTime)/10L));
		return POINT;
	}

2-2. synchronized를 적용한 로직 블록

로직 블록에 synchronized를 적용하며, 알고보니 위의 방법과 동일하여 같이 작성한다.

/*
	 * 동시성 테스트 검증을 위한 서비스
	 * 나-2 : 충전 서비스의 충전 로직 블록을 synchronized 적용한다.
	 * */
	public long syncCharge2(long id, long amount) {	
		//메소드 실행시간 확인을 위함
		long startTime = System.nanoTime();
		//(*검증로직반영) static field를 통해 결과 누적
		synchronized (this) {
			POINT = POINT + amount;
		}
		
		//메소드 실행시간 확인을 위함
		long endTime = System.nanoTime();
				
		//메소드 실행시간
		log.info("case 2 실행 시간 : {}", String.valueOf((endTime-startTime)/10L));
		return POINT;
	}

메소드에 synchronized를 적용하는 방법과 로직 블록에 synchronized를 적용하는 방법은 모두 결국 이를 호출하는 pointService 객체에 동기화를 적용하여 atomic한 연산을 할 수 있도록 하는 방법이다.

즉, 객체를 임계영역으로 설정하여 멀티스레드가 객체를 사용하지 못하도록 한다.

synchronized는 이전부터 java에서 성능적인 문제가 많아 잘 사용하지 않고, 사용을 지양해야 하는 방법으로 lock(임계기간)의 설정을 사용자가 하지 못하고 객체 임계기간이 모두 종료되는 것을 모든 스레드가 순차적으로 기다려야 하므로 비효율적인 방법이다.

2-3. concurrentHashMap

위에서의 synchronized의 비효율성을 보완하기 위해 스레드의 임계기간 설정을 사용자가 제어할 수 있도록 Thread safe한 concurrentHashMap을 이용해보았다.

/*
	 * 동시성 테스트 검증을 위한 서비스
	 * 나-3 : concurrentHashMap의 동기화 자료구조의 특징을 활용한다.
	 * */
	public long syncCharge3(long id, long amount) {	
		//메소드 실행시간 확인을 위함
		long startTime = System.nanoTime();
		
		/*
		 * cocurrentHashMap이 보장해주는 동기화 처리 함수를 충전 연산에 활용한다.
		 * 값이 있으면 누적
		 * 값이 없으면 POINT + amount
		 * */
		if(concurrentHashMap.containsKey(id))
			concurrentHashMap.put(id, concurrentHashMap.get(id) + amount);
		else
			concurrentHashMap.put(id, POINT + amount);
		
		//메소드 실행시간 확인을 위함
		long endTime = System.nanoTime();
				
		//메소드 실행시간
		log.info("case 3 실행 시간 : {}", String.valueOf((endTime-startTime)/10L));
		return concurrentHashMap.get(id);
	}

간단하게, concurrentHashMap의 put은 스레드별 atomic 연산을 보장하므로 이 연산에 동시성 연산을 적용하였다(POINT + amount 부분).

또한 concurrentHashMap의 key값에 사용자 id값을 적용, HashMap값에 누적할 수 있도록 조치하였고 동시성 연산이 종료한 후에는 이 값을 초기화하여 사용자 측에서 임계기간을 정할 수 있다는 의도를 넣어보기도 하였다.

이 경우, 확실히 동시성 연산(point + amount)을 하는 부분을 concurrentHashMap의 thread safe 연산 성질을 이용하여, 불필요한 임계영역 설정이나 비효율성을 제거하였다.

시간적인 소요는 얼마나 편차가 있을지 비교해보았는데, concurrentHashMap의 연산에 많은 시간을 소요하는 것처럼 보였으나 nanosec에 가까운 비교라 사실상 차이가 없는 것으로 생각하고 기존 synchronized보다 더 효율적인 방법이라 판단하였다.

2-4. ReentranceLock

기존 synchronized의 비효율적인 동시성 제어 방법을 대체하기 위해 java 1.8부터 제공하는 방법으로, 동일하게 객체단위의 임계영역 설정을 하지만 사용자가 lock의 잠금과 해제를 제어할 수 있다.

public class ThreadLock {
	//charge lock을 static화 하여 charge lock을 사용하는 모든 스레드의 전역적 적용을 가능하도록 함
	public final static ReentrantLock chargeLock = new ReentrantLock();
}

ReentranceLock도 임계영역 설정이 객체 단위이므로, 멀티 스레드가 서로 다른 lock 객체를 공유하지 않도록 하기 위해 static 객체로 구성해주었다.

lock객체를 만들기 위한 ThreadLock을 설정해주어, 관심사 분리를 해주었다.

/*
	 * 동시성 테스트 검증을 위한 서비스
	 * 나-4 : ReentranceLock을 활용하여 스레드 락 동기화를 적용한다.
	 * */
	public UserPoint syncCharge4(long id, long amount) throws InterruptedException {	
		//메소드 실행시간 확인을 위함
		long startTime = System.nanoTime();
		
		/*
		 * atomic charge를 위해 전역 chargeLock을 lock한다.
		 * POINT 연산을 완료한 이후 chargeLock을 unlock한다.
		 * */
		ThreadLock.chargeLock.lock();
		POINT = POINT + amount;
		ThreadLock.chargeLock.unlock();
		
		//메소드 실행시간 확인을 위함
		long endTime = System.nanoTime();
				
		//메소드 실행시간
		log.info("case 4 실행 시간 : {}", String.valueOf((endTime-startTime)/10L));
		return userPointTable.insertOrUpdate(id, amount);
	}

동시성 제어가 필요한 연산 전후에 lock 잠금 및 해제를 진행하여, 멀티 스레드 환경에서 연산이 atomic하게 이루어질 수 있도록 해주었다.

위에서 기술하였듯이 모든 멀티스레드가 하나의 동일한 lock 객체를 임계자원으로 가질 수 있도록 하였고, concurrentHashMap만큼 직관적이고 사용자가 제어할 수 있는 lock 설정 등을 기대할 수 있었다.

3. 참고자료

ReentracneLock 자료

ReentranceLock을 static으로 사용하였을때

Atomic 변수(Thread safe 증감)

ExecutorService 멀티스레드 테스트

0개의 댓글