본문 바로가기
실습/리눅스 서버 + 스프링 부트

[JAVA] 동시성있는 변수를 사용합시다.

by 이민우 2024. 11. 6.
728x90
반응형

옛날부터 포스팅하고 싶은 내용이 있었는데,

 

조금 귀찮기도 하고 너무 당연한 거라서 굳이 하지 않고 있었다.

 

그런데 바로 직전에 API에 여러 개의 요청을 동시에 보내는 테스트 코드도 작성했고 하니

이어서 계속해서 포스팅하고 싶었던 내용을 작성해볼까 한다.

 

동시성을 제어하는 변수

 

주제는 제목에서 알 수 있듯 "동시성" 이다.

 

당연한 말이지만 우리가 만드는 어플리케이션은 단일 사용자를 위한 것이 아니다. 다수의 사용자를 위한 것이고, 그러니 다수의 사용자가 오차가 거의 없이 동시에 어플리케이션에 접근하는 것을 유의해서 개발을 수행해야 한다.

 

그런 것에 개념이 없던 시절이 있었다. 그 때 아래와 같은 요구사항이 있었다.

 

대충 설명해보자면 아래와 같다.

  • A 서비스에는 B, C 메소드가 있다.
  • 누군가 B 메소드를 호출할 경우 B 메소드의 호출이 끝날 때까지 누구도 A 서비스 내에 함수를 호출할 수 없다.

 

위 요구사항을 구현하기 위해 간단하게 코드로 락을 구현했고, 아래와 같이 코드를 짰었다.

* 테스트용이니 굳이 서비스 클래스까지 구현하지 않고 컨트롤러만 구현한다.

 

ConcurrencyController.java

package com.mwlee.thymeleaftest;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
@RequestMapping("/test")
public class ConcurrencyController {

	private static Boolean isLock = false;

	/**
	 * Lock 걸기
	 */
	private void lock() {
		checkIsLock();
		isLock = true;
	}
	
	/**
	 * Lock 풀기
	 */
	private void unlock() {
		isLock = false;
	}
	
	/**
	 * Lock 상태일 때 대기
	 */
	private void checkIsLock() {
		while(isLock) {
			// lock 상태일 경우 풀릴 때까지 무한 대기
		}
	}
	
	@GetMapping("/B")
	public String BMethod(String str) throws InterruptedException {
		log.info("{} Called {} Method", str, "B");
		
		// 다른 메서드들이 접근하지 못하도록 Lock 걸기
		lock();
		// 로직이 10초 후에 끝난다고 가정.
		Thread.sleep(10*1000);
		// Lock 풀기
		unlock();

		log.info("{} Called {} Method Done.", str, "B");
		
		return "B";
	}	
	
	@GetMapping("/C")
	public String CMethod(String str) {
		log.info("{} Called {} Method", str, "C");
		// Lock 상태일 경우 대기
		checkIsLock();

		log.info("{} Called {} Method Done.", str, "C");
		
		return "C";
	}
	
}

 

간단하게 설명하면, lock이라는 변수를 하나 설정하고 true일 때만 접근 가능하도록 한 컨트롤러이다.

 

완료되었다면 간단하게 직전 포스팅을 참고해서 테스트 코드를 만들어보았다.

package com.mwlee.thymeleaftest;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

@AutoConfigureMockMvc
@SpringBootTest
class ConcurrencyTestApplicationTests {

	@Autowired
    private MockMvc mockMvc;
	
	@Test
	void contextLoads() {		
		String apiSet = "/test/%s?str=%d";

		// 동시에 50개의 요청을 보내기 위해 50개의 스레드풀 작성
		int targetCall = 50;
		
		ExecutorService executorService = Executors.newFixedThreadPool(targetCall);
		List<Callable<Void>> tasks = new ArrayList<>();
		
		for(int i=1; i<=targetCall; i++) {
			
			// 아래 tasks.add 람다에 final만 들어가서 final로 선언
			final String targetApi; 

		    // 20 B 메서드를 호출하고 그 외는 C 메서드 호출
		    if (i == 20) {
		        targetApi = String.format(apiSet, "B", i);
		    } else {
		        targetApi = String.format(apiSet, "C", i);
		    }

		    tasks.add(() -> {
		    	mockMvc.perform(get(targetApi))
		    	.andExpect(status().isOk());
		    	return null;
		    });
		}
		

		try {
			// 모든 작업을 병렬로 실행하고 결과 대기
	        List<Future<Void>> futures = executorService.invokeAll(tasks);
	
	        for (Future<Void> future : futures) {
	        	// 예외 발생 시 처리
	            future.get();
	        }
		}
		catch(Exception e) {
			e.printStackTrace();
		}
		finally {
			executorService.shutdown();
		}
	}

}

 

만약 저 코드를 돌리면 어떻게 될까?

 

이론상으로 봤을 때는 B 메서드를 호출하는 20번째 Request 이후로 들어오는 Request들은 잠시 멈춘 후 20번째 Request가 끝나는 10초 뒤에 다시 실행되기 시작해 반환될 것이다.

 

