Skip to main content
Summary

컨트롤러가 다시 무거워지는 순간

게시글 CRUD를 완성한 뒤 인증과 권한 검사까지 붙였다고 해 보자. 이제 사용자는 로그인해야 글을 쓰고, 작성자만 글을 수정하거나 삭제할 수 있다. 기능은 더 안전해졌지만 코드에는 새로운 부담이 생긴다.

컨트롤러가 다음 일을 모두 맡기 시작하기 때문이다.

  • 요청 데이터 검증

  • 로그인 사용자 확인

  • 작성자 권한 검사

  • 게시글 저장과 수정

  • 필요한 부가 처리 실행

  • 응답과 리다이렉트 결정

작은 게시판에서는 이 정도가 괜찮아 보인다. 하지만 댓글, 알림, 파일 첨부, 금지어 검사, 외부 API 연동이 추가되면 컨트롤러는 금방 다시 복잡해진다. 라우트에서 컨트롤러로 책임을 옮겼던 것처럼, 이제 컨트롤러 안의 일부 책임도 적절한 클래스로 나눌 필요가 있다.

이때 Laravel의 서비스 컨테이너와 의존성 주입을 이해하면 좋다. 서비스 컨테이너는 Laravel이 객체를 만들고 필요한 곳에 전달하는 장치다. 공식 문서에서도 Laravel 컨테이너는 클래스가 구체 클래스에만 의존하거나 의존성이 단순할 때 별도 설정 없이 객체를 해석할 수 있다고 설명한다.1

핵심은 간단하다. 클래스 안에서 필요한 객체를 직접 new로 만들지 않고, 바깥에서 받는다. 이 방식을 의존성 주입이라고 부른다.

서비스 컨테이너와 의존성 주입AI 생성 이미지

직접 만드는 코드와 주입받는 코드

먼저 좋지 않은 예를 보자.

namespace AppHttpControllers;

use AppServicesPostService;
use IlluminateHttpRequest;

class PostController extends Controller
{
    public function store(Request $request)
    {
        $service = new PostService();

        $data = $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string'],
        ]);

        $post = $service->createForUser($request->user(), $data);

        return redirect()->route('posts.show', $post);
    }
}

동작은 한다. 하지만 컨트롤러가 PostService를 직접 생성하고 있다. 나중에 PostService가 금지어 검사기, 알림 발송기, 파일 저장소 같은 다른 객체를 필요로 하면 컨트롤러도 함께 바뀌기 쉽다.

의존성 주입을 사용하면 컨트롤러는 객체를 직접 만들지 않는다.

namespace AppHttpControllers;

use AppServicesPostService;
use IlluminateHttpRequest;

class PostController extends Controller
{
    public function __construct(
        private PostService $posts,
    ) {
    }

    public function store(Request $request)
    {
        $data = $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string'],
        ]);

        $post = $this->posts->createForUser($request->user(), $data);

        return redirect()->route('posts.show', $post);
    }
}

PostController는 PostService가 필요하다고 생성자에 적어 둔다. 그러면 Laravel이 컨트롤러를 만들 때 PostService도 함께 만들어 넣어 준다. 컨트롤러는 "어떻게 만들지"가 아니라 "무엇이 필요한지"만 표현한다.

이것이 생성자 주입이다. 여러 메서드에서 같은 객체를 사용할 때 적합하다. 게시글 생성, 수정, 삭제 흐름이 모두 PostService를 사용한다면 생성자 주입이 자연스럽다.

메서드 주입은 필요한 순간에만 받는다

어떤 의존성은 특정 메서드에서만 필요하다. 이때는 메서드 인자로 받을 수 있다. Laravel은 이미 Request 객체와 라우트 모델 바인딩된 Post 모델을 이런 방식으로 전달해 왔다.

public function update(Request $request, Post $post)
{
    // Request와 Post는 Laravel이 메서드 실행 시점에 전달한다.
}

여기에 우리가 만든 클래스도 함께 받을 수 있다.

use AppActionsUpdatePost;
use AppModelsPost;
use IlluminateHttpRequest;

