만들리에/오락실 (스트리밍 게임)

추억의 오락실 (7/11) - 아줌마와 클린아키텍처

gamz 2021. 1. 27. 21:21

이번 포스팅부터는 오락실의 각 컴포넌트를 소개하면서 학습이 되었던 주제들을 하나씩 뽑아보고자 한다. 일단 이 한 문장을 지르긴 했는데 잘 될지 모르겠다. 일단 첫번째 대상은 프론트의 API 서버이자 오락실의 주요 비즈니스 로직을 담당하는 아줌마 컴포넌트이다. 이 컴포넌트의 주요 기능과 여기에 적용했던 클린 아키텍처에 대해서 간단히 소개해보고 소감을 정리해보겠다.

 

아줌마 소개

아줌마의 주요 기능은 간단하다. 아래와 같다.

플레이어 및 게임 API
  • 플레이어 생성
  • 내 플레이어 정보 (코인 등) 조회
  • 게임 목록 조회
  • 게임 실행
  • 게임 참가 가능 여부 조회

오락기 드라이버
  • 게임 실행시 오락기 Pod(프로세스)을 프로비저닝(Provisioning)
  • 오락기와 프론트 사이의 중개 역할

시그널링(WebRTC) API
  • SDP (Session Description Protocol) 교환
  • ICE (Interactive Connectivity Establishment) 정보 교환

 

그래, 이중 젤 간단해 보이는 게임 목록 조회 기능을 한번 즉흥적으로 구현해볼까? (아래 코드는 의존 관계의 예시를 들기 위해 작성한거라 실행 가능한 코드가 아님을 참고해주세요)

// @game.go
package usecases

import (
  "models"
  "repositories"
)

type GameUseCase struct {
  gameRepo *repositories.GameRepository
}

func (u *GameUseCase) AvailableGames() []*models.Game {
  ...
  games := u.gameRepo.FindAll()
  // filter available games
  ...
  
  return games
}


// @repos.go
package repositories

import (
  "models"
  "mysql"
)

type GameRepository struct {
  mysqlClient *mysql.MySQLClient
}

func (r *GameRepository) FindAll() []*models.Game {
  var games []*models.Game
  r.mysqlClient.query("SELECT ...", &games)
  return games
}


// @ctrls.go
packages controllers

import (
  "usecases"
  "net/http"
  "github.com/gin-gonic/gin"
)

type GameController struct {
  usecase *usecases.GameUseCase
}

func (c *GameController) GetAvailableGames(c *gin.Context) {
  games := c.usecase.AvailableGames()
  c.JSON(http.StatusOK, ...)
}

GameController의 GetAvailableGames 는 go-gin 프레임워크로 부터 호출되는 URL 핸들러이고 이는 GameUseCase 를 통해 GameRepository의 FindAll 을 호출하게 되며 결국에는 MySQL 클라이언트를 이용해 데이터를 가져온다. 이 예제의 포인트는 GameUseCase -> MySQL 클라이언트의 의존관계가 존재함을 보여주는 것이다.

 

이제 클린아키텍처를 조금 보고 다시 구현해볼까?

 

클린아키텍처

클린코드로 유명한 로버트 C. 마틴이 2017년에 책을 내면서(한국에는 2019년에 번역본 출간) 제시한 컨셉이다. 책을 읽는 내내 누가 보지도 않는데 고개를 끄덕였다. 바쁜 일정이라는 핑계로 우리는 얼마나 많은 "펌"웨어(Firmware)를 만들어내고 있었던가. 그리고 과연 그것들을 "소프트"웨어(Software)로 만들기 위한 혹은 지키기 위한 투쟁을 하고 있는가. 라는 각성을 일깨워 준 정말 고마운 책이다.

 

핵심 개념

클린아키텍처의 핵심은 의존 관계의 방향성이 한쪽으로만 흘러야한다는 것이다. 그 한쪽이라는 것은 Presenter(I/O에 가까운, 변경 가능성이 많은, 세부사항 등) 영역이 Core(비즈니스 로직, 세부 사항의 변경에 영향을 받지 않아야할 것들) 영역을 의존하는 방향이다. (아래 많이 인용되는 그림으로 보면 바깥 원에서 안쪽 원으로 의존하는게 바람직하다는 얘기다.)

 

 

 

이 의존 관계의 방향이라는 것은 작게는 클래스부터 패키지, 모듈 등 크게는 프로세스, 서비스, 시스템 등 다양한 계층과 스코프(Scope)에서 통찰을 주는데, 그것은 핵심적이면서 변경에는 유연했으면 하는 컴포넌트를 안쪽에 두고 바깥을 의존하지 않게 한다면 외부의 세부적인 변경 사항으로부터 보호할 수 있다는 것이다.

 

핵심 기법

