본문 바로가기
Data Platform/Akka

Akka HelloWorld

by 태하팍 2025. 3. 24.
반응형

Introduction to Actors

이번 시간에는  Helloworld 프로젝트를 통해서 actor APIs를 살펴보고 동작을 체크 합니다.

Module info

resolvers += "Akka library repository".at("https://repo.akka.io/maven")
val AkkaVersion = "2.10.2"
libraryDependencies ++= Seq(
  "com.typesafe.akka" %% "akka-actor-typed" % AkkaVersion,
  "com.typesafe.akka" %% "akka-actor-testkit-typed" % AkkaVersion % Test
)

아래는 실제 프로젝트의 build.sbt의 내용

Akka Actors

Actor Model은 동시 및 분산 시스템을 작성하기 위한 더 높은 수준의 추상화를 제공합니다. 
개발자가 명시적 잠금 및 스레드 관리를 처리하지 않아도 되므로 올바른 동시 및 병렬 시스템을 작성하기가 더 쉬워집니다. 
Actor는 Carl Hewitt가 1973년 논문에서 정의했지만 Erlang 언어로 대중화되었으며, 
예를 들어 Ericsson에서 매우 동시적이고 안정적인 통신 시스템을 구축하는 데 큰 성공을 거두었습니다.
Akka의 Actors API는 Erlang에서 일부 구문을 차용했습니다.

<< 소스코드 다운로드 >>

액터(Actor)의 기본적인 개념, 외부 및 내부 생태계를 이해하는 것이 중요합니다.
이를 통해
필요한 기능을 활용하고, 필요에 따라 커스터마이징할 수 있습니다.
자세한 내용은 Actor Systems, Actor References, Paths, Addresses를 참조하세요!

앞서 Actor Systems 에서 설명했듯이, 액터는 독립적인 실행 단위 사이에서 메시지를 주고받는 방식으로 동작 합니다.
그렇다면, 실제로 이것이 어떻게 구현되는지 살펴봅시다!

살펴보기전에 전체적인 흐름을 파악해야 합니다.

구성 요소설명

구성 요소 설명
ActorSystem[Greet] 최상위 액터. 시스템 시작점. ActorRef[Greet]처럼 동작
Behavior[Greet] Greet 메시지를 받을 수 있는 액터 동작 정의
ActorRef[Greet] ! 연산자를 통해 메시지를 보낼 수 있음
context 액터의 내부 도구 (로그, spawn, self, 등 제공)
message.replyTo 응답받을 액터 주소 (보통 ActorRef[Greeted])

 

우선 import가 필요합니다.

import akka.actor.typed.ActorRef
import akka.actor.typed.ActorSystem
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors

우선 메인소스부터 보겠습니다.

object HelloWorldMain {

  final case class SayHello(name: String)

  def apply(): Behavior[SayHello] =
    Behaviors.setup { context =>
      val greeter = context.spawn(HelloWorld(), "greeter")

      Behaviors.receiveMessage { message =>
        val replyTo = context.spawn(HelloWorldBot(max = 3), message.name)
        greeter ! HelloWorld.Greet(message.name, replyTo)
        Behaviors.same
      }
    }

  //#hello-world-main
  def main(args: Array[String]): Unit = {
    
    val system: ActorSystem[HelloWorldMain.SayHello] =
      ActorSystem(HelloWorldMain(), "hello")

    system ! HelloWorldMain.SayHello("World")
    system ! HelloWorldMain.SayHello("Akka")
    

    Thread.sleep(3000)
    system.terminate()
  }
  //#hello-world-main
}
//#hello-world-main

가장 눈에 띄는건 최상위 액터. 시스템 시작점. ActorRef[Greet]처럼 동작하는 ActorSystem입니다.
제네릭타입 [T]가 소스상 [HelloWorldMain.SayHello] 타입 입니다.

최상위 액터이며 시작점 입니다.

object HelloWorldMain {

  final case class SayHello(name: String)

