Wandu DI를 이용한 레거시 리팩토링

몇일전 Modern PHP User Group(MoernPUG) 모임에서 재미있는 이야기가 하나가 나왔습니다. 오래된 소스를 리팩토링을 하는데 의존성 주입을 일일이 해야하는지 이야기 하더군요. 그래서 Wandu DI 패키지를 활용하면 쉽게 해결이 될 것 같아 글을 적어보기로 했습니다.

DI?

일단은 DI(Dependency Injection)라는 개념을 조금은 알아두어야 할 필요가 있습니다. 간단히 이야기 하면 내부에서 Coupling을 생성하지 말라는 것입니다.

<?php
namespace Your\Service;

class MyService
{
	public function __construct()
	{
		$this->auth = new Authentication();
		$this->connection = new Database();
	}
}

위와 같은 소스는 내부에서 클래스를 생성하기 때문에 Coupling이 생성된다는 것이죠. 이것이 무엇이 문제인고 하니, 간단히 이야기해서 프로그램을 수정하기가 힘들어집니다. 또, 테스트하기가 힘들어집니다. 세세한 이야기를 할 수 없으니 자세한 내용을 알고 싶으시면 Clean Code(클린코드)라는 책을 한번 읽어보시면 좋을 것 같습니다.

그리하여 위 코드가 다음과 같이 변해야 한다는 것입니다.

<?php
namespace Your\Service;

class MyService
{
	public function __construct(AuthInterface $auth, Connectable $connection)
	{
		$this->auth = $auth;
		$this->connection = $connection;
	}
}

이렇게 의존성을 받아서 사용해야 한다는 것입니다. 그렇다면 위 MyService라는 클래스를 생성할 때 다음과 같은 일을 해야합니다.

<?php
namespace Your\Service;

$service = new MyService(new Authentication(), new Database());

그리고 위의 Authentication객체도 만약에 의존성을 필요로 하고, Database도 의존성을 필요로 한다면 소스는 다음과 같이 변할 것입니다.

<?php
namespace Your\Service;

$stream = new Stream();
$service = new MyService(
	new Authentication(
		new Encoder($stream),
		new Decoder($stream)
	),
	new Database(new MySql())
);

아니 무슨 클래스 하나 생성하는데 이렇게 복잡해지는 건지.. 매번 MyService객체를 생성할 때마다 이렇게 해야 한다면 아마도 의존성 주입이 좋은 방법이 아니라고 생각할 수 있습니다.

이러한 행동을 조금 편하게 하고자 하는 라이브러리가 바로 IoC Container라고 부릅니다. 유명한 패키지로는 Pimple이 있습니다. 위의 소스를 다음과 같이 풀어내자는 것입니다.

<?php
namespace Your\Service;

$container = new \Pimple\Container();
$container['stream'] = function ($c) {
	return new Stream();
};
$container['encoder'] = function ($c) {
	return new Encoder($c['stream']);
};
$container['decoder'] = function ($c) {
	return new Stream($c['stream']);
};
$container['auth'] = function ($c) {
	return new Authentication($c['encoder'], $c['decoder']);
};
$container['mysql'] = function ($c) {
	return new MySql();
}
$container['database'] = function ($c) {
	return new Database($c['mysql']);
}
$container['service'] = function ($c) {
	return new MyService($c['auth'], $c['database']);
}

// 실제로 불러올 때
$service = $container['service'];

$container['service']만 호출하면 MyService 클래스가 필요로 하는 의존성을 찾아서 생성해준다는 것입니다. 선언하는 과정이 귀찮아서 그렇지, 만약에 모든 클래스를 다 등록한다면 쉽게 우리가 원하는 객체를 등록할 수 있다는 것입니다.

Auto Resolve

그런데 조금이라도 편하게 할 수 있는 방법이 없을까를 고민할 것입니다. 이미 저 과정조차 너무 귀찮다는 것이지요. 그래서 등장한 개념이 Auto Resolve라는 개념입니다. 생성자에 Type Hint만 잘 주었다면 클래스 생성할때 알아서 타입에 맞게 주입해준다는 것이지요. Laravel같은 프레임워크를 이미 사용하고 있다면 프레임워크안에서 Container가 이미 하고 있는 일입니다. Laravel Container Resolve

아마도 이런 식이 될 것입니다.

<?php
namespace Your\Service;

// $container는 Laravel Container라고 가정한다면..
$container->bind(StreamInterface::class, Stream::class); // 인터페이스를 통해서 등록 가능
$container->bind(EncoderInterface::class, Encoder::class);

// 인터페이스가 없더라도 직접 클래스를 사용해도 됨.
$container->bind(Decoder::class);
$container->bind(Authentication::class);
$container->bind(MySql::class);
$container->bind(Connectable::class, Database::class);
$container->bind(MyService::class);

// 실제로 불러올 때
$service = $container[MyService::class];

훨씬 소스가 간결해졌습니다. 단 위와 같이 사용하기 위해서는 생성자에 타입힌트를 명시해야만 합니다.

<?php
namespace Your\Service;

// Auto Resolve가 해결할 수 없는 경우
public function __construct($auth, $connection)
{
	/* do something */
}

// Auto Resolve가 해결할 수 있도록 변경
public function __construct(Authentication $auth, Connectable $connection)
{
	/* do something */
}

