본문으로 건너뛰기
버전: 8.x

서비스 컨테이너 (Service Container)

소개

라라벨의 서비스 컨테이너는 클래스 간의 의존성 관리를 단순화하고, 개발자가 의존성 주입을 쉽게 활용할 수 있게 도와주는 강력한 도구입니다. 의존성 주입(Dependency Injection)이란, 클래스가 필요로 하는 의존성을 생성자가 받거나, 경우에 따라 "세터" 메서드를 통해 "주입"받는다는 개념입니다.

간단한 예제를 살펴보겠습니다.

<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Repositories\UserRepository;
use App\Models\User;

class UserController extends Controller
{
/**
* The user repository implementation.
*
* @var UserRepository
*/
protected $users;

/**
* Create a new controller instance.
*
* @param UserRepository $users
* @return void
*/
public function __construct(UserRepository $users)
{
$this->users = $users;
}

/**
* Show the profile for the given user.
*
* @param int $id
* @return Response
*/
public function show($id)
{
$user = $this->users->find($id);

return view('user.profile', ['user' => $user]);
}
}

위 예제에서 UserController는 사용자 정보를 어떤 데이터 소스에서 가져와야 합니다. 이를 위해 사용자 정보를 제공하는 서비스를 주입받고 있습니다. 이제 UserRepository는 대개 Eloquent를 사용하여 데이터베이스에서 사용자 정보를 가져올 것입니다. 하지만 이렇게 저장소 객체를 주입 받으면, 나중에 같은 인터페이스를 구현한 다른 저장소로 교체하거나, 테스트 시에는 UserRepository의 더미(가짜) 구현체를 손쉽게 주입할 수 있습니다.

라라벨 서비스 컨테이너의 원리를 깊이 있게 이해하면, 강력하고 확장성 있는 애플리케이션을 구축할 수 있을 뿐 아니라 라라벨 핵심 기능에도 직접 기여할 수 있습니다.

제로 구성(Zero Configuration) 해석

클래스가 의존성이 없거나, 오직 구체 클래스(인터페이스가 아닌)만 의존한다면, 컨테이너에 별도의 설정이나 바인딩이 필요하지 않습니다. 예를 들어, 아래의 코드를 routes/web.php 파일에 추가할 수 있습니다.

<?php

class Service
{
//
}

Route::get('/', function (Service $service) {
die(get_class($service));
});

이 예제에서 / 경로로 요청이 들어오면, 라라벨이 자동으로 Service 클래스를 해석하여 라우트에 주입합니다. 즉, 복잡한 설정 파일 없이도 의존성 주입의 강력함을 누릴 수 있습니다.

라라벨로 애플리케이션을 만들 때 작성하게 되는 많은 클래스들은, 컨트롤러, 이벤트 리스너, 미들웨어 등에서 자동으로 컨테이너를 거쳐 의존성이 주입됩니다. 또한 큐 작업handle 메서드에서도 타입힌트로 의존성을 받을 수 있습니다. 한 번 이 자동 의존성 주입의 편리함을 맛보면, 이제 컨테이너 없이는 개발할 수 없게 됩니다!

컨테이너를 언제 사용해야 하는가

제로 구성 해석 덕분에, 보통은 라우트·컨트롤러·이벤트 리스너 등에서 그냥 타입힌트만 해주면 애플리케이션 곳곳에서 컨테이너를 "직접" 다루지 않고도 의존성 주입을 사용할 수 있습니다. 예를 들어, 현재 요청(Request) 객체를 쉽게 받기 위해 라우트에 Illuminate\Http\Request를 타입힌트할 수 있습니다. 아래 코드는 컨테이너를 직접 등장시키지 않지만, 컨테이너가 뒤에서 이 의존성을 관리해줍니다.

use Illuminate\Http\Request;

Route::get('/', function (Request $request) {
// ...
});

이처럼 자동 의존성 주입과 파사드를 이용하면 직접 바인딩이나 해석을 신경 쓰지 않아도 라라벨 애플리케이션을 개발하는 일이 가능합니다.
그렇다면 언제 직접 컨테이너와 상호작용하는 것이 필요할까요? 대표적으로 두 가지 상황이 있습니다.

첫 번째는, 어떤 클래스를 특정 인터페이스를 구현하도록 만들고, 그 인터페이스에 타입힌트할 때입니다. 이런 경우에는 반드시 컨테이너에 이 인터페이스와 구현체를 바인딩해 주어야 합니다.
두 번째는, 라라벨 패키지를 작성해 다른 라라벨 개발자와 공유하려는 경우로, 이 경우 패키지의 서비스를 컨테이너에 바인딩해야 할 수 있습니다.

바인딩

기본 바인딩

