팀에 합류할 개발자를 뽑기 위해 면접 질문을 준비하고 있었다. “Service Provider가 뭔가요?”를 적다가 멈췄다. 나도 설명을 못 하겠다.

php artisan serve를 수백 번 쳤고, N+1 경고를 수십 번 봤다. 그런데 “본인 말로 설명해보세요”라고 하면 막힌다.

이 글은 그래서 썼다. 면접관용이 아니라, 나 같은 사람용이다.


N+1 문제 — 쿼리가 1,001번 나가는 이유

게시판을 만들었다. 게시글 목록에 작성자 이름을 표시해야 한다.

$articles = Article::all(); // 쿼리 1번

@foreach($articles as $article)
    {{ $article->user->name }} // 게시글마다 쿼리 1번
@endforeach

게시글이 10개면 쿼리 11번. 1,000개면 1,001번. 이게 N+1이다.

-- 1번: 게시글 전체
SELECT * FROM articles;

-- N번: 게시글마다 작성자를 따로 조회
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
...

문제는 코드만 보면 티가 안 난다는 거다. $article->user->name이 쿼리를 발생시킨다는 걸 모르면, 왜 페이지가 느린지 원인을 못 찾는다.

해결: with()

$articles = Article::with('user')->get();

끝이다. 쿼리 2번으로 줄어든다.

-- 1번: 게시글 전체
SELECT * FROM articles;

-- 2번: 필요한 작성자를 한 번에
SELECT * FROM users WHERE id IN (1, 2, 3, ...);

with()는 관련 데이터를 미리 한 번에 가져온다. 이걸 Eager Loading이라고 부른다. “게으르게 하나씩”이 아니라 “부지런히 한 번에.”

load()는 뭐가 다른가

$articles = Article::all();
$articles->load('user');

결과는 같다. WHERE IN으로 한 번에 가져온다. 차이는 타이밍뿐이다.

시점코드
with()가져올 때 같이Article::with('user')->get()
load()가져온 후 추가로$articles->load('user')

load()는 조건부로 쓸 때 유용하다. “관리자면 작성자 IP도 같이 가져와야 해” 같은 상황.

$articles = Article::all();

if (auth()->user()->isAdmin()) {
    $articles->load('user.loginHistory');
}

실무에서 잡는 법

Laravel은 N+1을 자동으로 잡아주는 기능이 있다.

// AppServiceProvider의 boot()에 한 줄 추가
Model::preventLazyLoading(!app()->isProduction());

개발 환경에서 Lazy Loading(N+1 유발)이 발생하면 예외를 던진다. 프로덕션에서는 끈다 — 에러를 터뜨리는 것보다 느린 게 낫다.

이 코드가 들어있는 곳이 Service Provider다. 다음 섹션의 주제.


Service Provider — 앱이 켜질 때 일어나는 일

php artisan serve를 치면 앱이 켜진다. 그 과정에서 라라벨은 “이 앱이 작동하려면 뭐가 필요한가?”를 준비한다. 그 준비를 하는 곳이 Service Provider다.

식당으로 비유하면 오픈 전 prep이다. 손님(Request)이 오기 전에 재료를 세팅하고, 가스를 켜고, 음악을 튼다.

class AppServiceProvider extends ServiceProvider
{
    // "재료 세팅" — 주문이 들어오면 이렇게 만들어
    public function register()
    {
        $this->app->bind(PaymentService::class, function () {
            return new PaymentService(config('services.toss.key'));
        });
    }

    // "오픈 직전 마무리" — 가스 켜고, 음악 틀어
    public function boot()
    {
        Model::preventLazyLoading(!app()->isProduction());
    }
}

register()와 boot()

두 메서드는 실행 순서가 다르다.

register() — 모든 Provider의 register()가 먼저 실행된다. 여기서는 “이런 게 있다”를 등록만 한다. 다른 서비스를 사용하면 안 된다 — 아직 등록 안 됐을 수 있으니까.

boot() — 모든 register()가 끝난 후 실행된다. 여기서는 등록된 서비스를 자유롭게 쓸 수 있다. 이벤트 리스너, 라우트 매크로, Blade 디렉티브, Eloquent 옵저버 같은 것들이 여기에 온다.

앱 부팅 순서:

1. 모든 Provider → register()  "재료 다 세팅"
2. 모든 Provider → boot()      "이제 재료 써도 됨"
3. 라우트 → 미들웨어 → 컨트롤러   "손님 받기 시작"

이미 쓰고 있는 것들

새 라라벨 프로젝트를 열면 config/app.php에 Provider 목록이 있다. 이미 매일 쓰고 있다.

Provider하는 일
RouteServiceProvider라우트 파일을 로딩한다
AuthServiceProviderGate, Policy를 등록한다
EventServiceProvider이벤트-리스너를 매핑한다
AppServiceProvider개발자가 직접 쓰는 범용 공간

패키지를 설치할 때 “ServiceProvider를 등록하세요”가 나오는 이유도 이거다. Provider를 등록하지 않으면 라라벨은 그 패키지의 존재를 모른다.

왜 이 구조인가

“그냥 index.php에서 전부 하면 안 되나?”

된다. 그런데 프로젝트가 커지면:

// index.php에서 전부 하면
$payment = new PaymentService(config('services.toss.key'));
$sms = new SmsService(config('services.solapi.key'));
$email = new EmailService(config('services.ses.key'));
Model::preventLazyLoading(!app()->isProduction());
Blade::directive('money', ...);
Event::listen(OrderPlaced::class, ...);
// ... 200줄

Service Provider는 이걸 역할별로 쪼갠다. 결제는 결제 Provider에, 인증은 Auth Provider에. 각자 자기 책임만 register하고 boot한다.


면접에서 확인하는 법

이 글을 쓴 이유가 면접 준비였으니, 정리해둔다.

N+1 확인

"Eloquent에서 N+1 문제가 뭐고, 어떻게 해결하나요?"
  • with() 또는 load() 말하면 → OK
  • “쿼리 로그 찍어보면 안다” 추가하면 → 실무 경험 있음
  • preventLazyLoading 언급하면 → 프로젝트 세팅도 해본 사람

Service Provider 확인

"Service Provider가 뭐 하는 건지 본인 말로 설명해주세요."

여기서 핵심은 **“본인 말로”**다. 공식 문서의 정의(“Service providers are the central place of all Laravel application bootstrapping”)를 외워서 말하면 이해한 게 아니다.

  • “앱 부팅할 때 필요한 걸 준비하는 곳” 수준이면 → 기본은 안다
  • register()boot()의 순서 차이를 설명하면 → 직접 만들어본 적 있다
  • “커스텀 Provider 만들어본 적 있나요? 어떤 용도로?”까지 가면 → 아키텍처 이해도 확인

정리

핵심한 줄
N+1관계 데이터를 하나씩 조회하는 문제with()로 한 번에 가져온다
Service Provider앱 부팅 시 필요한 것을 준비하는 구조register()로 등록, boot()로 사용

두 개념의 공통점이 있다. 매일 작동하고 있지만 눈에 안 보인다. with()를 안 쓰면 N+1이 조용히 성능을 갉아먹고, Service Provider를 모르면 “이 코드가 왜 여기 있지?”를 영원히 이해 못 한다.

보이지 않는 것을 설명할 수 있는 게 주니어와 시니어의 차이다. 이 글을 쓰기 전의 나처럼, “쓸 줄은 아는데 설명은 못 하는” 상태라면 — 이제는 할 수 있다.