  def apply(): Behavior[SayHello] =
    Behaviors.setup { context =>
      val greeter = context.spawn(HelloWorld(), "greeter")

      Behaviors.receiveMessage { message =>
        val replyTo = context.spawn(HelloWorldBot(max = 3), message.name)
        greeter ! HelloWorld.Greet(message.name, replyTo)
        Behaviors.same
      }
    }

다시 메인으로 돌아와서 system이라는 val 변수에 ActorSystem[HelloWorldMain.SayHello] 타입으로 
ActorSystem(HelloWorldMain(), "hello")는 SayHello메시지를 받을 수 있는 최상위 Actor System을 만든다는 뜻 입니다.
HelloWorldMain은 Behavior[SayHello]이고 그것을 ActorSystem(..)에 넣으면 System이 시작되면서 HelloWorldMain안에 있는
로직 Behaviors.setup이 실행되고 메시지를 받기 시작 합니다.(Behaviors.receiveMessage { message =>)

val system: ActorSystem[HelloWorldMain.SayHello] =
  ActorSystem(HelloWorldMain(), "hello")

그리고 메시지를 보낼때는
system ! HelloWorldMain.SayHello("World") 처럼
system은 ActorRef[SayHello]처럼 동작하기 때문에 SayHello("world") 메시지를 루트 Actor에게 전달하게 되고
HelloWorldMain 내부에서 메시지를 처리해서 HelloWorldBot과 HelloWorld Actor들이 생성됩니다.

HelloWorld Actor

이제 이러한 개념을 바탕으로 첫 번째 액터(Actor)를 정의할 수 있으며, 해당 액터는 “Hello!“라고 인사할 것입니다!

object HelloWorld {
  final case class Greet(whom: String, replyTo: ActorRef[Greeted])
  final case class Greeted(whom: String, from: ActorRef[Greet])

  def apply(): Behavior[Greet] = Behaviors.receive { (context, message) =>
    context.log.info("Hello {}!", message.whom)
    message.replyTo ! Greeted(message.whom, context.self)
    Behaviors.same
  }
}

HelloWorld object에서는 두 가지 메시지 타입을 정의한다.
하나는 액터에게 특정 인물을 인사(Greet) 하도록 명령하는 메시지이고,
다른 하나는 액터가 인사를 완료했음(Greeted)을 알리는 확인 메시지이다.

Greet 타입은 단순히 인사할 대상 정보뿐만 아니라, 메시지의 발신자가 제공한 ActorRef 도 포함한다.
이를 통해 HelloWorld 액터는 응답 메시지를 보낼 수 있다.

액터의 동작 (Behavior) - apply()메소드가 실제 Behavior(행동)을 정의!
액터의 동작은 Greeter(HelloWorld)의 함수에 정의되며, receive 동작 팩토리를 사용하여 구현됩니다.
다음 메시지를 처리하면,  그 결과로 새로운 Behavior(행동 상태) 를 반환하는데, 그 Behavior는 지금과 다를 수 있습니다.
무슨 말이냐면 Behavior = 상태(상태를 갖는 행동)이며 위 소스에서 메시지를 처리한 결과가 Actor의 상태(=Behavior)가 바뀔 수 있다는 뜻 입니다. 상태를 안바꾸면? Behaviors.same (상태를 바꿀 필요 없을 때는 현재 상태 그대로 유지!)
메시지를 처리한다는 말은 Greet("태하", actorRef) 메시지를 받았다고 가정하면
Akka는 이 메시지를 HelloWorld Actor한테 전달을 하고 이 Actor는 메시지를 처리하게 됩니다.

def apply(): Behavior[Greet] = Behaviors.receive { (context, message) =>
  context.log.info("Hello {}!", message.whom)
  message.replyTo ! Greeted(message.whom, context.self)
  Behaviors.same
}

위 소스에서 익명함수(context, message) => 이 부분이 Actor가 메시지를 받았을 때 어떤걸 수행할지..
즉, 처리하는 부분 입니다.

상태(state) 업데이트는 새로운 불변(immutable) 상태를 가지는 Behavior를 반환하는 방식으로 이루어진다.
그러나 이번 경우에는 상태 변경이 필요하지 않으므로, 현재 상태와 동일한 Behavior(same)를 반환한다.

이 Behavior는 Greet 클래스의 메시지 유형을 처리하도록 정의되었다. 
따라서 메시지 인자는 자동으로 Greet 타입이 되며, whom(인사할 대상)과 replyTo(응답을 받을 액터) 를 직접 접근할 수 있다.
(즉, 패턴 매칭 없이도 메시지의 속성에 접근 가능함)
일반적으로, 액터는 여러 메시지 타입을 처리할 수 있으며, 이들은 공통된 Trait을 확장하는 방식으로 구성될 수 있다.

위의 말을 이해하려면 Akka Typed가 내부적으로 타입 파라미터를 활용해서 메시지처리를 한다는걸 이해해야합니다.
ex) def receive[T](onMessage: (ActorContext[T], T) => Behavior[T]): Receive[T] 에서 def receive[T]에서 [T]는 타입 파라미터! 즉, T는 제네릭으로 이 함수가 다룰 메시지의 타입을 의미 합니다.  
      def apply(): Behavior[Greet] = Behaviors.receive { (context, message) =>

메시지 전송
마지막 줄에서는 HelloWorld 액터가 다른 액터에게 메시지를 전송하는 코드가 등장한다.
메시지 전송은 ! 연산자(읽는 법: “bang” 또는 “tell”)를 사용하며,
이는 비동기(asynchronous) 방식으로 동작하여, 호출하는 스레드를 블로킹하지 않는다.
또한, replyTo 주소는 ActorRef[Greeted] 타입 으로 선언되어 있다.
따라서 컴파일러는 해당 타입의 메시지만 전송할 수 있도록 제한하며, 다른 타입을 보내면 컴파일 오류가 발생한다.

프로토콜 정의
액터가 처리하는 메시지 타입과 응답 메시지 타입은, 해당 액터가 따르는 프로토콜을 정의한다.
이번 예제에서는 단순한 요청-응답(request–reply) 프로토콜이지만,
필요에 따라 더 복잡한 프로토콜도 구현 가능하다.
이러한 프로토콜과 해당 동작을 HelloWorld 객체 내부에 하나로 묶어 캡슐화하였다.

trait ActorRef[-T] extends RecipientRef[T] with Comparable[ActorRef[_]] with Serializable

더보기

ActorRef[T] 는 액터(Actor)의 주소 또는 식별자 역할을 하는 인터페이스이다.
이는 액터의 생명 주기 동안에만 유효하며, 해당 ActorRef 를 통해 해당 액터로 메시지를 보낼 수 있다.

만약 메시지를 보낸 후 액터가 종료되었거나, 메시지를 받기 전에 종료된 경우
해당 메시지는 버려지게 되며(Discarded),
최대한의 노력(best effort)으로 Akka의 DeadLetter 채널(Akka 이벤트 스트림 내의 DeadLetter)로 전달된다.
하지만, 이 전달은 보장되지 않는다 (not reliable).

https://doc.akka.io/api/akka-core/2.10/akka/actor/typed/ActorRef.html#tell(msg:T):Unit

abstract class Behavior[T] extends AnyRef

더보기

액터의 동작(Behavior)수신하는 메시지에 대한 반응을 정의한다.

메시지는 액터가 선언한 타입의 메시지일 수도 있고, 액터 자체 또는 하위 액터(child actor)의
생명 주기 이벤트(lifecycle event)를 나타내는 시스템 신호(Signal) 일 수도 있다.

Behavior를 정의하는 방법
Behavior는 다양한 방식으로 정의할 수 있다.
DSL(도메인 특화 언어)를 활용하는 방법
akka.actor.typed.scaladsl.Behaviors (Scala)
akka.actor.typed.javadsl.Behaviors (Java)
ExtensibleBehavior 클래스를 확장하는 방법

ActorContext와 Behavior의 이동성(Immobility)

이 부분에서 강조하는 핵심은?
  Akka의 액터는 특정 컨텍스트(context) 안에서만 동작해야 하며, 해당 컨텍스트를 벗어나거나 복제될 수 없다는 점이다.

 ActorContext를 포함하는 Behavior는 이동할 수 없다 (Immobile)
ActorContext 는 액터의 실행 환경(execution environment) 을 제공한다.
액터가 실행되는 동안, 이 ActorContext 를 사용하여 다른 액터를 생성하거나, 로그를 남기거나, 메시지를 보낼 수 있다.
하지만, 한 번 특정 액터의 컨텍스트에 바인딩된 Behavior는 다른 컨텍스트로 이동할 수 없다.
즉, 액터의 상태를 복사해서 다른 곳에서 실행하는 것(Replication)이나, 포크(Forking)해서 동시에 실행하는 것이 불가능하다.

이동할 수 없는 이유?
ActorContext특정 액터에 종속된 실행 환경이므로, 이를 다른 액터나 다른 노드로 옮기면
   의미가 불분명해지거나 오류가 발생할 수 있다.
따라서 Behavior는 현재 컨텍스트에서만 실행될 수 있으며, 컨텍스트를 변경하면 동작이 보장되지 않는다.

사용자 코드에서 확장 금지

 추상 클래스(abstract class)  사용자가 직접 확장하지 않도록 설계되었다.
사용자가 이를 확장하면 바이너리 호환성(Binary Compatibility)을 보장할 수 없게 될 가능성이 있음.

Behavior 클래스를 직접 확장하지 말라는 의미
Behavior[T] 는 추상 클래스이지만, 사용자가 직접 확장해서 새로운 Behavior를 만들면 안 된다.
이유는 바이너리 호환성(Binary Compatibility)을 보장할 수 없기 때문.
즉, Akka 내부에서 Behavior 구현이 변경될 가능성이 있기 때문에, 사용자가 이를 확장하면 이후
   Akka 업데이트 시 코드가 깨질 위험이 있음.

확장을 막는 이유?
Akka는 Behavior 를 내부적으로 여러 방식으로 최적화하여 실행할 수 있도록 설계되어 있음.
사용자가 직접 확장하면, 이러한 최적화 및 내부 메커니즘과 충돌할 가능성이 높음.

Behaviors.receive(...) 같은 팩토리 메서드를 사용하자!

Object Behavior

더보기

def receive[T](onMessage: (ActorContext[T], T) => Behavior[T]): Receive[T]

이 함수는 새로운 액터의 동작(Behavior) 을 정의하는 팩토리 함수이다.
액터가 메시지를 받았을 때(onMessage), 이를 처리하고 새로운 Behavior를 반환하는 방식으로 동작한다.
즉, 이 함수는 액터가 수신하는 메시지에 따라 어떤 동작을 할지를 정의하는 역할을 한다.

기능 설명
이 함수는 액터가 메시지와 생명 주기(lifecycle) 신호(signal)를 처리할 수 있도록 설정한다.
생성된 액터는 다른 액터가 스폰(spawn)하거나, akka.actor.typed.ActorSystem 의 최상위(guardian) 액터로 실행할 수 있다.
액터는 ActorContext 내에서 실행되며, 이를 통해
시스템(System)과 상호작용하거나,
새로운 액터를 생성(Spawning),
다른 액터를 감시(Watching)할 수 있다.

Abstract Behavior와의 차이점
AbstractBehavior 를 사용하는 방식보다 더 함수형 스타일(Functional Style)로 Behavior를 정의할 수 있다.
메시지를 처리한 후, 새로운 Behavior를 반환할 수도 있다.
상태(State)를 변경할 때는, 새로운 불변(Immutable) 상태를 가진 Behavior를 반환하는 방식으로 유지된다.

import akka.actor.typed.{ActorSystem, Behavior}
import akka.actor.typed.scaladsl.{Behaviors, ActorContext}

// 액터 정의
object HelloWorld {
  def apply(): Behavior[String] = Behaviors.receive { (context, message) =>
    context.log.info(s"Received message: $message")
    Behaviors.same // 상태 변경 없이 동일한 Behavior 유지
  }
}

// 액터 실행
val system: ActorSystem[String] = ActorSystem(HelloWorld(), "HelloWorldSystem")
system ! "Hello, Akka!"  // 메시지 전송

더 많은 액터와 상호작용
Carl Hewitt(액터 모델 창시자)은 다음과 같이 말했다.
“하나의 액터는 액터가 아니다(One Actor is no Actor).”
즉, 대화할 상대가 없다면 액터는 존재 의미가 없다.
따라서 Greeter 액터와 상호작용할 다른 액터가 필요하다.
이를 위해 HelloWorldBot 액터를 만들 예정이며, 이 액터는 Greeter의 응답을 받은 후 추가적인 인사 메시지를 보낸다.
그리고 최대 메시지 횟수에 도달할 때까지 이러한 과정을 반복한다.

HelloWorldBot
이 Actor가 변수를 사용하지 않고 각 Greeted 답변에 대한 동작을 변경하여 카운터를 관리하는 방식에 주목하세요. 
actor 인스턴스가 한 번에 하나의 메시지를 처리하기 때문에 synchronized 또는 AtomicInteger와 같은 동시성 가드가 
필요하지 않습니다.

object HelloWorldBot {

  def apply(max: Int): Behavior[HelloWorld.Greeted] = {
    bot(0, max)
  }

  private def bot(greetingCounter: Int, max: Int): Behavior[HelloWorld.Greeted] =
    Behaviors.receive { (context, message) =>
      val n = greetingCounter + 1
      context.log.info("Greeting {} for {}", n, message.whom)
      if (n == max) {
        Behaviors.stopped
      } else {
        message.from ! HelloWorld.Greet(message.whom, context.self)
        bot(n, max)
      }
    }
}

세 번째 액터는 위에서 설명한 메인 액터이며 Greeter와 HelloWorldBot을 생성하고 서로 상호작용을 시작합니다.

object HelloWorldMain {

  final case class SayHello(name: String)

  def apply(): Behavior[SayHello] =
    Behaviors.setup { context =>
      val greeter = context.spawn(HelloWorld(), "greeter")

      Behaviors.receiveMessage { message =>
        val replyTo = context.spawn(HelloWorldBot(max = 3), message.name)
        greeter ! HelloWorld.Greet(message.name, replyTo)
        Behaviors.same
      }
    }

}

이제 위의 액터들을 가지고 ActorSystem을 실행해보겠습니다.

val system: ActorSystem[HelloWorldMain.SayHello] =
  ActorSystem(HelloWorldMain(), "hello")

system ! HelloWorldMain.SayHello("World")
system ! HelloWorldMain.SayHello("Akka")

정의된 HelloWorldMain 동작에서 Actor 시스템을 시작하고 두 개의 SayHello 메시지를 보내 
두 개의 별도 HelloWorldBot actor와 단일 Greeter actor 간의 상호 작용을 시작합니다.

애플리케이션은 일반적으로 JVM당 여러 actor를 실행하는 단일 ActorSystem으로 구성됩니다.

참고 사이트 :

https://doc.akka.io/libraries/akka-core/current/

https://doc.akka.io/libraries/akka-core/current/typed/guide/introduction.html

반응형

'Data Platform > Akka' 카테고리의 다른 글

Akka에서 Actor 생성 시 금지하는 방식  (0) 2025.03.27
Akka Classic 뽀개기_Overview  (0) 2025.03.26
Akka Classic  (0) 2025.03.25