Article
Laravel Eloquent paginate() 사용 시 필수: ORDER BY 절
문제 상황: 신비한 데이터 불일치
통계 시스템에서 이상한 버그가 보고되었습니다.
증상: 페이지를 새로고침할 때마다 일정 확률로 다른 집계 결과가 나타남
처음에는 프론트엔드의 데이터 취합 오류로 의심했습니다. 하지만 조사 결과, 훨씬 더 근본적인 문제였습니다.
새로고침 1: 결과 = [10, 11, 12, 13, 14]
새로고침 2: 결과 = [12, 10, 11, 14, 13] ← 순서가 다름!
새로고침 3: 결과 = [10, 11, 12, 13, 14]
근본 원인 파악: ORDER BY 절의 부재
백엔드 쿼리를 분석한 결과, paginate() 호출에서 ORDER BY 절이 없었습니다.
// ❌ 문제있는 코드
$items = Model::paginate(15);
// ✅ 올바른 코드
$items = Model::orderBy('id', 'desc')->paginate(15);
이게 정말 중요한 차이입니다.
ORDER BY가 없으면 왜 순서가 바뀔까?
페이지네이션의 동작 원리
페이지네이션은 내부적으로 OFFSET을 사용합니다.
-- 페이지 1 (LIMIT 10 OFFSET 0)
SELECT * FROM items LIMIT 10 OFFSET 0;
-- 페이지 2 (LIMIT 10 OFFSET 10)
SELECT * FROM items LIMIT 10 OFFSET 10;
OFFSET으로 데이터를 선택할 때, 데이터베이스는 반환할 행의 순서를 결정해야 합니다.
순서 보장의 부재
ORDER BY 절이 없으면:
-- ❌ 순서가 정의되지 않음
SELECT * FROM items LIMIT 10 OFFSET 0;
-- ✅ 순서가 명시됨
SELECT * FROM items ORDER BY id DESC LIMIT 10 OFFSET 0;
SQL 표준에서 ORDER BY 없는 SELECT는 행 순서를 보장하지 않습니다.
복수 데이터베이스 환경에서의 악화
회사가 여러 대의 리드(Read) 데이터베이스를 운영한다면 상황은 더 심해집니다:
요청 1: 리드DB 1 선택 → 행 순서 = [A, B, C, D, E]
요청 2: 리드DB 2 선택 → 행 순서 = [C, A, E, B, D]
요청 3: 리드DB 3 선택 → 행 순서 = [B, D, A, C, E]
각 데이터베이스가 다른 순서로 데이터를 반환할 수 있기 때문입니다.
현상의 재현
1. 순서 없는 쿼리
// 데이터: items with id = 1, 2, 3, 4, 5
$items = Model::paginate(3);
// 반환: [2, 4, 1] 또는 [4, 2, 1] 또는 [1, 2, 3] (비결정적)
2. 리드 데이터베이스 환경
Primary DB: id 순서 = [1, 2, 3, 4, 5]
Replica DB 1: id 순서 = [3, 1, 5, 2, 4] (다를 수 있음)
Replica DB 2: id 순서 = [5, 3, 1, 4, 2] (또 다를 수 있음)
로드 밸런싱으로 다른 레플리카에 요청하면 완전히 다른 결과를 얻습니다.
해결 방법: ORDER BY 추가
기본 해결책
paginate() 호출 전에 반드시 orderBy()를 추가합니다.
// ✅ 올바른 방법
$items = Model::orderBy('id', 'desc')->paginate(15);
// 또는
$items = Model::orderBy('created_at', 'desc')->paginate(15);
// 또는
$items = Model::orderBy('updated_at', 'desc')->paginate(15);
복합 정렬
여러 기준으로 정렬이 필요한 경우:
$items = Model::orderBy('created_at', 'desc')
->orderBy('id', 'desc')
->paginate(15);
중요: 복합 정렬의 순서도 일관되어야 합니다. 첫 번째 정렬 기준이 같을 때만 두 번째 기준이 적용됩니다.
조건문과 함께 사용
$items = Model::where('status', 'active')
->where('deleted_at', null)
->orderBy('created_at', 'desc')
->paginate(15);
주의: where() 뒤에 orderBy()를 하는 것이 일반적인 패턴입니다.
베스트 프랙티스
1. Eloquent Scope 활용
Order 규칙을 모델 레벨에서 관리합니다:
class Item extends Model {
public function scopeOrdered($query) {
return $query->orderBy('created_at', 'desc')
->orderBy('id', 'desc');
}
}
// 사용
$items = Item::ordered()->paginate(15);
이 방식의 장점:
- 모든 쿼리에서 일관된 정렬 적용
- 정렬 로직 변경 시 한 곳만 수정
- 가독성 향상
2. 기본 정렬 설정
모델에서 기본 정렬을 정의할 수도 있습니다:
class Item extends Model {
protected $table = 'items';
public function newQuery() {
return parent::newQuery()
->orderBy('created_at', 'desc');
}
}
// 모든 쿼리에 자동으로 정렬 적용
$items = Item::paginate(15);
3. Repository 패턴
대규모 프로젝트에서는 Repository 클래스에서 관리:
class ItemRepository {
public function getPaginated($perPage = 15) {
return Item::where('status', 'active')
->orderBy('created_at', 'desc')
->paginate($perPage);
}
}
성능 최적화 고려사항
인덱스 설정
정렬에 사용되는 컬럼에는 반드시 인덱스가 필요합니다.
// Migration
Schema::create('items', function (Blueprint $table) {
$table->id();
$table->timestamp('created_at')->index(); // ← 인덱스 추가
$table->string('name');
});
복합 인덱스
여러 정렬 기준을 사용한다면 복합 인덱스가 효율적입니다:
// Migration
$table->index(['created_at', 'id']); // ← 정렬 순서대로 인덱스 생성
테스트 작성
paginate() 동작을 테스트할 때는 순서를 검증해야 합니다.
public function test_paginate_returns_ordered_results() {
$item1 = Item::create(['name' => 'Item 1', 'created_at' => now()]);
sleep(1);
$item2 = Item::create(['name' => 'Item 2', 'created_at' => now()]);
$items = Item::orderBy('created_at', 'desc')->paginate(10);
// ✅ 검증: Item 2가 먼저 나와야 함
$this->assertEquals($item2->id, $items->first()->id);
}
실제 버그 해결 사례
Before: 버그 있는 코드
public function getStatistics() {
$items = Item::paginate(20); // ❌ ORDER BY 없음
return [
'total' => count($items),
'avg_value' => $items->avg('value'),
];
}
증상: 새로고침할 때마다 평균값이 다르게 나옴
After: 수정된 코드
public function getStatistics() {
$items = Item::orderBy('id', 'desc')
->paginate(20); // ✅ ORDER BY 추가
return [
'total' => count($items),
'avg_value' => $items->avg('value'),
];
}
결과: 일관된 결과 반환
팀 규칙으로 정립
코드 리뷰 체크리스트에 추가:
## Code Review Checklist
### Eloquent Query
- [ ] paginate() 사용 시 orderBy() 확인
- [ ] Order By 컬럼에 인덱스 확인
- [ ] 정렬 순서가 비즈니스 로직과 일치하는지 확인
자동화 검사
정적 분석 도구로 이런 문제를 사전에 감지할 수 있습니다:
// PHPStan / Psalm 규칙
// paginate() 호출 전에 orderBy() 있는지 확인
마치며
페이지네이션은 단순한 기능처럼 보이지만, 데이터 일관성의 핵심입니다.
핵심 규칙
paginate() 사용 = ORDER BY 필수
이것은 선택이 아닙니다.
체크리스트
모든 paginate() 호출을 다음과 같이 검토하세요:
☑ paginate() 직전에 orderBy() 있는가?
☑ 정렬 컬럼에 인덱스가 있는가?
☑ 복수 DB 환경에서도 순서가 보장되는가?
☑ 새로고침해도 결과가 일치하는가?
이 간단한 규칙을 지키면, 데이터 일관성 문제를 사전에 방지할 수 있습니다. 특히 대규모 시스템에서 여러 데이터베이스를 운영할 때는 더욱 중요합니다.
댓글