그렇다면 어떻게 의존의 한방향을 유지할까? 비즈니스 로직 중에 데이터를 DB(가령, MySQL)에 저장하는 로직이 있다면 결국 코어의 경계 어딘가에서는 "import mysql" 정도는 해야하는거 아닌가? 이를 해결해주는 원칙이 하나 있다. 바로 SOLIDDIP(Dependency Inversion Principle)이다.

 

 

위에서 구현했던 GameRepository를 예를 들면, 이녀석의 핵심 기능만 인터페이스로 만든 후 Core 에 위치 시킨 후 이를 구현하는 GameMysqlRepository를 구현해서 Presenter 에 위치시킨다면 화살표 방향이 Presenter -> Core 로 바뀐다. 즉, Dependency Inversion 이 일어난 것이다. 이제 Core 에서 GameRepository 인터페이스를 사용하는 녀석은 MySQL 이라는 세부사항에 대해서 알 필요가 없어진다. 

 

그런데 이런 그림으로 의존관계를 정리하다보면 의존하는 객체들을 어떻게 생성하고 관리하고 할당해주느냐에 대해 몇가지 이슈가 생기게 된다. 그 이슈들과 해결 방법들을 좀 보자.

 

우선 한가지 이슈를 생각해보자. GameUseCase는 코어(core)영역이라 이 객체를 생성할때 GameRepository 인터페이스에 할당할 실제 구현체인 GameMysqlRepository 를 알면 안된다. 그렇다면 어떻게 할당해줄까?

 

Dependency Injection (의존성 주입)

이때 필요한 기법이 DI 이다. DI 자체는 개념적으로 간단하다. 보통은 GameUseCase가 GameRepository를 사용하면서 생성하는 등 구체적인 대상 객체에 의존하게 되는데 만약 무엇인가(Injector)가 GameUseCase의 GameRepository에 적절한 객체를 주입(Injection)해 준다면 GameUseCase는 GameRepository 생성의 상세 등에 집착할 필요가 없어진다.

// @game.go
package usecases

import (
  "models"
  "repositories"
)

type GameUseCase struct {
  gameRepo *repositories.GameRepository
}

...

// @factories.go in presenter layer
package factories

import (
  "usecases"
  "repositories"
)

func NewGameUseCase() *usecases.GameUseCase {
  return &usecases.GameUseCase{
    gameRepo: &repositories.GameMysqlRepository{
      ...
    }
  }
}

 

위의 예제에서 그 무엇인가(Injector)에 해당하는게 팩토리 함수인 NewGameUseCase이다. 여기서 GameRepository의 실제 구현체인 GameMysqlRepository를 생성해서 GameUseCase에 주입해주는 것이다. 그럼 GameUseCase 입장에서는 GameRepository의 실제 구현체가 무엇인지 상관 없이(Loose-coupling) 인터페이스가 제공하는대로 사용만 하면 된다. 이렇게 하면 또 하나의 장점으로 GameUseCase를 테스트하기 쉬워진다. 테스트시에 GameRepository를 Mock 객체로 쉽게 교체할 수 있기 때문이다.

 

하지만 이것만으로는 조금 부족한 부분이 있다. 내가 직접 수동으로 생성해서 Injection을 해주다 보면 해당 객체가 필요한 부분마다 코드가 반복되어야할 것이고 Injection 하려는 객체에 또 Injection이 필요한 객체가 있다면(Nested) 그것들도 내가 한땀한땀 핸들링해야할 것이다. 그럼 어떻게 하면 좋을까?

 

IoC Container

이럴때 필요한게 IoC 컨테이너이다. 이는 반드시 필요한건 아닐 수도 있지만 가독성이나 코드 유지보수 관점에서는 유의미하다. 이 링크의 예제가 극단적인 상황을 잘 보여주는 것 같아 인용해본다.

 

만약 내가 ShippingService 객체에 직접 Injection을 시도한다면 아래처럼 복잡한 모양이 될 것이다. 심지어 Nested 도 보인다.

var svc = new ShippingService(new ProductLocator(), 
   new PricingService(), new InventoryService(), 
   new TrackingRepository(new ConfigProvider()), 
   new Logger(new EmailLogger(new ConfigProvider())));

 

그런데 만약 어떤 IoC 가 있고 이 녀석이 Injection이 되야할 혹은 필요한 객체들을 모두 등록받고 있다면, IoC 컨테이너한테 "IShippingService 인터페이스를 따르는 구현체를 내놔라" 라고 한줄로 정리할 수도 있을 것이다.  

var svc = IoC.Resolve<IShippingService>();

 

이제 몇가지 기법을 익혀봤으니 다시 아줌마의 게임 목록 기능 구현으로 돌아가보자.

 

아줌마, 클린아키텍처로 다시

