게임 서버 개발에 스칼라 사용하기

Pierre Ricadat

알림: 이 글은 Scala for Game Server Development의 한국어 번역본입니다.

스튜디오 킹덤 팀은 스칼라 프로그래밍 언어 (이하 스칼라)를 사용하여 인기 게임 <쿠키런: 킹덤> 게임 서버를 만들었습니다. 스칼라는 다른 프로그래밍 언어들보다 덜 알려졌지만, 그래도 우리가 만든 게임 서버에는 정말 잘 맞는 언어라고 생각합니다. 킹덤 팀이 왜, 그리고 어떻게 스칼라를 사용하여 게임 서버를 개발했는지 이번 글에서 여러분께 공유하려 합니다.

이번 글에서, 저는 게임 개발의 몇 가지 측면을 살펴보면서 스칼라가 어떤 장점이 있는지 구체적으로 이야기해보려 합니다.

  1. 게임 로직을 정확하게 만드는 것은 매우 중요합니다. 버그 때문에 게임 유저의 노력이 물거품이 된다면, 정말 끔찍한 일이 아닐 수 없습니다. 또한 게임에 따라, 게임의 로직은 얼마든지 크고 복잡해질 수 있기 때문에 게임 로직을 에러 없이 만들기는 쉽지 않습니다.
  2. 게임은 매우 빠르게 변화하며, 게임 유저는 항상 새로운 기능을 원합니다. 이는 곧 게임 서버의 코드가 어쩔 수 없이 거대해질 수 밖에 없고, 또한 계속해서 진화해야 함을 의미합니다. 인기있는 게임이 되기 위해서는 꾸준히 새로운 기능을 출시해야만 합니다.
  3. 여러 플레이어가 참여하는 게임을 설계할 때는 동시성과 확장성 문제를 다뤄야 합니다. 게임 서버는 매우 많은 요청을 동시에 받게 되며, 이 중에는 같은 엔티티를 가리키는 요청도 많이 있을 것입니다.

게임 로직의 정확성

타입의 강력함

스칼라는 정적 타입 언어로, 컴파일할 때 타입이 정의되고, 타입의 존재를 인식할 수 있습니다. 덕분에 동적 타입 언어에서 흔히 발생하는 수많은 런타임 에러들 (예를 들어 자바스크립트의 TypeError 같은 에러)을 미리 예방할 수 있습니다. 하지만 다른 정적 타입 언어에 비해, 스칼라는 타입을 사용하여 훨씬 더 많은 일을 할 수 있습니다.

간단한 예로, Option[A] 처럼 A 라는 타입의 값이 있을 수도 있고, 없을 수도 있습니다. 다른 프로그래밍 언어에서는 A 타입의 값이 null 값을 담을 경우 코드 이곳 저곳에서 null 값에 대한 예외 처리 로직을 넣어야 하고, 잘못 동작할 수 있는 위험을 감수해야 하지만, 스칼라에서는 관용적으로 Option[A] 라는 타입으로 반환하도록 만들 수 있습니다. 개발자는 이 경우 반드시 A 의 값이 없을 경우를 고려하여 코드를 작성해야 하며, 이 때 패턴 매칭이나 getOrElse 혹은 fold 같은 함수를 사용하여 코드를 작성할 수 있습니다.

// Option[GnomeResearchLevel] 를 반환합니다.
val currentLevel = getGnomeResearchLevel(researchDataId)

// currentLevel에 값이 없을 경우를 반드시 고려해야 하는데,
// 이 때, 초기 레벨 값을 대신 설정할 수 있습니다.
val nextLevel = currentLevel.map(_.up).getOrElse(GnomeResearchLevel.initial)

여기서 더 나아가서, refined라는 스칼라 라이브러리를 이용하여 타입이 가질 수 있는 값의 종류를 제한할 수 있습니다. 예를 들어, “킹덤의 왕국 레벨” 숫자값 타입의 변수에서는 반드시 1 또는 그 이상의 값을 가져야 합니다. 단순히 Int 타입을 사용하지 않고, PosInt 라는 타입을 이용하여 값이 반드시 양의 정수가 되도록 제한할 수 있습니다. 이렇게 하면, 값이 절대로 0으로 설정될 일이 없어서, 값이 0이 될 경우를 고려해야 하거나 0으로 나누기 같은 연산 에러를 일으킬 가능성을 걱정하지 않아도 됩니다.