단순 바인딩

대부분의 서비스 컨테이너 바인딩은 서비스 프로바이더 내에서 등록하게 됩니다. 그래서 아래 예제들 역시 이 문맥을 기준으로 설명합니다.

서비스 프로바이더 내에서는 항상 $this->app 프로퍼티를 통해 컨테이너에 접근할 수 있습니다. bind 메서드를 사용해 바인딩하려는 클래스나 인터페이스의 이름과, 해당 클래스의 인스턴스를 반환하는 클로저를 등록할 수 있습니다.

use App\Services\Transistor;
use App\Services\PodcastParser;

$this->app->bind(Transistor::class, function ($app) {
return new Transistor($app->make(PodcastParser::class));
});

이때, 클로저의 인자로 컨테이너 자신이 전달됩니다. 이를 활용해서 해당 객체의 하위 의존성도 컨테이너를 통해 해석할 수 있습니다.

보통 서비스 프로바이더 내에서 컨테이너와 상호작용하지만, 프로바이더 바깥에서 직접 컨테이너를 사용하고 싶다면 App 파사드를 사용할 수 있습니다.

use App\Services\Transistor;
use Illuminate\Support\Facades\App;

App::bind(Transistor::class, function ($app) {
// ...
});

[!TIP] 클래스가 어떤 인터페이스에도 의존하지 않는다면 컨테이너에 바인딩할 필요가 없습니다. 컨테이너는 이런 객체는 리플렉션(reflection)으로 자동 해석할 수 있기 때문입니다.

싱글턴 바인딩

singleton 메서드는 컨테이너에 클래스나 인터페이스를 한 번만 해석해서, 이후에는 같은 인스턴스를 계속 반환하도록 바인딩합니다.

use App\Services\Transistor;
use App\Services\PodcastParser;

$this->app->singleton(Transistor::class, function ($app) {
return new Transistor($app->make(PodcastParser::class));
});

Scoped 싱글턴 바인딩

scoped 메서드는 라라벨의 요청(Request)이나 작업(Job) 수명 주기 동안에만 한 번 해석되어 그 주기 안에서 같은 인스턴스를 재사용하게 만듭니다.
이 메서드는 singleton과 비슷하지만, Laravel Octane 워커가 새로운 요청을 처리하거나, queue worker가 새 작업을 처리할 때마다 등록된 인스턴스가 리셋됩니다.

use App\Services\Transistor;
use App\Services\PodcastParser;

$this->app->scoped(Transistor::class, function ($app) {
return new Transistor($app->make(PodcastParser::class));
});

인스턴스 바인딩

이미 만들어 둔 객체 인스턴스를 컨테이너에 바인딩하고 싶다면 instance 메서드를 사용할 수 있습니다. 이 인스턴스는 이후에도 동일하게 반환됩니다.

use App\Services\Transistor;
use App\Services\PodcastParser;

$service = new Transistor(new PodcastParser);

$this->app->instance(Transistor::class, $service);

인터페이스를 구현체와 바인딩하기

서비스 컨테이너의 강력한 기능 중 하나는, 인터페이스를 특정 구현체와 바인딩할 수 있다는 점입니다.
예를 들어, EventPusher라는 인터페이스와, 이를 구현한 RedisEventPusher가 있다고 가정해봅니다. 다음처럼 구현체를 컨테이너에 바인딩할 수 있습니다.

use App\Contracts\EventPusher;
use App\Services\RedisEventPusher;

$this->app->bind(EventPusher::class, RedisEventPusher::class);

이 설정을 해두면, 컨테이너가 EventPusher 타입이 필요할 때마다 실제로는 RedisEventPusher 구현체가 주입됩니다. 이제 컨테이너에서 해석되는 모든 클래스(컨트롤러, 이벤트 리스너, 미들웨어 등)의 생성자에 인터페이스 타입힌트를 사용할 수 있습니다.

use App\Contracts\EventPusher;

/**
* Create a new class instance.
*
* @param \App\Contracts\EventPusher $pusher
* @return void
*/
public function __construct(EventPusher $pusher)
{
$this->pusher = $pusher;
}

컨텍스트별 바인딩

한 프로젝트에서 같은 인터페이스를 사용하는 여러 클래스가, 서로 다른 구현체를 받아야 하는 경우가 있습니다.
예를 들어, 두 개의 컨트롤러가 Illuminate\Contracts\Filesystem\Filesystem 컨트랙트의 서로 다른 구현체에 의존해야 할 때, 라라벨은 이를 위한 간단하고 직관적인 API를 제공합니다.

use App\Http\Controllers\PhotoController;
use App\Http\Controllers\UploadController;
use App\Http\Controllers\VideoController;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;