1. 일단 GameRepository를 인터페이스로 만들어서 MySQL 관련 의존과 떨어뜨리자. 패키지도 그냥 models 에 붙여 둠.

// @repos.go
package models

type GameRepository interface {
  FindAll() []*Game
}

 

2. 이제 이 인터페이스의 MySQL 구현체를 별도로 구현하자. (Go에서 인터페이스 구현은 Duck-Typing 으로 하기 때문에 GameRepository를 참조할 필요는 없음)

// @repos_mysql.go
package repositories

import (
  "models"
  "github.com/jmoiron/sqlx"
)

type GameMySqlRepository struct {
  DB *sqlx.DB
}

func (r *GameMySqlRepository) FindAll() []*models.Game {
  var games []*models.Game
  r.DB.Get(&games, "SELECT ...")
  return games
}

 

3. IoC Container 를 활용한 DI를 시도해보자.

 

찾아보니 Golang에서 DI 관련 유력한 오픈소스가 두가지가 있는데 하나는 우버에서 만든 reflection 기반의 uber-go/dig 이고 또 하나는 구글에서 만든 compile-time 기반의 google/wire 이다. star 수로만 보면 wire를 더 선호하는거 같은데 컴파일타임에 추가 프로세싱이 번거로워 보여서 나는 reflection 기반으로 가기로 했다. 하지만 dig 조차도 (물론 더 다양한 방식을 커버하지만) 복잡해보여서 더욱 간단하고 가벼운 녀석을 찾다가 golobby/container 를 발견했고 쓰기로 했다.

 

// @app.go
package main

import (
  "models"
  "repositories"
  "github.com/golobby/container"
  ...
  ...
)

func initContainer() {
  container.Singleton(func() *sqlx.DB {
    db, err := sqlx.Open("mysql", DB_URI)
    ...    
    return db
  })
  
  container.Singleton(func(db *sqlx.DB) models.GameRepository {
    return &repositories.GameRepositoryMySqlImpl{DB: db}
  })
  
  container.Singleton(func(repo models.GameRepository) *usecases.GameUseCase {
    return &usecases.GameUseCase{GameRepo: repo}
  })
  
  container.Singleton(func(usecase *usecases.GameUseCase) *ctrls.GameCtrl {
    return &ctrls.GameCtrl{GameUseCase: usecase}
  })
}

func main() {
  ...
  initContainer()
  
  ...
}

 

4. 마무리로 웹프레임워크(go-gin)까지 한번 이어보자.

// @ctrls.go
package ctrls
...

type GameCtrl struct {
  GameUseCase *usecases.GameUseCase
}

func (ctrl *GameCtrl) GetAvailableGames(c *gin.Context) {
  ...
  games = ctrl.GameUseCase.AvailableGames()
  ...
  c.JSON(http.StatusOK, jsend.New(dto.GamesToDto(games)))
}

// @app.go
package main

import (
  ...
  "ctrls"
  "github.com/golobby/container"
  "github.com/gin-gonic/gin"
)

func initContainer() {
  ...
}

func main() {
  ...
  initContainer()
  
  container.Make(func(gameCtrl *ctrls.GameCtrl) {
    engine := gin.Default()
    ...
    engine.Handle("GET", "/api/v1/games", gameCtrl.GetAvailableGames)
    engine.Run(":8080")
  })
  ...
}

 

소감

  • 클린 아키텍처를 코드 전반적인 부분이 속속들이 녹여내는건 쉬운건 아니다. 지름길로 가려고 의존관계에 대한 고민을 내팽개치고 싶은 유혹을 때때로 받았다. 이를 극복하기 위해 책에서도 인용된 "빠르게 하기위한 유일한 방법은 제대로 하는 것이다" 라는 말을 계속 되내였다.

  • 실질적으로 유의미하다고 느낀 장점은 웹프레임워크/디비드라이버 등의 고려 없이 로직에 대한 테스트를 작성하는 것이 확실히 용이해진다는 점이다. 사실 동적타입 언어를 쓰면 어떻게든 mocking 을 해서 쉽게 테스트할 수 있지만 정적타입 언어에서는 그렇지 않다보니 결국 DI를 어떻게 잘 활용해야할텐데 클린아키텍처를 품으면 그런 것들을 자연스럽게 해소해나갈 수 있을 것 같다.

  • Golang의 DI를 함에 있어 거의 수동으로 IoC 컨테이너에 객체들을 등록하고 가져와서 주입해주는 코드를 작성해야 했는데 이게 현재 언어가 제공하는 한계라서 그런건지는 모르겠다. 암튼 뭔가 verbose하다는 느낌을 받았다. 반면에 비교해서 Spring의 빈(Bean) 스캔 및 등록, 리텐션, 주입 방법 등 IoC 컨테이너와 DI 등에 대한 매커니즘이 얼마나 우아하고 효율적인지 느낄 수 있었다.