Dev Lang/JAVA

[Java] 프록시 패턴을 통한 멀티 스레드 환경 조성하기

개발자 성현 2025. 4. 4. 21:15

멀티스레드에서의 동시성

멀티스레드 환경에서의 컬렉션 프레임워크들은 공유변수에 대한 값을 보장할 수 있을까?

수 많은 스레드의 접근에도 유효한 값을 보장하는지에 대한 궁금증을 해결하기 위해 아래의 예시를 준비했습니다.

 

단순 컬렉션 구현 코드

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. 유지보수 어려움: 모든 구현체에 동기화를 직접 넣어야 합니다.
  2. 코드 중복: 같은 로직이 반복됩니다.
  3. 단일 책임 원칙 위배: 컬렉션이 동기화 책임까지 지게 됩니다.

 

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는 별도 수정 없이 그대로 사용 가능하다.
  • 프록시 하나로 여러 리스트 구현체에 동기화 적용 가능 → 유지보수성, 재사용성 향상