thumbnailthumbnail

킹덤 서버! 튜토리얼

노수지Server

안녕하세요, 킹덤 서버셀에서 소프트웨어 엔지니어로 근무하고 있는 노수지입니다. 이번 글에서는 킹덤 서버셀에 합류한 이후, 지난 1년 동안 팀의 기술 문화를 체득하고 실제 서비스 코드에 기여하기까지의 과정을 정리해 보려 합니다. 킹덤 서버팀은 Scala를 활용한 함수형 프로그래밍(FP) 지향적인 코드베이스를 보유하고 있습니다. 저 또한 합류 당시에는 함수형 프로그래밍에 익숙하지 않았기에, 이러한 기술 스택은 큰 도전이자 생소한 과제로 다가왔습니다.

이 글에서는 저처럼 해당 패러다임에 낯선 개발자들을 위해 킹덤 팀이 어떤 체계적인 온보딩 시스템과 가이드를 제공하고 있는지 소개하려 합니다. 신규 입사자가 이러한 팀의 지원을 통해 어떻게 기술적 장벽을 넘고 개발 프로세스에 안정적으로 합류하게 되는지 그 여정을 공유하고자 합니다.


#Stage 1. 함수형 프로그래밍 이해하기

입사 후 가장 먼저 테크리드님이 직접 작성하신 스칼라 학습 레포지토리를 전달받았습니다. 기본 조건문과 반복문부터 시작해 Scala 고유의 문법과 함수형 프로그래밍(FP)의 정수를 익힐 수 있도록 설계된 실습용 코드들이 가득했습니다. 이 과정에서 “기존 값을 수정하지 않고 새로운 상태를 생성한다” 는 함수형 패러다임을 체득하게 됩니다.

#part 1. 순수 함수

Java와 같은 명령형 언어에서는 객체 내부의 필드를 직접 수정(Mutation)하여 상태를 변경합니다. 반면, 스칼라의 순수함수는 불변성(Immutability) 을 원칙으로 합니다. 예를 들어, Java에서 Map의 값을 수정하면 기존 Map 객체 자체가 변하지만, 스칼라에서는 기존 Map은 그대로 둔 채 변경사항이 반영된 새로운 Map 객체를 반환합니다. 이 구조는 동일한 입력에 대해서 항상 동일한 출력을 보장합니다. 수학에서 많이 보이는 y = f(x)f가 순수 함수입니다.

그렇다면 "새롭게 만들어진 객체는 어디에 적용(set)되는가?"라는 의문이 생길 수 있습니다. 순수함수의 세계에서 함수는 외부의 상태를 직접 바꾸는 '액션'을 취하지 않습니다. 대신 "입력값(A)을 넣으면 결과값(B)이 나온다" 는 정의에만 집중합니다. 이 방식에서 상태값은 어딘가에 고정되어 변하는 것이 아닙니다. 대신, '현재 상태를 토대로 다음 상태를 계산하는 함수들' 이 체인처럼 연결되어 흐릅니다. 각 함수는 이전 단계에서 계산된 최신 상태를 전달받아 새로운 상태를 만들어내고, 이 과정이 끝까지 흐르면 최종적으로 계산된 상태값이 도출됩니다. 이렇게 완성된 설계도는 프로그램의 가장 바깥쪽 레이어(Runtime)에 도달해서야 비로소 실제 저장소에 반영(set)되거나 처리됩니다.

class Library {
    private Set<BookId> books = new HashSet<>();
    private Map<BookId, String> borrowedBooks = new HashMap<>();
    
    // 기존 객체 내부의 상태(Map)를 직접 수정 (Side Effect)
    public void borrowBook(BookId bookId, String username) {
        borrowedBooks.put(bookId, username);
    }
}
가변 객체를 이용하는 Java
case class Library(
    books: Set[BookId],
    borrowedBooks: Map[BookId, String]
) {
    // 기존 상태를 수정하지 않고, 변경된 데이터가 담긴 새 Library 인스턴스를 반환
    def borrowBook(bookId: BookId, username: String): Library = {
        // copy를 통해 변경된 부분만 교체한 새 객체 생성
        copy(borrowedBooks = borrowedBooks.updated(bookId, username))
    }
}
불변 객체를 이용하는 스칼라

