Swift: Actor란?

Heechan
HcleeDev
Published in
9 min readJul 10, 2021
Photo by Sergi Dolcet Escrig on Unsplash

이번에도 Swift 5.5부터 추가된 기능을 공부해보는 시간을 가졌다. 이번주에 소개할 내용은 Actor로, 이 또한 Concurrency와 관련된 문제를 해결하기 위해 도입되었다. 사람이 한번에 한 일만 할 수 있듯이, 우리는 Actor를 만들어서 이 Actor가 한번에 하나의 일만 수행하도록 만들 수 있다. 처음엔 왜 ‘배우'지? 라고 생각했는데, 그냥 행동하는 무언가로 생각하는게 맞을 것 같다.

이번 글에서는 Actor의 도입 배경, 개념, 그리고 사용 예시에 대해 알아보도록 하자.

Actor와 그 등장 배경

Swift의 동시성 모델은 다양한 동시성 버그들로부터 안전한 프로그래밍 환경을 제공하기 위해 만들어졌다. 이 중에서도 대표적인 버그 상황은 바로 data race 컨디션으로, 각자 다른 스레드에서 같은 데이터에 접근할 때 생기는 문제점이다.

지난 번에 작성한 Structed Concurrency에서도 data race를 방지하기 위해 컴파일러 단위부터 검사해주고 있음을 알 수 있었는데, 그걸 가능하게 하는 것이 바로 Actor다.

프로그래머는 Actor로 ‘하나의 동시성 영역’ 안에서 유지되는 상태의 모임을 만들 수 있고, 이 Actor가 여러가지 명령을 수행할 수 있도록 설정할 수 있다. 각 Actor는 각자의 data를 ‘Actor Isolation’이라는 개념을 이용해 보호한다. 이를 통해 한 데이터에 동시에 단 하나의 스레드만 접근할 수 있도록 해 의도치 않은 data race를 막을 수 있다.

actor는 새로운 타입으로 등장했다. Structed Concurrency에서 본 기억으로는 프로퍼티 래퍼인줄 알았는데 아예 새로운 타입으로 개발되었다.

위 사진처럼 actor 를 사용할 수 있다. actor 는 reference type이다. 그리고 다른 타입들 처럼 initializer도 가질 수 있고, 메서드, 프로퍼티, subscript도 가질 수 있다.

가장 크게 다른 점은 actor 로 정의된 경우엔 반드시 data race를 방지할 수 있도록 Actor Isolation을 지키며 작성되어야 한다는 점이다. 이는 컴파일러 단에서 검사를 해주기 때문에 Safe한 프로그램을 작성할 수 있게 된다.

Actor Isolation

말그대로 ‘고립’시킨다. Actor의 mutable한 상태를, Actor를 고립시킴으로써 보호한다.

가장 기본적인 원칙은 Actor의 프로퍼티에는 오직 self 를 통해서만 접근할 수 있도록 하는 것이다.

저 위에 있었던 BankAccount의 정의와 연결된 예시다.

이 코드는 BankAccount의 extension이고, 위 원칙에 따르면 self로 자기 자신의 프로퍼티에 접근할 수 있다.

하지만 마지막 줄에서 에러가 발생하는데, 이는 다른 자신이 아닌 다른 BankAccount, otherbalance에 접근했기 때문이다.

이 예시는 은행 계좌에서 다른 은행 계좌로 송금하는 기능을 보여주고 있다. 만약 여기서 에러가 발생하지 않고 그대로 사용되었다고 쳤을 때 어떤 문제가 일어날 수 있을까?

A랑 B 계좌는 C 계좌에 돈을 보내고 싶어 한다. 이때 A랑 B는 각각 transfer(amount: amount, to: C) 를 실행시킨다. 각자 다른 스레드에서 돌아가는 A와 B는, 순차적으로 처리되는 것이 보장되지 않아 동시에 구동될 수 있다. 이때 명령이 이 순서대로 진행되었다고 생각해보자.

  • A가 other.balance 로 C의 계좌에 10000원이 있음을 알아냄
  • B가 other.balance 로 C의 계좌에 10000원이 있음을 알아냄
  • B가 7500원을 보내기 위해 other.balance = 10000 + 7500 을 실행해 C의 계좌에는 17500원이 저장됨
  • A가 5000원을 보내기 위해 other.balance = 10000 + 5000 을 실행해 C의 게좌에는 15000원이 저장됨

이 순서대로 진행되면 C가 받아야 하는 돈은 12500원임에도 불구하고 마지막에 실행된 명령 때문에 5000원만 받는 참사가 벌어진다. 실제로 이런 data race가 벌어지면 그 은행은…

이렇기 때문에 일단 각 actor 에 저장된 프로퍼티를 다른 객체에서 접근할 수 없도록, 오직 self 를 통해서만 접근 가능하도록 해 상태를 보존하는 것이다.

Cross-Actor Reference와 비동기적 처리

그런데 위 사진의 코드를 보면 other 에 접근한다고 무조건 에러가 나는 것은 아니라는 점을 눈치챌 수 있다.

print 문 안에서는 other.accountNumber 에 접근해도 에러가 나지 않는다는 것을 확인할 수 있는데, actor 로 정의한 객체임에도 외부에서 접근하기 위해선 어떻게 해야 할까?

