AKKA 액터모델은 JVM 기반에 작동하며 SCALA/JAVA뿐만아니라 Kotlin으로도 이용가능하며
TypedActor 액터모델을 활용해 Kotlin으로 작동시켜보는 샘플모음집입니다.
액터모델 러닝커버를 낮추기위해 Copilot를 통해 액터모델을 생성하고 개선해 가는 방법을 채택하였으며 코틀린으로 시작하는 개발팀과 함께 학습하기 위해 작성을 시작하였습니다.
목차:
GIT : https://github.com/psmon/java-labs/tree/master/KotlinBootLabs
kotlin spring-boot-starter-webflux 템플릿으로 시작되었으며 akka 종속및 유닛테스트 종석은 다음을이용
val scalaVersion = "2.13" val akkaVersion = "2.7.0" dependencies { // Akka implementation(platform("com.typesafe.akka:akka-bom_$scalaVersion:$akkaVersion")) // Akka UnTyped Actor implementation("com.typesafe.akka:akka-actor_$scalaVersion:$akkaVersion") implementation("com.typesafe.akka:akka-stream_$scalaVersion:$akkaVersion") // Akka Typed Actor implementation("com.typesafe.akka:akka-actor-typed_$scalaVersion:$akkaVersion") testImplementation("com.typesafe.akka:akka-actor-testkit-typed_$scalaVersion:$akkaVersion") // Logging implementation("ch.qos.logback:logback-classic:1.4.12") implementation("com.typesafe.akka:akka-slf4j_$scalaVersion:$akkaVersion") // TestToolKit testImplementation("com.typesafe.akka:akka-testkit_$scalaVersion:$akkaVersion") // JUnit testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("io.projectreactor:reactor-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.3") } |
액터 모델은 수십 년 전 Carl Hewitt 가 고성능 네트워크에서 병렬 처리를 처리하는 방법으로 제안했습니다. 당시에는 사용할 수 없었던 환경이었습니다. 오늘날 하드웨어 및 인프라 기능은 Hewitt의 비전을 따라잡고 능가했습니다. 결과적으로 까다로운 요구 사항이 있는 분산 시스템을 구축하는 조직은 기존의 객체 지향 프로그래밍(OOP) 모델로는 완전히 해결할 수 없는 문제에 직면하지만 액터 모델에서는 이점을 얻을 수 있습니다.
오늘날 액터 모델은 매우 효과적인 솔루션으로 인정받을 뿐만 아니라 세계에서 가장 까다로운 애플리케이션 중 일부에 대한 프로덕션에서 입증되었습니다. 액터 모델이 해결하는 문제를 강조하기 위해 이 주제에서는 기존 프로그래밍 가정과 최신 멀티스레드, 멀티 CPU 아키텍처의 현실 간의 다음과 같은 불일치에 대해 설명합니다.
OOP의 핵심 기둥은 캡슐화 입니다 . 캡슐화는 객체의 내부 데이터가 외부에서 직접 접근할 수 없음을 지시합니다. 큐레이트된 메서드 집합을 호출하여서만 수정할 수 있습니다. 객체는 캡슐화된 데이터의 불변성을 보호하는 안전한 작업을 노출할 책임이 있습니다.
예를 들어, 정렬된 이진 트리 구현에 대한 연산은 트리 순서 불변성을 위반하는 것을 허용해서는 안 됩니다. 호출자는 순서가 그대로 유지되기를 기대하며 특정 데이터에 대해 트리를 쿼리할 때 이 제약 조건에 의존할 수 있어야 합니다.
OOP 런타임 동작을 분석할 때, 우리는 때때로 메서드 호출의 상호작용을 보여주는 메시지 시퀀스 차트를 그립니다. 예를 들어:
불행히도, 위의 다이어그램은 실행 중 인스턴스의 생명선을 정확하게 표현하지 못합니다 . 실제로 스레드는 이러한 모든 호출을 실행하고 불변식의 적용은 메서드가 호출된 동일한 스레드에서 발생합니다. 실행 스레드로 다이어그램을 업데이트하면 다음과 같습니다.
여러 스레드 에서 무슨 일이 일어나는지 모델링하려고 하면 이 설명의 중요성이 명확해집니다 . 갑자기 깔끔하게 그린 다이어그램이 부족해집니다. 여러 스레드가 같은 인스턴스에 액세스하는 것을 보여드리려고 할 수 있습니다.
두 스레드가 동일한 메서드에 들어가는 실행 섹션이 있습니다. 불행히도 객체의 캡슐화 모델은 해당 섹션에서 발생하는 일에 대해 아무것도 보장하지 않습니다. 두 호출의 명령어는 임의의 방식으로 끼워넣을 수 있으며, 이는 두 스레드 간의 어떤 유형의 조정 없이 불변성을 그대로 유지할 수 있는 희망을 제거합니다. 이제 이 문제가 많은 스레드의 존재로 인해 더 복잡해진다고 상상해 보세요.
이 문제를 해결하는 일반적인 방법은 이러한 메서드 주위에 잠금을 추가하는 것입니다. 이렇게 하면 주어진 시간에 최대 하나의 스레드만 메서드에 진입할 수 있지만, 이는 매우 비용이 많이 드는 전략입니다.
이러한 현실은 서로 이길 수 없는 상황을 초래합니다.
또한 잠금은 실제로 로컬에서만 잘 작동합니다. 여러 머신에서 조정하는 경우 유일한 대안은 분산 잠금입니다. 불행히도 분산 잠금은 로컬 잠금보다 몇 배나 덜 효율적이며 일반적으로 확장에 대한 엄격한 제한을 부과합니다. 분산 잠금 프로토콜은 여러 머신에서 네트워크를 통해 여러 번의 통신 왕복이 필요하므로 대기 시간이 엄청나게 늘어납니다.
객체 지향 언어에서 우리는 일반적으로 스레드나 선형 실행 경로에 대해 거의 생각하지 않습니다. 우리는 종종 시스템을 메서드 호출에 반응하고, 내부 상태를 수정한 다음 메서드 호출을 통해 서로 통신하여 전체 애플리케이션 상태를 앞으로 구동하는 객체 인스턴스의 네트워크로 생각합니다.
그러나 멀티스레드 분산 환경에서 실제로 일어나는 일은 스레드가 메서드 호출을 따라 객체 인스턴스의 네트워크를 "횡단"한다는 것입니다. 결과적으로 스레드가 실제로 실행을 주도합니다.
요약하면 :
80~90년대의 프로그래밍 모델은 변수에 쓰는 것이 메모리 위치에 직접 쓰는 것을 의미한다고 개념화했습니다(로컬 변수가 레지스터에만 존재할 수 있다는 사실을 다소 흐리게 만듭니다). 현대 아키텍처에서 - 사물을 조금 단순화하면 - CPU는 메모리에 직접 쓰는 대신 캐시 라인 에 씁니다 . 이러한 캐시의 대부분은 CPU 코어에 로컬합니다. 즉, 한 코어의 쓰기는 다른 코어에서 볼 수 없습니다. 로컬 변경 사항을 다른 코어, 그리고 다른 스레드에서 볼 수 있도록 하려면 캐시 라인을 다른 코어의 캐시로 전송해야 합니다.
JVM에서 우리는 휘발성 마커나 Atomic
래퍼를 사용하여 스레드 간에 공유할 메모리 위치를 명시적으로 표시해야 합니다. 그렇지 않으면 잠긴 섹션에서만 액세스할 수 있습니다. 왜 모든 변수를 휘발성으로 표시하지 않을까요? 코어 간에 캐시 라인을 전송하는 것은 매우 비용이 많이 드는 작업이기 때문입니다! 그렇게 하면 관련 코어가 추가 작업을 수행하지 못하게 암묵적으로 지연되고 캐시 일관성 프로토콜(CPU가 주 메모리와 다른 CPU 간에 캐시 라인을 전송하는 데 사용하는 프로토콜)에 병목 현상이 발생합니다. 그 결과 속도가 엄청나게 느려집니다.
이런 상황을 알고 있는 개발자라도 어떤 메모리 위치를 휘발성으로 표시해야 할지, 어떤 원자 구조를 사용해야 할지 파악하는 것은 어려운 일입니다.
요약하면 :
오늘날 우리는 종종 호출 스택을 당연하게 여깁니다. 하지만, 호출 스택은 다중 CPU 시스템이 일반적이지 않았기 때문에 동시 프로그래밍이 그렇게 중요하지 않았던 시대에 발명되었습니다. 호출 스택은 스레드를 교차하지 않으므로 비동기 호출 체인을 모델링하지 않습니다.
문제는 스레드가 작업을 "백그라운드"에 위임하려고 할 때 발생합니다. 실제로 이는 다른 스레드에 위임하는 것을 의미합니다. 이는 호출이 스레드에 엄격하게 로컬하기 때문에 간단한 메서드/함수 호출이 될 수 없습니다. 일반적으로 발생하는 일은 "호출자"가 작업자 스레드("호출 대상")가 공유하는 메모리 위치에 객체를 넣고, 그러면 해당 스레드가 어떤 이벤트 루프에서 객체를 집어올리는 것입니다. 이를 통해 "호출자" 스레드는 계속 진행하여 다른 작업을 수행할 수 있습니다.
첫 번째 문제는 "호출자"에게 작업 완료를 어떻게 알릴 수 있는가입니다. 하지만 더 심각한 문제는 작업이 예외로 실패할 때 발생합니다. 예외는 어디로 전파될까요? 실제 "호출자"가 누구인지 전혀 무시한 채 작업자 스레드의 예외 처리기로 전파됩니다.
심각한 문제입니다. 워커 스레드는 이 상황을 어떻게 처리할까요? 실패한 작업의 목적을 일반적으로 알지 못하기 때문에 문제를 해결할 수 없을 가능성이 큽니다. "호출자" 스레드는 어떻게든 알림을 받아야 하지만 예외로 풀릴 호출 스택이 없습니다. 실패 알림은 사이드 채널을 통해서만 수행할 수 있습니다. 예를 들어 "호출자" 스레드가 준비가 되면 결과를 기대하는 곳에 오류 코드를 넣는 것입니다. 이 알림이 없으면 "호출자"는 실패 알림을 받지 못하고 작업이 손실됩니다! 이는 네트워크 시스템이 작동하는 방식과 놀랍게도 비슷합니다. 여기서 메시지/요청은 아무런 알림 없이 손실되거나 실패할 수 있습니다.
이런 나쁜 상황은 상황이 정말 잘못되어 스레드에 의해 지원되는 작업자가 버그를 만나 복구할 수 없는 상황에 처하면 더욱 악화됩니다. 예를 들어, 버그로 인해 발생한 내부 예외가 스레드의 루트까지 버블링되어 스레드가 종료됩니다. 그러면 스레드가 호스팅하는 서비스의 정상적인 작동을 누가 다시 시작해야 하며, 알려진 양호한 상태로 어떻게 복원해야 하는지에 대한 의문이 즉시 제기됩니다. 언뜻 보기에 이는 관리하기 쉬운 것처럼 보일 수 있지만 갑자기 새롭고 예상치 못한 현상에 직면하게 됩니다. 스레드가 현재 작업하고 있던 실제 작업이 더 이상 작업을 가져오는 공유 메모리 위치(일반적으로 대기열)에 없습니다. 사실, 예외가 맨 위에 도달하여 모든 호출 스택을 풀었기 때문에 작업 상태가 완전히 손실됩니다! 네트워킹이 관련되지 않은 로컬 통신(메시지 손실이 예상됨)임에도 불구하고 메시지를 잃었습니다.
요약하면:
다음으로, 액터 모델을 사용하여 이러한 과제를 어떻게 극복할 수 있는지 살펴보겠습니다.
AKKA 액터모델이 국내활용 사례가 잘없어 인기있는것은 아니지만 리액티브 스트림이 활발한 글로벌 기업의 경우 많은 활용사례가 있습니다.
액터모델은 언어/플랫폼 종속적이지 않는 개발모델로, 자바진영으로 한정 없이 활용 Case는 더 많을것으로 추정합니다.