아카 시작하기 Ch.04 고장 나도록 허용하라(예외 처리)

Ada·2023년 8월 28일

Akka

목록 보기
7/32

자바나 C# 에서의 예외처리는 보통 try-catch구문을 사용한다.

이 코드는 보통 예외가 발생한 소스로부터 멀리 떨어져있기 때문에 예외가 발생한 이유를 알기 어렵고,
그것을 어떻게 '처리'해야 하는지는 더욱 알기 어렵다.
그렇게 때문에 할 수 있는 일이 예외가 발생했다는 사실을 로그 파일에 기록하는 것으로 국한된다.

액터 모델에서는 "고장 나도록 허용하라" 는 철학을 취했다.

수상한 코드륵 try-catch 블록으로 일일이 감싸는 게 아니라 예외가 발생하도록 그냥 내버려 두라는 전략이다.

예외를 처리하지 않겠다는 이야기가 아니라, 예외를 멀리 던져버릴 수 있는 try-catch와 다르게
어느 액터가 발생시킨 예외는 그 액터의 감시자(supervisior)가 곧바로 처리하도록 만들겠다는 전략이다.

이번 장에서 공부할 코드의 메인 클래스는 두 개이다.

하나는 BadMain 클래스이고 다른 하나는 GoodMain 클래스이다.
이 둘의 차이는 핑액터에게 "good"을 보내는가, "bad"를 보내는가로 국한된다.

// GoodMain

import akka.actor.{ActorSystem, Props}
import org.study.actor.PingActor

object GoodMain {
  def main(args: Array[String]) = {
    val actorSystem = ActorSystem.create("TestSystem")
    val ping = actorSystem.actorOf(Props.create(classOf[PingActor]))
    ping ! "good"
  }
}

// BadMain

import akka.actor.{ActorSystem, Props}
import org.study.actor.PingActor

object BadMain {
  def main(args: Array[String]) = {
    val actorSystem = ActorSystem.create("TestSystem")
    val ping = actorSystem.actorOf(Props.create(classOf[PingActor]))
    ping ! "bad"
  }
}

다음은 액터 계층구조에서 루트에 해당하는 PingActor 이다.

import akka.actor.{Actor, Props}
import akka.event.Logging

/**
  * "good"이나 "bad" 메시지를 받으면 Ping1Actor라는 자식 액터에게 전달.
  * "done" 메시지를 받으면 화면에 결과를 출력.
  */
class PingActor extends Actor {
  val log = Logging(context.system, this)
  val child = context.actorOf(Props.create(classOf[Ping1Actor]), "ping1Actor")

  def receive = {
    case msg@"good" => child ! msg
    case msg@"bad" => child ! msg
    case msg@"done" => log.info("a task is successfully completed.")
  }
}

다음으로는 감시전략과 관련해서 중요한 내용을 담고잇는 Ping1Actor 코드이다.

SupervisorStrategy 라는 객체가 정의되는 부분에 주의해야 한다.

자식 액터가 ArithmeticException 을 발생시키면 Resume 이라는 메서드를 호출하고,
NullPointerExcetion 을 발생시키면 Restart 라는 메서드를 호출한다.

import akka.actor.SupervisorStrategy.{Escalate, Restart, Resume, Stop}
import akka.actor.{Actor, OneForOneStrategy, Props}
import akka.event.Logging
import scala.concurrent.duration._

/**
  * 자식 액터들을 감시하기 위한 전략을 선언하는 액터
  */
class Ping1Actor extends Actor {
  val log = Logging(context.system, this)
  val child1 = context.actorOf(Props.create(classOf[Ping2Actor]), "ping2Actor")
  val child2 = context.actorOf(Props.create(classOf[Ping3Actor]), "ping3Actor")

  def receive = {
    case msg : String  =>
      log.info(s"Ping1Actor received $msg")
      child1.tell(msg, sender)
      child2.tell(msg, sender)
  }

  override val supervisorStrategy =
    OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 1 minute) {
      case _: ArithmeticException => Resume  // Ping2Actor는 "bad" 메시지를 받으면 ArithmeticException을 발생
      case _: NullPointerException => Restart // Ping3Actor는 "bad" 메시지를 받으면 NullPointerException을 발생
      case _: IllegalArgumentException => Stop
      case _ => Escalate

    }
}

다음은 "bad" 라는 메시지를 전달받았을 때 일부러 ArithmeticException 을 발생시키는 Ping2Actor의모습이다.
이 액터는 다시 시작될 때 화면에 메시지를 출력시킨다.

import akka.actor.Actor
import akka.event.Logging

class Ping2Actor extends Actor {
  val log = Logging(context.system, this)