#part 2. 함수를 일급 객체로 다루기

스칼라에서는 함수를 일급 객체(First-class Object) 로 취급합니다. 이는 함수를 변수에 담을 수도, 다른 함수의 인자로 전달할 수도, 혹은 함수의 결과값으로 반환할 수도 있음을 의미합니다. 이러한 특징을 가장 직관적으로 보여주는 것이 바로 람다식(익명 함수) 입니다. 람다식은 복잡한 선언 없이 (입력값) => 로직의 형태로 즉석에서 함수를 정의합니다.

// 1. 함수를 변수에 할당
val double = (x: Int) => x * 2
val four = double(2)

// 2. 고차 함수에서의 활용
// 별도의 메서드 정의 없이, 람다식을 통해 '어떻게 처리할지'의 로직만 가볍게 전달합니다.
val numbers = List(1, 2, 3)
val doubledList = numbers.map(x => x * 2) // 각 요소에 람다 적용

람다식을 사용하면 코드가 극도로 간결해질 뿐만 아니라, 로직 자체를 값으로 취급해 이리저리 전달할 수 있게 됩니다. 이는 앞서 언급한 순수함수와 결합해, 거대한 비즈니스 로직을 작은 함수들의 조립식 결합으로 해결합니다.

더 자세한 스칼라와 함수형 프로그래밍 이야기를 듣고 싶다면 현수님의 기술블로그를 추천합니다.


#Stage 2. ZIO/ZPure 이해하기

스칼라 기초를 다진 후에는 학습서 Zionomicon을 통해 킹덤 아키텍처의 핵심인 ZIOZPure를 학습합니다. ZIO는 실제 실행하는 계층(DB I/O, 통신 등)에서 ZPure는 결과를 계산하는 계층에서 사용됩니다.

#part 1. 순수한 레이어를 위한 ZPure

ZPure는 부수 효과와 완전히 격리된 로직을 설계할 때 사용합니다. ZPure[W, S1, S2, R, E, A]라는 복잡한 타입을 가지는데, 이는 "환경 R과 초기 상태 S1이 주어졌을 때, 로그(Chunk[W])를 남기고 상태를 S2로 변화시키며, 에러 E 혹은 결과 A를 반환함" 을 의미합니다.

(S1, R) => (Chunk[W], E | A)

ZPure의 가장 큰 특징은 부수 효과(Side-Effect)가 전혀 없다는 점입니다. 오직 입력된 값(R, S)만을 이용해 결과를 만들어내기 때문에, 동일한 입력에는 항상 동일한 결과가 보장됩니다. 예를 들어, 재고 상태(S1)를 받아 모든 재고를 0으로 만드는 로직은 아래와 같이 작성할 수 있습니다.

// 상태 S1 내의 stocks 전 재고를 0으로 만드는 순수 로직
lazy val reset: ZPure[Nothing, S1, S2, Any, Nothing, Unit] = {
  for {
    s1 <- ZPure.get[S1]
    // 기존 상태를 복사하여 새로운 상태 s2를 생성 (순수 계산)
    s2 = s1.copy(stocks = s1.stocks.mapValuesCompat(_ => 0))
    _  <- ZPure.set(s2)
  } yield ()
}

여기서 reset은 실제로 DB를 수정하는 것이 아니라, "상태가 이렇게 변해야 한다"라는 계산 결과만을 안전하게 만들어냅니다.

#part 2. 실행을 위한 Effect인 ZIO

우리가 ZPure로 짠 로직은 그 자체로는 아무런 일도 일으키지 않는 설계도와 같습니다. 비유하자면, ZPure는 "요리 레시피"를 작성하는 과정이고, 이 레시피(로직)가 실제 요리(실행)로 이어지기 위해서는 이를 감싸 안는 '효과'의 단위로 변환되어야 합니다. 킹덤 아키텍처는 이 '순수한 레시피'를 먼저 완성한 뒤, 이를 실제로 실행시켜 우리가 아는 게임 서비스를 제공합니다.

