Java 동시성 컬렉션이 필요한 이유

java.util 패키지의 컬렉션 프레임워크(ArrayList, HashMap, LinkedList 등)는 멀티스레드 환경에서 안전할까? 결론부터 말하자면, 대부분의 컬렉션은 Thread-Safe하지 않는다

Java 컬렉션 프레임워크는 Thread-Safe한가?

Thread-Safe의 정의

thread-safe란 여러 스레드가 동시에 접근해도 문제없이 동작하는 것을 의미한다

import java.util.ArrayList;
import java.util.List;

public class SimpleListMainV0 {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        
        // Thread-1과 Thread-2가 동시에 실행된다고 가정
        list.add("A");  // Thread-1 실행
        list.add("B");  // Thread-2 실행
        System.out.println(list);
    }
}

위 코드를 보면 add() 메서드가 단순히 컬렉션에 데이터 하나를 추가하는 것처럼 보여 원자적(atomic) 연산처럼 느껴진다. 하지만 실제로는 원자적 연산이 아니다.

컬렉션 내부 동작 이해하기

컬렉션의 add() 메서드가 왜 원자적이지 않는지 이해하기 위해 간단한 리스트 직접 구현

public interface SimpleList {
    int size();
    void add(Object e);
    Object get(int index);
}
import java.util.Arrays;
import static util.ThreadUtils.sleep;

public class BasicList implements SimpleList {
    private static final int DEFAULT_CAPACITY = 5;
    private Object[] elementData;
    private int size = 0;

    public BasicList() {
        elementData = new Object[DEFAULT_CAPACITY];
    }

    @Override
    public int size() {
        return size;
    }

    @Override
    public void add(Object e) {
        elementData[size] = e;  // 연산 1
        sleep(100);  // 멀티스레드 문제를 쉽게 확인하기 위한 코드
        size++;  // 연산 2
    }

    @Override
    public Object get(int index) {
        return elementData[index];
    }

    @Override
    public String toString() {
        return Arrays.toString(Arrays.copyOf(elementData, size)) 
            + " size=" + size + ", capacity=" + elementData.length;
    }
}

원자적이지 않는 이유

add() 메서드

  • 배열에 데이터 저장: elementData[size] = e
  • 사이즈 증가: size++

두 개의 독립적인 연산으로 구성되어 있다. 더욱이 size++ 자체도 다음과 같은 세 단계로 이루어진다

  • size 값 읽기
  • 1 증가
  • 결과 저장

동시성 문제 재현

테스트 코드 작성

import static util.MyLogger.log;

public class SimpleListMainV2 {
    public static void main(String[] args) throws InterruptedException {
        test(new BasicList());
    }

    private static void test(SimpleList list) throws InterruptedException {
        log(list.getClass().getSimpleName());

        // A를 리스트에 저장하는 작업
        Runnable addA = new Runnable() {
            @Override
            public void run() {
                list.add("A");
                log("Thread-1: list.add(A)");
            }
        };

        // B를 리스트에 저장하는 작업
        Runnable addB = new Runnable() {
            @Override
            public void run() {
                list.add("B");
                log("Thread-2: list.add(B)");
            }
        };

        Thread thread1 = new Thread(addA, "Thread-1");
        Thread thread2 = new Thread(addB, "Thread-2");

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
        
        log(list);
    }
}

실행 결과

[     main] BasicList
[ Thread-1] Thread-1: list.add(A)
[ Thread-2] Thread-2: list.add(B)
[     main] [B, null] size=2, capacity=5

---- 또는 ----
[     main] BasicList
[ Thread-2] Thread-2: list.add(B)
[ Thread-1] Thread-1: list.add(A)
[     main] [B] size=1, capacity=5

문제 발생

두 개의 데이터를 추가하였음에도 불구하고

  • A는 사라짐
  • B만 존재
  • size는 2로 표시 (데이터 정합성 깨짐)

문제 발생 과정

상황 1 – 데이터 덮어쓰기
  • Thread-1이 elementData[0] = “A” 실행
  • Thread-2가 즉시 elementData[0] = “B” 실행 (size가 아직 0)
  • A가 B도 덮어써짐
  • Thread-1가 size++ → size = 1
  • Thread-2가 size++ → size = 2
상황 2 – size 증가 문제

경우에 따라 두 스레드가 동시에 size++를 실행하면

  • 둘 다 size = 0을 읽음
  • 둘 다 1을 계산
  • 둘 다 size = 1을 저장
  • 최종 사이즈 = 1 (2개를 추가하였음에도 불구하고)

실무에서의 위험성

이러한 동시성 문제는 실무에서 매우 치명적이다

  • 데이터 유실: 사용자 회원가입 시 회원 정보 유실
  • 디버깅 어려움: 재현이 어렵고 간헐적으로 발생

중요: ArrayList, LinkedList, HashMap 등 대부분의 java.util 컬렉션은 Thread-Safe하지 않다

Synchronized를 이용한 해결

동기화된 리스트 구현

import java.util.Arrays;
import static util.ThreadUtils.sleep;

public class SyncList implements SimpleList {
    private static final int DEFAULT_CAPACITY = 5;
    private Object[] elementData;
    private int size = 0;

    public SyncList() {
        elementData = new Object[DEFAULT_CAPACITY];
    }

    @Override
    public synchronized int size() {
        return size;
    }

    @Override
    public synchronized void add(Object e) {
        elementData[size] = e;
        sleep(100);
        size++;
    }