$this->app->when(PhotoController::class)
->needs(Filesystem::class)
->give(function () {
return Storage::disk('local');
});

$this->app->when([VideoController::class, UploadController::class])
->needs(Filesystem::class)
->give(function () {
return Storage::disk('s3');
});

기본값 바인딩

클래스가 의존성 객체 이외에도 정수형 등 단순(primitive) 값을 필요로 할 때도 있습니다.
컨텍스트별 바인딩을 통해 원하는 값을 주입할 수 있습니다.

$this->app->when('App\Http\Controllers\UserController')
->needs('$variableName')
->give($value);

또한, 클래스가 태그된 여러 인스턴스의 배열을 의존성으로 받을 수도 있습니다. 이때는 giveTagged 메서드를 사용해 해당 태그의 모든 바인딩을 한 번에 주입할 수 있습니다.

$this->app->when(ReportAggregator::class)
->needs('$reports')
->giveTagged('reports');

설정 파일의 값을 주입하려면 giveConfig 메서드를 사용할 수도 있습니다.

$this->app->when(ReportAggregator::class)
->needs('$timezone')
->giveConfig('app.timezone');

타입 지정 가변 인자 바인딩

간혹 하나의 클래스에서 가변 인자(variadic, ...$args)를 통해 여러 타입 객체 배열을 전달받을 수도 있습니다.

<?php

use App\Models\Filter;
use App\Services\Logger;

class Firewall
{
/**
* The logger instance.
*
* @var \App\Services\Logger
*/
protected $logger;

/**
* The filter instances.
*
* @var array
*/
protected $filters;

/**
* Create a new class instance.
*
* @param \App\Services\Logger $logger
* @param array $filters
* @return void
*/
public function __construct(Logger $logger, Filter ...$filters)
{
$this->logger = $logger;
$this->filters = $filters;
}
}

이런 의존성을 컨텍스트별 바인딩으로 해석하려면, give 메서드에 Filter 인스턴스 배열을 반환하는 클로저를 전달하면 됩니다.

$this->app->when(Firewall::class)
->needs(Filter::class)
->give(function ($app) {
return [
$app->make(NullFilter::class),
$app->make(ProfanityFilter::class),
$app->make(TooLongFilter::class),
];
});

더 간단하게, 클래스 이름 배열만 지정해도 컨테이너가 자동으로 인스턴스를 만들어 의존성으로 주입합니다.

$this->app->when(Firewall::class)
->needs(Filter::class)
->give([
NullFilter::class,
ProfanityFilter::class,
TooLongFilter::class,
]);

태그된 가변 인자 의존성

가변 인자 의존성의 타입이 특정 클래스(Report ...$reports)일 때, needsgiveTagged를 조합해 해당 태그가 붙은 모든 바인딩을 간편하게 주입할 수 있습니다.

$this->app->when(ReportAggregator::class)
->needs(Report::class)
->giveTagged('reports');

태깅

특정 "분류"에 해당하는 바인딩들을 한 번에 모두 해석해야 할 때가 있습니다.
예를 들어, 다양한 Report 인터페이스 구현체 배열을 받는 리포트 분석기를 만들 때, 먼저 각 구현체를 등록하고 이후 한꺼번에 태그를 붙일 수 있습니다.

$this->app->bind(CpuReport::class, function () {
//
});

$this->app->bind(MemoryReport::class, function () {
//
});

$this->app->tag([CpuReport::class, MemoryReport::class], 'reports');

이제 태그가 부여된 서비스들을 컨테이너의 tagged 메서드로 모두 불러올 수 있습니다.

$this->app->bind(ReportAnalyzer::class, function ($app) {
return new ReportAnalyzer($app->tagged('reports'));
});

바인딩 확장

extend 메서드는 이미 해석된 서비스를 수정하거나 데코레이터 패턴 등으로 감쌀 때 사용합니다.
이 메서드는 하나의 인자를 받는데, 해당 인자는 서비스와 컨테이너 인스턴스를 매개변수로 받아 바뀐 서비스를 반환해야 합니다.

$this->app->extend(Service::class, function ($service, $app) {
return new DecoratedService($service);
});

해석(Resolving)

make 메서드

make 메서드를 사용해 컨테이너에서 클래스 인스턴스를 해석(생성)할 수 있습니다.
make는 해석하려는 클래스나 인터페이스의 이름을 인자로 받습니다.

use App\Services\Transistor;

$transistor = $this->app->make(Transistor::class);

만약 해석하려는 클래스의 일부 의존성을 컨테이너에서 자동으로 해석할 수 없다면, makeWith 메서드로 직접 연관 배열 형태로 전달할 수 있습니다. 예를 들어, Transistor 서비스의 생성자에 $id 파라미터를 직접 넣어주고 싶다면 아래와 같이 할 수 있습니다.