type PosInt = Int Refined Positive // 0보다 큰 정수값을 담는 새로운 타입 정의

val a: PosInt = 1
val b: PosInt = 0 // 컴파일 에러를 발생시킵니다.

컴파일러는 설정된 값이 유효한지 아닌지 확인할 수 있습니다. 클라이언트로부터 오는 값의 경우, 클라이언트의 요청을 분석한 후, 값이 타입의 제약 조건을 만족하지 않을 경우 즉시 에러를 발생시킵니다.

PosInt를 사용할 수도 있지만, 만약 “쿠키 레벨”도 PosInt 타입을 사용한다면, 잘못된 값을 함수에 전달할 위험성도 지니게 됩니다. 이를 예방하기 위해, newtype 스칼라 라이브러리를 사용하여, PosInt 타입에 특별한 의미를 부여하는 새로운 타입을 정의할 수 있습니다.

@newtype case class KingdomLevel(value: PosInt)

def receiveKingdomLevelRewards(level: KingdomLevel): List[Reward]

이제 컴파일러는 receiveKingdomLevelReward 함수를 호출할 때, KingdomLevel 타입 외의 데이터는 전달할 수 없도록 호출을 제한할 것입니다.

지금까지의 내용을 활용하여 타입에 정확한 의미와 제약 조건을 부여함으로서, 불필요한 버그를 줄이고 코드의 품질을 개선할 수 있습니다.

타입을 통한 문서화

Option 타입을 사용하기만 해도 함수가 결괏값을 반환할 수도, 반환하지 않을 수도 있음을 쉽게 알 수 있습니다. 이와 같이 타입을 사용하여 함수가 어떤 종류의 연산을 수행하는지 설명할 수도 있습니다. 몇 가지 예를 들면 다음과 같습니다.

  • Option[A]: 함수가 A 타입의 결괏값을 반환할 수도, 반환하지 않을 수도 있습니다. 다른 부수 효과는 없습니다.
  • Either[E, A]: 함수 실행이 실패하면 E 타입의 에러를 반환하거나, 성공하면 A 타입의 값을 반환할 수 있습니다. 다른 부수 효과는 없습니다.
  • ZIO[R, E, A]: 함수가 실행에 필요한 환경 R 타입이 필요하며, 실행이 실패하면 E 타입의 에러를 반환하거나, 실행이 성공하면 A 타입의 값을 반환할 수 있습니다. 또한 부수 효과 (입출력)가 있을 수 있습니다. 부수 효과가 있을 수 있는 R => Either[E, A] 타입이라고 볼 수 있습니다.
  • ZPure[W, S1, S2, R, E, A]: 함수가 실행에 필요한 환경 R 타입이 필요하며, 초기 상태로 S1 타입이 필요하고, 실행이 실패하면 E 타입의 에러를 반환하거나, 성공하면 S2 타입의 업데이트된 상태값, A 타입의 실행 결괏값을 반환하며, 실행 중 발생한W 타입의 로그 값들을 반환합니다. (R, S1) => (List[W], Either[E, (S2, A)]) 와 같은 의미이며, 코드에 부수 효과는 없습니다.

위의 타입들을 사용하여 함수가 정확히 무슨 일을 하는지 알 수 있습니다. 보시다시피 타입 매개 변수들을 매우 많이 사용하고 있습니다. 그러나 실제로는 타입 별칭을 사용하여 훨씬 더 짧고 간편하게 표현할 수 있습니다. 몇 가지 예를 들어보겠습니다.

// 메트릭 값을 증가시키는 함수
// 반환 타입에서 부수 효과가 있으며, 절대 실패하지 않는다.
def incrementInFlightMessagesMetric: UIO[Unit]

// 프로토버프 데이터를 킹덤 레벨 데이터로 변환하는 함수
// 반환 타입에서 실패 가능성이 있을 수 있습니다. 실패 시 오류 정보로 채워진 리스트가 반환됩니다.
// EitherNel[E, A] = Either[NonEmptyList[E], A]
def extractKingdomLevel(raw: Protos): EitherNel[ExtractionError, KingdomLevel]

// 사용자의 현재 PVP (킹덤 아레나) 상태 정보를 반환하는 함수
// 반환 타입에서 사용자 상태 정보에 접근하지만, 상태 정보를 수정하지 않습니다.
def getCurrentPvpState: KingdomReader[Pvp]

