서비스 컨테이너

질문 게시판에 '췩힌집'님이 올리신 질문에 대합 답변입니다. 댓글로 너무 길어져서 글로 적었습니다. 주말에 잠깐 시간내서 적은거라 글이 엉성합니다.

일단 컨테이너를 왜 사용하는지 제대로 이해하려면 SOLID principle이라는 OOP의 5대 원칙을 이해하셔야 합니다. 결국 Container를 사용하는 이유는
SOLID를 사용하면서 생기는 어쩔수 없는 부작용을 해결하기 위함입니다. 해당 사항을 답변으로 드리기에는 너무 길어지고
댓글 형식의 글로는 어려움이 많습니다. 최대한 간단하게 설명을 드릴테니 일단 SOLID도 살펴보시기 바랍니다. SOLID로 검색하면 대부분 영어 사이트가 나오니 한글로 검색하실때는 "OOP 5대 원칙"으로 검색하시면 됩니다. 질문 있으시면 댓글로 계속 달아주세요.

클라스를 기반으로 하는 언어에서는 int와 char같은 타입을 제외하면 모든게 전부 클라스입니다. 간단한 회원가입을 하는 클라스가 아래처럼 있습니다. 예제에 나오는 코드는 모드 pseudo (가짜) 코드입니다.

class RegisterUser
{
        public function register($username, $password)
        {
            // username과 password를 가지고 회원가입 처리
        }
}

실제로 회원가입 로직을 처리 하려면 RegisterUser클라스에서 다른 클라스에 필수적으로 의존해야 합니다. DB를 사용하면 DB에 접근가능한 DAO든 ORM이든 무엇인가가 있어야겠죠..

class RegisterUser
{
    public function register($username, $password)
    {
        // 해당 회원이 이미 가입되있는지 확인
        DB::table('users')->where('username', $username)->get();
    }
}

위의 코드에 큰 문제가 있을까요? 문제의 기준이 서로 다르고 위의 코드가 작동은 하지만 썩좋은 코드는 아닙니다. 저 한줄의 코드로 RegisterUser는 DB접속 없이는 유닛 테스트도 안되고 자신과는 상관없는 DB관련 처리를 하고 있으며 추후에 DB를 처리하는 클라스를 변경했을때 RegisterUser를 변경해야 합니다. 지금 예제에서는 DB를 사용하는 클라스가 RegisterUser 하나지만 그 숫자는 계속 늘어납니다.

해당 코드의 문제점을 초기에 사람들이 인지하고 고치기 시작했습니다. 해당 코드를 조금 변경해보도록 하겠습니다.

interface UserRepository
{
    public function canRegisterWithUsername($username);
}

class DBUserRepository implements UserRepository
{
    public function __construct(DAO $dao)
    {

    }
    public function canRegisterWithUsername($username) {// logic}
}

class RegisterUser
{
    private $repo;
    public function __construct(UserRepository $repo)
    {
        $this->repo = $repo;
    }