  override def preRestart(reason: Throwable, message: Option[Any]) = {
    log.info("Ping2Actor preRestart..")
  }

  override def postRestart(reason: Throwable) = {
    log.info("Ping2Actor postRestart..")
  }

  override def postStop = {
    log.info("Ping2Actor postStop..")
  }

  override def receive = {
    case msg@"good" =>
      goodWork
      sender ! "done"
    case msg@"bad" =>
      badWork
  }

  private def goodWork = log.info("Ping2Actor is good.")
  private def badWork = { val a = 1 / 0 } /** 일부러 ArithmeticException을 발생시킨다 */
}

끝으로 "bad"리는 메시지를 전달받았을 때 일부러 NullPointerException 을 발생시키는 Ping3Actor의 모습이다.

전체적인 모습은 Ping2Actor 와 거의 비슷하다. 이 액터도 다시 시작될 때 화면에 메시지를 출력시킨다.

import akka.actor.Actor
import akka.event.Logging

class Ping3Actor extends Actor {
  val log = Logging(context.system, this)

  override def preRestart(reason: Throwable, message: Option[Any]) = {
    log.info("Ping3Actor preRestart..")
  }

  override def postRestart(reason: Throwable) = {
    log.info("Ping3Actor postRestart..")
  }

  override def postStop = {
    log.info("Ping3Actor postStop..")
  }

  override def receive = {
    case msg@"good" =>
      goodWork
      sender ! "done"
    case msg@"bad" =>
      badWork
  }

  private def goodWork = log.info("Ping3Actor is good.")
  private def badWork = { throw new NullPointerException } /** 일부러 NullPointerException을 발생시킨다 */
}

GoodMain

위 사진은 각각 GoodMain일 때와 BadMain일 때의 출력값이다.

SupervisorStrategy

위의 코드에서는 Ping2Actor 와 Ping3Actor 의 부모인 Ping1Actor 에서 감시전략을 정의했다.

  • ArithemeticException 이 발생하면 Resume 을 호출하라.
  • NullPointerException 이 발생하면 Restart 를 호출하라.
  • IllegarArgumentException 이 발생하면 Stop 을 호출하라.
  • 나머지 모든 예외에 대해서는 escalate 를 호출하라.

아카 내부의 코드는 예외를 포착했을 때 그 액터의 감시자가 감시전략을 갖고 있는지 확인한다.

만약(Ping1Actor의 경우처럼) 별도의 감시전략을 갖고 있으면 그 전략을 사용하고
그렇지 않으면 아카가 기본적으로 제공하는 전략을 사용한다.

어떤 액터가 자신만의 감시전략을 수립하려면 내부에 SupervisorStrategy 객체를 정의하고
다음 메서드를 통해서 기본전략을 오버라이드 하면 된다.

  override val supervisorStrategy =
    OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 1 minute) {
      case _: ArithmeticException => Resume  // Ping2Actor는 "bad" 메시지를 받으면 ArithmeticException을 발생
      case _: NullPointerException => Restart // Ping3Actor는 "bad" 메시지를 받으면 NullPointerException을 발생
      case _: IllegalArgumentException => Stop
      case _ => Escalate

    }

이러한 매커니즘이 존재하기 때문에 액터 코드에서 receive 내부에서 별도의 try-catch 구문을 선언하는 데 시간을 할애하지 않아도 된다.

액터를 벗어난 예외는 반드시 감시자에게 전달되어 어떤 식으로든 '처리' 가 되기 때문이다.

아카에서 감시자는 해당 액터를 생성한 부모 액터이다.
따라서 부모 액터는 모든 자식 액터의 감시자이다.

Resume

Resume 은 예외를 발생시킨 메시지를 무시하고 메일박스에 있는 다음 메시지를 처리하라는 뜻이다.

즉, ArithmeticException이 발생하면 그 예외를 발생시킨 메시지 ("bad")를 버리고, 메일박스에 있는 다음 메시지를 처리하라는 뜻이 된다.

그렇기 때문에 Ping2Actor의 경우에는 "/ by zero" 라는 메시지가 화면에 출력되었을 뿐, 다른 일은 일어나지 않았다.

이제 Ping2Actor 에게 다른 메시지가 전달되면 아무 일도 없었다는 듯이 receive가 호출될 것이다.

Restart

하지만 restart 는 다르다.

preRestart - constructgor - postRestart 가 호출된다.

생성자가 호출되었다는 사실로부터 유추할 수 있듯이
restart 메서드는 객체를 감시자의 판단에 의해 완전히 새로 만들고,
데이터 구조를 모두 초기화한다.

만약 그런 내부 데이터를 반드시 유지할 필요가 있다면, preRestart 메서드에서 데이터를 외부에 저장하고 생성자나 postRestart 메서드에서 불러들여야 한다.