이와 같이 타입을 활용하면, 함수의 동작을 강제할 뿐만 아니라 타입의 이름이나 타입 구성 형태를 읽기만 해도 함수가 무슨 일을 하는지 명확하게 알 수 있습니다. 발생할 수 있는 모든 오류가 반환 타입에서 명확하게 선언되므로 (오류가 발생하지 않더라도 그렇습니다.), 예외가 갑자기 발생하여 전파될 걱정을 하지 않아도 됩니다. 부수 효과도 마찬가지입니다.

직접 도메인 특화 언어 (DSL) 만들기

지금까지 값이 가질 수 있는 유효한 범위를 정하거나, 함수가 무슨 일을 하는지를 타입을 이용해 표현하는 방법을 살펴보았으며, 컴파일러가 프로그래밍 상의 에러를 일찍 잡아내도록 하는 방법도 살펴보았습니다. 여기서 더 나아가 “유효한” 동작만을 수행하도록 코드 수준의 제약을 추가할 수 있습니다. 특정 작업에만 사용하기 위한 **별도의 도메인 특화 언어 (이하 DSL)**을 만드는 방식으로 구현할 수 있습니다. 스칼라는 그 자체로도 매우 강력하고 간결하여 하위 언어를 만들 때에도 좋습니다. 마치 사양 문서를 작성하듯 코드를 만듦으로서 꽤 많은 부분을 이룰 수 있습니다.

거창한 예를 들지 않더라도 DSL을 만들면, 특정 코드 영역에서 지정된 동작을 수행하도록 제한할 수 있습니다. 예를 하나 들어보겠습니다. 우리 게임에서는 여러 엔티티 사이에 트랜잭션을 지원합니다. 다시 말해 서로 다른 두 사용자 (혹은 사용자와 길드)에 명령어를 각각 보낸 뒤, 모든 명령어의 실행 결과가 성공적으로 끝날 때에만 변경 사항을 반영할 수 있습니다.

그래서 우리 팀에서는 트랜잭션을 위해 작은 DSL을 만들고, 다음의 작업만 수행할 수 있도록 했습니다.

  • ID로 엔티티를 식별할 수 있게 합니다: kingdom(userId) , guild(guildId)
  • 엔티티로 명령어를 보냅니다: entity ! command
  • 보내기 작업을 하나로 묶습니다: 순차적으로 보내기 위해 >>> , 병렬로 보내기 위해 &&& 연산자를 사용합니다.

이제 다음과 같이 간단한 트랜잭션을 작성할 수 있습니다.

transaction { context =>
  (kingdom(context.userId) ! SendFriendRequest(friendId)) &&&
    (kingdom(friendId) ! ReceiveFriendRequest(context.userId))
}

transaction 블록은 우리가 사용할 DSL이 사용될 곳을 나타냅니다. 이 블록 안에서 “보통 코드”는 사용할 수 없고, 우리가 만든 DSL만 사용할 수 있습니다. 그런 다음, 현재 사용자에게 SendFriendRequest 명령 (이 사용자가 친구 요청을 보낼 수 있는지 확인하고, 친구 추가할 상대의 대기 중인 친구 요청 정보를 저장하는 명령)을 보내고, 다른 사용자에게는 ReceiveFriendRequest 명령 (수신된 요청을 저장하는 명령)을 보냅니다.

이 작은 트랜잭션 시스템에서 다른 종류의 작업은 지원하지 않습니다. 만약 레디스나 카프카 같은 곳에 데이터를 저장하기 위한 코드를 transaction 블록 안에서 추가하려 한다면, 문제가 발생했을 때 제대로 롤백되지 않을 것입니다. 하지만 별도의 DSL을 사용하기 때문에 이런 일은 불가능합니다. 코드를 추가하려고 하면, DSL에서 허용하지 않는 작업을 하려고 했기 때문에 컴파일 에러가 발생할 것입니다.

대부분의 비즈니스 로직은 이와 같이 DSL을 사용하여 구현했으며, 각 DSL에서는 다음의 작업만 할 수 있도록 기능이 제한되어 있습니다.

  • 결괏값과 함께 성공 상태를 표현합니다.
  • ValidationError 와 함께 실패 상태를 표현합니다.
  • 환경 (모든 게임 설정 정보를 담는 거대한 개체로, 캐릭터 목록, 레벨, 이벤트 등이 들어있습니다.) 데이터에 접근할 수 있습니다.
  • 사용자 상태에 접근할 수 있습니다.
  • 사용자 상태를 변경하는 이벤트를 처리합니다. (이벤트 소싱을 사용합니다.)

