멀티스레드에서의 동시성
멀티스레드 환경에서의 컬렉션 프레임워크들은 공유변수에 대한 값을 보장할 수 있을까?
수 많은 스레드의 접근에도 유효한 값을 보장하는지에 대한 궁금증을 해결하기 위해 아래의 예시를 준비했습니다.
단순 컬렉션 구현 코드
package thread.collection.simple.list;
import static util.ThreadUtils.*;
import java.util.Arrays;
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;
sleep(100); // 멀티스레드 문제를 쉽게 확인하는 코드
size++;
}
@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;
}
}
실행 코드
package thread.collection.simple.list;
import static util.MyLogger.*;
public class SimpleListMainV2 {
public static void main(String[] args) throws InterruptedException {
test(new BasicList());
}
public 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);
}
}
멀티 스레드 환경에서 list의 공유변수인 size나 배열의 값은 원자적으로 연산이 되었을까?
출력결과(시도할 때 마다 달라짐)
23:01:27.066 [ main] BasicList
23:01:27.172 [ Thread-1] Thread-1: list.add(A)
23:01:27.174 [ Thread-2] Thread-2: list.add(B)
23:01:27.174 [ main] [B, null] size =2, capacity =5
멀티스레드 환경에서의 문제점
BasicList는 여러 스레드가 동시에 add() 메서드를 호출할 수 있는 구조입니다.
그러나 아래의 결과는…
elementData[size] = e;
size++;
이 두 줄은 **원자적 연산(atomic operation)**이 아닙니다.
즉, 두 스레드가 동시에 이 코드를 실행하게된다면 아래와 같은 결과 나올 수 있습니다.
- 같은 인덱스에 값을 덮어쓸 수 있고,
- size는 두 번 증가했지만, 실제 데이터 배열에는 하나만 들어가거나,
- null이 삽입되기도 하는 등의 문제가 발생할 수 있습니다.
이 현상은 바로 경쟁 조건(Race Condition) 때문입니다.
동기화(Synchronized) 방식의 해결
synchronized 키워드를 사용하면 한 번에 하나의 스레드만 접근할 수 있게 되어 이러한 문제를 해결할 수 있습니다.
public synchronized void add(Object e) {
elementData[size] = e;
sleep(100);
size++;
}
하지만 이 방식은 몇 가지 한계를 가집니다.
- 유지보수 어려움: 모든 구현체에 동기화를 직접 넣어야 합니다.
- 코드 중복: 같은 로직이 반복됩니다.
- 단일 책임 원칙 위배: 컬렉션이 동기화 책임까지 지게 됩니다.
1. Syncronized
package thread.collection.simple.list;
import static util.ThreadUtils.*;
import java.util.Arrays;
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 Object get(int index) {
return elementData[index];
}
@Override
public String toString() {
return Arrays.toString(Arrays.copyOf(elementData, size)) +
" size =" + size + ", capacity =" + elementData.length;
}
}
과연 최선일까?
모든 컬렉션에 대한 syncronized를 처리할 경우, 유지 보수 및 생성해야하는 코드가 2배가 된다.
단일 스레드에서 작업하는 컬렉션, 멀티 스레드에서 작업하는 컬렉션
→ 그렇기에 다른 방법을 찾아야한다.
프록시 패턴을 이용한 해결
"프록시 하나로 모든 리스트를 동기화할 수 있다!"
핵심 아이디어
- BasicList, BasicLinkedList 등 다양한 리스트 구현체는 SimpleList 인터페이스를 구현합니다.
- 프록시인 SyncProxyList는 이 인터페이스를 구현하면서,
- 내부에 원본 리스트를 필드로 가지고,
- 모든 메서드 호출에 동기화 기능만 추가하고,
- 호출을 원본 객체에게 위임(delegate)합니다.
프록시 패턴의 주요 목적
- 접근 제어: 실제 객체에 대한 접근을 제한하거나 통제할 수 있습니다.
- 성능 향상: 실제 객체의 생성을 지연시키거나 캐싱하여 성능을 최적화할 수 있습니다.
- 부가 기능 제공: 실제 객체에 추가적인 기능(로깅, 인증, 동기화 등)을 투명하게 제공할 수 있습니다.
프록시 패턴으로 구현된 코드
package thread.collection.simple.list;
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) {
target.add(e);
}
@Override
public synchronized Object get(int index) {
return target.get(index);
}
@Override
public synchronized String toString() {
return target.toString() + " by " + this.getClass().getSimpleName();
}
}
프록시란(출처: 김영한님의 자바 고급 편)
- 우리말로 대리자, 대신 처리해주는 자라는 뜻이다.
- 프록시를 쉽게 풀어서 설명하자면 친구에게 대신 음식을 주문해달라고 부탁하는 상황을 생각해 볼 수 있다.
- 예를 들어, 당신이 피자를 먹고 싶은데, 직접 전화하는 게 부담스러워서 친구에게 대신 전화해서 피자를 주문해달라고 부탁한다고 해보자. 친구가 피자 가게에 전화를 걸어 주문하고, 피자가 도착하면 당신에게 가져다주는 것이다. 여기서 친구가 프록 시 역할을 하는 것이다.
- 나(클라이언트) 피자 가게(서버)
- 나(클라이언트) 친구(프록시) 피자 가게(서버)
프록시 패턴을 구성하기 위한 의존 관계
프록시 패턴 흐름
프록시 패턴 흐름 최종 정리
1.프록시는 원본과 같은 인터페이스를 구현한다
- SyncProxyList는 SimpleList 인터페이스를 구현한다.
- 따라서 클라이언트(test() 등) 입장에서는 어떤 구현체가 전달되든 상관없이 동일하게 사용 가능하다.
- 프록시는 그저 SimpleList 구현체 중 하나일 뿐이다.
2. 프록시는 내부에 원본 객체를 가진다
- SyncProxyList는 내부에 BasicList, BasicLinkedList 등 원본 리스트 객체를 필드로 가지고 있다.
- 프록시는 일부 기능(예: 동기화)을 먼저 수행한 후, 원본 객체에 위임(delegate)한다.
3. 동기화는 프록시가 담당한다
- SyncProxyList는 synchronized 키워드를 통해 멀티스레드 환경에서의 안전성을 보장한다.
- 원본 코드(BasicList)는 전혀 수정하지 않아도 되고,
- 프록시를 통해 동기화가 적용된 상태로 메서드가 호출된다.
4. 확장성과 재사용성이 뛰어나다
- 이후 SimpleList를 구현한 다른 리스트 예: BasicLinkedList가 추가되더라도,
- SyncProxyList는 별도 수정 없이 그대로 사용 가능하다.
- 프록시 하나로 여러 리스트 구현체에 동기화 적용 가능 → 유지보수성, 재사용성 향상
'Dev Lang > JAVA' 카테고리의 다른 글
[Java] 제네릭 클래스의 이해와 활용 (0) | 2025.01.06 |
---|---|
[JAVA] 자바의 기본형과 참조형의 값 공유 특성 (1) | 2024.12.28 |