액티브 오브젝트(Active Object) 패턴은 객체지향 프로그래밍에서 객체를 비동기적으로 실행하기 위해 사용되는 디자인 패턴입니다. 이 패턴의 핵심 목표는 하나의 객체 내에서 메서드 호출을 병렬 처리하거나, 작업을 비동기적으로 처리하여 프로그램의 응답성을 높이는 것입니다. 일반적으로 액티브 오브젝트 패턴은 메서드 호출을 요청 큐에 추가한 뒤, 별도의 스레드에서 요청을 처리하고 그 결과를 호출자에게 나중에 반환하는 방식으로 동작합니다.
액티브 오브젝트 패턴의 주요 구성 요소는 다음과 같습니다:
- 프록시(Proxy): 호출자가 직접 액티브 오브젝트의 메서드를 호출하지 않고 프록시를 통해 호출합니다. 프록시는 메서드 호출을 큐에 저장하고 실제 실행을 위임합니다.
- 스케줄러(Scheduler): 요청 큐에서 작업을 스케줄링하여 비동기적으로 실행할 작업을 관리합니다.
- 메서드 요청(Method Request): 큐에 추가되는 비동기 메서드 호출을 나타냅니다.
- 실행기(Executor): 실제로 메서드 요청을 처리하고 결과를 반환합니다.
액티브 오브젝트 패턴을 처음 제안한 엔지니어는 Douglas C. Schmidt입니다. 그는 이 패턴을 병렬 처리 및 동시성 문제를 해결하기 위한 디자인 패턴으로서 제안했습니다. 이 패턴은 주로 멀티스레딩 환경에서 응답성을 높이고 동시성을 관리하기 위해 사용됩니다.
이러한 디자인 패턴은 특정한 장치 또는 언어 프레임워크를 제한하는것이 아니라 개념이 하나로 오늘날의 이벤트 드리븐 방식에 영향을 주고 채택하는 컨셉입니다.
C++에서는 멀티플레이어 네트워크 프로그래밍 게임영역에 이러한 컨셉을 적용해 고성능 큐를 직접 작성하면서 멀티플레이어 게임서버영역에 성공한 법칙으로
웹 진영에서도 Kafka/Redis/Rabbit MQ등의 장치의 등장으로 어려운 멀티스레드 네트워크 프로그래밍 필요없이 대용량 트래픽 처리가 가능하게 되었습니다.
능동적 객체 VS 수동적 객체
능동적 객체와 수동적 객체를 간단하게 정리하면 호출자에 의해만 반응 하는 객체는 수동적 객체입니다.
4칙연산을 시킬수 있는 함수를 제공하는 객체의 경우를 의미합니다. 주로 StateLess한 개발방식에 활용될수 있습니다.
객체를 생성하고나서 5초또는 우리가 지정한 시간에 스스로 "Hello World" 라고 말할수 있으면 능동적 객체입니다.
그리고 이 객체는 추가로 커멘드에 반응해 바로 실행하는것이 아닌 큐에 적재해 명령을 꺼내 다음 실행자에게 위임할수 있으며
메시지가 흘러간 시간에따라 자신의 상태관리도 할수 있습니다.
액터모델도 이러한 특성을 가지고 있으며 액티브 오브젝트 패턴이 적용된 능동적 객체에 해당합니다.
실험주제
만개의 독립적 객체를 생성하고 , 생성후 3~5초 구간 랜덤한 스케줄로 각 객체가 "Hello Word" 를 외침
위와 같은 문제를 액터모델을 통해 구현해 보겠습니다. 위 주제가 쉬운것처럼 보일수 있지만 생각보다 고려해야할 문제가 많이있습니다.
먼저 액터모델로 위와같은 문제를 해결한후 다른 모델을 이용했을때와 장단점을 비교해보겠습니다.
액터 모델의 기본생성과 스케줄러는 앞장에 설명되었음으로 생략하고 핵심코드 위주로 설명을 진행합니다.
각각의 객체가 스케줄러를 가지고 있으며선 3~5초구간 스스로 이벤트를 발생시키는 액터
init { val randomDuration = Duration.ofSeconds(ThreadLocalRandom.current().nextLong(3, 6)) timers.startSingleTimer(AutoOnceProcess, randomDuration) } override fun createReceive(): Receive<PrivacyRoomCommand> { return newReceiveBuilder() .onMessage(SetTestProbe::class.java, this::onSetTestProbe) .onMessage(SendMessage::class.java, this::onSendMessage) .onMessage(AutoOnceProcess::class.java, this::onAutoOnceProcess) .build() } private fun onAutoOnceProcess(command: AutoOnceProcess): Behavior<PrivacyRoomCommand> { logger.info("AutoOnceProcess received in PrivacyRoomActor $identifier") if (::testProbe.isInitialized) { testProbe.tell(HelloResponse("Hello World")) } else { logger.warn("testProbe is not initialized") } return this }
- 능동적 객체의 특징은 주로 이벤트 핸들러를 연결시키고 , 명령을 메일박스함에 담은후 명령을 하나씩 꺼내어 해당 핸들러를 하나씩 실행시킵니다.
- command 를 수행할 함수이름에 on~ 이라는 prefix를 주로 붙이게됩니다. ( 누가 먼저 붙이기 시작했는지는 알수없음 )
유닛테스트
유닛테스트는 작동코드를 설명하고 우리가 작성한 로직을 셀프 검증할수 있습니다.
이것이 작성되어져 있다고하면 남의 코드또한 이해할수 있고 사용방법을 알수있기때문에 개선의 활동으로도 이어질수 있습니다.
"복잡하지 않고 간단하게 작성되면 읽혀질것이고 다이어그램과 함께 있다면 기억될것이며 유닛테스트까지 있다고하면 사용되어질 것입니다. -Sam"
repeat(testCount) { i -> val identifier = "testIdentifier-$i" val privacyRoomActor: ActorRef<PrivacyRoomCommand> = testKit.spawn(PrivacyRoomActor.create(identifier)) privacyRoomActor.tell(SetTestProbe(probe.ref)) } repeat(testCount) { probe.expectMessage(Duration.ofSeconds(10), HelloResponse("Hello World")) }
- 액터를 testCount만큼 생성합니다. 여기서는 10000값이 이용되었습니다.
- SetTestProbe : 해당 객체는 특정 객체에게 외치기때문에 들을수 있는 관찰자를 지정합니다. 여기서는 테스트 객체입니다.
- probe.expectMessage : 10초이내에 Hello World가 들렸는데 객체수만큼 검정합니다. 모든 객체가 한번씩 외쳐야 이 테스트를 통과합니다.
성능테스트 탑재
측정하지 않으면 개선할수 없습니다. 해당 객체를 동적으로 생성하는 시간과 소비하는 메모리를 측정하는 것은 중요합니다.
import java.lang.management.ManagementFactory import java.lang.management.OperatingSystemMXBean import java.lang.management.MemoryMXBean // Measure memory and CPU usage after the test val afterMemoryUsage = memoryMXBean.heapMemoryUsage.used val afterCpuLoad = osMXBean.systemLoadAverage // Calculate the difference val memoryUsed = (afterMemoryUsage - beforeMemoryUsage) / (1024 * 1024) val cpuLoadIncrease = afterCpuLoad - beforeCpuLoad val cpuLoadPercentage = afterCpuLoad * 100 println("Memory used: $memoryUsed MB") println("CPU load increase: $cpuLoadIncrease") println("CPU load percentage: $cpuLoadPercentage%")
1만개의 스케줄러를 가진 객체를 개별 동적으로 생성하고 스케줄러가 작동하는것 까지 측정된 메모리와 수행시간입니다.
- 수행시간 : 5초 934ms
- 사용된 메모리 : 27mb
- CPU측정은 단일지점 코드에서 하기어려운부분으로 별도의 장치를 이용해야함
스레드모델과 비교
여기서 스레드모델과 비교를 해보겠습니다. 전제 조건이 스케줄러를 가진 객체 각각 이라는 조건이였기때문에
액터1 과 스레드 1을 각각 생성한다는 전제조건에 비교를 이어가보겠습니다.
객체생성 시간과 메모리
스레드는 스택메모리를 1GB또는 2GB를 가지고 있는 무거운 시스템 객체입니다.
다음은 단순하게 스레드 객체 생성에 드는 오버헤드에 대한 추정정보입니다.
- 각 스레드는 고유한 스택 메모리를 필요로 합니다. 스택 크기는 일반적으로 기본적으로 몇 MB로 설정됩니다(보통 1MB ~ 2MB).
- 10,000개의 스레드가 각각 1MB의 스택을 할당받으면 10GB의 메모리가 필요하게 됩니다.
- 대략 10초 ~ 20초: 스레드당 1~2ms가 소요된다고 가정했을 때, 10,000개의 스레드를 생성하는 데 이 정도의 시간이 걸릴 것으로 예상됩니다.
이 문제를 스레드모델로 해결하려면 더 적은 객체를 이용해 단일 스레드가 여러개 객체의 로직을 처리하는 Agent방식으로 변경해야합니다.
자원공유 문제를 해결해야하기때문에 복잡한 멀티스레딩 프로그래밍 기법이 필요하게 될수 있습니다.
단순하게 생성문제만 비교했고 아직 스레드 모델에 스케줄러 기능을 탑재하지도 않은 상태입니다.
스레드가 10000만개 생성되었다고 가정하고 여기에 스케줄러를 적용해보겠습니다.
스레드 모델에서 반복실행
fun main() { val thread = Thread { while (true) { println("Task executed at: ${System.currentTimeMillis()}") Thread.sleep(3000) // 3 seconds } } thread.start() }
스레드내에 스케줄을 제어하려면 진행중인 컨텍스트의 흐름을 블록을 하고 대기를 해야합니다.
스레드 간 컨텍스트 스위칭(Context Switching)은 CPU가 하나의 스레드에서 다른 스레드로 작업을 전환하는 과정으로, 이 과정은 비용이 발생합니다. 이 비용은 다양한 요소에 의해 결정되며, 주로 CPU 자원과 시간에 영향을 미칩니다.
스레드 모델에서 비싼비용중 하나는 컨텍스트 스위칭비용으로 CPU 멀티코어가 4개라고 가정하더라고 1만개에 전환에 발생하는 비용은 커다란점입니다.
스레드풀을 이용하거나 외부장치를 이용해 이 문제를 해결할수도 있습니다.
여기서 소개한 액터모델을 채택하지 않고 다음과 같은 문제를 해결을 시도해봅니다.
- 만개의 독립적 객체를 동적으로 생성하고 , 생성후 3~5초 구간 랜덤한 스케줄로 각 객체가 "Hello Word" 를 외치고 수신자는 이것을 검증
- 사용메모리는 100mb 이내여야함
- 모든 객체 생성과 수행시간은 6초이내여야함
아마 이러한 문제를 해결하는데 꼭 액터모델이 아니여도 다양한 더 좋은 방법및 다양한 장치가 있을수 있을것같으며
여기서는 액터모델을 중심으로 문제를 해결하였으며 여기서 설명된 액터와 테스트기에 대한 전체 코드입니다.
전체코드 :
- https://github.com/psmon/java-labs/blob/master/KotlinBootLabs/src/main/kotlin/com/example/kotlinbootlabs/ws/actor/PrivacyRoomActor.kt
- https://github.com/psmon/java-labs/blob/master/KotlinBootLabs/src/test/kotlin/com/example/kotlinbootlabs/ws/actor/PrivacyRoomActorTest.kt