ZPure가 만든 순수한 결과물은 최종적으로 ZIO를 통해 현실에서 실행됩니다. ZIO[R, E, A] 환경 R에서 에러 E 혹은 결과 A를 반환하는 '효과'를 나타내며, ZPure와 달리 DB 조회, 네트워크 통신, 로깅 등 실제 부수 효과(Side-Effect) 를 관리하고 실행합니다. 이러한 계층 구조를 갖는 이유는 부수 효과에 대한 통제력 때문입니다.

  1. 먼저 ZPure에서 복잡한 도메인 로직을 오류 없이 계산합니다. (테스트가 쉽고 안전함)
  2. 계산된 결과를 바탕으로 ZIO가 외부 시스템(DB 등)과 상호작용합니다.
  3. 이 과정에서 ZIO는 동시성 제어(Fiber), 재시도 로직(Retry), 리소스 관리 등의 런타임 기능을 제공합니다.

결과적으로 "순수하게 계산된 결과를 기반으로, 부수 효과가 있는 ZIO를 실행시킨다" 는 원칙을 지킴으로써, 코드의 예측 가능성을 높이고 버그가 파고들 틈을 최소화합니다. 이 아키텍처에 대해 더 깊이 알고 싶다면 테크리드님의 블로그 글을 참고해 보시기 바랍니다.

ZPure로 순수하게 계산한 결과를 ZIO로 실행하는 Tri-Z 구조
ZPure로 순수하게 계산한 결과를 ZIO로 실행하는 Tri-Z 구조


#Final Stage. 라이브 서비스에 코드 작성하기

#part 1. 테스트 짜기

Scala와 ZIO에 익숙해졌다면, 이제 메인 레포지토리에 직접 코드를 작성할 차례입니다. 처음부터 수많은 동시성 처리와 대규모 데이터 모델이 포함된 핵심 시스템(또는 대형 인게임 콘텐츠) 개발에 바로 투입되기보다는, 시스템의 맥락과 팀 내 컨벤션을 파악하기 위해 테스트 코드 작성부터 시작하게 됩니다.

킹덤의 대표 대형 컨텐츠인 에피소드, 한 컨텐츠에 여러 기능이 섞여있습니다.
킹덤의 대표 대형 컨텐츠인 에피소드, 한 컨텐츠에 여러 기능이 섞여있습니다.

킹덤 서버의 테스트는 크게 유닛 테스트(Unit Test)시나리오 테스트(Scenario Test) 두 가지 계층으로 나뉩니다. 킹덤의 소원나무 기능을 예시로 설명하겠습니다.

킹덤의 소원나무 기능
킹덤의 소원나무 기능

유닛 테스트는 이름 그대로 기능의 최소 단위가 의도대로 작동하는지 검증합니다. 앞서 배운 순수함수가 실제 도메인 규칙을 잘 지키고 있는지 확인하는 단계입니다.

  • '소원 하나 들어주기' 기능을 테스트한다면 아래 항목들을 개별적으로 확인합니다.
  • 요구 품목인 아이템이 정확히 소모되었는가?
  • 개별 소원에 따른 보상이 유저 인벤토리에 정확히 들어왔는가?
  • 일일 보상 게이지가 로직에 맞게 상승했는가?

시나리오 테스트는 여러 기능이 맞물려 돌아가는 사용자 시나리오에서 시스템이 올바르게 반응하는지 확인합니다. 킹덤의 소원나무처럼 특정 횟수 이상의 소원을 들어주었을 때 발생하는 '일일 보상(보물상자) 획득' 같은 흐름이 대표적인 대상입니다.

  • N개의 소원을 들어주었을 때, 누적 횟수가 정확히 카운트되는가?
  • 일일 게이지가 목표치에 도달했을 때 보상 획득 활성화 상태가 정상적으로 변경되는가?
  • 실제 보상 획득 시, 보상 아이템이 지급됨과 동시에 게이지가 초기화되거나 다음 단계로 넘어가는가?

#part 2. 작은 버그 수정해보기

