Laravel을 쓰다 보면 한 번쯤 듣는 조언이 있다.
“Repository 패턴을 써야 유지보수가 쉬워요.” “Interface를 만들어야 SOLID 원칙에 맞아요.”
이 조언을 따르면 파일이 3배로 늘어나고, 얻는 건 없다. 왜 그런지 설명한다.
Laravel의 실용적 아키텍처: MVC + Service
Laravel 프로젝트가 커지면 Controller가 뚱뚱해진다.
// Fat Controller — 모든 게 여기에 있다
class UserController
{
public function register(Request $request)
{
$validated = $request->validate([
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
'name' => 'required|string',
]);
$user = User::create([
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'name' => $validated['name'],
]);
Mail::to($user)->send(new WelcomeMail($user));
Log::info("User registered: {$user->id}");
return redirect()->route('home');
}
}
유효성 검사, 생성, 메일, 로깅이 전부 Controller에 있다. 이걸 해결하는 건 Repository가 아니라 Service Layer다.
// Thin Controller — HTTP만 처리한다
class UserController
{
public function register(RegisterRequest $request, UserService $service)
{
$user = $service->register($request->validated());
return redirect()->route('home');
}
}
// Service — 비즈니스 로직이 여기에 있다
class UserService
{
public function register(array $data): User
{
$user = User::create([
'email' => $data['email'],
'password' => Hash::make($data['password']),
'name' => $data['name'],
]);
Mail::to($user)->send(new WelcomeMail($user));
Log::info("User registered: {$user->id}");
return $user;
}
}
// Form Request — 유효성 검사는 여기서
class RegisterRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
'name' => 'required|string',
];
}
}
각 레이어의 역할이 명확하다:
| 역할 | 담당 |
|---|---|
| HTTP 요청/응답 | Controller |
| 유효성 검사 | Form Request |
| 비즈니스 로직 | Service |
| 데이터 접근 | Eloquent Model |
| 반복 쿼리 추상화 | Scope |
이 구조에서 Repository가 끼어들 자리가 없다.
Repository 패턴이 과설계인 5가지 이유
1. Eloquent가 이미 추상화다
Repository 패턴의 원래 목적은 데이터 접근 로직을 추상화하는 것이다. 그런데 Eloquent의 Active Record 패턴이 이미 그 일을 하고 있다.
// Repository가 하는 일
class UserRepository
{
public function findActiveUsers()
{
return User::where('status', 'active')->get();
}
public function findByEmail(string $email)
{
return User::where('email', $email)->first();
}
}
// Eloquent가 이미 하는 일
User::where('status', 'active')->get();
User::where('email', $email)->first();
Repository 클래스는 Eloquent 호출을 한 번 더 감싸고 있을 뿐이다. 파일 하나, 메서드 열 개가 추가됐는데 — 얻는 건 없다.
2. DB를 교체하는 일은 일어나지 않는다
Repository의 대표적 장점으로 “MySQL에서 MongoDB로 바꿀 수 있다”가 나온다.
현실을 보자. 프로덕션에서 DB를 바꾼 프로젝트가 몇이나 되는가? 그리고 실제로 바꾼다면, 쿼리 패턴 자체가 근본적으로 다르기 때문에 Repository 추상화로는 해결이 안 된다. RDB의 JOIN과 NoSQL의 embedded document는 Interface로 통일할 수 있는 게 아니다.
일어나지 않는 미래를 위해 오늘 코드를 복잡하게 만드는 건 과설계다.
3. Eloquent Scope가 Repository 메서드를 대체한다
반복되는 쿼리 로직을 재사용하고 싶다면 — Repository가 아니라 Scope를 쓰면 된다.
// Model에 Scope 정의
class User extends Model
{
public function scopeActive(Builder $query): Builder
{
return $query->where('status', 'active');
}
public function scopeSeller(Builder $query): Builder
{
return $query->whereHas('seller');
}
}
// 사용 — 체이닝이 된다
User::active()->seller()->paginate(20);
Repository 메서드는 체이닝이 안 된다. $repo->findActiveSellers()처럼 조합마다 새 메서드를 만들어야 한다. Scope는 자유롭게 조합된다.
4. 테스트는 실제 DB를 치면 된다
Repository의 가장 강력한 논거가 “테스트 시 DB를 모킹할 수 있다”는 것이다.
Laravel은 RefreshDatabase trait, SQLite 인메모리 DB, Model Factory를 기본 제공한다. 실제 DB를 쓰는 테스트가 가능하고, 오히려 더 안전하다.
class UserServiceTest extends TestCase
{
use RefreshDatabase;
public function test_register_creates_user(): void
{
$service = new UserService();
$user = $service->register([
'email' => 'test@example.com',
'password' => 'password123',
'name' => 'Test User',
]);
$this->assertDatabaseHas('users', ['email' => 'test@example.com']);
}
}
Repository를 모킹하면 쿼리가 진짜로 동작하는지 검증이 안 된다. 모킹 테스트는 통과했는데 프로덕션에서 쿼리가 터지는 게 더 위험하다.
5. Laravel의 설계 철학과 충돌한다
Taylor Otwell은 불필요한 추상화 레이어에 반대 입장을 여러 차례 밝혔다. Laravel은 실용주의 프레임워크다. Eloquent를 직접 쓰라고 만든 걸 굳이 감싸는 건 프레임워크의 의도를 거스르는 것이다.
Java Spring의 패턴을 Laravel에 그대로 가져오면 Java처럼 파일이 늘어나고, Laravel의 장점인 생산성이 사라진다.
Interface는? 그것도 과설계인가?
구현체가 하나뿐이면 과설계다.
// 과설계 — UserService는 영원히 하나다
interface UserServiceInterface
{
public function register(array $data): User;
}
class UserService implements UserServiceInterface
{
public function register(array $data): User { ... }
}
// AppServiceProvider에 바인딩까지
$this->app->bind(UserServiceInterface::class, UserService::class);
파일 2개, 바인딩 1개가 추가됐다. UserService를 교체할 일이 있는가? 없다.
Interface가 진짜 유용한 경우 — 구현체가 여러 개일 때:
// 결제 게이트웨이 — 구현체가 3개다
interface PaymentGateway
{
public function charge(int $amount): PaymentResult;
}
class TossPayments implements PaymentGateway
{
public function charge(int $amount): PaymentResult { ... }
}
class PortOne implements PaymentGateway
{
public function charge(int $amount): PaymentResult { ... }
}
class Stripe implements PaymentGateway
{
public function charge(int $amount): PaymentResult { ... }
}
// 외부 API 어댑터 — 플랫폼마다 다른 구현
interface LoanPlatformAdapter
{
public function submitApplication(Application $app): Response;
}
class PlatformA implements LoanPlatformAdapter { ... }
class PlatformB implements LoanPlatformAdapter { ... }
class PlatformC implements LoanPlatformAdapter { ... }
이때는 Interface 없이는 코드가 더 지저분해진다. if ($platform === 'A') ... elseif ...가 곳곳에 퍼진다.
판단 기준:
| 질문 | Interface |
|---|---|
| 구현체가 2개 이상인가? | 쓴다 |
| 지금 1개지만 곧 추가되는가? | 쓴다 |
| 구현체가 영원히 1개인가? | 안 쓴다 |
“나중에 바뀔 수 있으니까”가 이유라면 — 99%는 안 바뀐다. 지금 구현체가 2개 이상일 때만 쓰면 된다.
정리: Laravel 프로젝트의 실용적 아키텍처
Request
→ Controller (HTTP 처리, 얇게)
→ Form Request (유효성 검사)
→ Service (비즈니스 로직)
→ Eloquent Model (데이터 접근)
→ Scope (반복 쿼리 추상화)
→ Response
| 하지 않아도 되는 것 | 이유 |
|---|---|
| Repository 패턴 | Eloquent가 이미 추상화 |
| 모든 Service에 Interface | 구현체가 1개면 불필요 |
| DTO (Data Transfer Object) | Laravel의 Form Request + array가 충분 |
| 해야 하는 것 | 이유 |
|---|---|
| Service Layer | Controller를 얇게 유지 |
| Form Request | 유효성 검사 분리 |
| Eloquent Scope | 반복 쿼리 재사용 |
| Interface (구현체 2개+일 때) | 다형성이 필요한 경우에만 |
Laravel은 실용주의 프레임워크다. Java의 패턴을 가져와서 파일을 3배로 늘리는 대신, 프레임워크가 제공하는 도구를 제대로 쓰면 된다. Repository 없이도 깔끔하고 테스트 가능한 코드를 만들 수 있다.