회원가입 시 무료 등록 슬롯을 자동 부여해야 했다. User::create()를 호출하는 지점이 6곳에 흩어져 있었다. 이메일 가입, 전화번호 가입, 소셜 로그인, 앱 OAuth — 전부 따로 논다.

6곳을 다 고치는 건 빠뜨릴 게 뻔하다. Observer를 쓰면 created 이벤트 하나로 전부 커버된다. 그래서 UserObserver::created()에 슬롯 생성 로직을 넣었다.

여기까지는 공식 문서만 봐도 된다. 그런데 문득 궁금해졌다 — Observer는 정확히 언제, 어떻게 호출되는 걸까? User::create() 한 줄을 치면 프레임워크 안에서 무슨 일이 벌어지는 건가?

소스를 열어봤다.


전체 그림

먼저 결론부터. User::create([...])를 호출하면 이런 순서로 일이 벌어진다.

User::create([...])
  → save()
    → fireModelEvent('saving')         ← ① saving
    → performInsert()
      → fireModelEvent('creating')     ← ② creating
      → DB INSERT 실행
      → fireModelEvent('created')      ← ③ Observer 호출 시점
    → finishSave()
      → fireModelEvent('saved')        ← ④ saved

Observer의 created() 메서드는 ③번 시점 — DB INSERT가 끝난 직후, 같은 요청 안에서 동기적으로 호출된다. $user->id가 이미 할당된 상태이므로 바로 쓸 수 있다.

이제 각 단계를 소스로 따라가본다.


1단계: Observer 등록 — 앱이 부팅될 때

Observer가 호출되려면 먼저 등록이 되어 있어야 한다. 등록은 모델이 부팅될 때 일어난다.

모델에 Observer 연결하기

Laravel 11부터 #[ObservedBy] 어트리뷰트를 쓸 수 있다.

use App\Observers\UserObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy(UserObserver::class)]
class User extends Authenticatable
{
    // ...
}

이전에는 EventServiceProvider에 수동으로 등록해야 했다. 어트리뷰트 방식이 깔끔하다 — 모델 파일만 보면 어떤 Observer가 붙어있는지 바로 보이니까.

부팅 시 일어나는 일

모델이 처음 사용될 때, HasEvents 트레이트의 bootHasEvents()가 실행된다.

// Illuminate\Database\Eloquent\Concerns\HasEvents

public static function bootHasEvents()
{
    static::observe(static::resolveObserveAttributes());
}

resolveObserveAttributes()가 리플렉션으로 #[ObservedBy] 어트리뷰트를 읽어서 Observer 클래스 목록을 반환한다. 그 목록이 observe()로 전달된다.

observe()는 Observer를 하나씩 registerObserver()에 넘긴다.

protected function registerObserver($class)
{
    $className = $this->resolveObserverClassName($class);

    foreach ($this->getObservableEvents() as $event) {
        if (method_exists($class, $event)) {
            static::registerModelEvent($event, $className.'@'.$event);
        }
    }
}

getObservableEvents()는 Eloquent가 지원하는 모든 이벤트 목록을 반환한다:

['retrieved', 'creating', 'created', 'updating', 'updated',
 'saving', 'saved', 'restoring', 'restored', 'replicating',
 'deleting', 'deleted', 'forceDeleting', 'forceDeleted']

이 목록을 순회하면서 “이 Observer에 created 메서드가 있나?” 확인한다. 있으면 registerModelEvent()로 등록한다.

protected static function registerModelEvent($event, $callback)
{
    if (isset(static::$dispatcher)) {
        $name = static::class;

        static::$dispatcher->listen("eloquent.{$event}: {$name}", $callback);
    }
}

최종적으로 Laravel의 Event Dispatcher에 이런 리스너가 등록된다:

이벤트: "eloquent.created: App\Models\User"
리스너: "App\Observers\UserObserver@created"

정리하면: 모델 부팅 → 어트리뷰트 파싱 → Observer 메서드 탐색 → Event Dispatcher에 리스너 등록. 이 과정이 한 번만 실행되고, 이후 모든 User::create() 호출에 적용된다.


2단계: User::create() → save()

이제 실제로 User::create(['name' => '...', 'email' => '...'])를 호출하면 무슨 일이 벌어지는지 본다.

// Illuminate\Database\Eloquent\Builder

public function create(array $attributes = [])
{
    return tap($this->newModelInstance($attributes), function ($instance) {
        $instance->save();
    });
}

새 User 인스턴스를 만들고 save()를 호출한다. 여기서부터 이벤트 체인이 시작된다.

// Illuminate\Database\Eloquent\Model

public function save(array $options = [])
{
    $this->mergeAttributesFromCachedCasts();

    $query = $this->newModelQuery();

    if ($this->fireModelEvent('saving') === false) {    // ← ① saving
        return false;
    }

    if ($this->exists) {
        $saved = $this->isDirty() ?
            $this->performUpdate($query) : true;
    } else {
        $saved = $this->performInsert($query);           // ← 새 모델이므로 INSERT
    }

    if ($saved) {
        $this->finishSave($options);                     // ← ④ saved (안에서 발화)
    }

    return $saved;
}

새 모델이니까 $this->existsfalse다. performInsert()로 간다.


3단계: performInsert() — 핵심