테스트 작성에 어느 정도 익숙해질 즈음부터는, 실제 코드에 존재하는 사소한 버그를 수정하거나 데이터 검증 로직을 강화하는 업무를 맡게 됩니다. 예를 들어, '개발맛 쿠키의 젤리 15종 물리치기' 라는 콘텐츠가 있다고 가정해 봅시다. 서버와 클라이언트는 각각 1번부터 15번 젤리에 대한 정보를 관리하고 있습니다. 만약 유저가 단 하나의 젤리도 처치하지 못한 채 게임이 종료되면, 기존 클라이언트에서는 기본값으로 '0' 을 전송하고 있었습니다.

하지만 서버에는 '0번 젤리'에 대한 데이터가 존재하지 않았기에 에러가 발생했고, 결과적으로 유저는 "해당 요청을 처리하는 데 문제가 발생했습니다" 라는 문구를 마주하며 정상적으로 게임을 마칠 수 없는 상황이었습니다. 킹덤 팀은 이 문제를 해결하기 위해 "1번 젤리조차 처치하지 못한 경우 클라이언트는 0 대신 Empty(None)를 보내고, 서버는 콘텐츠 참여 보상만 지급한다" 는 결론을 내렸습니다. 이때 제가 맡은 업무가 바로 이 예외 상황을 안전하게 처리하도록 기존 로직을 수정하는 것이었습니다.

def finishJellyWave(wave: Int): List[Reward] = 
  for {
    // wave가 0일 경우, 데이터를 찾지 못해 에러(Exception) 발생
    clearRewardDatas <- inquireJellyWaveClearRewards(wave) 
    playRewardDatas  <- inquireJellyWavePlayRewards       // 게임 입장 보상 조회
    rewards          <- rewards(clearRewardDatas ++ playRewardDatas)
  } yield rewards
수정전: 젤리를 하나도 물리치지 못했을 때, 에러 발생
def finishJellyWave(waveOpt: Option[Int]): List[Reward] = 
  for {
    // 패턴 매칭을 통해 클리어한 웨이브가 있을 때만 보상을 조회하도록 수정
    clearRewardDatas <- waveOpt match {
      case Some(wave) => inquireJellyWaveClearRewards(wave)
      case None       => ZPure.succeed(Nil) // 처리한 웨이브가 없어도 에러 없이 통과
    }
    playRewardDatas <- inquireJellyWavePlayRewards
    rewards         <- rewards(clearRewardDatas ++ playRewardDatas)
  } yield rewards
수정 후: 젤리를 하나도 물리치지 못했을 때, 기본 보상 지급

#part 3. 본격적인 콘텐츠 개발

특정 콘텐츠의 담당자로 배정되면, 먼저 킥오프 회의에서 기획에 대해 설명을 듣습니다. 해당 기획을 바탕으로 기획자 및 클라이언트 개발자분들과 협의하여 데이터 구조를 설계합니다. 데이터 구조 설계와 기능 정의가 완료되면 본격적인 구현에 들어갑니다.

예를 들어, 조커 뽑기와 유사한 '카드 뽑기 게임' 을 만든다고 가정해 봅시다. 기획안이 간단하여 서버는 결과 조회 및 보상 지급 기능만 구현하면 되는 상황입니다. 클라이언트에서 "n번째 카드를 뽑겠다"는 요청을 보내면, 서버는 슬롯 데이터를 무작위로 섞은 뒤 당첨된 보상이 무엇인지 판정하여 응답을 내려줍니다.

// 잘못된 코드
lazy val finishShellGame: Result[(List[ShellGameIndex], Int, List[Reward])] = 
  for {
    shellGameData <- inquireShellGameData // 게임 설정값 조회
    slots         =  shellGameData.slots
    _             <- shuffle(slots) // 각 슬롯을 섞음
    selectedSlot  =  Random.randInt(0, slots.size) // 당첨 슬롯 결정
    rewards       <- rewards(shellGameData.slot(selectedSlot).rewards) // 보상 획득
    _             <- liftEvent(ShellGameFinished(slots, selectedSlot, rewards)) // 상태 변경 이벤트 발생
  } yield (slots, selectedSlot, rewards)
초안

구현을 마친 후 서버를 띄워 테스트를 진행하다 보면, 동료 개발자의 코드 리뷰나 QA, 기획팀의 피드백을 받게 됩니다.

