스프링 자바 동시성 문제 - (1) List 와 Map

이진우·2024년 4월 8일
0

스프링 학습

목록 보기
29/41

찾아본 계기

ArrayListHashMap 모두 Thread safe 하지 않다는 글을 보았다.
이를 알았으니 테스트를 통해 이를 직접 확인 하고 어떤 경우에 ArrayList 와 HashMap 등을 구현체로 쓰는 것을 지양하고
어떤 경우에는 사용해도 되는지에 대해 알아보기 위해 이 글을 작성
해서
정리하려 한다.

개념 정리

동시성 문제

동시성 문제란 여러 쓰레드 혹은 여러 쓰레드가 같은 자원에 접근할 때 발생할 수 있고 그 결과로 데이터 정합성이 깨질 수 있다.

쓰레드

쓰레드는 개념적으로 프로그램 내에서 실행되는 프로그램 제어흐름(실행단위) 을 의미한다.

한 프로세스가 여러 쓰레드를 갖는 것을 다중 쓰레드 환경 이라고 하는데 우리가 흔히 톰캣 기반으로 스프링 부트를 세팅하면 다중 쓰레드 환경이 된다.

다중 쓰레드 환경은 프로세스 처럼 작업을 동시에 처리할 수 있어서 그 효율이 좋고, 자원을 공유하기에 쓰레드간 데이터 전송에 편리한 측면이 있다.

스프링부트 에서 쓰레드는 기본적으로 유저의 요청마다 할당 된다는 개념으로 이해할 수 있다.

스프링은 프로그램 실행에 필요한 쓰레드들을 미리 생성해 놓는다. 이를 쓰레드 풀이라고 지칭한다.

순서

  1. core size(IDLE) 만큼의 스레드를 할당
  2. 유저 요청이 들어오면 작업 큐에 담아 둔다.
  3. core size 만큼의 쓰레드 중 할당 가능한 쓰레드가 있다면 작업 큐에서 작업을 꺼내 쓰레드에 작업을 할당한다.
  • 유휴 상태의 쓰레드가 없는 경우: 작업은 작업 큐에서 대기
  • 그 상태 지속되어 작업 큐가 꽉차면 : 쓰레드를 새로 생성함
  • 다시 할당 가능한 쓰레드가 있으면 사용하고 작업큐가 꽉차면 새로 쓰레드를 생성하는 과정을 쓰레드 최대 사이즈(max size) 에 도달할 때까지 한다.

max size , 작업 큐의 크기, 할당 가능한 쓰레드의 갯수(core size) 는 application.yml 파일에서 조절이 가능하다.

server:
  tomcat:
    threads:
      max: 200 # 생성할 수 있는 thread의 총 개수
      min-spare: 10 # 항상 활성화 되어있는(idle) thread의 개수
    accept-count: 100 # 작업 큐의 사이즈

이렇게 말이다.

이렇게 설정을 안해주면 ServerProperties 클래스에서 Default 값에 의해 설정되고 이를 직접 확인할 수 있다.

위 Default 값은
처음에 10개의 쓰레드가 할당 되어 있는데 10개의 쓰레드가 다 차있고, 작업 큐 100개도 역시 다 차있다면 쓰레드를 새로 할당한다. 이 과정은 쓰레드가 최대 200개가 될 때까지 반복된다.

요약

유저가 요청을 보낼 때 작업이 쓰레드에 할당된다.
쓰레드를 통해 공유된 자원에 접근할 수 있고 이를 통해 데이터 정합성에 문제가 생기는 동시성 문제가 발생할 수 있다.

테스트 환경 구축

ListService

public class ListService {
      public static List<Integer> lists = new ArrayList<>();
      public static Map<Integer,Integer> map=new HashMap<>();
}

여러 쓰레드에서 접근할 수 있는 static 한 list 와 map 을 ListService 라는 클래스에 선언한다.

ListServiceTest

import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.Test;

class ListServiceTest {
    private static final int THREAD_COUNT=2000;


    @Test
    void addList() throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(10000);

        for(int i=0;i<10000;i++){
            executorService.execute(() -> {
                try {
                    ListService.lists.add(1);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        System.out.println("list의 size는 " + ListService.lists.size());

    }

    @Test
    void addMap() throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(10000);

        for(int i=0;i<10000;i++){
            int finalI = i;
            executorService.execute(()->{
                try{
                   ListService.map.put(finalI,2*finalI);
                }
                finally {
                    latch.countDown();
                }
            });
        }

        latch.await();


        System.out.println("map 의 size 는 "+ListService.map.size());

    }
}
  • THREAD_COUNT 의 변수를 통해 다중 쓰레드 환경으로 세팅한다.
  • ExecutorService 를 통해 ThreadPool 을 사용하고 쓰레드를 관리한다.
  • latch : latch.await() 는 latch의 countdown 값이 0이 된다면 쓰레드 수행
  • lists.size() 를 출력

기대하는 상황 및 실제 상황

list 의 size는 정상적인 상황이라면
만 번 list 에 저장을 했기 때문에 10000개가 되어야 한다.

또한 Map의 size 역시 정상적인 상황이라면
만 번 map 에 저장을 했기 때문에 10000개가 되어야 한다.

그래서 직접 실행을 해보면

size 가 10000개가 아니라 그 보다 작은 어떤 숫자이다.
이는 ArrayList와 HashMap 이 Thread safe 하지 않기 때문이다.
이런 상황이 실제 환경이라면 요청을 했는데 그 정보가 사라질 수 있다는 의미이다.

수정

ListService 수정

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;

public class ListService {
      public static List<Integer> lists = new CopyOnWriteArrayList<>();
      public static Map<Integer,Integer> map=new ConcurrentHashMap<>();
      
}

이제는 이와 같이 수정을 하고 테스트를 다시 돌려본다.

정상적으로 10000개가 list 와 map 모두 찍혀 있는 것을 볼 수 있다.

이를 어떻게 활용하는지

이는 List 에 혹은 Map에 여러 사용자가 동시에 접근할 일이 있을 때 유효한 얘기이다.

우리가 일반적으로 게시판 프로젝트를 만들때는 이에 대한 고민을 하지 않아도 되지만

만약 여러 사람에 의해 실시간으로 Map 에 무언가를 집계하는 로직이 있거나

Map에다가 Session 을 담는 WebSocket 으로 실시간 채팅 기능을 구현할 때면

이에 대한 내용을 고려해야 한다.

실제로 다른 사람들이 이해대해 고민한 내용 :
https://github.com/ytw9699/Dokky/issues/334

Ref

https://velog.io/@sihyung92/how-does-springboot-handle-multiple-requests : 스프링 부트의 쓰레드 풀

https://www.inflearn.com/questions/347336/threadlocal-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-arraylist-hashmap-hashset : 영한님 답변

profile
기록을 통해 실력을 쌓아가자

0개의 댓글