protected function performInsert(Builder $query)
{
    if ($this->usesUniqueIds()) {
        $this->setUniqueIds();                           // UUID/ULID 모델이면 ID 먼저 세팅
    }

    if ($this->fireModelEvent('creating') === false) {   // ← ② creating
        return false;
    }

    if ($this->usesTimestamps()) {
        $this->updateTimestamps();                       // created_at, updated_at 세팅
    }

    $attributes = $this->getAttributesForInsert();

    if ($this->getIncrementing()) {
        $this->insertAndSetId($query, $attributes);      // auto-increment: INSERT 후 ID 받기
    } else {
        if (empty($attributes)) {
            return true;
        }
        $query->insert($attributes);                     // UUID 등: 그냥 INSERT
    }

    $this->exists = true;
    $this->wasRecentlyCreated = true;

    $this->fireModelEvent('created', false);             // ← ③ Observer 호출!

    return true;
}

여기가 핵심이다.

  1. creating 이벤트를 먼저 발화한다 — 여기서 false를 반환하면 INSERT 자체가 취소된다
  2. DB INSERT가 실행된다
  3. $this->exists = true로 설정한다 — 이제 이 모델은 “DB에 존재하는 상태”다
  4. created 이벤트를 발화한다 — 이 시점에 Observer가 호출된다

$user->id가 이미 있으므로, Observer에서 바로 쓸 수 있다:

class UserObserver
{
    public function created(User $user): void
    {
        ListingSlot::create([
            'user_id'      => $user->id,    // ← 이미 할당됨
            'type'         => 'free',
            'status'       => ListingSlot::STATUS_AVAILABLE,
            'activated_at' => now(),
            'expires_at'   => now()->addYear(),
        ]);
    }
}

4단계: fireModelEvent() — 이벤트가 발화되는 방식

fireModelEvent('created', false)가 호출되면 내부에서 이런 일이 벌어진다.

protected function fireModelEvent($event, $halt = true)
{
    if (! isset(static::$dispatcher)) {
        return true;
    }

    $method = $halt ? 'until' : 'dispatch';

    // 1차: $dispatchesEvents 배열에 등록된 커스텀 이벤트 먼저 실행
    $result = $this->filterModelEventResults(
        $this->fireCustomModelEvent($event, $method)
    );

    if ($result === false) {
        return false;
    }

    // 2차: 커스텀 이벤트가 값을 반환하지 않았을 때만 Observer 호출
    return ! empty($result) ? $result : static::$dispatcher->{$method}(
        "eloquent.{$event}: ".static::class, $this
    );
}

주목할 점이 있다. 이벤트 발화가 2단계라는 것이다.

1차 — 커스텀 이벤트 ($dispatchesEvents): 모델에 $dispatchesEvents = ['created' => UserCreated::class]가 정의되어 있으면 이 이벤트가 먼저 실행된다.

2차 — Observer (string-based 이벤트): 1차에서 반환값이 없을 때만 실행된다.

대부분의 경우 $dispatchesEvents를 안 쓰기 때문에 이 구분을 모르고 지나간다. 하지만 나중에 둘을 동시에 쓰면 “왜 Observer가 안 도는 거지?”를 경험할 수 있다. 원인을 모르면 디버깅이 불가능한 종류의 버그다.


알아두면 좋은 함정 두 가지

1. 트랜잭션이 없다

User::create()는 트랜잭션으로 감싸지 않는다.

무슨 뜻이냐면 — User INSERT는 성공했는데 Observer 안의 ListingSlot::create()에서 예외가 터지면, User는 DB에 남아있고 슬롯은 없는 상태가 된다.

이걸 방지하려면 호출부에서 트랜잭션으로 감싸거나:

DB::transaction(function () {
    User::create([...]);
    // Observer의 ListingSlot::create()도 이 트랜잭션 안에서 실행됨
});

또는 Observer 안에서 실패를 직접 처리해야 한다.

2. withoutEvents()로 Observer를 끌 수 있다

테스트에서 대량 유저를 만들 때, 매번 슬롯을 생성하면 느려진다. withoutEvents()로 Observer를 통째로 끌 수 있다.

User::withoutEvents(function () {
    User::factory()->count(100)->create();
    // Observer 호출 안 됨 — 슬롯도 안 만들어짐
});

편리하지만 위험하다. 테스트에서 이걸 쓰면 Observer 로직 자체를 테스트할 수 없다. “슬롯이 생성되는지” 확인하는 테스트에서는 withoutEvents()를 쓰면 안 된다.


정리

앱 부팅 시:
  #[ObservedBy] 파싱 → Observer 메서드 탐색 → Event Dispatcher에 리스너 등록

User::create() 호출 시:
  save() → performInsert()
    → creating 이벤트 (여기서 false 반환하면 INSERT 취소)
    → DB INSERT
    → created 이벤트 (★ Observer 호출)
  → finishSave()
    → saved 이벤트
핵심
등록모델 부팅 시, 리플렉션으로 Observer 메서드를 Event Dispatcher에 등록
실행DB INSERT 직후, 같은 요청 안에서 동기 호출. $user->id 사용 가능
2단계 디스패치$dispatchesEvents → Observer 순서. 커스텀 이벤트가 값을 반환하면 Observer는 실행 안 됨
트랜잭션create()에 트랜잭션 없음. Observer 실패 시 부모 레코드만 남을 수 있음

Observer는 “등록하면 알아서 돌아가는 것”이다. 맞다. 그런데 “알아서”의 내부를 모르면, 안 돌아갈 때 원인을 못 찾는다. 소스를 한 번 따라가본 사람과 안 본 사람의 차이는, 문제가 생겼을 때 나온다.