확인 결과, shuffle 함수는 섞인 새로운 리스트를 반환할 뿐 기존 리스트를 직접 수정(Mutation)하지 않는 스칼라의 불변성 원칙을 간과한 것이 원인이었습니다. 이를 아래와 같이 수정하여 다시 배포합니다.

lazy val finishShellGame: Result[(List[ShellGameIndex], Int, List[Reward])] = 
  for {
    shellGameData <- inquireShellGameData 
    slots         <- shuffle(shellGameData.slots) // 섞인 새 리스트를 받도록 수정
    selectedSlot  =  Random.randInt(0, slots.size) 
    // ... 후략
  }
카드가 섞이지 않는 버그 픽스

이후 코드 리뷰 과정에서 또 다른 피드백이 들어옵니다.

아까 수정했던 shuffle 메서드도 getRandInt와 마찬가지로 side-effect가 통제된 함수입니다. 이를 사용함으로써, 선언부에서 미리 결과를 계산해 저장하는 것이 아니라 실행될 때마다 다르게 섞이게 할 수 있습니다.

lazy val finishShellGame: Result[(List[ShellGameIndex], Int, List[Reward])] = 
  for {
    shellGameData <- inquireShellGameData 
    slots         <- shuffle(shellGameData.slots) 
    selectedSlot  <- getRandInt(0, slots.size) // Side-effect가 통제된 자체 randInt 사용
    rewards       <- rewards(shellGameData.slot(selectedSlot).rewards)
    _             <- liftEvent(ShellGameFinished(slots, selectedSlot, rewards))
  } yield (slots, selectedSlot, rewards)
자체 메서드 사용

이러한 수정 과정을 반복하며 코드의 퀄리티를 점진적으로 높여 나갑니다. 단순히 동작하는 코드를 짜는 것을 넘어, 이벤트 발생 후의 요구사항 검증, 로그 기록 등 다양한 예외 케이스를 고려하는 법을 배웁니다. 특히 특정 콘텐츠의 수정이 시스템 전체나 다른 기능에 미칠 영향까지 유의하며 개발하는 역량을 키우게 됩니다.

모든 QA와 리뷰를 마치면 마침내 코드가 라이브 서버에 배포됩니다. 배포 이후에도 본인이 맡은 기능에서 버그가 발생하지 않는지 세밀하게 모니터링합니다. 라이브 환경에서 발생하는 이슈들은 QA 단계에서 잡히지 않은 까다로운 케이스가 많습니다. 만약 재화 지급 오류 등이 발생했다면, 이미 재화를 소모한 유저의 회수 여부나 전 서버 보상 지급 등을 기획팀과 긴밀히 논의하여 후속 대응 방향을 결정하게 됩니다.

#Clear! 어깨너머로 배우는 성장의 즐거움

킹덤 서버팀은 콘텐츠 개발 외에도 성능 개선을 위한 다양한 시도들이 끊임없이 일어나는 곳입니다. 스칼라 프로젝트 서버 안정화를 위한 'shardcake' 같은 라이브러리 개발이 대표적인데, 동료분들이 치열하게 고민하시는 모습을 어깨너머로 지켜보는 것만으로도 공부가 많이 됩니다. 가끔씩은 작게나마 일손을 보태면서 프로젝트의 맥락을 이해하고, 우리 팀이 어떤 방향을 지향하는지 자연스럽게 익혀나가고 있습니다. 기술적인 부분뿐만 아니라 팀의 전반적인 분위기나 동료들의 관심사를 알아가는 것도 소소한 재미 중 하나고요.

#마치며

이렇게 킹덤 서버에서 보낸 시간들을 정리해 보았습니다. 사실 생소한 테크 스택으로 첫 커리어를 시작한다는 게 조금 겁나기도 했지만, 뛰어난 분들과 함께 호흡하며 배울 수 있는 기회는 그 두려움보다 훨씬 값졌습니다. 혹시 Scala나 함수형 프로그래밍(FP)이 낯설어 지원을 망설이고 계신 분이 있다면, 너무 주저하지 말고 도전해 보세요! 이곳에서의 경험은 다른 곳에서 얻기 드문 즐겁고 든든한 자산이 될 거예요. 킹덤 서버에서 여러분과 함께 일할 수 있는 날을 기다리고 있겠습니다.