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을 기본으로 하기 때문에 동일하게 발생한다:

프레임워크ORMEager Loading
LaravelEloquentwith('user')
DjangoDjango ORMselect_related('user')
RailsActiveRecordincludes(:user)
JavaHibernateJOIN FETCH
Node.jsPrismainclude: { 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 Loadingwith()
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 — 도구는 이미 있다. 쓰기만 하면 된다.