제목과 같이 라라벨에서 DB에 접근해 쿼리를 사용해 데이터를 가져오는 방법은 Eloquent, Query Builder, Raw Queries 3가지 방법이 있습니다.
고민할 필요 없이 이 3가지 중 무조건 어떤 것을 써야 한다면 좋겠지만, 때에 따라 반드시 특정 문법으로 코드를 작성하는게 가장 나은 상황들이 있습니다.
모델 기반으로 객체 지향적이고 직관적인 코드로 인해 Eloquent도 많이 사용되나, ORM 오버헤드로 인하여 다소 성능이 밀리며, 엘로퀀트로는 작성하지 못하는 코드도 존재합니다.
이번 기회에 각각의 장단점과 필요한 경우를 비교하며, Best Practice를 정의하시면 좋을 것 같아 정리해보겠습니다.
Eloquent, Query Builder, Raw Query가 어떻게 다른건지 간단한 예시를 들어야 이해가 빠를 것 같기 때문에 코드 예시부터 들겠습니다.
// Eloquent example
User::where('name', '홍길동1')->get();
// Query Builder example
DB::table('users')->where('name', '홍길동1')->get();
// Raw Queries example
DB::select(DB::raw('SELECT * FROM users WHERE name = :=name'), ['name' => '홍길동1']);
// Eloquent + Raw Queries example
User::where('name', '홍길동1')->orderByRaw('CASE WHEN is_used = 1 THEN 1 ELSE 0 END', 'DESC')->get()
| 항목 | Eloquent | Query Builder | Raw Query |
|---|---|---|---|
| 사용 편의성 | 객체 지향적, 직관적 | 체이닝 방식을 통한 가독성 향상 | SQL 문법 그대로 사용 |
| 보안 | SQL Injection 자동 방지 | SQL Injection 자동 방지 | 수동 바인딩 필요 |
| 성능 | 다소 느림(ORM 오버헤드) | 비교적 빠름 | 최적화된 SQL 직접 실행 가능 |
| 유지보수성 | 가독성 뛰어남, ORM 사용 가능 | 적절한 추상화로 관리 용이 | 복잡한 쿼리 관리의 어려움 |
| 확장성 | 모델 기반 다양한 기능 제공 | DB에 관계없이 사용 가능 | 특정 DBMS에 종속 가능성 |
| 추천할 사용 사례 | 일반적인 CRUD, 모델 관계 활용 | 복잡한 조회, 대량 데이터 처리 | 고성능 SQL 최적화 필요 |
우리는 객체 지향적이며, 가독성이 좋고 유지보부사 뛰어나면서도 성능이 뛰어난 코드를 작성하기 위해 노력해야 하지만, 라라벨에서는 안타깝게도 3마리 토끼를 다 붙잡을 만능 쿼리 방법은 없습니다.
여담으로 다른 언어에서는 이 3마리 토끼를 다 잡는 방법이 있다고 합니다.
그중에 재밌는 코드 2개만 살펴보자면, C#의 Entity Framework Core에서는 LINQ(Language Integrated Query)라는 것을 이용해 SQL을 추상화하면서도 네이티브 SQL을 직접 실행 가능하다고 하네요.
var users = context.Users
.Where(u => u.Age > 20)
.OrderBy(u => u.Name)
.ToList();
또한, 못들어보셨을 수 있지만 MIT에서 개발한 Julia라는 언어가 있습니다. C보다는 약간 느리지만 현대적인 언어 구조를 갖고 있고, 함수형 문법을 사용할 수 있어 성능이 중요한 곳에서는 많이 사용된다고 하네요.
query = From(:users) |>
Where(Fun.">"(:age, 20)) |>
OrderBy(:name)
재밌죠?
다른 언어는 됐고, 다시 라라벨로 돌아오죠.
제가 보여드린 특징 표에서 다른 것들은 코드를 보면 어느정도 이해가 가능하나, 성능 부분에서는 좀 더 설명이 필요할 것 같습니다.
먼저 단순한 다른 코드부터 한번 다시 보시죠.
// Eloquent
$users = User::where('status', 'active')->get();
// Query Builder
$users = DB::table('users')->where('status', 'active')->get();
// Raw Query
$users = DB::select('SELECT * FROM users WHERE status = ?', ['active']);
Eloquent는 내부적으로 다시 한 번 Query Builder를 사용합니다. 그렇기 때문에 모델 객체로 변환시키는 오버헤드가 발생하기 때문에 쿼리 빌더보다 빠를 수가 없죠.
Quety Builder는 데이터를 배열로 가져와 Elouqnet보다 가볍고 빠르다는 장점이 있으나,
Raw Query는 DML 문법과 거의 완벽하게 똑같이 사용하기 때문에 변환하는 작업이 가장 적기 때문에 가장 빠르죠. 하지만 간단한 쿼리라면 바로 이해할 수 있지만, 조인을 비롯해 상황별 여러가지 조건이 더해진다면 아주 복잡한 쿼리가 되어 눈알이 빠질 것입니다.
이번엔 조인을 한 코드를 볼까요?
// Eloquent
$orders = Order::with('user')->get();
// Query Builder
$orders = DB::table('orders')
->join('users', 'orders.user_id', '=', 'users.id')
->select('orders.*', 'users.name')
->get();
// Raw Query
$orders = DB::select('SELECT orders.*, users.name FROM orders JOIN users ON orders.user_id = users.id');
엘로퀀트의 N+1 문제
위 예시에서는 with()를 사용해 orders 테이블에서 user 테이블을 함께 가져왔으나, 만약 with()를 를 사용하지 않고, $orders = Order::all();로 먼저 불러온 뒤, 이후에 $orders->user->name과 같은 방식으로 호출하게 된다면 그 유명한 N+1 문제가 발생합니다.
N+1 쿼리 문제는 관계형 데이터 조회 시 발생하는 대표적인 성능 저하 현상으로, ORM의 지연 로딩(Lazy Loading) 방식에서 나타납니다.
$orders->user를 호출할 때마다 매번 1번의 추가 쿼리를 실행하게 되는거죠.
이를 위해 예시와 같이 with() 함수를 통해 미리 불러온다면 N+1 문제를 방지할 수 있으나, 그렇다고 또 언제나 with() 함수로 불러와야 하는 것은 아닙니다.
$orders = Order::with('user', 'items', 'payments')->get();와 같이 필요하지 않은 관계 및 데이터들을 다 가져오게 되면 불필요한 메모리 사용량이 커지므로, ->select()를 통해 필요한 관계만 정확히 가져오는 것이 좋습니다.
또, $orders = Order::with('user.profile', 'items.product.supplier')->get();와 같이 깊이 있는 관계까지 다 가져오는 경우에는 SQL이 복잡해지며, 이는 곧 성능 저하로 연결돼죠.
또한, 현재는 get()을 통해 여러개의 데이터를 가져왔기 때문에 with()로 미리 방지하는 것이 좋지만, first()로 하나의 데이터만 가져오는 경우에는 어떨까요?
// with()->first()로 주문 조회 후 해당 유저 정보 가져오는 코드
$order = Order::with('user')->first();
$orderUser = $order->user;
-- 실제 동작되는 쿼리
SELECT * FROM orders LIMIT 1;
SELECT * FROM users WHERE id = ?;
// first로 주문만 조회 후 해당 유저 정보 가져오는 코드
$order = Order::first();
$orderUser = $order->user;
-- 실제 동작되는 쿼리
SELECT * FROM orders LIMIT 1;
SELECT * FROM users WHERE id = ?;
보시는 바와 같이 동일한 쿼리를 사용합니다.
$order->user를 여러번 하게 된다면 당연히 똑같이 N+1 문제가 발생하겠지만, 단 한 번만 사용한다면 굳이 first()와 with()를 한꺼번에 사용할 필요는 없죠.
Query Builder와 Raw Query 비교
위에서 Eloquent는 내부적으로 다시 한 번 Query Builder를 사용한다고 말씀드렸죠. 그러면 이제 Query Builder와 Raw Query를 비교해보겠습니다.
Query Builder는 SQL 구문을 체이닝 방식으로 조립하여 최적화된 쿼리를 생성합니다. 제가 만약 순서를 뒤죽박죽 섞었다면, 최적화된 순서로 만든다는 거죠. 그리고 Raw Query는 직접 작성된 SQL을 즉시 실행하여 변환 과정이 최소화됩니다.
제가 작성한 두 쿼리의 실행 속도를 비교해보겠습니다.
| 방식 | 평균 실행 시간(1,000건 기준) | 메모리 사용량 |
|---|---|---|
| Query Builder | 0.04171초 | 12.5MB |
| Raw Query | 0.04461초 | 11.8M |
제가 분명 위에서 작성된 raw 코드를 그대로 실행하는 Raw Query가 Query Builder보다 더 빠르다고 말씀드렸는데 Query Builder가 더 빠르게 나와 의아하실 겁니다.
이처럼 일부 케이스에서 Query Builder가 약간 더 빠른 것으로 관측되는 경우가 있는데, 이는 PHP 버전 및 캐시 설정에 따라 영향을 받습니다.
다만, 지금과 같이 1,000여건의 소량 데이터를 처리할 경우에는 이런 경우가 더러 있으나, 10만건 이상의 대량 데이터 처리 시에는 Raw Query가 평균적으로 15~20%더 빠릅니다.
메모리는 Raw Query가 5~6% 더 낮은 메모리 사용량을 보이며, 이는 객체 변환 과정에서 발생하는 오버헤드 차이로 보입니다.
또한, 쿼리 튜닝에 대한 지식이 있으시다면 힌트 등 성능 향상에 도움을 주는 구문을 사용 가능합니다.
예시로, MySQL 8.0 이상부터는 옵티마이저 힌트를 제공하기 때문에 이를 이용한 예시를 보여드리겠습니다.
// Raw Query로 힌트 사용
$results = DB::select('
SELECT /*+ INDEX(orders idx_user_id) */ *
FROM orders
WHERE user_id = ?',
[$userId]
);
// Query Builder로 힌트 사용
DB::table('orders')->selectRaw('/*+ INDEX(idx_user_id) */ *')
->where('user_id', $userId)
->get();
참고로, 일부 데이터베이스에서는 Query Builder로 힌트 등의 특수 구문 파싱 문제가 있을 수 있다고 합니다.
with()로 N+1 문제 예방 필요)with()로 N+1 문제 예방 필요)// 조건부 쿼리 빌더
$query = Article::query();
$query->when($request->has('category'), function ($q) use ($request) {
return $q->where('category_id', $request->category);
});
$query->whereHas('comments', function ($q) {
$q->where('approved', true);
});
// Eloquent + Raw 표현식
User::select('*')
->selectSub(
DB::raw('(SELECT COUNT(*) FROM posts WHERE posts.user_id = users.id)'),
'post_count'
)
->get();