약속된 작업만 수행할 수 있도록 제한한 덕분에, 비즈니스 로직 중간에서 예기치 않은 에러가 발생할 가능성을 막을 수 있었습니다.

빠르게 변화에 대응하기

중요한 것에만 집중하기

지금까지 본 것처럼, DSL을 작성하여 코드가 올바른 작업만 실행하면서도 작게 유지될 수 있도록 제한할 수 있습니다. 이는 스칼라 프로그래밍 언어의 간편함과 결합되면서 훨씬 더 큰 이점을 가져다 주는데, 코드를 매우 짧고 읽기 쉽게 만들어줍니다.

DSL을 만들면 프로그램에서 복잡한 세부 사항을 효과적으로 감출 수 있어, 비즈니스 로직에서 정말 중요한 것에만 집중할 수 있게 해줍니다. 위의 트랜잭션 예제는 사실 매우 복잡한 코드가 그 안에서 작동하게 됩니다. 다른 노드 위에 저장된 엔티티로 메시지를 보내기 위해, 노드의 위치를 파악하고, 어떤 메시지 프로토콜을 사용하여 메시지를 보낼 것인지 결정하는 등의 일을 해야 할 것입니다. 영속 레이어에서는 모든 쓰기 작업을 하나의 트랜잭션으로 묶어 한 번에 반영되거나 롤백되도록 처리해야 하고, 실제 데이터베이스와 상호 작용해야 할 것입니다. 그러나 비즈니스 로직을 작성할 때 이런 부분들을 신경쓰기는 너무 복잡합니다.

즉, DSL를 통해 기술적인 레이어를 비즈니스 로직과 분리할 수 있어 개별적으로 구현하고 테스트할 수 있습니다. 그 결과, 새로운 기능을 추가하는 작업이 대부분인 비즈니스 로직을 어떤 간섭도 없이 구현할 수 있어 훨씬 더 효율적입니다.

여기 사용자가 길드에 가입할 때 사용되는 간단한 비즈니스 로직 함수 예제가 있습니다. 게임 설정으로부터 메타데이터를 가져오고, 길드 상태를 확인한 후, 요청이 올바른지 확인합니다. 문제가 없다면, 이벤트를 처리하면서 사용자가 길드에 가입했다는 사실을 기록합니다. 여기서 메시징 프로토콜, 영속성, 트랜잭션, 데이터베이스 잠금 등 어떤 것이든 밑에서 일어나는 기술적인 세부 사항은 전혀 신경쓰지 않았다는 점을 주목하세요.

val joinGuild: KingdomProgram[Unit] =
  for {
	metadata    <- inquireGuildMetadata
    requesterId <- inquireRequesterId
    memberCount <- getGuild(_.members.size)
    ()          <- assertThat(
                     memberCount < metadata.maxMemberCount,
                     ValidationError.GuildFull
                   )
    ()          <- liftEvent(GuildMemberJoined(requesterId))
  } yield ()

덕분에 새로운 개발자가 우리 팀에 합류하더라도 온보딩 걱정은 자연스럽게 덜 수 있게 됩니다. 왜냐하면 사용할 수 있는 연산자가 얼마 되지 않아 DSL을 쉽게 이해하고 코드를 작성할 수 있습니다. 기술적인 개념보다는 도메인 지식을 배우는 데 집중할 수 있습니다.

보일러플레이트와 헤어질 시간!

새로운 기능을 구현하면 많은 양의 코드를 작성해야 합니다. 클라이언트를 위한 새로운 API를 공개해야 하고, 영속성 레이어를 구현하며, 서로 다른 레이어 사이에 주고 받는 데이터 타입을 변환하는 등의 일이 될 것입니다. 스칼라를 사용하면 보일러플레이트 코드를 최대한 줄일 수 있습니다. 이는 강력한 매크로 엔진컴파일러 플러그인을 작성할 수 있는 스칼라의 능력 덕분입니다.

