Pada arsitektur monolit modern menggunakan Laravel dan Inertia.js, seluruh transisi halaman client-side bergantung pada endpoint XHR backend. Ketika middleware global mengeksekusi query database tanpa isolasi, degradasi performa terjadi secara sistemik di seluruh aplikasi. Gejala umumnya: Time to First Byte (TTFB) melonjak hingga lebih dari 1,5 detik pada navigasi rute sederhana, disertai utilisasi CPU database yang menyentuh 100% akibat eksekusi query identik berulang.
Gejala dan Investigasi Melalui Profiling
Insiden bermula dari laporan latensi tinggi pada aksi klik menu navigasi frontend. Pemeriksaan metrik APM menunjukkan lonjakan query count per HTTP request, dari rata-rata 3-5 query menjadi 40-70 query untuk endpoint yang hanya merender komponen statis.
Investigasi dilakukan dengan mengaktifkan SQL logging dan memeriksa Laravel Telescope pada entri request ber-header X-Inertia: true:
-- Log query berulang pada setiap navigasi rute dashboard
SELECT * FROM `users` WHERE `id` = 120 LIMIT 1;
SELECT * FROM `tenants` WHERE `tenants`.`id` = 4 LIMIT 1;
SELECT `roles`.*, `model_has_roles`.`model_id` FROM `roles`
INNER JOIN `model_has_roles` ON `roles`.`id` = `model_has_roles`.`role_id`
WHERE `model_has_roles`.`model_id` = 120;
SELECT `permissions`.* FROM `permissions`
INNER JOIN `role_has_permissions` ON `permissions`.`id` = `role_has_permissions`.`permission_id`
WHERE `role_has_permissions`.`role_id` IN (1, 2);Backtrace query tersebut tidak berasal dari controller rute yang dituju, melainkan bermuara langsung pada method share() di app/Http/Middleware/HandleInertiaRequests.php.
Root Cause: Evaluasi Eager pada Shared Props
Middleware HandleInertiaRequests berjalan di setiap lifecycle request web Inertia. Masalah N+1 terjadi saat data model relasional diakses langsung sebagai array primitif di method share() tanpa closure pembungkus dan tanpa eager loading.
Berikut contoh implementasi bermasalah yang umum ditemukan:
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'tenant' => $request->user()->tenant, // Dynamic property access mentrigger query
'roles' => $request->user()->roles->pluck('name'), // Lazy loading relasi
'permissions' => $request->user()->getAllPermissions()->pluck('name'),
] : null,
],
]);
}Terdapat dua akar masalah teknis di sini:
- Evaluasi Instan (Eager Evaluation): Nilai array dievaluasi pada saat middleware dikonstruksi, bahkan jika client melakukan partial reload yang tidak meminta prop
auth. - Un-eager Loaded Relations: Mengakses dynamic property seperti
$request->user()->tenantdan$request->user()->rolesmemicu query tambahan secara implisit via magic method Eloquent setiap kali request diproses.
Solusi Langkah demi Langkah
1. Bungkus Shared Props dalam Closure
Inertia mengevaluasi closure secara lazy. Jika frontend melakukan partial request untuk prop tertentu, closure untuk prop lain yang tidak diminta tidak akan dieksekusi sama sekali oleh backend.
2. Eager Loading dan Request Memoization
Gunakan helper once() bawaan Laravel untuk mencegah duplikasi eksekusi jika data user diakses di beberapa layer request yang sama, serta gunakan loadMissing() jika relasi belum tersedia.
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'app';
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => fn () => $this->resolveAuthPayload($request),
]);
}
private function resolveAuthPayload(Request $request): ?array
{
$user = $request->user();
if (!$user) {
return null;
}
// ponytail: memoize per lifecycle request, ganti ke caching redis jika permission graph kompleks
return once(function () use ($user) {
$user->loadMissing(['tenant:id,name,slug', 'roles.permissions']);
return [
'id' => $user->id,
'name' => $user->name,
'tenant' => $user->tenant?->only('id', 'name', 'slug'),
'roles' => $user->roles->pluck('name'),
'permissions' => $user->roles->flatMap->permissions->pluck('name')->unique()->values(),
];
});
}
}Perubahan ini memangkas eksekusi query dari puluhan menjadi maksimal 2 query terisolasi (user relations eager load) yang hanya dipanggil jika frontend benar-benar membutuhkan prop auth.
Validasi Otomatis Menggunakan Query Budget Test
Mencegah regresi performa berulang di middleware global memerlukan automated test yang memastikan alokasi query tidak melampaui batas (budget) yang ditentukan.
namespace Tests\Feature;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class InertiaSharedPropsTest extends TestCase
{
use RefreshDatabase;
public function test_shared_props_do_not_exceed_query_budget(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::flushQueryLog();
DB::enableQueryLog();
$this->actingAs($user)
->get(route('dashboard'), ['X-Inertia' => 'true'])
->assertOk();
$queries = DB::getQueryLog();
$queryCount = count($queries);
// Budget maksimal 4: session/auth user (1), dashboard page controller (<=2), eager relations (1)
$this->assertLessThanOrEqual(
4,
$queryCount,
"Query count melebihi budget. Terdeteksi {$queryCount} query pada request shared props."
);
}
}Catatan: Jangan pernah mengembalikan model Eloquent utuh secara langsung ke shared props tanpa whitelist field (misal via
only()atau resource class). Mengembalikan instance model utuh berisiko membocorkan atribut sensitif dan memaksa serialisasi relasi yang tidak terkontrol.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!