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 LayerController를 얇게 유지
Form Request유효성 검사 분리
Eloquent Scope반복 쿼리 재사용
Interface (구현체 2개+일 때)다형성이 필요한 경우에만

Laravel은 실용주의 프레임워크다. Java의 패턴을 가져와서 파일을 3배로 늘리는 대신, 프레임워크가 제공하는 도구를 제대로 쓰면 된다. Repository 없이도 깔끔하고 테스트 가능한 코드를 만들 수 있다.