수많은 프로그래밍 언어들과 마찬가지로, 우리 팀에서도 프로토버프 파일 정의를 사용하여 서버 코드를 생성합니다. 하지만 비즈니스 로직에서 생성된 코드를 사용하지는 않는데, 생성된 코드는 우리가 정의한 refine 타입을 사용하지 않으며, 자동 생성된 코드에서 사용하는 타입보다는 더 사용하기 편리한 데이터 타입을 사용하는 것을 선호하기 때문입니다. (컬렉션은 일반적으로 데이터 타입 선택이 무척 중요한 영역입니다.) 다시 말해, 우리가 클라이언트로부터 받은 자동 생성된 타입으로 이루어진 개체를 우리 자신의 것으로 변환해야 한다는 것입니다. 수천가지 개체들을 사용하여 통신을 진행하게 되며, 이는 많은 양의 보일러플레이트 덩어리와 씨름하는 일일 수 있습니다. 하지만, 스칼라 매크로 덕분에, 이런 변환 작업의 대부분이 자동으로 처리될 뿐 아니라, 컴파일 시점에서 데이터의 유효성을 검증할 수도 있습니다.

여기서 각 데이터 타입마다 Transformer 인스턴스를 만들었는지 확인해야 합니다. 매크로는 필드 이름이 일치하고 각 해당 필드 유형 사이에 변환기가 있다면, 두 개의 클래스 간에 Transformer를 자동으로 생성하는 데 사용됩니다.

예제를 살펴보겠습니다. 다음의 프로토버프 타입이 있습니다.

message CraftProduction {
  string id = 1;
  repeated int32 cookie_data_ids = 2;
  int32 completed_job_count = 3;
}

생성된 코드는 다음과 같은 모습일 것입니다.

case class CraftProduction(
  id: String,
  cookieDataIds: Seq[Int],
  completedJobCount: Int
)

그러나 앞서 살펴본 매크로를 이용한다면 다음과 같이 더 “개선된" 타입을 만들 수 있을 것입니다.

case class CraftProduction(
  id: ActivityId,
  participants: NonEmptyList[CookieDataId],
  completedJobCount: Int
)

모든 ID는 ActivityIdCookieDataId 처럼 좀 더 정확한 타입을 사용하여 단순히 정수 값으로만 취급하여 값이 섞이지 않도록 분리하고, 또한 NonEmptyList 타입을 사용하여 최소한 하나 이상의 값이 들어있는 리스트여야 함을 나타냅니다. 그리고 cookieDataIds 필드 대신 다른 이름을 사용하여 게임 서버 코드를 이해하기 쉽게 만들었습니다.

아래 타입들 사이를 변환하기 위해 각각의 Transformer 들이 필요할 것입니다.

  • String 타입과 ActivityId 타입 사이를 변환하는 Transformer
  • Int 타입과 CookieDataId 타입 사이를 변환하는 Transformer
  • Seq 타입과 NonEmptyList 타입 사이를 변환하는 Transformer

ID를 처리하는 Transformer는 단지 값을 보조하는 타입이므로 구조가 매우 단순하여, 일반화된 방법으로 만들 수 있을 것입니다. Seq 타입과 NonEmptyList 타입 사이를 변환하는 Transformer 역시 구조가 단순하며, 다만 Seq 안에 들어있는 요소가 하나도 없을 경우에만 실패로 처리하고, 그 외에는 NonEmptyList 로 쉽게 변환할 수 있을 것입니다.

위의 예제를 만들기 위해, 다음과 같이 변환기를 정의할 수 있습니다.

implicit val transformer =
  Transformer
    .define[entities.CraftProduction, protobuf.CraftProduction]
    .withFieldRenamed(_.participants, _.cookieDataIds)
    .buildTransformer

매크로는 모든 필드를 자동으로 변환할 수 있고, participants 필드와 cookieDataIds 필드 사이의 관계를 정의했기 때문에, 이를 토대로 변환기를 만들 수 있을 것입니다. 대부분의 경우 필드 이름이 같은 방식으로 지정되므로 이런 형태의 코드를 전혀 작성할 필요가 없습니다. 만약 누락된 부분이 있다면, 컴파일 단계에서 변환할 수 없는 필드와 그 이유를 알려주는 에러를 발생시킬 것이므로 문제를 미리 찾을 수 있어 매우 유용합니다. 코드가 에러 없이 컴파일된다면 예상대로 작동할 것을 확신할 수 있는 것입니다.

보일러플레이트를 줄이기 위해 매크로와 코드 생성에 의존하는 또 다른 예는 그래프 QL을 사용하는 것입니다. 매크로를 사용하면 서버 데이터 클래스에서 그래프 QL 스키마를 자동으로 생성할 수 있으며, 컴파일러 플러그인은 스칼라 JS (자바스크립트로 변환되는 스칼라 코드)로 구동되는 클라이언트 코드를 자동으로 생성합니다. 힘들이는 것 없이 동일한 코드에서 클라이언트와 서버를 모두 얻을 수 있습니다!