그런데 이러한 편리한 Auto Resolve를 하기 위해서는 Laravel을 사용해야 합니다. 그렇다면 Legacy는 어떻게?! Pimple을 사용하면 됩니다. 단, Auto Resolve라는 기능은 없습니다. 만약에 Auto Resolve를 지원하는 경량 DI Container를 사용하고 싶다면? Wandu DI를 사용하시면 됩니다.

Wandu DI를 이용한 리팩토링

Wandu DI

자세한 내용은 문서에 나와있습니다. :D

이 과정을 하기 위해서는 그전에 해야하는 몇가지 작업이 있습니다.

  1. Legacy코드가 Composer를 사용가능해야합니다.
  2. 리팩토링 해야하는 코드는 Class를 기반으로 작성되어야 합니다.

제일 처음에 일단 composer가 사용가능해야합니다. 그리고 리팩토링해야하는 부분의 코드를 클래스 기반으로 다시 작성해야합니다. 어차피 리팩토링의 필요성을 느끼셨다면 이미 여기까지는 다 하셨을 것이라고 생각합니다. 그리고 나중에 이 앞부분의 내용을 포스팅해보도록 하겠습니다.

우선 Composer를 이용해서 Wandu DI를 설치합니다. 그리고 Container객체를 바로 사용하셔도 되지만, 보통은 해당 객체를 상속받아서 사용하는 것을 추천해드립니다.

<?php
namespace Your\Service;

class Application extends Wandu\DI\Container
{
}

그 다음에는 위 클래스를 생성합니다.

<?php
namespace Your\Service;

$app = new Application();

그냥 생성하시면 됩니다. 아주 쉽습니다.

<?php
namespace Your\Service;

$app->bind(StreamInterface::class, Stream::class); // 인터페이스를 통해서 등록 가능
$app->bind(EncoderInterface::class, Encoder::class);

// 인터페이스가 없더라도 직접 클래스를 사용해도 됨.
$app->bind(Decoder::class);
$app->bind(Authentication::class);
$app->bind(MySql::class);
$app->bind(Connectable::class, Database::class);
$app->bind(MyService::class);

// 실제로 불러올 때
$service = $app[MyService::class];

Laravel과 유사하게 동작합니다. 그런데 모든 코드를 이런식으로 직접 넣으면 이 또한 소스가 복잡해질 것입니다. 그래서 기능단위로 ServiceProvider로 묶어 내면 좋습니다.

<?php
namespace Your\Service;

use ArrayAccess;
use Wandu\DI\ContainerInterface;
use Wandu\DI\Container;
use Wandu\DI\ServiceProviderInterface;

class AuthServiceProvider implements ServiceProviderInterface
{
	public function register(ContainerInterface $app)
	{
		$app->bind(StreamInterface::class, Stream::class);
		$app->bind(EncoderInterface::class, Encoder::class);
		$app->bind(Decoder::class);
		$app->bind(Authentication::class);
	}
}

그리고 DatabaseServiceProvider를 하나더 만들면 좋겠네요.

<?php
namespace Your\Service;

use ArrayAccess;
use Wandu\DI\ContainerInterface;
use Wandu\DI\Container;
use Wandu\DI\ServiceProviderInterface;

class DatabaseServiceProvider implements ServiceProviderInterface
{
	public function register(ContainerInterface $app)
	{
		$app->bind(MySql::class);
		$app->bind(Connectable::class, Database::class);
	}
}

다음과 같이 사용하면 됩니다.

<?php
namespace Your\Service;

$app->register(new AuthServiceProvider);
$app->register(new DatabaseServiceProvider);

$app->bind(MyService::class);

// 실제로 불러올 때
$service = $app[MyService::class];

위 처럼 등록하고자 하는 MyService를 bind하고나서 불러와도 됩니다. 그러나 Container내부에 있는 모든 객체는 Singleton의 형태입니다.

<?php
namespace Your\Service;

$service1 = $app[MyService::class]; // 항상 Singleton을 유지
$service2 = $app[MyService::class];

$service1 == $service2; // true
$service1 === $service2; // true

그래서 만약에 매번 객체를 생성하고 싶다면 다음과 같이 사용하면 됩니다.

<?php
namespace Your\Service;

$service1 = $app->create(MyService::class);
$service2 = $app->create(MyService::class);

$service1 == $service2; // true
$service1 === $service2; // false

그리고 레거시 코드 특성상 Container($app)객체를 어디서든 접근하고 싶을 것입니다.

<?php
namespace Your\Service;

use Wandu\Di\Container;

class Application extends Container
{
	protected static $instance;

	public static function setInstance($instance)
	{
		static::$instance = $instance;
	}

	public static function getInstance()
	{
		return static::$instance;
	}
}
<?php
namespace Your\Service;

Application::setInstance($app);

// 앞으로 이렇게 해서 가지고 올 수 있음.
$app = Application::getInstance();

getInstance, setInstance를 통해 가지고 옵니다. 주의해야하는 점은 위와 같은 패턴은 안티패턴이기 때문에 리팩토링이 끝나고 나면 최대한 제거해야합니다. :-)

참고로, Container에 클래스 등록은 프로그램의 가장 처음에 해야 나중에 관리하기가 편합니다. 이점에 유의하며 리팩토링 합시다!

조금 더 나아가서..

Wandu DI에는 Container를 통해서 매서드 호출도 가능합니다.

<?php
namespace Your\Service;

$myAction = function (Connectable $connect) {
	/* do something */
	return true;
};

$app->call($myAction); // return true

call 메서드는 매개변수로 callable한 모든 변수를 다 받을 수 있습니다. 자세한 내용은 Wandu DI, call method를 참고하시면 됩니다.