티스토리 뷰

들어가며

흔한 경우는 아니지만, 가끔 멀티 쓰레드 환경에서 컬렉션을 사용해야할 수 있다.

 

예를 들어 우리 서비스에서 외부로 HTTP webhook을 통해 이벤트를 발행해주는 시스템이 있다고 하자. 해당 시스템이 잘 작동하는지는 간단히 모니터링 도구를 통해 확인해보면 되겠지만, 우리 팀원이 아닌 QA팀이나 연계 업체 지원을 위해 테스트용 클라이언트를 개발환경에 띄워보고 싶을 수 있다.

 

테스트용 이벤트 클라이언트

1. 서비스에서 POST /event 요청을 통해 이벤트를 보내준다.
2. 발행된 이벤트는 따로 가공하지 않고 어딘가에 쌓아둔다.
3. GET /event 요청을 통해 쌓아둔 이벤트를 조회할 수 있는 기능을 제공한다.

 

아주 간단하고 빠르게 구현해본다면, 단순히 List를 이용해 이벤트를 쌓아볼 수 있다. 아래의 예시에서는 간단하게 ArrayList를 사용하여 구현했다. [전체 프로젝트]

 

public class EventRepository implements EventHolder {
    private final List<EventItem> list;
    private final int initialCapacity = 200;

    public EventRepository() {
        this.list = new ArrayList<>(initialCapacity);
    }

    @Override
    public void add(EventItem e) {
        list.add(e);
    }

    @Override
    public List<EventItem> getAll() {
        return new ArrayList<>(list);
    }

    @Override
    public void removeAll() {
        list.clear();
    }
}

 

그러면 SpringBoot 설정 기본값에 따라 여러 개의 Tomcat NIO 쓰레드가 동시적으로 요청을 처리하면서 ArrayList에 값을 추가하거나, 추가된 값을 읽을 것이다. 그렇다면 이렇게 여러 쓰레드가 하나의 ArrayList 인스턴스에 접근하는 상황은 안전하다고 보장할 수 있는가?

 

안전하지 않은 케이스

직관적으로 멀티 쓰레드 환경에서 ArrayList를 사용하는 것이 안전하지 않다는 것을 보여주는 예제가 있다.

 

/*
Author: Nikita Rybak
Source: https://stackoverflow.com/a/3589363
*/

import java.lang.*;
import java.util.*;

class MutilThreadList {
    static class ListTester implements Runnable {
        private List<Integer> a;
    
        public ListTester(List<Integer> a) {
            this.a = a;
        }
    
        public void run() {
            try {
                for (int i = 0; i < 20; ++i) {
                    a.add(i);
                    Thread.sleep(10);
                }
            } catch (InterruptedException e) {
            }
        }
    }
    
    
    public static void main(String[] args) throws Exception {
        ArrayList<Integer> a = new ArrayList<Integer>();
    
        Thread t1 = new Thread(new ListTester(a));
        Thread t2 = new Thread(new ListTester(a));
    
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(a.size());
        for (int i = 0; i < a.size(); ++i) {
            System.out.println(i + "  " + a.get(i));
        }
    }
}

 

두 개의 쓰레드가 생성되고, 두 쓰레드는 동시에 하나의 ArrayList 인스턴스에 대해 0부터 20까지의 정수를 추가한다. 메인 쓰레드는 두 쓰레드의 작업이 종료되기를 기다린 뒤(Thread::join), ArrayList 인스턴스의 사이즈와 추가된 값을 출력하게 된다.

 

예상대로면 사이즈는 40, 추가된 값은 0,0,1,1,2,2,3,3,...(순서는 섞이더라도 0부터 20까지의 정수가 두 개씩 존재)이 되어야 한다. 그러나 실제 예제를 실행하면 사이즈는 37부터 39까지 다양하게 출력되며, 추가된 값을 보면 한 개씩만 추가된 정수가 보인다.

 

37 // 40이 아님
0  0
1  0
2  1
3  1
4  2
5  2
6  3
7  3
8  4
9  4
10  5
11  5
12  6
13  6
14  7
15  7
16  8
17  8
18  9
19  9
20  10
21  10
22  11 // 11이 한 번만 추가됨
23  12
24  12
25  13
26  13
27  14 // 14가 한 번만 추가됨
28  15 // 15가 한 번만 추가됨
29  16
30  16
31  17
32  17
33  18
34  18
35  19
36  19

 

