회원가입 시 무료 등록 슬롯을 자동 부여해야 했다. 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->exists는 false다. 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;
}
여기가 핵심이다.
creating이벤트를 먼저 발화한다 — 여기서false를 반환하면 INSERT 자체가 취소된다- DB INSERT가 실행된다
$this->exists = true로 설정한다 — 이제 이 모델은 “DB에 존재하는 상태”다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는 “등록하면 알아서 돌아가는 것”이다. 맞다. 그런데 “알아서”의 내부를 모르면, 안 돌아갈 때 원인을 못 찾는다. 소스를 한 번 따라가본 사람과 안 본 사람의 차이는, 문제가 생겼을 때 나온다.