    public function register($username, $password)
    {
        if (!$this->repo->canRegisterWithUsername($username)) {
            throw new UsernameExists($username);
        }
    }
}

인터페이스를 모르시면 RegisterUser만 보시면 됩니다. 주목해야 할 점은, DB를 register에서 바로 사용하는대신 RegisterUser의 __construct로 UserRepository를 넘겨주고 있습니다. 이런 식으로 변경하면 유닛테스트도 가능하고 추후에 UserRepository가 DB든 아님 File이든 상관없이 변경이 가능해집니다.

위의 코드를 컨트롤러에서 사용하려면 대략 코드가 아래 같습니다.

class aController
{
    public function registerUser()
    {
        $registerUser = new RegisterUser(new UserRepository(new DAO([
            'host' => 'localhost',
            'username' => 'username',
            'password' => 'password',
            'db' => 'test'
        ])));
        $registerUser->register($_POST['username'], $_POST['password']);
    }
}

문제가 보이시나요? constructor에 클라스를 넘겨주기 시작하면 생기는 첫번째 문제입니다. RegisterUser는 UserRepository를 의존하고
UserRepository는 DAO를 의존하기 때문에 RegisterUser만 사용하려는 입장에서도 UserRepository와 DAO모두 작성 해줘야하고 알고 있어야 합니다.

RegisterUser를 사용하는 코드 (aController) 입장에서는 RegisterUser를 사용하기 위해서 컨트롤러가 RegisterUser 팩토리가 되어버렸습니다.

이런 문제를 해결하긴 위한 방법은 의외로 간단합니다. 간단한 팩토리 클라스를 만들어주면 됩니다.

class DAOFactory
{
    public function getMYSQLDAO()
    {
        return new DAO([
            'host' => 'localhost',
            'username' => 'username',
            'password' => 'password',
            'db' => 'test'
        ]);
    }
}

class RepositoryFactory
{
    public function getUserRepository()
    {
        $dao = (new DAOFactory())->getMYSQLDAO();
        return new DBUserRepository($dao);
    }
}

class ServiceFactory
{
    public function getRegisterUserService()
    {
        $repository = (new RepositoryFactory())->getUserRepository();
        return new RegisterUser($repository);
    }
}

class aController
{
    public function registerUser()
    {
        $registerUser = (new ServiceFactory())->getRegisterUserService();
        $registerUser->register($_POST['username'], $_POST['password']);
    }
}

문제가 해결된거 같아 보이죠? 완벽하게는 아닙니다. 이제 클라스 하나 추가할때마다 따로 팩토리를 다 만들어줘야 하고 해당 팩토리들이 모든 코드에서 사용됩니다. 팩토리가 없으면 아무것도 못하는 상태가 되어버립니다. 어플리케이션 전체가 팩토리와 커플링 되있습니다. 이런식으로 프로그래밍을 못하는건 아닙니다. 예전에는 이런식으로 하는 경우가 많았고 지금도 그럽니다. 프로그래밍을 못하는건 아니지만 분명 개선점은 있습니다.

그래서 등장하는게 서비스 컨테이너 입니다. 여러가지 팩토리를 만들어서 팩토리들간 의존을 하기보다 그냥 하나의 큰 전역 팩토리를 만들고 해당 오브젝트로 모든걸 해결 하자는게 아이디어 입니다.

class ServiceContainer
{
    private static $registeredClasses = [];

    public static function register($key, $aClass)
    {
        self::$registeredClasses[$key] = $aClass;
    }