use App\Services\Transistor;

$transistor = $this->app->makeWith(Transistor::class, ['id' => 1]);

서비스 프로바이더 바깥, 즉 $app 변수를 직접 쓸 수 없는 코드 위치에서는 App 파사드를 통해 인스턴스를 해석할 수 있습니다.

use App\Services\Transistor;
use Illuminate\Support\Facades\App;

$transistor = App::make(Transistor::class);

그리고, 만약 클래스 생성자에서 라라벨 컨테이너 자체를 주입받고 싶다면 Illuminate\Container\Container 클래스를 생성자에 타입힌트하면 됩니다.

use Illuminate\Container\Container;

/**
* Create a new class instance.
*
* @param \Illuminate\Container\Container $container
* @return void
*/
public function __construct(Container $container)
{
$this->container = $container;
}

자동 주입

또한 도메인 객체의 생성자(예: 컨트롤러, 이벤트 리스너, 미들웨어 등)에서 의존성을 타입힌트하면, 대부분의 의존성이 자동으로 주입됩니다. 큐 작업handle 메서드도 마찬가지입니다. 실제로는 이런 방식으로 대부분의 객체를 컨테이너가 자동으로 해석하도록 구현하는 것이 가장 좋습니다.

예를 들어, 컨트롤러의 생성자에서 저장소(Repository)를 타입힌트로 명시하면, 이 저장소는 자동으로 해석되어 주입됩니다.

<?php

namespace App\Http\Controllers;

use App\Repositories\UserRepository;

class UserController extends Controller
{
/**
* The user repository instance.
*
* @var \App\Repositories\UserRepository
*/
protected $users;

/**
* Create a new controller instance.
*
* @param \App\Repositories\UserRepository $users
* @return void
*/
public function __construct(UserRepository $users)
{
$this->users = $users;
}

/**
* Show the user with the given ID.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function show($id)
{
//
}
}

메서드 호출 및 주입

때로는 객체 인스턴스의 특정 메서드를 호출할 때, 해당 메서드에 필요한 의존성도 자동으로 주입 받으면서 호출하고 싶을 수 있습니다.
아래와 같은 클래스를 예로 들어보겠습니다.

<?php

namespace App;

use App\Repositories\UserRepository;

class UserReport
{
/**
* Generate a new user report.
*
* @param \App\Repositories\UserRepository $repository
* @return array
*/
public function generate(UserRepository $repository)
{
// ...
}
}

위의 generate 메서드를 컨테이너를 통해 호출하려면 아래와 같이 할 수 있습니다.

use App\UserReport;
use Illuminate\Support\Facades\App;

$report = App::call([new UserReport, 'generate']);

call 메서드는 실제로는 어떤 PHP 콜러블(callable)도 받을 수 있습니다.
또한 다음과 같이, 클로저를 호출할 때도 자동으로 필요한 의존성이 주입됩니다.

use App\Repositories\UserRepository;
use Illuminate\Support\Facades\App;

$result = App::call(function (UserRepository $repository) {
// ...
});

컨테이너 이벤트

서비스 컨테이너는 객체를 해석(생성)할 때마다 "이벤트"를 발생시킵니다. 이 이벤트는 resolving 메서드를 사용해 감지할 수 있습니다.

use App\Services\Transistor;

$this->app->resolving(Transistor::class, function ($transistor, $app) {
// "Transistor" 타입의 객체가 해석될 때마다 호출됩니다...
});

$this->app->resolving(function ($object, $app) {
// 어떤 타입이든 객체가 해석될 때마다 호출됩니다...
});

이벤트 핸들러에는 실제 해석되는 객체 인스턴스가 전달되므로, 객체의 프로퍼티를 추가 설정하거나 꾸미는 등 다양한 후처리를 할 수 있습니다.

PSR-11

라라벨의 서비스 컨테이너는 PSR-11 표준 인터페이스를 구현하고 있습니다. 따라서 PSR-11 컨테이너 인터페이스를 타입힌트하여 라라벨의 컨테이너 인스턴스를 받을 수 있습니다.

use App\Services\Transistor;
use Psr\Container\ContainerInterface;

Route::get('/', function (ContainerInterface $container) {
$service = $container->get(Transistor::class);

//
});

만약 넘긴 식별자(identifier)를 해석할 수 없는 경우, 예외가 발생합니다. 이때 식별자가 한 번도 바인딩된 적이 없다면 Psr\Container\NotFoundExceptionInterface 예외가, 식별자는 존재하지만 정상적으로 해석할 수 없을 때는 Psr\Container\ContainerExceptionInterface 예외가 발생합니다.