ArrayList의 add 메서드가 동기화되어있지 않으므로 당연한 결과라고 할 수 있다. ArrayList의 add 메서드를 보자. add 메서드가 호출되면, 현재 데이터를 담고있는 배열의 길이를 확인하고, 배열의 크기가 부족하면 배열을 늘린 뒤에, 값을 추가된 마지막 인덱스에 쓰게 된다.

 

    /**
     * This helper method split out from add(E) to keep method
     * bytecode size under 35 (the -XX:MaxInlineSize default value),
     * which helps when add(E) is called in a C1-compiled loop.
     */
    private void add(E e, Object[] elementData, int s) {
        if (s == elementData.length)
            elementData = grow();
        // 이 지점에서 다른 쓰레드가 add를 호출하면?
        elementData[s] = e;
        size = s + 1;
    }

    /**
     * Appends the specified element to the end of this list.
     *
     * @param e element to be appended to this list
     * @return {@code true} (as specified by {@link Collection#add})
     */
    public boolean add(E e) {
        modCount++;
        add(e, elementData, size);
        return true;
    }

 

그런데 이렇게 배열의 길이를 늘린 시점에 다른 쓰레드가 add 메서드를 호출하면 어떻게 될까? 배열의 길이가 이미 늘어났기 때문에 배열의 길이를 확인하는 if문을 통과하게 되고, 결국 같은 인덱스에 대해 두 쓰레드가 각각 값을 덮어쓰게 될 것이다. 이러한 예외는 이미 Java 레퍼런스에 경고되어 있다. 동기화가 전혀 고려되어있지 않기 때문에 단순히 add 메서드 뿐 아니라 값을 변경할 때 여러 사이드 이펙트가 발생할 수 있다. 

 

 

대안

우선 위와 같이 Javadoc에서 안내하는 것처럼 Collections::synchronizedList를 활용할 수 있다. 이 메서드는 다음과 같이 인자로 념겨진 List 인스턴스의 각 메서드에 대해 락을 걸어주는 래퍼 클래스를 반환한다. 이를 통해 모든 메서드 사용이 Thread-safe함을 보장할 수 있다.

 

 

또는 CopyOnWriteArrayList를 활용할 수 있다. CopyOnWriteArrayList는 값을 변경하는 add, set 등과 같은 메서드를 락을 가진 상태로, 새로운 배열을 복사하여 작업을 수행하도록 구현하고 있다. 이에 따라 현재 배열의 길이를 확인하여 배열을 확장했던 ArrayList와는 달리 값을 변경할 때 마다 배열을 복사하기 때문에 비용이 발생하지만, get 메서드에 대해 동기화 없이 Thread-safe를 보장할 수 있다는 장점이 있다.

 

 

즉, 쓰기 작업보다 읽기 작업이 많거나 비슷한 수준이라면 Collections::synchronizedList를 통해 ArrayList를 감싸서 사용하는 것이 유리할 것이다. 각 메서드에 대해 락을 잡는 대신, 필요할 때에만 배열을 복사하여 확장하기 때문이다. 반면에 쓰기 작업보다 읽기 작업이 많다면 CopyOnWriteArrayList가 유리할 것이다. 모든 변경 작업에 배열을 새로 복사하는 대신 get 메서드에 락을 잡지 않기 때문이다.

위의 테스트용 이벤트 클라이언트는 ArrayList 대신 CopyOnWriteArrayList를 사용하도록 수정하였다. 테스트 목적으로 배포된 만큼 발행된 이벤트를 잘 받았는지 조회하는 요청이 더 빈번할 것으로 예상되기 때문이다. [전체 프로젝트]

 

한편 List와 비슷하게 Map의 경우에도, HashMap은 멀티 쓰레드 환경에서 안전하지 않다. 대신 각 메서드마다 락을 잡는 HashTable과 각 노드마다 락을 잡아 속도가 향상된 ConcurrentHashMap을 사용할 수 있다. 이렇게 멀티 쓰레드로 동작하는 스프링에서 컬렉션을 사용할 때에는 라이프 사이클(요청이 끝나면 사용되지 않는가)을 확인하고, Thread-safe 여부를 따져볼 필요가 있다는 것을 알 수 있었다.

 

참고 자료

https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ArrayList.html

https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Collections.html#synchronizedList(java.util.List)

https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/CopyOnWriteArrayList.html

https://stackoverflow.com/questions/3589308/arraylist-and-multithreading-in-java

https://taes-k.github.io/2021/12/26/synchronizedlist-copyonwritelist/

'컴퓨터 > 자바' 카테고리의 다른 글

Thread의 구현 및 실행, start()와 run()  (0) 2022.12.19
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/10   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함