    @Override
    public synchronized Object get(int index) {
        return elementData[index];
    }

    @Override
    public synchronized String toString() {
        return Arrays.toString(Arrays.copyOf(elementData, size)) 
            + " size=" + size + ", capacity=" + elementData.length;
    }
}

실행 결과

public class SimpleListMainV2 {
    public static void main(String[] args) throws InterruptedException {
        test(new SyncList());
    }
}

실행 결과

[     main] SyncList
[ Thread-1] Thread-1: list.add(A)
[ Thread-2] Thread-2: list.add(B)
[     main] [A, B] size=2, capacity=5

데이터가 정상적으로 저장

동작 원리

  • Thread-1이 add() 호출 → lock 획득
  • Thread-2가 add() 호출 시도 → blocked 상태로 대기
  • Thread-1이 작업 완료 후 lock 반납
  • Thread-2가 lock 획득 후 작업 수행

한계점

synchronized 키워드만으로 해결은 가능하지만, 문제가 있다

  • 코드 중복: BasicList와 거의 동일한 코드를 복사해야 한다
  • 유지보수 문제: ArrayList, LinkedList 등 모든 컬렉션마다 별도 구현 필요
  • 확장성 부족: 원본 코드 변경 시 동기화 버전도 함께 수정 필요

Proxy 패턴을 활용한 해결책

Proxy 패턴

Proxy(대리자)패턴은 실제 객체에 대한 접근을 제어하기 위해 대리인 역할을 하는 객체를 제공하는 디자인 패턴

실생활 비유

  • 당신(클라이언트)이 피자를 주문하고 싶음
  • 친구(Proxy)에게 대신 주문 부탁
  • 친구가 피자가게 (실제 객체)에 주문
  • 피자를 받아서 당신에게 전달

Proxy 구현

public class SyncProxyList implements SimpleList {
    private SimpleList target;  // 실제 대상 객체

    public SyncProxyList(SimpleList target) {
        this.target = target;
    }

    @Override
    public synchronized int size() {
        return target.size();
    }

    @Override
    public synchronized void add(Object e) {
        // 1. lock 획득 (synchronized)
        // 2. 원본 메서드 호출
        target.add(e);
        // 3. 원본 메서드 반환
        // 4. lock 반납
    }

    @Override
    public synchronized Object get(int index) {
        return target.get(index);
    }

    @Override
    public String toString() {
        return target.toString() + " by " + this.getClass().getSimpleName();
    }
}

사용 방법

public class SimpleListMainV2 {
    public static void main(String[] args) throws InterruptedException {
        // 원본 리스트를 Proxy로 감싸기
        test(new SyncProxyList(new BasicList()));
        
        // 또는 명시적으로
        // BasicList basicList = new BasicList();
        // SyncProxyList proxyList = new SyncProxyList(basicList);
        // test(proxyList);
    }
}

실행 결과

[     main] SyncProxyList
[ Thread-1] Thread-1: list.add(A)
[ Thread-2] Thread-2: list.add(B)
[     main] [A, B] size=2, capacity=5 by SyncProxyList

Proxy 패턴의 장점

원본 코드 수정 불필요
// BasicList 코드는 전혀 건드리지 않음
// Proxy만 추가하면 동기화 기능 획득
재사용성
// 어떤 SimpleList 구현체든 사용 가능
new SyncProxyList(new BasicList());
new SyncProxyList(new LinkedList());
new SyncProxyList(new CustomList());
관심사의 분리
  • BasicList: 비즈니스 로직(데이터 저장)에만 집중
  • SyncProxyList: 동기화 처리에만 집중

동작 원리 상세 분석

정적 의존관계 (클래스 레벨)

Client (test 메서드)
    ↓ 의존
SimpleList (인터페이스)
    ↑ 구현
    ├─ BasicList
    └─ SyncProxyList
  • 클라이언트는 SimpleList 인터페이스만 알고 있음
  • 구현체가 무엇이든 상관없이 동작
  • 코드 변경 없이 유연하게 교체 가능

런타임 의존관계 (객체 레벨)

1. 객체 생성
   BasicList (X001) 생성
   ↓
   SyncProxyList (X002) 생성 시 X001을 target으로 주입
   
2. 메서드 호출 흐름
   Client → SyncProxyList.add()
              ↓ (synchronized 적용)
              BasicList.add()
              ↓
              실제 작업 수행
              ↓ (return)
           SyncProxyList
           ↓ (lock 반납)
        Client

핵심 포인트

  • Proxy가 먼저 lock을 획득
  • 원본 메서드를 호출
  • 원본 메서드가 완료될 때까지 lock 유지
  • 결과 반환 후 lock 반납

Proxy 패턴의 활용

Proxy 패턴은 다양한 목적으로 활용

  • 접근 제어: 권한 검사, 인증
  • 성능 향상: 지연 로딩, 캐싱
  • 부가 기능: 로깅, 트랜잭션, 동기화
// 로깅 Proxy 예시
public class LoggingProxyList implements SimpleList {
    private SimpleList target;
    
    @Override
    public void add(Object e) {
        System.out.println("Adding: " + e);
        target.add(e);
        System.out.println("Added: " + e);
    }
}

Spring AOP: Sping의 핵심 기능인 AOP(Aapect-Oriented Programming)도 Proxy 패턴을 극한으로 활용한 예시이다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 1편, 멀티스레드와 동시성