팀에 합류할 개발자를 뽑기 위해 면접 질문을 준비하고 있었다. “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 | 라우트 파일을 로딩한다 |
AuthServiceProvider | Gate, 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를 모르면 “이 코드가 왜 여기 있지?”를 영원히 이해 못 한다.
보이지 않는 것을 설명할 수 있는 게 주니어와 시니어의 차이다. 이 글을 쓰기 전의 나처럼, “쓸 줄은 아는데 설명은 못 하는” 상태라면 — 이제는 할 수 있다.