    public static function get($key)
    {
        return self::registeredClasses[$key];
    }
}

가장 간단한 서비스 컨테이너의 일종입니다. 별거 없죠? 실제로 사용할때는 어플리케이션을 부팅 (?) 하는 과정에서 클라스들을 등록해줍니다. index.php에서 하는 경우도 있고..이거는 어디서 하든 자유입니다.

// 등록과정
ServiceContainer::register("dao.mysql", new DAO(['
    'host' => 'localhost',
    'username' => 'username',
    'password' => 'password',
    'db' => 'test'
']));

ServiceContainer::register('repository.user', new UserRepository(ServiceContainer::get('dao.mysql')));
ServiceContainer::register('registerUser', new RegiserUser(ServiceContainer.get('repository.user')));

컨트롤러 코드는 이제 아래처럼 변경됩니다.

class aController
{
    public function registerUser()
    {
        $registerUser = ServiceContainer::get('registerUser');
        $registerUser->register($_POST['username'], $_POST['password']);
    }
}

이제 ServiceContainer로 모든걸 관리해서 간단해지기는 했지만 여전히 개선점은 남아있습니다.

만약 컨트롤러가 아닌 다른 코드에서 RegisterUser 클라스를 사용하려면 어떻게 해야 할까요?

첫번재 방법으로는 아래처럼 ServiceContainer를 함수안에서 사용하면 됩니다. 문제는..이 방법은 이 글 맨앞에서 나왔던 DB:: 사용과 똑같은 문제를 가지고 있습니다.

class aClass
{
    public function doSomething()
    {
        $registerUser = ServiceContainer::get('registerUser');
    }
}

두번째 방법으로는 aClass의 constructor에 ServiceContainer를 주입하면 됩니다. 현제 코드는 static으로 되있어서 아래처럼 작동이 안되지만, 포인트는
서비스 컨테이너를 constructor로 받는다는 것입니다.

class aClass
{
    private $container;
    public function __construct(ServiceContainer $container)
    {
        $this->container = $container;
    }

    public function doSomething()
    {
        $registerUser = $this->container->get('registerUser');
    }
}

이 두번째 방법의 문제는...모든 클라스가 이제 필요한 클라스를 모두 서비스 컨테이너에 의존하게 됩니다.
살펴보면 서비스 컨테이너가 팩토리보다 개선된점은 클라스의 숫자만 줄었을뿐 팩토리와 똑같은 문제점들을 가지고 있습니다.

이런 문제점들을 해결하기 위해서 IoC 컨테이너가 등장합니다. 아래가 가장 필수적인 IoC 컨테이너가 가져야할 형태입니다.

class Container
{
    public function register($abstract, $concret)
    {
    }

    public function get($abstract)
    {

    }
}

뭔가 이상하지 않나요? 서비스 컨테이너와 똑같습니다.

이 부분이 조금 헷갈리는 부분입니다. Automatic Resolution이라는 기능을 제외하고는 (이것도 사실 필수는 아님.) IoC 컨테이너와 Service 컨테이너의
기능은 거의 똑같습니다.

IoC Container, IoC, 그리고 Dependency Injection이라는 단어를 조심해야 합니다. Inversion of Control 이라는것은 하나의 방법(?)이지 패턴이 아니고
Dependency Injection도 사실 엄청 간단합니다. 그냥 클라스A가 필요한 다른 클라스B를 클라스A에게 넘겨주는 행동을 의존성 주입이라고 합니다.

라라벨4 에서는 현제 라라벨5의 서비스 컨테이너를 IoC 컨테이너라고 불렀습니다. 이름이 변경된 이유는 정확하게는 모르겠습니다만..위에서 설명을 했듯이 IoC 컨테이너와 서비스 컨테이너는 많이 비슷합니다.

컨테이너의 역활보다는 컨테이너로 무엇을 하느냐에 주목해야 합니다.

한글로 뭐라고 부르는지 모르겠지만..위에 나왔던 서비스 컨테이너를 이용해서 필요한 클라스를 가져오는것을 Service Location이라고 부릅니다.

이제 서비스 컨테이너를 조금 개선해서 Service Location이 아닌 Dependency Injection으로 변경해보도록 하겠습니다.

Location을 Injection으로 변경하려면 Composition Root라는곳을 정해야 합니다. 예를들어서 컨트롤러A에서 RegisterUser를 사용하려고 아래처럼 코딩했습니다.

class aController
{
    public function __construct(RegisterUser $registerUser)
    {

    }
}

해당 코드를 작동하게 하려면 Contorller가 아닌 다른 어딘가에서 aController를 만들어주어야 합니다. 그 장소를 Composition Root라고 부릅니다.
라라벨에서는 컨트롤러 같은 경우는 Route 클라스가 Compsotion Root가 됩니다.

라라벨의 Illuminate\Routing\Route 클라스의 runController() 함수를 보시면 아래같은 코드가 있습니다. 여기서 컨테이너를 이용해서
컨트롤러를 만들어주고 있습니다.

if ( ! method_exists($instance = $this->container->make($class), $method))
    throw new NotFoundHttpException;

Service Provider 같은 경우는 각각의 모듈 (예를 들어 ORM)에서 제공하는 클라스들을 서비스 컨테이너에 등록해주는 역활을 합니다.

전체적인 그림을 그리자면

Service Provider에서는 특정 모듈이 제공하는 클라스들을 Service Container에 등록해주고 Composition Root (라라벨의 경우는 Route 클라스)에서
특정 컨트롤러나 클라스를 만들때 서비스 컨테이너에 등록된 클라스들이 있으면 찾아서 자동으로 inject 해줍니다.

comments powered by Disqus