그러면 예상대로 될까? 로그를 보자.

 

20번째 메서드가 끝이 났으나, 그 후로 아무리 기다려도 다른 Request들이 접근하지 못했다.

 

이는 Boolean이 동시성을 보장하지 못해, 비록 B 메서드는 lock 변수를 false로 바꿔놓았으나, 다른 Request들에서는 lock 변수가 여전히 true인 탓에 Lock이 유지된 상태이기 때문이다.

 

동시성을 보장하려면 어떻게 해야할까? 일단 기본적인 접근으로 synchronized로 메소드를 선언하는 방법이 있다.

 

synchronized는 자바에서 동기화된 코드 블록을 정의하는데 사용한다. 모니터 락을 걸어 해당 코드 블록이 실행되는 동안 다른 스레드가 해당 코드 블록에 접근하지 못하도록 락을 설정하는 것이다.

 

즉 특정 스레드가 메서드에 접근했을 경우 다른 스레드의 접근을 막고 대기하게 만든다.

 

하지만 이 또한 해결책이 될 수는 없다. lock 변수는 하나인데, 그걸 접근하는 메서드는 세 개이니, 각 메서드의 동시성을 별도로 보장한다고 해도 그게 lock 변수에 대한 동시성이 될 수는 없다.

 

그렇다면 해결책은 메소드가 아니라 애초에 lock이라는 변수에 동시성을 제공하는 것이다.

어떻게 lock 변수에 동시성을 제공할까? 그야 쉽다. Boolean 이라는 변수 타입이 동시성을 제공하지 않아 발생하는 문제이니, 애초에 동시성을 제공하는 Boolean을 사용하면 된다.

 

동시성을 제공하는 Boolean은 AtomicBoolean이다. 기본적으로 일반 Boolean처럼 "=" 를 통해 선언할 수는 없으며, get, set 함수를 통해 사용해야 한다.

 

코드를 아래와 같이 AtomicBoolean을 사용하도록 수정해보았다.

package com.mwlee.thymeleaftest;

import java.util.concurrent.atomic.AtomicBoolean;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
@RequestMapping("/test")
public class ConcurrencyController {

	private static AtomicBoolean isLock = new AtomicBoolean(false);

	/**
	 * Lock 걸기
	 */
	private void lock() {
		checkIsLock();
		isLock.set(true);
	}
	
	/**
	 * Lock 풀기
	 */
	private void unlock() {
		isLock.set(false);
	}
	
	/**
	 * Lock 상태일 때 대기
	 */
	private void checkIsLock() {
		while(isLock.get()) {
			// lock 상태일 경우 풀릴 때까지 무한 대기
		}
	}
	
	@GetMapping("/B")
	public String BMethod(String str) throws InterruptedException {
		log.info("{} Called {} Method", str, "B");
		
		// 다른 메서드들이 접근하지 못하도록 Lock 걸기
		lock();
		// 로직이 10초 후에 끝난다고 가정.
		Thread.sleep(10*1000);
		// Lock 풀기
		unlock();

		log.info("{} Called {} Method Done.", str, "B");
		
		return "B";
	}	
	
	@GetMapping("/C")
	public String CMethod(String str) {
		log.info("{} Called {} Method", str, "C");
		// Lock 상태일 경우 대기
		checkIsLock();

		log.info("{} Called {} Method Done.", str, "C");
		
		return "C";
	}
	
}

 

이제 코드를 실행시켜보자.

 

B Method가 끝나고 난 후에 밀렸던 C Method 들이 끝이나며, 정상적으로 동시성이 제어되었음을 확인할 수 있다.

 

이처럼 다중 사용자가 사용하는 동시성은 매우 중요하다. 하지만 기본적으로 학교나 학원에서 가르치는 변수들은 동시성까지는 보장하지 않으며, 현업에서 저렇게 사용했다가는 괜한 낭패를 보게될 수 있다.

 

고로 다중 사용자가 접근하는 변수는 반드시 동시성이 포함된 변수를 사용하자. 그리고 예시는 아래와 같다.

데이터 구조 / 클래스 동시성 보장 클래스
Boolean java.util.concurrent.atomic.AtomicBoolean
Integer java.util.concurrent.atomic.AtomicInteger
Long java.util.concurrent.atomic.AtomicLong
Reference<T> java.util.concurrent.atomic.AtomicReference
Integer[] java.util.concurrent.atomic.AtomicIntegerArray
Long[] java.util.concurrent.atomic. AtomicLongArray
Reference<T>[] java.util.concurrent.atomic.AtomicReferenceArray
Map<K, V> java.util.concurrent.ConcurrentHashMap
java.util.concurrent.ConcurrentSkipListMap
List<E> (ArrayList) java.util.concurrent.CopyOnWriteArrayList
(LinkedList) java.util.concurrent.LinkedBlockingDeque
Set<E> java.util.concurrent.CopyOnWriteArraySet
Queue<E> (논블로킹) java.util.concurrent.ConcurrentLinkedQueue
(블킹) java.util.concurrent.LinkedBlockingQueue

 

728x90
반응형