public function update(Request $request, Post $post, UpdatePost $updatePost)
{
    abort_unless($request->user()->is($post->user), 403);

    $data = $request->validate([
        'title' => ['required', 'string', 'max:255'],
        'body' => ['required', 'string'],
    ]);

    $updatePost->handle($post, $data);

    return redirect()->route('posts.show', $post);
}

생성자 주입과 메서드 주입의 기준은 단순하다.

  • 여러 메서드가 계속 사용하면 생성자 주입

  • 특정 메서드에서만 필요하면 메서드 주입

  • 값이 아니라 역할을 가진 객체라면 주입 후보

  • 요청에서 온 단순 문자열, 숫자, 배열은 검증 후 메서드 인자로 넘기는 편이 명확함

의존성 주입은 코드를 더 길게 만들기 위한 기법이 아니다. 객체를 만드는 책임과 사용하는 책임을 분리해 변경에 강한 구조를 만들기 위한 방법이다.

서비스 클래스는 무엇을 담아야 할까

게시글 저장 흐름을 서비스 클래스로 옮겨 보자.

namespace AppServices;

use AppModelsPost;
use AppModelsUser;

class PostService
{
    public function createForUser(User $user, array $data): Post
    {
        return $user->posts()->create([
            'title' => $data['title'],
            'body' => $data['body'],
        ]);
    }

    public function update(Post $post, array $data): Post
    {
        $post->update([
            'title' => $data['title'],
            'body' => $data['body'],
        ]);

        return $post->refresh();
    }
}

이 정도만 보면 "굳이 서비스가 필요한가?"라는 생각이 들 수 있다. 좋은 의문이다. 단순히 Post::create() 한 줄을 감싸기 위해 서비스 클래스를 만드는 것은 과할 수 있다.

서비스 클래스가 유용한 경우는 다음과 같다.

  • 하나의 작업이 여러 모델을 함께 변경할 때

  • 저장 전후에 알림, 로그, 파일 처리, 외부 API 호출이 필요할 때

  • 같은 업무 규칙을 컨트롤러, 콘솔 명령, 큐 작업에서 반복 사용할 때

  • 트랜잭션으로 묶어야 하는 작업이 있을 때

  • 컨트롤러가 HTTP 처리보다 업무 규칙을 더 많이 담기 시작할 때

반대로 다음 상황에서는 서두를 필요가 없다.

  • 컨트롤러에서 Eloquent 메서드 한두 개만 호출한다

  • 재사용되는 규칙이 없다

  • 서비스 이름이 PostService처럼 넓지만 실제 역할이 불분명하다

  • 코드를 옮겼을 뿐 책임이 더 명확해지지 않았다

처음에는 컨트롤러와 Eloquent만으로 충분하다. 중복과 복잡도가 실제로 보일 때 서비스를 도입하는 편이 유지보수하기 쉽다.

인터페이스로 결합도를 낮추기

서비스 컨테이너의 장점은 구체 클래스뿐 아니라 인터페이스도 다룰 수 있다는 점이다. 인터페이스는 "이런 메서드를 가진 역할이 필요하다"는 약속이다. 실제 구현은 나중에 바꿀 수 있다.

게시글에 금지어 검사를 붙인다고 해 보자. 처음에는 배열에 있는 단어만 검사하지만, 나중에는 외부 스팸 검사 API를 사용할 수도 있다.

먼저 계약을 만든다.

namespace AppContracts;

interface SpamChecker
{
    public function passes(string $title, string $body): bool;
}

구현체는 이 계약을 따른다.

namespace AppServices;

use AppContractsSpamChecker;

class LocalKeywordSpamChecker implements SpamChecker
{
    public function __construct(
        private array $blockedWords = [],
    ) {
    }

    public function passes(string $title, string $body): bool
    {
        $text = $title . ' ' . $body;

        foreach ($this->blockedWords as $word) {
            if (str_contains($text, $word)) {
                return false;
            }
        }

        return true;
    }
}

이제 PostService는 구체 클래스가 아니라 인터페이스에 의존한다.

namespace AppServices;

use AppContractsSpamChecker;
use AppModelsPost;
use AppModelsUser;

class PostService
{
    public function __construct(
        private SpamChecker $spamChecker,
    ) {
    }