이 글에서 소개한 대부분의 매크로와 컴파일러 플러그인은 저희가 개발한 것이 아닌, 오픈 소스 라이브러리에서 사용할 수 있는 매크로와 컴파일러 플러그인들입니다. 스칼라 오픈 소스 생태계는 매우 역동적이며 보일러플레이트를 줄이기 위한 많은 방안을 제공합니다.

안전한 리팩토링

스칼라를 사용한 함수형 프로그래밍에서 참조 투명성은 중요한 개념입니다. 참조적으로 투명한 함수는 프로그램의 동작을 변경하지 않고 해당 함수를 해당 값으로 대체할 수 있습니다.

다음의 예를 살펴보겠습니다.

def add(a: Int, b: Int) = a + b

val sum = add(1, 2)

코드 어디서나 sum 또는 add(1, 2)를 서로 바꿔서 사용할 수 있습니다. 어떤 것을 사용하든 의미는 동일합니다.

또 다른 예를 살펴보겠습니다.

object Test {
  var total = 0

  def add(a: Int): Int = {
	total = total + a
	total
  }
}

val sum = Test.add(2)

반면 위의 코드에서 sumTest.add(2) 는 각각 완전히 다른 결과를 만들 것입니다. sum 은 항상 같은 결과를 반환하지만, Test.add(2)total 에 변경을 일으키게 되고, 실행할 때마다 다른 값을 반환할 것입니다. 이 때 add참조 투명성이 없다고 말할 수 있습니다.

참조 투명성을 지키는 것이 왜 중요할까요? 코드 조각을 함수로 교체할 수 있다는 것은, 코드의 동작을 깨뜨릴 위험 없이 리팩토링을 폭넓게 수행할 수 있습니다. 그 외에도 정적 타입 지정은 코드 이동이 항상 의미가 있는지 확인하고, 타입이 다르면 컴파일러에서 알려주어 에러를 쉽게 찾을 수 있습니다. 리팩토링이 쉬우면, 만들었던 기능과 함께 계속해서 성장하고 발전하면서, 코드를 유지 관리하거나, 기능을 확장하기 쉽기 때문입니다.

참조 투명성은 스칼라에만 국한된 것은 아니지만, 스칼라 커뮤니티와 라이브러리 생태계 내에서는 모든 기능이 참조적으로 투명하고 불변 데이터 유형을 사용하는 방식의 프로그래밍 방식을 사용할 것을 적극 권장합니다. 또한, 스칼라 프로그래밍 언어를 사용하는 많은 개발자들이 참조 투명성을 지키기 위해 노력합니다.

멀티 유저 문제

로컬 동시성

멀티 유저 게임을 위한 게임 서버를 만들 때, 여러 요청이 동시에 같은 데이터를 활용하는 기능을 자주 개발하게 됩니다. 그리고 종종 동시에 작업을 실행하고 결과를 수집하기도 합니다. 또한 애플리케이션 생애주기 전체에 걸쳐 백그라운드에서 작업을 실행하도록 만들 필요가 있습니다. 그 외에도 정말 많습니다. 서버 자원을 소진하지 않고 동시에 많은 작업을 해내야 할 필요가 있습니다.

스칼라에서는 Future 라는 개념을 제공하지만, ZIO (우리 팀에서 사용 중인 라이브러리), Cats Effect, Monix 등 다양한 라이브러리들을 대신 사용할 수도 있습니다. 이런 라이브러리들은 모두 파이버 위에서 만들어졌다는 공통점이 있는데, 파이버란 런타임 시스템이 관리하는 경량화된 “가상 스레드"입니다. 전통적인 스레드와 달리, 동시성 프로그래밍을 매우 단순하게 만들어주고, 원하는대로 만들어 사용하기 쉽습니다.

동시성 라이브러리들은 계산을 병렬로 실행할 수 있는 여러가지 오퍼레이터를 제공합니다. 예를 들어, fork 를 이용하여 다른 파이버에서 계산이 실행되도록 만들거나, join 을 사용하여 기존 스레드가 결과를 반환할 때까지 기다리게 하거나, interrupt 로 파이버를 중단시키거나, race 로 여러 파이버들을 경합시키는 등의 작업을 할 수 있습니다. 그러나 대부분은 고차원의 오퍼레이터인 ZIO.foreachParZIO#zipPar 같은 오퍼레이터를 활용할 수 있으므로 스레드 작업을 직접 제어할 필요가 없습니다. 또한 파이버를 기반으로 만든 편리한 여러 데이터 타입, 예를 들어 Promise, Schedule, Queue 같은 데이터 타입을 활용할 수도 있습니다.