이런 식으로 Actor에서 다른 Actor에 접근하는 것을 Cross-Actor Reference라고 하며, 이 Cross-Actor Reference가 제대로 작동하는 경우는 두 가지 밖에 없다.

첫 번째는 접근하는 프로퍼티가 immutable할 경우다. 다시 확인해보면 BankAccount에서 balance 는 변수지만, accountNumber 는 상수로 정의되어있다. 상수는 값이 변하지 않기 때문에 data race 문제가 발생할 일이 없다. 따라서 상수에 접근하는 것은 self 로 막지 않고 누구나 할 수 있도록 했다.

두 번째 경우는, Cross-Actor Reference가 비동기 함수 안에서 등장하는 경우다. Async 함수 안의 await 가 붙어있는 명령은 당장 그걸 실행하는 것이 아니라, 이 명령을 차후에 실행시켜달라고 해당 Actor에 메세지를 보내는 것과 다름없다. 그 Actor는 이 메세지들을 가지고 있다가, 다른 작업을 끝내고 나서 ‘mail box’에서 메세지를 하나씩 꺼내 처리하도록 할 수 있다. 이러면 동시적으로 이 객체에 접근하게 될 가능성이 없어지기 때문에 data race를 걱정하지 않아도 된다.

아래 예시와 함께 더 자세히 알아보자.

transferother.deposit 메서드를 호출한다. 이 메서드는 await 가 달려있기 때문에 transfer 를 호출한 BankAccount는 other에서 작업이 완료될 때까지 suspend된다.

이를 쉽게 이해하기 위해선 각 Actor가 각자의 작업 흐름을 가지고 있다고 생각하면 될 것 같다. 애초에 Actor는 ‘하나의 동시성 영역’ 안에서 만들어졌기 때문에 괜찮다.

만약 A가 B에 5000원을 보내고 싶어서 A.transfer(amount: 5000.0, to: B) 를 실행했다면, 내부 await other.deposit(amount: amount) 에서 B에게 너의 deposit 을 실행해줘라! 메세지를 보낸다. 그러고 A의 작업흐름은 suspend되어 멈춘다.

B는 자신의 작업 흐름 속에서 할 일을 하고 있다가, ‘mail box’에 A의 deposit 실행 요청이 도착한다. 한 번에 하나의 작업만 할 수 있기 때문에, B는 하던 일을 마친 후 ‘mail box’에 있는 A의 요청을 꺼내서 실행한다. 완료되면 B는 다른 메세지를 꺼내 일을 하고 A는 A의 작업 흐름을 다시 진행한다.

이런 식으로 흘러가면 프로퍼티에는 self 로만 접근한다는 원칙이 깨지지 않고, B는 한번에 하나의 작업만 할 수 있기 때문에 프로퍼티에 여기저기서 한번에 접근해 data race가 발생할 가능성도 없다.

하지만 아무런 생각 없이 이렇게 바로 프로퍼티에 set 접근을 해서는 안된다. 지금까지 말한 Cross-Actor Reference는 모두 read-only인 경우에만 가능하며, 이런 식으로 직접 프로퍼티를 변경할 수 없다.

Closure

Actor는 그 요소들이 모두 data race가 발생할 가능성이 없도록, 안전하게 설계되어야 한다. 이는 메서드, 즉 Closure에도 동시에 적용된다.

Actor 내부 클로저가 self 를 통해 프로퍼티에 접근하는 것이 뭐가 이상할까 싶긴 하지만, 만약 여러 클로저가 동시에 굴러가는 상황이 된다면 문제가 생길 수 있다. 예를 들자면 이런 경우가 있다.

단순한 forEach 문이 아니라 각 Closure가 동시에 돌아가는 parallelForEach 가 있고, 그걸 사용한다고 쳤을 때, 이런 식으로 self 로 접근하더라도 data race가 발생할 가능성이 높다.

따라서 closure들이 순차적으로 실행되는 forEach 문으로 변경하면, data race가 발생할 가능성이 없어지기 때문에 에러 없이 빌드를 할 수 있다.

결론

결국 Actor에 대한 것을 보다보면, 일관적으로 적용되는 규칙이 있다. Actor 밖에서 Actor의 프로퍼티를 직접 변경하지 않도록 코드를 작성하는 것. 이 점이 가장 큰 특징이 아닐까 싶다. 이를 지기키 위해 Actor 내부는 Synchronous하게 유지하고, 외부에서 Actor를 참조할 때는 Asynchronous하게 접근하도록 해야 한다.

동시성 관련해서 Task도 있고, Actor도 있고… Swift 개발하는 사람들도 참 대단하다는 생각이 든다. 나는 봐도 이해하기 어려운데 이런걸 어떻게 생각했고 대체 어떻게 실구현한건지 신기하다.

중간에 딱히 언급하진 않았지만 Sendable과의 관계도 좀 중요한 것 같은데, 언젠간 Sendable도 한번 더 공부해볼 수 있도록 해야겠다.

참고한 것

swift-evolution/0306-actors.md at main · apple/swift-evolution (github.com)

[Concurrency] Actors & actor isolation — Evolution / Pitches — Swift Forums

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science