    public function createForUser(User $user, array $data): Post
    {
        if (! $this->spamChecker->passes($data['title'], $data['body'])) {
            throw new RuntimeException('게시글에 허용되지 않는 표현이 포함되어 있습니다.');
        }

        return $user->posts()->create([
            'title' => $data['title'],
            'body' => $data['body'],
        ]);
    }
}

여기서는 예제를 단순하게 보이기 위해 RuntimeException을 사용했다. 실제 애플리케이션에서는 사용자에게 어떻게 보여 줄지에 맞춰 검증 오류로 돌려보내거나, 별도의 예외를 만들어 처리하는 편이 더 좋다.

문제가 하나 있다. Laravel은 PostService를 만들 때 SpamChecker 인터페이스가 필요하다는 사실은 알 수 있지만, 어떤 구현체를 넣어야 하는지는 알 수 없다. 이때 서비스 컨테이너에 바인딩을 등록한다.

서비스 프로바이더에서 바인딩 등록하기

바인딩은 "이 타입이 필요하면 이 클래스를 사용하라"는 규칙이다. 보통 서비스 프로바이더의 register 메서드에 등록한다. Laravel의 서비스 프로바이더는 애플리케이션 부팅 과정에서 컨테이너 바인딩, 이벤트 리스너, 미들웨어, 라우트 같은 초기 설정을 등록하는 중심 위치다.2

작은 프로젝트에서는 app/Providers/AppServiceProvider.php에 등록해도 된다.

namespace AppProviders;

use AppContractsSpamChecker;
use AppServicesLocalKeywordSpamChecker;
use IlluminateSupportServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(SpamChecker::class, function () {
            return new LocalKeywordSpamChecker(
                config('posts.blocked_words', [])
            );
        });
    }

    public function boot(): void
    {
        // 모든 프로바이더 등록이 끝난 뒤 필요한 부팅 작업을 여기에 둔다.
    }
}

그리고 설정 파일을 둘 수 있다.

// config/posts.php

return [
    'blocked_words' => ['광고', '도박'],
];

이제 컨테이너는 SpamChecker가 필요할 때 LocalKeywordSpamChecker를 만들어 넣는다. 컨트롤러와 PostService는 구현체 이름을 몰라도 된다. 나중에 외부 API 기반 검사기로 바꾸고 싶다면 바인딩만 교체하면 된다.

$this->app->bind(SpamChecker::class, ExternalApiSpamChecker::class);

bind는 필요할 때마다 새 객체를 만들 수 있다. 같은 인스턴스를 계속 재사용해야 하는 객체라면 singleton을 사용할 수 있다. 예를 들어 설정이 고정된 외부 API 클라이언트처럼 생성 비용이 있고 상태를 안전하게 공유할 수 있는 객체에 적합하다. 요청이나 큐 작업 단위로만 공유하고 싶다면 Laravel의 scoped 바인딩도 검토할 수 있다.1

별도 프로바이더를 만들 수도 있다.

php artisan make:provider PostServiceProvider

최근 Laravel에서는 이 명령으로 만든 프로바이더가 bootstrap/providers.php에 자동 등록된다. 직접 파일을 만들었거나 프로젝트 구조가 다르다면 해당 파일에 프로바이더가 들어 있는지 확인하자. 중요한 원칙은 같다.

  • register: 컨테이너 바인딩 등록

  • boot: 모든 등록이 끝난 뒤 실행할 부팅 작업

  • 바인딩이 많아지면 기능별 프로바이더로 분리

  • 단순한 앱에서는 AppServiceProvider에 작게 시작

액션 클래스와 리포지터리는 언제 쓸까

서비스 컨테이너를 배우면 모든 코드를 인터페이스와 서비스로 감싸고 싶어진다. 하지만 추상화는 비용이 있다. 파일 수가 늘고, 코드 흐름을 따라가기 어려워질 수 있다.

액션 클래스는 하나의 사용 사례를 명확하게 표현할 때 좋다. 예를 들어 "게시글 생성"이라는 작업이 검증 이후에도 여러 단계를 가진다면 CreatePost라는 클래스로 분리할 수 있다.

namespace AppActions;

