ORM은 SQL을 안 쓰고 객체로 DB를 다루게 해준다. 편하다. 근데 그 편의에는 가격표가 붙어있다.
// 이게 뒤에서 뭘 하는지 아는가?
$articles = Article::with('user')->latest()->paginate(20);
이 한 줄이 SQL 2개를 실행하고, 모델 객체 40개를 생성하고, 관계를 매핑한다. 알고 쓰면 도구고, 모르고 쓰면 함정이다.
이 글은 Eloquent(Laravel ORM)에서 반복적으로 마주치는 7가지 숨겨진 비용을 정리한다. 다른 ORM(Django, Rails, Prisma, Hibernate)에서도 동일한 구조로 발생하는 문제들이다.
1. N+1 — 루프 안의 보이지 않는 쿼리
ORM에서 가장 악명 높은 함정이다.
// ❌ 글 20개 조회 → 쿼리 21번
$articles = Article::where('board_id', 1)->get();
foreach ($articles as $article) {
echo $article->user->nickname;
// 매번 SELECT * FROM users WHERE id = ? 실행
}
실행되는 쿼리:
1. SELECT * FROM articles WHERE board_id = 1 -- 1번
2. SELECT * FROM users WHERE id = 3 -- 글마다
3. SELECT * FROM users WHERE id = 7 -- 1번씩
4. SELECT * FROM users WHERE id = 12 -- 총 N번
... (20번 반복)
= 21번
글이 20개면 21번, 100개면 101번. 데이터가 늘수록 선형으로 느려진다.
해결: Eager Loading
// ✅ 쿼리 2번으로 해결
$articles = Article::with('user')
->where('board_id', 1)
->get();
실행되는 쿼리:
1. SELECT * FROM articles WHERE board_id = 1
2. SELECT * FROM users WHERE id IN (3, 7, 12, ...)
= 2번. 끝.
with('user')가 “이 관계도 미리 가져와”라고 선언하는 것이다. 루프를 돌기 전에 필요한 데이터를 한 번에 로딩한다.
N+1을 사전 방지하는 법
// AppServiceProvider.php
// 개발 환경에서 Lazy Loading 발생 시 에러
Model::preventLazyLoading(!app()->isProduction());
이걸 켜두면 with() 없이 관계에 접근할 때 에러를 던진다. 프로덕션에 나가기 전에 잡을 수 있다.
이건 Eloquent만의 문제인가?
아니다. ORM이라는 패턴 자체의 문제다. 모든 ORM이 Lazy Loading을 기본으로 하기 때문에 동일하게 발생한다:
| 프레임워크 | ORM | Eager Loading |
|---|---|---|
| Laravel | Eloquent | with('user') |
| Django | Django ORM | select_related('user') |
| Rails | ActiveRecord | includes(:user) |
| Java | Hibernate | JOIN FETCH |
| Node.js | Prisma | include: { user: true } |
문법만 다르고 구조는 같다.
2. SELECT * — 안 쓰는 컬럼까지 전부 가져오기
// ❌ 목록 화면인데 body(TEXT)까지 전부 로딩
$articles = Article::where('board_id', 1)->get();
// SELECT * FROM articles — 본문 포함 수만 건
목록 화면에 필요한 건 제목, 작성자, 날짜뿐이다. 근데 body (본문 전체, 수천 자)까지 매번 가져온다. 20건이면 체감 없지만, 1,000건을 처리하는 배치 작업이면 메모리가 터진다.
ORM은 기본이 SELECT *이다. SQL을 안 보이게 하니까 개발자가 의식하지 않는다.
해결: 필요한 컬럼만 선택
// ✅ 목록에 필요한 것만
$articles = Article::select('id', 'title', 'user_id', 'created_at')
->where('board_id', 1)
->get();
판단 기준: 단건 조회(show)는 SELECT *도 괜찮다. 목록 조회(index)와 배치 작업은 반드시 select()를 쓴다.
3. 루프 INSERT — 건마다 쿼리 1번
// ❌ 1,000건을 1,000번 INSERT
$rows = [...]; // CSV에서 파싱한 1,000건
foreach ($rows as $row) {
Article::create($row);
}
INSERT INTO articles (...) VALUES (...); -- 1번
INSERT INTO articles (...) VALUES (...); -- 2번
INSERT INTO articles (...) VALUES (...); -- 3번
... (1,000번 반복)
DB와 1,000번 왕복한다. 네트워크 비용이 쿼리 실행보다 클 수 있다.
해결: 벌크 INSERT
// ✅ 한 번에 INSERT
Article::insert($rows);
// INSERT INTO articles (...) VALUES (...), (...), (...), ...
// 쿼리 1번
주의: insert()는 Eloquent 이벤트(creating, created)가 실행되지 않는다. timestamps도 자동 설정 안 된다. 이벤트가 필요한 경우:
// chunk로 나눠서 create
collect($rows)->chunk(100)->each(function ($chunk) {
foreach ($chunk as $row) {
Article::create($row); // 이벤트 실행됨
}
});
1,000번이 아니라 10번(100건씩)으로 줄이는 타협이다.
4. PHP에서 필터링 — DB가 할 일을 코드가 하기
// ❌ 전부 가져온 다음 PHP에서 필터
$articles = Article::all(); // 10만 건 메모리에 로딩
$recent = $articles->where('created_at', '>', now()->subDays(7));
10만 건을 PHP 메모리에 올린 후, PHP가 하나씩 비교해서 걸러낸다. DB는 이 작업을 인덱스로 밀리초 만에 할 수 있는데, PHP가 초 단위로 하고 있다.
Eloquent Collection의 where()와 Query Builder의 where()가 문법이 같아서 헷갈린다. get() 이전의 where()는 SQL, 이후의 where()는 PHP라는 걸 의식해야 한다.
해결: DB에서 필터
// ✅ DB가 필터링
$recent = Article::where('created_at', '>', now()->subDays(7))->get();
❌ Article::all()->where(...) — PHP가 필터 (느림)
✅ Article::where(...)->get() — DB가 필터 (빠름)
판단 기준: get(), all(), -> 이후에 where, filter, sortBy를 쓰고 있다면 의심하라. DB에서 할 수 있는 작업이 PHP로 넘어온 것일 수 있다.
5. Accessor 안에 숨은 쿼리
// ❌ 모델 accessor 안에 쿼리가 숨어있음
class Article extends Model
{
public function getPopularityAttribute(): int
{
return $this->comments()->count() * 3
+ $this->reactions()->count() * 2;
}
}
겉에서 보면 $article->popularity는 그냥 속성 접근이다. 근데 안에서 쿼리 2번이 실행된다.
// 뷰에서
@foreach ($articles as $article)
{{ $article->popularity }}
// 글마다 쿼리 2번 → 20개면 40번
@endforeach
N+1보다 나쁜 N×2 문제. 그리고 밖에서 안 보인다. with()로 해결할 수도 없다 — 관계가 아니라 accessor니까.
해결: withCount 또는 캐시 컬럼
// ✅ 방법 1: withCount로 서브쿼리 활용
$articles = Article::withCount(['comments', 'reactions'])
->where('board_id', 1)
->get();
// 뷰에서
{{ $article->comments_count * 3 + $article->reactions_count * 2 }}
// ✅ 방법 2: 캐시 컬럼 사전 계산
// 마이그레이션에 popularity_score 컬럼 추가
// 스케줄러로 주기적 업데이트
$schedule->call(function () {
Article::each(function ($article) {
$article->update([
'popularity_score' =>
$article->comments()->count() * 3
+ $article->reactions()->count() * 2
]);
});
})->hourly();
원칙: accessor에 쿼리를 넣지 않는다. 넣으면 밖에서 비용이 안 보인다.
6. get()->count() — 세기 위해 전부 만들기
// ❌ 모델 10만 개를 생성한 후 개수만 셈
$count = Article::where('board_id', 1)->get()->count();
실행 과정:
1. SELECT * FROM articles WHERE board_id = 1 -- 10만 행 반환
2. PHP가 10만 개의 Eloquent 모델 객체 생성 -- 메모리 수백 MB
3. Collection::count() 호출 -- 결과: 100000
숫자 하나를 얻기 위해 10만 개의 객체를 만들었다.
해결: DB에서 세기
// ✅ 숫자만 반환
$count = Article::where('board_id', 1)->count();
// SELECT COUNT(*) FROM articles WHERE board_id = 1
// 결과: 100000 (숫자 하나)
같은 패턴의 실수들:
// ❌ 존재 확인
$exists = Article::where('slug', $slug)->get()->isNotEmpty();
// ✅
$exists = Article::where('slug', $slug)->exists();
// ❌ 최댓값
$max = Article::all()->max('view_count');
// ✅
$max = Article::max('view_count');
// ❌ 합계
$sum = Article::where('board_id', 1)->get()->sum('view_count');
// ✅
$sum = Article::where('board_id', 1)->sum('view_count');
원칙: 집계 함수(count, sum, max, min, avg, exists)는 DB에서 실행한다. get() 이후에 집계하고 있다면 잘못된 것이다.
7. 트랜잭션 없이 연쇄 저장
// ❌ 3단계 저장 중 2단계에서 실패하면?
$article = Article::create([
'title' => '제목',
'body' => '본문',
'board_id' => 1,
'user_id' => auth()->id(),
]);
$article->tags()->attach([1, 2, 3]); // ← 여기서 에러 나면?
Notification::send(
$article->board->subscribers,
new ArticlePublished($article)
);
태그 attach에서 에러가 나면 — 글은 이미 저장됐고, 태그는 없고, 알림은 안 갔다. 반쪽짜리 데이터가 DB에 남는다.
이건 ORM이 각 작업을 독립적인 쿼리로 실행하기 때문이다. Raw SQL로 직접 짜면 트랜잭션을 의식하게 되지만, ORM은 create(), attach() 각각이 별개의 쿼리라는 걸 숨긴다.
해결: 트랜잭션으로 묶기
// ✅ 전부 성공하거나 전부 실패하거나
DB::transaction(function () {
$article = Article::create([
'title' => '제목',
'body' => '본문',
'board_id' => 1,
'user_id' => auth()->id(),
]);
$article->tags()->attach([1, 2, 3]);
});
// 알림은 트랜잭션 밖에서 — 글 저장과 무관하게 실패할 수 있으니까
Notification::send(
$article->board->subscribers,
new ArticlePublished($article)
);
트랜잭션 안에서 에러가 나면 전부 롤백된다. 글도 안 남고 태그도 안 남는다. 반쪽짜리 데이터가 생기지 않는다.
판단 기준: “이 작업들 중 하나만 실패하면 나머지도 없었던 일로 해야 하나?” — 답이 yes면 트랜잭션이 필요하다.
정리
| 함정 | 원인 | 해결 |
|---|---|---|
| N+1 | 루프에서 관계 Lazy Loading | with() |
| SELECT * | 기본이 전체 컬럼 조회 | select() |
| 루프 INSERT | 건마다 쿼리 실행 | insert() |
| PHP 필터링 | get() 후 Collection 필터 | where() 후 get() |
| Accessor 쿼리 | 속성 접근 뒤에 숨은 쿼리 | withCount() |
| get()->count() | 전부 로딩 후 집계 | DB 집계 함수 |
| 트랜잭션 누락 | 독립 쿼리의 부분 실패 | DB::transaction() |
7가지의 공통점: ORM이 SQL을 숨기기 때문에 생기는 문제다.
ORM을 쓰지 말라는 게 아니다. ORM의 가치는 빠른 쿼리가 아니라 빠른 개발이다. 대신 뒤에서 어떤 쿼리가 날아가는지 의식하는 습관이 필요하다. Laravel Debugbar, preventLazyLoading, EXPLAIN — 도구는 이미 있다. 쓰기만 하면 된다.