한 가지 기억할 점은 액터가 다시 시작된다고 해도, 메일박스에 있던 메시지들은 영향을 받지 않는다는 것이다.

Ping1Actor 는 Ping3Actor를 child2 라는 변수를 이용해 참조하고 있다.

변수의 타입은 물론 ActorRef 이다. NullPointException이 발생했을 때 ActorRef 객체 내부에서 Ping3Actor라는 클래스의 인스턴스가 새로 만들어져서 낡은 인스턴스를 교체하였다.

하지만 Ping1Actor 는 여전히 그 액터를 child2 라는 변수로 참고한다.

액터시스템을 사용하면서 단순히 Resume 이 아니라 Restart 를 사용하는 경우는 해당 액터 내부에서 관리되는 데이터가 예외가 발생하는 상황에 의해서 오염되었다고 판단될 때다.

즉, Restart를 사용하는 것은 '초기화'를 위한 전략이다.

A라는 액터에서 X라는 예외가 발생하는 경우에는 A 내부의 값이 오염될 수 있다고 판단될 때,
SupervisorStrategy 를 이용해서 X라는 예외에 restart 라는 동작을 매핑해준다.

Stop

stop 은 감시전략이 취할 수 있는 가장 가혹한 대응이다.

stop에 의해서 동작이 중지된 액터는 다른 액터에 의해서 다시 생성되기 전까지 액터시스템 안에 존재할 수 없다.

stop이라는 것은 액터가 갖는 라이프사이클에서 최후의 단계, 죽음에 해당한다.

액터는 preStop 메서드를 구현함으로써 완전히 생명을 잃기 전에 필요한 일을 수행할 수 있다.

Escalate

액터의 감시전략은 resume, restart, stop 에게 매핑되지 않은 예외가 발생하면
자신의 감시자, 즉 부모 액터에게 알린다. 이것이 escalate가 수행하는 일이다.

기본 감시전략

액터를 구현하면서 감시전략을 별도로 정의하지 않으면 아카가 기본적으로 제공하는 전략이 사용된다.

기본 전략은 다음과 같이 정의되어 있다.

  • ActorInitializationException stop() 을 호출한다.

  • ActorKilledException stop() 을 호출한다.

  • Exception restart()를 호출한다.

  • Throwable escalate()를 호출한다.

일반적으로 발생하는 예외는 세 번째 Exception 에 의해서 포착되므로,
감시전략을 정의하지 않은 액터의 자식은 대부분 restart 에 의해서 처리된다.

빠른 성능에 대한 요구가 있거나, 액터의 내부 상태를 굳이 초기화할 필요가 없다고 판단될 때는 resume 을 사용하는 것이 더 효율적이다.

재귀와 연대책임

재귀

어떤 액터가 예외를 발생시켜서 그 액터의 부모가 restart를 호출했다고 가정하자.

새롭게 생성되는 액터는 다른 액터들의 부모일 수도 있다.

이런 액터가 생성된다면 그 액터가 거느리고 있는 자식 액터들도 모두 자동적으로 새로 생성된다.

즉, restart 는 재귀적인 방식으로 자식과 후손들에게 적용된다.

만약 트리구조가 십만 개의 액터로 이루어져 있다고 생각해볼 때, 트리의 상위에 위치하는 액터가 다시 시작하게 되면 그 아래에 있는 수 천, 수 만 개의 액터가 재귀적인 방식으로 다시 시작하게 된다.

아카는 해당 액터의 후손 액터들을 모두 중단시키고, 그들을 구성하는 객체를 새로 만들어 저마다의 ActorRef에 집어넣는다.

그렇기 때문에 restart 는 단순히 액터 하나만 고려해서는 안 된다.

재귀적 동작방식 때문에 미처 생각하지 못한 커다란 비용을 수반하는 동작이 될 수도 있따.

그래서 액터시스템을 이용하여 실전코드를 작성할 때는 이런 면들을 모두 고려할 수 있는 능력을 갖추어야 한다.

연대책임

감시전략이 어떤 예외를 포착해서 매핑되어 있는 메서드를 호출할 때 연대책임을 물을 수도 있다.

resume, restart, stop 등의 메서드를 예외를 발생시킨 액터에게 국한하여 적용시킬 수도 있고, 모든 자식에게 한번에 적용시킬 수도 있다.

OneForOneStrategy는문제의 액터에게만 벌을 주는 방식이고,
AllForOneStrategy는 연대책임을 묻는 방식이다.

커다란 트리구조에서 AllForOnStrategy 가 restart와 결합되어 사용된다면, 눈에 보이지 않는 비용이 더 커질 수도 있다.

profile
백엔드 프로그래머

0개의 댓글