use AppContractsSpamChecker;
use AppModelsPost;
use AppModelsUser;

class CreatePost
{
    public function __construct(
        private SpamChecker $spamChecker,
    ) {
    }

    public function handle(User $user, array $data): Post
    {
        if (! $this->spamChecker->passes($data['title'], $data['body'])) {
            throw new RuntimeException('게시글을 저장할 수 없습니다.');
        }

        return $user->posts()->create($data);
    }
}

컨트롤러에서는 필요한 메서드에서만 주입받을 수 있다.

use AppActionsCreatePost;
use IlluminateHttpRequest;

public function store(Request $request, CreatePost $createPost)
{
    $data = $request->validate([
        'title' => ['required', 'string', 'max:255'],
        'body' => ['required', 'string'],
    ]);

    $post = $createPost->handle($request->user(), $data);

    return redirect()->route('posts.show', $post);
}

리포지터리 패턴은 더 신중하게 도입해야 한다. Eloquent 자체가 이미 데이터 조회와 저장을 꽤 읽기 쉽게 표현한다. 그래서 PostRepository를 만들고 그 안에 Post::find($id), Post::create($data)만 감싸는 것은 보통 큰 이득이 없다.

리포지터리가 도움이 되는 경우는 따로 있다.

  • 데이터 출처가 Eloquent가 아닐 수 있다

  • 복잡한 조회 조건이 여러 곳에서 반복된다

  • 캐시를 포함한 조회 규칙을 한곳에 모아야 한다

  • 읽기 모델과 쓰기 모델을 분리해야 할 만큼 구조가 커졌다

단순한 반복 쿼리는 먼저 Eloquent 스코프를 고려하자.

// AppModelsPost.php
public function scopePublished($query)
{
    return $query->where('is_published', true);
}
$posts = Post::published()->latest()->paginate(10);

컨테이너는 추상화를 강요하는 도구가 아니다. 필요한 객체를 적절한 위치에 연결해 주는 도구다.

컨테이너를 이해하면 테스트도 쉬워진다

의존성 주입의 또 다른 장점은 교체 가능성이다. 실제 스팸 검사 API를 호출하지 않고 테스트용 구현체를 넣을 수 있다.

$this->app->instance(SpamChecker::class, new FakeSpamChecker());

이 코드는 테스트에서 "SpamChecker가 필요하면 가짜 객체를 사용하라"고 컨테이너에 알려 준다. 테스트를 다룰 때 이 장의 내용이 다시 등장한다. 테스트하기 쉬운 코드는 대부분 의존성이 밖에서 들어오는 코드다.

이번 장의 기준

서비스 컨테이너는 Laravel의 "자동으로 되는 것들"을 만드는 핵심 장치다. 컨트롤러에 Request가 들어오고, 생성자에 적은 서비스가 자동으로 전달되는 일은 서비스 컨테이너 위에서 동작한다. 라우트 파라미터가 모델로 바뀌는 과정도 라우팅 시스템과 컨테이너의 협력 속에서 자연스럽게 이어진다.

실무에서 기억할 기준은 다음과 같다.

  • 컨트롤러는 HTTP 요청과 응답 흐름에 집중한다.

  • 반복되는 업무 규칙은 서비스나 액션 클래스로 옮긴다.

  • 구현을 바꿀 가능성이 있거나 외부 시스템을 감싸야 한다면 인터페이스를 고려한다.

  • 인터페이스를 사용했다면 서비스 프로바이더에서 구현체를 바인딩한다.

  • 단순한 Eloquent 호출을 무조건 리포지터리로 감싸지 않는다.

  • 추상화는 문제가 보일 때 도입하고, 도입했다면 코드 흐름이 더 명확해져야 한다.

컨테이너를 이해하면 Laravel의 자동 주입은 더 이상 마법처럼 보이지 않는다. 필요한 객체를 어디서 만들고, 어떤 구현을 넣고, 언제 교체할지 결정할 수 있는 설계 도구가 된다.

참고

1Laravel Service Container documentation

2Laravel Service Providers documentation

책 목록

라라벨 학습서: 웹 기초부터 배포까지 배우는 Laravel 실전 입문

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기