Laravel로 웹 앱 만들기: 라우팅, Eloquent ORM, Blade, Middleware, Queue, 배포
이 글의 핵심
Laravel 프로젝트 생성부터 라우팅과 컨트롤러, Eloquent로 데이터베이스 다루기, Blade 템플릿, 미들웨어, 큐를 이용한 비동기 작업, 배포까지 순서대로 다룹니다.
이 글의 핵심
Laravel로 웹 앱을 만드는 흐름을 정리한 글입니다. Eloquent ORM, Blade 템플릿, Middleware, Queue, Artisan CLI, 배포를 예제로 다룹니다. 본문 일부(미들웨어 등록)는 Laravel 10 이하 구조이며, Laravel 11에서 바뀐 점은 해당 위치에 적었습니다.
실무에서 마주치는 문제들
코드 구조가 엉망이에요
프레임워크 없이 작성된 오래된 PHP 코드는 한 파일 안에 SQL 조회, 비즈니스 로직, HTML 출력이 섞여 있는 경우가 많습니다. 기능을 고치려면 파일 전체를 읽어야 하고 테스트도 어렵습니다. Laravel은 라우트, 컨트롤러, 모델, 뷰를 정해진 폴더에 나누는 구조를 제공해서, 처음 보는 사람도 “이 URL의 처리는 어디 있는가”를 바로 찾을 수 있습니다.
SQL 쿼리가 복잡해요
문자열로 SQL을 조립하면 사용자 입력이 섞일 때 SQL 인젝션 위험이 생기고, 조회 결과를 배열로 다루다 보니 관계 데이터를 따라가는 코드가 길어집니다. Eloquent는 테이블을 클래스로, 행을 객체로 다루게 해 주고 값은 자동으로 바인딩되어 인젝션을 막습니다. $user->posts처럼 관계를 속성으로 따라갈 수도 있습니다.
인증 구현이 어려워요
비밀번호 해싱, 세션 고정 공격 방지, CSRF 토큰, 비밀번호 재설정 링크의 만료 처리 같은 보안 세부 사항을 직접 구현하면 빠뜨리기 쉽습니다. Laravel은 이런 기능을 프레임워크 차원에서 제공하고, Breeze나 Jetstream 같은 스타터 킷으로 로그인·회원가입 화면까지 한 번에 생성할 수 있습니다.
대가는 마법처럼 보이는 동작입니다. Facade(Route::, Mail::), 자동 의존성 주입, 이름 규칙에 따른 테이블 매핑처럼 코드에 드러나지 않는 관례가 많아서, 관례를 모르면 “왜 이게 동작하는지” 혹은 “왜 안 되는지” 추적하기 어렵습니다. 공식 문서가 잘 정리되어 있으므로, 막히는 동작은 해당 기능의 문서를 먼저 확인하는 것이 가장 빠릅니다.
Laravel이란?
핵심 특징
Laravel은 PHP 기반 웹 프레임워크입니다. 주요 장점:
- 우아한 문법: 읽기 쉬운 코드
- Eloquent ORM: 강력한 ORM
- Blade: 템플릿 엔진
- Artisan: CLI 도구
- Queue: 비동기 작업
- Ecosystem: Forge, Vapor, Nova
프로젝트 생성
설치
composer create-project laravel/laravel my-app
cd my-app
php artisan serve
브라우저에서 http://localhost:8000 열림
composer create-project는 Laravel 프로젝트 뼈대를 받고 의존성을 설치한 뒤, .env.example을 복사해 .env를 만들고 암호화 키(APP_KEY)를 생성합니다. 저장소를 새로 클론한 경우에는 이 과정이 없으므로 cp .env.example .env와 php artisan key:generate를 직접 실행해야 합니다. 이를 빠뜨리면 No application encryption key has been specified 에러 화면이 뜨는데, 새로 합류한 팀원이 가장 먼저 만나는 에러입니다. Laravel 11부터는 기본 DB가 SQLite라 설치 직후 바로 마이그레이션까지 돌아가지만, MySQL이나 PostgreSQL을 쓰려면 .env의 DB_* 값을 바꿔야 합니다.
php artisan serve는 PHP 내장 웹 서버를 띄우는 개발용 명령입니다. 한 번에 요청 하나씩 처리하는 등 성능과 안정성이 운영용으로 설계되지 않았으므로, 운영 환경에서는 Nginx + PHP-FPM이나 Laravel Octane을 씁니다.
Routing
// routes/web.php
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/users', [UserController::class, 'index']);
Route::get('/users/{id}', [UserController::class, 'show']);
Route::post('/users', [UserController::class, 'store']);
Route::put('/users/{id}', [UserController::class, 'update']);
Route::delete('/users/{id}', [UserController::class, 'destroy']);
// 또는 Resource Route
Route::resource('users', UserController::class);
라우트는 HTTP 메서드와 경로를 클로저나 컨트롤러 메서드에 연결합니다. [UserController::class, 'index'] 배열 문법을 쓰려면 파일 위쪽에 use App\Http\Controllers\UserController;가 있어야 하며, 빠뜨리면 Target class [UserController] does not exist. 에러가 납니다. {id}는 경로 매개변수로, 컨트롤러 메서드의 $id 인자로 전달됩니다.
Route::resource는 위의 다섯 줄을 포함해 create·edit 화면용 라우트까지 일곱 개를 한 번에 등록합니다. 예제처럼 개별 라우트와 함께 쓰면 같은 경로가 두 번 등록되니 실제로는 둘 중 하나만 씁니다. JSON API라면 화면용 라우트가 필요 없으므로 Route::apiResource를 씁니다. php artisan route:list를 실행하면 등록된 모든 라우트와 이름, 미들웨어를 표로 볼 수 있어 라우트 문제를 디버깅할 때 가장 먼저 확인할 곳입니다.
여기서 흔히 막히는 문제가 CSRF입니다. routes/web.php의 라우트에는 CSRF 보호 미들웨어가 적용되어 있어서, Postman이나 프런트엔드 앱에서 CSRF 토큰 없이 POST /users를 보내면 419 Page Expired가 돌아옵니다. Blade 폼이라면 @csrf 지시어를 넣으면 되고, 외부 클라이언트가 호출하는 JSON API라면 CSRF 보호가 없는 routes/api.php에 정의하는 것이 맞습니다(Laravel 11에서는 php artisan install:api로 이 파일을 추가합니다). API 라우트는 자동으로 /api 접두사가 붙는다는 점도 기억해 둡니다.
Eloquent ORM
모델 생성
php artisan make:model User -m
-m 옵션은 모델과 함께 테이블을 만드는 마이그레이션 파일을 생성합니다. 다만 새 Laravel 프로젝트에는 인증용 User 모델과 users 테이블 마이그레이션이 이미 들어 있어서, 이 명령을 그대로 실행하면 모델이 이미 존재한다는 메시지가 나오거나 마이그레이션이 중복되어 Table 'users' already exists 에러가 납니다. 실제로는 기본 User 모델을 수정해 쓰고, 새 모델(Post 등)을 만들 때 이 명령을 씁니다.
모델 정의
// app/Models/User.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $fillable = ['name', 'email', 'age'];
protected $hidden = ['password'];
protected $casts = [
'created_at' => 'datetime',
];
public function posts()
{
return $this->hasMany(Post::class);
}
}
Eloquent는 이름 규칙으로 많은 것을 추론합니다. 클래스 이름 User에서 테이블 이름 users를, hasMany(Post::class)에서 posts 테이블의 user_id 외래 키를 찾습니다. 규칙과 다른 이름을 쓴다면 protected $table = 'members';처럼 명시해야 합니다.
$fillable은 대량 할당(mass assignment) 방어 장치입니다. User::create($request->all())처럼 요청 전체를 넘겨도 $fillable에 있는 필드만 저장되므로, 사용자가 폼에 is_admin=1을 몰래 끼워 넣어도 무시됩니다. $fillable에 없는 필드를 create로 넣으려 하면 조용히 버려지는데, 새 컬럼을 추가하고 $fillable을 갱신하지 않아 “값이 저장되지 않는다”며 헤매는 경우가 흔합니다. $hidden은 모델을 JSON으로 바꿀 때 제외할 필드이고, $casts는 DB 값을 PHP 타입(날짜, 불리언, 배열)으로 자동 변환합니다. created_at과 updated_at은 이미 기본으로 날짜 객체로 변환되므로 예제의 $casts 항목은 없어도 됩니다.
posts()처럼 관계를 정의하면 $user->posts로 사용자의 글 목록을 가져올 수 있습니다. 편리하지만 목록 화면에서 @foreach($users as $user) {{ $user->posts->count() }}처럼 쓰면 사용자 수만큼 추가 쿼리가 나가는 N+1 문제가 생깁니다. User::with('posts')->get()처럼 미리 불러오면 쿼리 두 번으로 끝납니다. 개발 환경에서 Model::preventLazyLoading()을 켜 두면 지연 로딩이 일어날 때 예외가 나서 N+1을 배포 전에 발견할 수 있습니다.
마이그레이션
// database/migrations/2026_05_21_000000_create_users_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->integer('age')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('users');
}
};
마이그레이션은 DB 스키마 변경을 코드로 기록한 것입니다. up()은 변경을 적용하고, down()은 되돌립니다. php artisan migrate를 실행하면 아직 적용되지 않은 마이그레이션만 순서대로 실행되고, 적용 기록은 migrations 테이블에 남습니다. 파일 이름 앞의 날짜가 실행 순서를 정하므로 직접 이름을 바꾸지 않는 것이 좋습니다.
이미 운영 DB에 적용된 마이그레이션 파일은 고치지 않는다는 것이 가장 중요한 규칙입니다. 컬럼을 추가하려고 기존 create_users_table 파일을 수정해도, 이미 적용된 파일이라 운영 DB에서는 다시 실행되지 않습니다. 새 변경은 php artisan make:migration add_phone_to_users_table처럼 새 파일로 만듭니다. 개발 중에 자주 쓰는 migrate:fresh는 모든 테이블을 지우고 처음부터 다시 만드는 명령이라, 운영 DB에 연결된 상태로 실행하면 모든 데이터가 사라집니다.
Controller
생성
php artisan make:controller UserController --resource
CRUD
// app/Http/Controllers/UserController.php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function index()
{
$users = User::all();
return response()->json($users);
}
public function show($id)
{
$user = User::findOrFail($id);
return response()->json($user);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'email' => 'required|email|unique:users',
'age' => 'nullable|integer|min:0',
]);
$user = User::create($validated);
return response()->json($user, 201);
}
public function update(Request $request, $id)
{
$user = User::findOrFail($id);
$validated = $request->validate([
'name' => 'string|max:100',
'email' => 'email|unique:users,email,' . $id,
'age' => 'nullable|integer|min:0',
]);
$user->update($validated);
return response()->json($user);
}
public function destroy($id)
{
$user = User::findOrFail($id);
$user->delete();
return response()->json(null, 204);
}
}
findOrFail은 레코드가 없으면 ModelNotFoundException을 던지고, Laravel이 이를 자동으로 404 응답으로 바꿉니다. 컨트롤러에서 if (!$user) 검사를 반복하지 않아도 되는 이유입니다. 메서드 인자를 $id 대신 User $user로 선언하면 라우트 모델 바인딩이 동작해 Laravel이 경로의 ID로 모델을 찾아 넣어 주므로 findOrFail 줄 자체가 사라집니다. 이때 라우트 매개변수 이름({user})과 인자 이름이 같아야 합니다.
$request->validate()는 규칙을 통과하지 못하면 예외를 던지고, 요청이 JSON을 기대하면(Accept: application/json) 필드별 에러 메시지를 담은 422 응답을, 일반 폼 요청이면 이전 페이지로 리다이렉트하며 에러를 세션에 담습니다. API 클라이언트가 Accept 헤더를 보내지 않으면 JSON 대신 리다이렉트(302) 응답을 받아 당황하는 경우가 많습니다. 검증을 통과한 값만 $validated에 담기므로 이것을 그대로 create에 넘기는 패턴이 안전합니다.
update의 'unique:users,email,' . $id는 “이 ID의 행은 제외하고 중복 검사”라는 뜻으로, 자기 이메일을 그대로 두고 저장해도 중복 에러가 나지 않게 합니다. 문자열 결합보다 Rule::unique('users')->ignore($id)가 읽기 쉽고 실수도 적습니다. index의 User::all()은 테이블 전체를 메모리에 올리므로 사용자가 많아지면 User::paginate(20)으로 바꿔야 하고, 응답 모양을 제어하려면 모델을 그대로 JSON으로 내보내기보다 API Resource(UserResource) 클래스를 쓰는 것이 권장됩니다. 검증 규칙이 길어지면 php artisan make:request StoreUserRequest로 Form Request 클래스로 옮겨 컨트롤러를 가볍게 유지합니다.
Blade 템플릿
레이아웃
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html>
<head>
<title>@yield('title')</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/users">Users</a>
</nav>
<main>
@yield('content')
</main>
</body>
</html>
페이지
{{-- resources/views/users/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Users')
@section('content')
<h1>Users</h1>
<ul>
@foreach($users as $user)
<li>
<a href="/users/{{ $user->id }}">
{{ $user->name }}
</a>
</li>
@endforeach
</ul>
@endsection
Blade의 @yield는 자식 템플릿이 채울 자리이고, 자식은 @extends로 레이아웃을 지정한 뒤 @section으로 그 자리를 채웁니다. {{ $user->name }}은 값을 출력하면서 HTML 특수 문자를 자동으로 이스케이프하므로, 사용자가 이름에 <script>를 넣어도 문자 그대로 표시되어 XSS가 막힙니다. 이스케이프 없이 출력하는 {!! $html !!}은 신뢰할 수 있는 HTML에만 써야 하며, 사용자 입력에 쓰면 그대로 스크립트 실행으로 이어집니다.
이 뷰는 5장의 컨트롤러와 짝이 맞지 않는다는 점도 짚어 둡니다. 컨트롤러의 index는 JSON을 반환하므로 이 템플릿을 쓰려면 return view('users.index', ['users' => User::all()]);처럼 뷰를 반환하는 별도 메서드가 필요합니다. 하나의 컨트롤러가 화면과 API를 모두 처리하게 만들기보다는, 웹용 컨트롤러와 Api 네임스페이스의 컨트롤러를 나누는 편이 구조가 명확합니다. 링크 주소를 href="/users/{{ $user->id }}"처럼 문자열로 조립하는 대신 라우트에 이름을 붙이고 route('users.show', $user)를 쓰면, 나중에 URL 구조를 바꿔도 뷰를 고칠 필요가 없습니다. 레이아웃은 컴포넌트 방식(<x-app-layout>)으로도 만들 수 있고, 최근 스타터 킷은 이 방식을 씁니다.
Middleware
생성
php artisan make:middleware CheckAge
정의
// app/Http/Middleware/CheckAge.php
namespace App\Http\Middleware;
use Closure;
class CheckAge
{
public function handle($request, Closure $next)
{
if ($request->age < 18) {
return response('Unauthorized', 401);
}
return $next($request);
}
}
등록
// app/Http/Kernel.php
protected $routeMiddleware = [
'check.age' => \App\Http\Middleware\CheckAge::class,
];
사용
Route::get('/adult-content', function () {
return 'Adult content';
})->middleware('check.age');
미들웨어는 요청이 컨트롤러에 도달하기 전(그리고 응답이 나가기 전)에 끼어드는 계층입니다. $next($request)를 호출하면 다음 미들웨어나 컨트롤러로 요청이 넘어가고, 호출하지 않고 응답을 반환하면 거기서 처리가 끝납니다. $next($request)의 반환값(응답)을 받아 헤더를 추가한 뒤 반환하면, 응답 단계에서 동작하는 미들웨어가 됩니다.
예제에는 고칠 부분이 있습니다. $request->age는 쿼리 문자열이나 폼 입력의 age 값이라 사용자가 ?age=20을 붙이기만 하면 통과합니다. 실제 나이 확인이라면 인증된 사용자 정보($request->user()->age)를 봐야 합니다. 상태 코드도 401(인증 필요)보다 403(권한 없음)이 의미에 맞습니다. 인증 여부는 Laravel이 제공하는 auth 미들웨어로, 권한은 Gate나 Policy($this->authorize('update', $post))로 처리하는 것이 관례입니다.
등록 방법은 버전에 따라 다릅니다. 위 app/Http/Kernel.php의 $routeMiddleware는 Laravel 9까지의 이름이고, Laravel 10에서는 $middlewareAliases로 바뀌었습니다. Laravel 11부터는 Kernel.php 파일 자체가 없어지고 bootstrap/app.php의 ->withMiddleware(function (Middleware $middleware) { $middleware->alias(['check.age' => CheckAge::class]); })에서 등록합니다. 인터넷의 예제를 따라 했는데 Kernel.php가 없어서 당황했다면 이 변경 때문입니다.
Queue (비동기 작업)
Job 생성
php artisan make:job SendWelcomeEmail
Job 정의
// app/Jobs/SendWelcomeEmail.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $user;
public function __construct($user)
{
$this->user = $user;
}
public function handle()
{
Mail::to($this->user->email)->send(new WelcomeEmail($this->user));
}
}
디스패치
use App\Jobs\SendWelcomeEmail;
$user = User::create($data);
SendWelcomeEmail::dispatch($user);
Worker 실행
php artisan queue:work
ShouldQueue를 구현한 Job은 dispatch()할 때 바로 실행되지 않고 큐(DB 테이블, Redis 등)에 직렬화되어 저장되며, 별도 프로세스인 워커가 꺼내 실행합니다. 회원가입 응답은 메일 서버를 기다리지 않고 바로 나가고, 메일 발송이 실패해도 워커가 재시도할 수 있습니다. .env의 QUEUE_CONNECTION이 sync면 큐를 거치지 않고 즉시 실행되므로, 개발 중 디버깅에는 편하지만 운영 환경에서 이 값이 남아 있으면 비동기 처리의 이점이 사라집니다. 반대로 database나 redis로 설정해 놓고 워커를 띄우지 않으면 Job이 쌓이기만 하고 메일이 영영 발송되지 않습니다. “로컬에서는 메일이 오는데 서버에서는 안 온다”는 문의의 상당수가 워커 미실행입니다.
SerializesModels는 모델 객체 전체가 아니라 모델의 ID만 저장했다가, 워커가 실행할 때 DB에서 다시 조회합니다. 그래서 Job이 실행되기 전에 사용자가 삭제되면 ModelNotFoundException으로 Job이 실패합니다. 또 DB 트랜잭션 안에서 사용자를 만들고 곧바로 dispatch하면, 트랜잭션이 커밋되기 전에 워커가 먼저 실행되어 아직 없는 사용자를 찾다 실패할 수 있습니다. SendWelcomeEmail::dispatch($user)->afterCommit()이나 큐 설정의 after_commit 옵션으로 커밋 후에 넣도록 하면 해결됩니다. 예제의 WelcomeEmail은 php artisan make:mail WelcomeEmail로 만든 Mailable 클래스이며 use App\Mail\WelcomeEmail; import가 필요합니다.
운영 환경에서는 워커를 supervisor나 systemd로 상시 실행하고, 실패 시 재시작하게 설정합니다. 워커는 코드를 메모리에 올린 채 계속 도는 장기 실행 프로세스라, 배포 후 php artisan queue:restart를 호출하지 않으면 이전 코드로 계속 Job을 처리합니다. 배포 직후 새로 추가한 Job 속성에서 에러가 난다면 이 재시작을 빠뜨린 경우가 많습니다. 실패한 Job은 failed_jobs 테이블에 기록되고 php artisan queue:retry all로 다시 실행할 수 있으며, Job 클래스에 $tries와 $backoff 속성을 두면 재시도 횟수와 간격을 정할 수 있습니다.
배포
환경 설정
# .env
APP_ENV=production
APP_DEBUG=false
APP_URL=https://myapp.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=myapp
DB_USERNAME=root
DB_PASSWORD=secret
최적화
# 설정 캐싱
php artisan config:cache
# 라우트 캐싱
php artisan route:cache
# View 캐싱
php artisan view:cache
# Composer 최적화
composer install --optimize-autoloader --no-dev
APP_DEBUG=false는 운영 환경에서 반드시 확인해야 할 설정입니다. true로 두면 에러가 날 때 스택 트레이스와 함께 환경 변수, DB 접속 정보가 화면에 그대로 노출됩니다. .env는 Git에 커밋하지 않고 서버에서 직접 관리하며, 예제의 DB_USERNAME=root처럼 DB 관리자 계정을 앱이 쓰게 하지 말고 필요한 권한만 가진 전용 계정을 만듭니다.
캐시 명령은 요청마다 설정 파일과 라우트 파일을 읽고 해석하는 비용을 없애 줍니다. 여기에는 중요한 함정이 하나 있습니다. config:cache를 실행하면 .env 파일을 더 이상 읽지 않으므로, 설정 파일(config/*.php) 밖의 코드에서 env('API_KEY')를 직접 호출하면 null이 반환됩니다. 로컬에서는 잘 되던 외부 API 연동이 배포 후 “키가 없다”며 실패하는 전형적인 원인이라, 환경 변수는 항상 설정 파일에서 env()로 읽고 코드에서는 config('services.api.key')로 꺼내 쓰는 것이 규칙입니다. .env를 수정한 뒤에는 config:cache를 다시 실행해야 반영됩니다.
배포 순서는 보통 코드 받기 → composer install --no-dev → php artisan migrate --force(운영 환경에서는 확인 프롬프트 때문에 --force 필요) → 캐시 명령 → queue:restart → PHP-FPM 재시작(OPcache 갱신) 순입니다. 이 과정을 매번 손으로 하면 단계를 빠뜨리기 쉬우므로 Laravel Forge, Envoyer, 또는 CI 스크립트로 자동화하는 경우가 많습니다.
정리 및 체크리스트
핵심 요약
- Laravel: PHP 웹 프레임워크
- Eloquent ORM: 강력한 ORM
- Blade: 템플릿 엔진
- Artisan: CLI 도구
- Queue: 비동기 작업
- Ecosystem: 풍부한 생태계
구현 체크리스트
- Laravel 프로젝트 생성
- Models 및 Migration 작성
- Controller 구현
- Routing 설정
- Blade 템플릿 작성
- Middleware 구현
- Queue 설정
- 배포
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. Laravel vs Symfony, 어떤 게 나은가요?
A. Laravel은 관례와 기본값이 잘 갖춰져 있어 빠르게 시작하기 좋고, 인증·큐·결제 같은 공식 패키지 생태계가 넓습니다. Symfony는 컴포넌트를 골라 조합하는 방식이라 설정이 많은 대신 구조를 세밀하게 제어할 수 있어 대규모·장기 프로젝트에서 선호되기도 합니다. 참고로 Laravel도 내부적으로 Symfony의 HTTP·콘솔 컴포넌트를 많이 사용합니다.
Q. PHP 8이 필요한가요?
A. Laravel 10은 PHP 8.1 이상, Laravel 11은 PHP 8.2 이상이 필요합니다. 서버의 PHP 버전이 프로젝트 요구 버전보다 낮으면 composer install 단계에서 의존성 해결에 실패합니다.
Q. 성능은 어떤가요?
A. PHP-FPM은 요청마다 앱을 새로 부팅하므로 OPcache와 설정·라우트 캐시가 기본이고, 느린 페이지의 대부분은 N+1 쿼리나 인덱스 없는 조회가 원인입니다. 부팅 비용 자체를 줄이고 싶다면 앱을 메모리에 상주시키는 Laravel Octane을 검토할 수 있지만, 요청 사이에 상태가 남는 코드를 조심해야 합니다.
Q. 개발 환경은 어떻게 맞추나요?
A. Docker 기반의 Laravel Sail(php artisan sail:install)을 쓰면 PHP, DB, Redis 버전을 팀 전체가 같은 컨테이너로 맞출 수 있습니다. macOS라면 Laravel Herd처럼 로컬에 PHP 환경을 간단히 설치해 주는 도구도 있습니다.