간단한 예를 하나 살펴보겠습니다. 유저가 우리가 만든 게임 클라이언트를 실행하면, 사용자가 게임을 종료하기 전까지 서버로 스트림이 연결된 후 계속 유지될 것입니다. 스트림이 유지되는 동안, 주기적으로 “마지막 연결일시" 정보를 사용자 데이터베이스에 기록하려고 합니다.

updateLastConnectionDate(userId)
  .repeat(Schedule.spaced(1 hour))
  .fork

어렵지 않습니다. 이 코드를 실행하면, updateLastConnectionDate 함수가 매 시간마다 호출됩니다. fork를 사용했으므로, 함수를 호출하는 코드는 즉시 반환되고, 작업은 백그라운드에서 실행될 것입니다. 기본적으로, 파이버는 상위 파이버가 종료되면 자동으로 종료되므로, 백그라운드 프로세스는 gRPC 스트림의 연결이 끊어지면 자동으로 종료됩니다. 상위 파이버가 종료된 후에도 계속 파이버를 유지하려면, .forkDaemon 을 사용할 수 있습니다.

또한 ZIO에는 무척 유용한 데이터 타입인 Hub 가 있습니다. Queue 는 안에 들어있는 항목이 한 곳에만 전달되지만, Hub 에 들어있는 항목은 이벤트를 수신하는 모든 곳에 전달됩니다. 앞서 살펴본대로 gRPC 스트림이 연결된 모든 사용자들에게 전체 메시지를 보내는 상황을 상상해보겠습니다. 각 게임 서버에서는 카프카로부터 오는 메시지 (글로벌 메시지가 카프카를 통해 전달될 것입니다)를 구독하는 파이버를 시작할 수 있을 것이고, 수신된 각각의 메시지를 허브로 보낼 것입니다. 그다음, 연결된 각각의 gRPC 스트림에서는 허브에서 발생하는 이벤트를 수신하며 들어오는 메시지를 모두 받을 것입니다.

def createHub(consumer: Consumer): UIO[Hub[Message]] =
	Hub.unbounded[Message].flatMap { hub => // 크기 제한이 없는 허브를 만듭니다.
    consumer
     .read                                // 카프카에서 데이터를 읽습니다.
     .mapZIO(event => hub.publish(event)) // 모든 메시지를 허브로 보냅니다.
     .runDrain                            // 스트림을 끝까지 읽습니다.
     .fork                                // 이 작업을 별도의 파이버에서 처리합니다.
	 .as(hub)                         // 허브를 반환합니다.
  }

// 허브에 보낸 모든 메시지를 포함할 스트림을 만듭니다.
val stream = ZStream.fromHub(hub)

결국 게임 서버에서 관리하는 gRPC 스트림은 이처럼 다양한 곳에서 오는 다양한 스트림을 조합하여 하나로 통합한 것입니다. Hub 데이터 타입을 활용하여 우리 팀에서는 특정 키만 선택적으로 구독할 수 있는 IndexedPubSub 라는 또 다른 타입을 만들어 편하게 활용할 수 있었습니다. Hub 뿐 아니라, Ref, Promise, Queue 등 여러 타입 덕분에, 로컬 동시성 문제를 ZIO를 이용하여 단순하게 만들 수 있었습니다.

분산 동시성

실제 애플리케이션에서, 동시성 문제는 로컬에서만이 아니라 분산된 환경에서도 자주 발생합니다. 우리 팀은 게임 서버를 단순히 노드를 추가하기만 해도 쉽게 확장할 수 있는 구조를 만들기 원했습니다. 여러 노드를 사용하면 몇 가지 문제점이 발생할 것입니다. 예를 들어, 만약 길드에 자리가 한 개만 남은 상황에서, 두 명의 유저가 동시에 그 길드에 정확히 같은 시간에 가입 요청을 보낸다면, 한 사용자는 가입에 성공할 것이고, 다른 한 명은 실패할 것입니다.

