Laravel 개발 패턴
확장 가능하고 유지 관리가 용이한 프로덕션급 Laravel 아키텍처 패턴입니다.
사용 시기
- Laravel 웹 애플리케이션 또는 API 구축
- 컨트롤러, 서비스 및 도메인 로직 구조화
- Eloquent 모델 및 관계 작업
- 리소스 및 페이지네이션이 포함된 API 설계
- 큐, 이벤트, 캐싱 및 백그라운드 작업 추가
작동 방식
- 명확한 경계(컨트롤러 -> 서비스/액션 -> 모델)를 중심으로 앱을 구조화합니다.
- 명시적 바인딩과 범위 지정 바인딩을 사용하여 라우팅을 예측 가능하게 유지하고, 접근 제어를 위해 여전히 권한 부여를 적용합니다.
- 타입 모델, 캐스트 및 스코프를 선호하여 도메인 로직의 일관성을 유지합니다.
- IO 집약적인 작업은 큐에 넣고 비용이 많이 드는 읽기는 캐시합니다.
- 구성을
config/*에 중앙 집중화하고 환경을 명시적으로 유지합니다.
예제
프로젝트 구조
명확한 레이어 경계(HTTP, 서비스/액션, 모델)가 있는 기존 Laravel 레이아웃을 사용합니다.
권장 레이아웃
app/
├── Actions/ # 단일 목적의 사용 사례
├── Console/
├── Events/
├── Exceptions/
├── Http/
│ ├── Controllers/
│ ├── Middleware/
│ ├── Requests/ # Form request 유효성 검사
│ └── Resources/ # API 리소스
├── Jobs/
├── Models/
├── Policies/
├── Providers/
├── Services/ # 도메인 서비스 조정
└── Support/
config/
database/
├── factories/
├── migrations/
└── seeders/
resources/
├── views/
└── lang/
routes/
├── api.php
├── web.php
└── console.php
컨트롤러 -> 서비스 -> 액션
컨트롤러를 얇게 유지하세요. 오케스트레이션은 서비스에, 단일 목적 로직은 액션에 배치합니다.
final class CreateOrderAction
{
public function __construct(private OrderRepository $orders) {}
public function handle(CreateOrderData $data): Order
{
return $this->orders->create($data);
}
}
final class OrdersController extends Controller
{
public function __construct(private CreateOrderAction $createOrder) {}
public function store(StoreOrderRequest $request): JsonResponse
{
$order = $this->createOrder->handle($request->toDto());
return response()->json([
'success' => true,
'data' => OrderResource::make($order),
'error' => null,
'meta' => null,
], 201);
}
}
라우팅 및 컨트롤러
명확성을 위해 경로-모델 바인딩과 리소스 컨트롤러를 선호합니다.
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('projects', ProjectController::class);
});
경로 모델 바인딩 (범위 지정)
범위 지정 바인딩을 사용하여 테넌트 간 액세스를 방지합니다.
Route::scopeBindings()->group(function () {
Route::get('/accounts/{account}/projects/{project}', [ProjectController::class, 'show']);
});
중첩 경로 및 바인딩 이름
- 이중 중첩을 방지하기 위해 접두사와 경로를 일관성 있게 유지합니다(예:
conversationvsconversations). - 바인딩된 모델과 일치하는 단일 매개변수 이름을 사용합니다(예:
Conversation의 경우{conversation}). - 중첩 시 부모-자식 관계를 적용하기 위해 범위 지정 바인딩을 선호합니다.
use App\Http\Controllers\Api\ConversationController;
use App\Http\Controllers\Api\MessageController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->prefix('conversations')->group(function () {
Route::post('/', [ConversationController::class, 'store'])->name('conversations.store');
Route::scopeBindings()->group(function () {
Route::get('/{conversation}', [ConversationController::class, 'show'])
->name('conversations.show');
Route::post('/{conversation}/messages', [MessageController::class, 'store'])
->name('conversation-messages.store');
Route::get('/{conversation}/messages/{message}', [MessageController::class, 'show'])
->name('conversation-messages.show');
});
});
매개변수가 다른 모델 클래스로 확인되도록 하려면 명시적 바인딩을 정의합니다. 사용자 정의 바인딩 로직의 경우 Route::bind()를 사용하거나 모델에서 resolveRouteBinding()을 구현합니다.
use App\Models\AiConversation;
use Illuminate\Support\Facades\Route;
Route::model('conversation', AiConversation::class);
서비스 컨테이너 바인딩
명확한 의존성 연결을 위해 서비스 프로바이더에서 인터페이스를 구현체에 바인딩합니다.
use App\Repositories\EloquentOrderRepository;
use App\Repositories\OrderRepository;
use Illuminate\Support\ServiceProvider;
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
}
}
Eloquent 모델 패턴
모델 구성
final class Project extends Model
{
use HasFactory;
protected $fillable = ['name', 'owner_id', 'status'];
protected $casts = [
'status' => ProjectStatus::class,
'archived_at' => 'datetime',
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
}
사용자 정의 캐스트 및 값 객체
엄격한 타입 지정을 위해 열거형 또는 값 객체를 사용합니다.
use Illuminate\Database\Eloquent\Casts\Attribute;
protected $casts = [
'status' => ProjectStatus::class,
];
protected function budgetCents(): Attribute
{
return Attribute::make(
get: fn (int $value) => Money::fromCents($value),
set: fn (Money $money) => $money->toCents(),
);
}
N+1 문제 방지를 위한 열성적 로딩
$orders = Order::query()
->with(['customer', 'items.product'])
->latest()
->paginate(25);
복잡한 필터를 위한 쿼리 객체
final class ProjectQuery
{
public function __construct(private Builder $query) {}
public function ownedBy(int $userId): self
{
$query = clone $this->query;
return new self($query->where('owner_id', $userId));
}
public function active(): self
{
$query = clone $this->query;
return new self($query->whereNull('archived_at'));
}
public function builder(): Builder
{
return $this->query;
}
}
글로벌 스코프 및 소프트 삭제
기본 필터링에는 글로벌 스코프를, 복구 가능한 레코드에는 SoftDeletes를 사용합니다.
계층적 동작을 의도하지 않는 한 동일한 필터에 대해 글로벌 스코프 또는 명명된 스코프 중 하나만 사용하고 둘 다 사용하지 마십시오.
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
final class Project extends Model
{
use SoftDeletes;
protected static function booted(): void
{
static::addGlobalScope('active', function (Builder $builder): void {
$builder->whereNull('archived_at');
});
}
}
재사용 가능한 필터를 위한 쿼리 스코프
use Illuminate\Database\Eloquent\Builder;
final class Project extends Model
{
public function scopeOwnedBy(Builder $query, int $userId): Builder
{
return $query->where('owner_id', $userId);
}
}
// 서비스, 리포지토리 등에서
$projects = Project::ownedBy($user->id)->get();
다단계 업데이트를 위한 트랜잭션
use Illuminate\Support\Facades\DB;
DB::transaction(function (): void {
$order->update(['status' => 'paid']);
$order->items()->update(['paid_at' => now()]);
});
마이그레이션
명명 규칙
- 파일 이름은 타임스탬프를 사용합니다:
YYYY_MM_DD_HHMMSS_create_users_table.php - 마이그레이션은 익명 클래스(명명된 클래스 없음)를 사용합니다. 파일 이름이 의도를 전달합니다.
- 테이블 이름은
snake_case이며 기본적으로 복수형입니다.
마이그레이션 예제
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->string('status', 32)->index();
$table->unsignedInteger('total_cents');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
Form Request 및 유효성 검사
유효성 검사는 Form Request에 유지하고 입력을 DTO로 변환합니다.
use App\Models\Order;
final class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', Order::class) ?? false;
}
public function rules(): array
{
return [
'customer_id' => ['required', 'integer', 'exists:customers,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.sku' => ['required', 'string'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
];
}
public function toDto(): CreateOrderData
{
return new CreateOrderData(
customerId: (int) $this->validated('customer_id'),
items: $this->validated('items'),
);
}
}
API 리소스
리소스와 페이지네이션을 사용하여 API 응답을 일관되게 유지합니다.
$projects = Project::query()->active()->paginate(25);
return response()->json([
'success' => true,
'data' => ProjectResource::collection($projects->items()),
'error' => null,
'meta' => [
'page' => $projects->currentPage(),
'per_page' => $projects->perPage(),
'total' => $projects->total(),
],
]);
이벤트, 작업 및 큐
- 부작용(이메일, 분석)을 위해 도메인 이벤트를 실행합니다.
- 느린 작업(보고서, 내보내기, 웹훅)에는 큐에 넣은 작업을 사용합니다.
- 재시도 및 백오프가 있는 멱등성 핸들러를 선호합니다.
캐싱
- 읽기 집약적인 엔드포인트와 비용이 많이 드는 쿼리를 캐시합니다.
- 모델 이벤트(생성/업데이트/삭제) 시 캐시를 무효화합니다.
- 관련 데이터를 캐시할 때 태그를 사용하여 쉽게 무효화합니다.
구성 및 환경
- 비밀은
.env에, 구성은config/*.php에 유지합니다. - 환경별 구성 재정의를 사용하고 프로덕션에서
config:cache를 사용합니다.