이 문제를 풀 수 있는 일반적인 방법은 **단일 기록자 원칙 (Single Writer Principle)**을 적용하는 것입니다. 이 원칙은 주어진 시간에 각각의 엔티티의 상태를 변경할 수 있는 인스턴스를 단 하나만 만들어 유지하는 것을 말합니다. 다시 말해, 길드에 대한 메시지를 처리하는 프로세스가 주어진 시간 범위 안에 단 하나만 있게 만드는 것입니다. 이렇게 하면, 프로세스는 두 개의 메시지를 순차적으로 처리할 수 있으므로, 먼저 도착한 사람만 가입에 성공하도록 만들 수 있는 것입니다.

이 방식을 구현하는 방법은 여러 가지가 있을 수 있지만, 스칼라 에코 시스템에서는 풍부한 옵션을 제공합니다. 그 중 하나는 카프카 컨슈머 그룹을 사용하여 하나의 컨슈머가 특정 엔티티 ID에 대한 모든 메시지를 수신하여 처리하도록 만드는 것입니다. 우리의 경우, 이런 “동기적인 방식"의 워크플로우 때문에 (클라이언트는 모든 요청에 즉각적으로 답을 받도록 설계되었기 때문에), 조금 다른 방법인 액터 샤딩 패턴을 사용했습니다. 이 패턴은 배우기 쉬운 Akka 라이브러리 덕분에 많이 유명해졌습니다. (현재 우리 팀에서는 Akka를 대신하여 비슷한 원칙을 따르는 독자적인 구현체를 사용하고 있습니다.)

액터는 기본적으로 큐 (”메일함"이라고도 부릅니다.)에서 메시지를 순차적으로 수신하는 작은 프로세스입니다. 샤딩은 액터가 노드 그룹에 걸쳐 분산되어, 호출하려는 액터의 ID를 사용하여 메시지를 보내기만 하면 액터를 실행할 수 있습니다. 이 개념은 위치 투명성이라고도 불리며, 액터가 어느 위치에서 실행되고 있는지를 모르더라도 메시지를 보낼 수 있습니다. 액터는 같은 노드에서 실행되고 있을 수도, 다른 노드에서 실행될 수도 있습니다.

앞서 소개한 다음의 DSL은 메시지를 보내기 위해 두 개의 다른 노드가 아래의 코드를 동시에 호출할 수 있습니다.

guild(guildId) ! JoinGuild(inviteId)

내부적으로 샤딩 시스템에서는 guildId 에 지정한 값을 처리해야 할 액터가 어느 노드에서 실행되고 있는지 찾아서, 해당 노드에 페이로드 (JoinGuild)를 포함시켜 메시지를 보낼 것입니다. 메시지를 받은 노드에서는 이 길드 ID를 처리할 액터에게 메시지들을 전달할 것입니다. 가장 먼저 메시지를 받을 액터에서는 작업을 처리한 후 성공 응답을 내보낼 것입니다. 처리가 끝난 후에 도착하는 메시지는 실패로 응답을 내보낼 것입니다.

강력한 라이브러리 덕분에, 스칼라는 응답 시간에 영향을 주거나, 교착 상태 같은 문제를 일으킬 수 있는 분산 잠금 기술에 의존하지 않고서도 분산 처리를 유연하게 구현할 수 있고, 동시에 도착하는 페이로드를 정말 쉽게 처리할 수 있습니다. 그리고 Akka로 구현하든 ZIO로 구현하든, 액터 패턴은 우리의 삶을 다시 한 번 더 쉽게 만듭니다.

몇 가지 실용적이고 간단한 예제를 통해 우리는 Scala가 게임 서버를 구축하기 위한 정말 흥미로운 선택인 것을 알 수 있었습니다. 강력한 타입 시스템과 함수형 프로그래밍의 모범 사례를 결합하여, 에러가 발생할 위험을 최소화하고, 비즈니스 논리에만 집중해서 개발할 수 있다는 사실도 알았습니다. 그리고, 스칼라의 풍부한 오픈 소스 라이브러리 생태계 덕분에 보일러 플레이트 코드를 줄이고, 모든 종류의 동시성 문제를 해결할 수 있습니다.

지금까지의 이야기가 재미있으셨나요? 더 자세한 내용을 알고 싶으신가요? 우리 팀과 함께 해요!

데브시스터즈는 최고의 인재를 찾고 있습니다.

자세한 내용은 채용 사이트를 확인해주세요!

© 2024 Devsisters Corp. All Rights Reserved.