[Laravel] DB 성능 최적화 & 대용량 데이터 사용 전략

readerr·2025년 4월 1일

PHP&Laravel

목록 보기
3/3
post-thumbnail

성능 최적화의 필요성

서비스를 운영하면서 가장 많이 마주치게 되는 문제 중 하나는 성능 이슈입니다.
CodeIgniter나 Symfony도 있긴 하지만, 현재 PHP 생태계는 Laravel이 장악하고 있다고 봐도 무방하죠.
저는 원래 Java&Spring boot 조합만 사용해 보다가 PHP&Laravel을 사용한지는 2년 정도 돼었습니다.
편리한 점도 많았지만, 프레임워크를 사용하면서 몇가지 문제들을 마주치고, 이를 어떻게 해결해나갈 수 있을지 고민해 본 결과로 성능 최적화 경험과 전략을 공유하고자 합니다.
이 글에서는 실제 프로젝트에서 마주한 성능 병목 현상과 이를 해결한 방법, 그리고 제 나름대로 검증한 최적화 전략을 다뤄보겠습니다.

성능 비교 : Eloquent ORM vs Query Builder

Laravel을 사용하면서 가장 먼저 마주치게 되는 선택지는 Eloquent ORM과 Query Builder 중 어떤 것을 사용할지 결정해야 합니다. 2가지 방식은 각각 장단점이 있는데, 그것부터 먼저 알아보겠습니다.

Eloquent ORM

Eloquent는 객체 지향적인 접근 방식으로 데이터베이스와 상호작용할 수 있게 해주며, MVC 아키텍처 패턴을 기본으로 하여 코드 구조화와 재사용성을 촉진합니다. 또한 timestamp, soft delete, eager loading 등의 내장 기능을 제공합니다.

사실 이렇게 얘기하면 와닿지 않겠죠, 예시를 봅시다.

$users = User::where('status', 'active')->get();

User라는 모델을 만들고, 이를 사용하기 때문에 객체지향적으로 사용할 수 있는 것이죠. 또 개인 경험상 어떤 테이블을 사용하는지 모델의 이름을 통해 쉽게 유추가 가능하기도 하지만, 그와 연결된 join이나 where절 또한 엘로퀀트로 된 쿼리가 가장 읽기 쉬웠습니다.
다만, Eloquent는 대량의 데이터셋을 다룰 때 성능 이슈가 발생할 수 있습니다. 실제 프로젝트에서 10만 건 이상의 레코드를 조회하는 경우, Eloquent는 Raw SQL보다 최대 3배까지 성능이 느려지기도 합니다.

그 이유를 알아보려면 실행 과정을 알아야 합니다.
작성된 Eloquent는 컴파일 단계에서 Eloquent -> Query Builder -> Raw SQL로 변경되며, 런타임 단계에서 DB 서버로 전송하여 쿼리를 실행해 결과를 반환합니다.
하지만 저도 의문이 들었던 것이 있는데, Eloquent -> Query Builder -> Raw SQL의 변환 과정이 고정된 시간이 소요되어야 하는 것 아닌지, 어째서 큰 용량의 데이터를 불러올 때 최대 3배나 더 걸리는지가 궁금해서 찾아봤습니다.

Eloquent는 모델을 이용하기 때문에 ->get()을 통해 10만개의 행을 가져온다고 한다면, 10만개의 모델 인스턴스를 생성합니다. 즉, 10만개의 행을 하나의 인스턴스에 담는 것이 아니고, 10만개 각각 개별적으로 모델 인스턴스가 생성되는 거죠.
솔직히 정말 심하게 비효율적이라고 생각하는데, 우리의 객체지향을 위해서는 프레임워크 개발자분들도 어쩔 수 없는 선택이었다고 생각합니다. 참고로 Spring boot의 JPA 또한 같은 방식을 사용한다고 하네요.
어쩄든 이와 같은 이유 때문에 상당한 오버헤드가 발생합니다. 또한, hasManybelongsTo 와 같이 관계를 지정할 경우에는 당연히 더 큰 오버헤드가 발생하겠죠.

Query Builder

반면 Query Builder는 대용량 데이터셋이나 복잡한 쿼리의 경우 SQL 쿼리에 더 많은 제어를 제공하고, 성능 최적화에 필요한 조작이 가능합니다. 특히 집계 함수나 복잡한 조인을 사용할 때 Query Builder가 더 효율적인 경우가 많았습니다(물론 코드는 엘로퀀트가 더 이쁩니다).
역시 우선 간단한 예시부터 보고 가시죠.

// Query Builder 방식
$users = DB::table('users')->where('status', 'active')->get();

성능 테스트 결과, 대량 데이터(약 10만 건)를 조회할 때 Query Builder는 Eloquent보다 약 40% 빠른 성능을 보였습니다. 따라서 데이터량이 많거나 복잡한 쿼리를 실행할 때는 Query Builder를 사용하는 것이 좋습니다.
하지만, 역시 가능한 10만건이나 되는 데이터를 한 번에 조회하지는 않는 게 좋을 것 같고, Query Builder는 Eloquent에 비해 주의해야 할 점이 적으니 이정도로 넘어갑시다.

데이터베이스 쿼리 최적화 기법

1. N+1 문제

N+1 문제는 Laravel 개발자가 가장 자주 마주치는 성능 이슈 중 하나입니다. 이는 관련 데이터를 로드할 때 추가적인 쿼리가 실행되는 문제입니다.
와닿지 않을테니 역시 쿼리부터 보시죠.

// N+1 문제 발생 코드
$users = User::all();
foreach ($users as $user) {
    echo $user->profile->bio;
}

// Eager Loading으로 해결
$users = User::with('profile')->get();
foreach ($users as $user) {
    echo $user->profile->bio;
}

user 테이블과 해당 유저의 id를 외래키로 가진 profile 테이블이 있다고 가정하며, 이를 위해 관계형으로 $user->profile과 같이 가져온다면 조회를 하는 쿼리가 한번 더 실행되게 됩니다. 그리고 foreach로 계속해서 불러오게 된다면 N번의 쿼리를 실행하게 되겠죠.
이를 with를 통해 미리 불러올 수 있게 되는데, 이를 Eager Loading이라고 합니다. 실제로 저도 현재 회사로 이직하고 나서, 기존 프로젝트에서 이 N+1 문제를 가진 코드를 상당히 많이 발견했고, 특히 느렸던 API에 대해서는 이 Eager Loading을 적용한 후에 응답 시간이 2.3초에서 0.8초로 크게 감소했습니다.

2. 인덱스 활용하기

여러분도 아시겠지만, 테이블에 적절한 인덱스를 추가하는 것은 쿼리 성능을 크게 향상시킬 수 있습니다. 라라벨에서도 아래와 같이 이를 제공하고 있습니다.

Schema::table('users', function (Blueprint $table) {
    $table->index('email');
});

다만, 이처럼 프로그램 단에서 인덱스 등의 스키마를 정의하는 것은 다른 개발자들과의 상의가 필요합니다. 제가 다녔던 회사들은 모두 스키마를 MySQL 또는 Oracle에서 직접 정의하고 관리했었기 때문에, DB가 크고 이에 대한 관리를 아주 잘 하고있는 회사라면 모르겠지만, 프로그램 단에서 스키마를 직접 정의하는 것은 위험 요소도 많기 때문에 주의해야 할 것 같습니다.

3. 선택적 컬럼 조회하기

필요한 컬럼만 선택적으로 조회하면 데이터 전송량을 줄이고 성능을 향상시킬 수 있습니다.

// 모든 컬럼을 가져오는 대신
$users = User::all();

// 필요한 컬럼만 선택
$users = User::select('id', 'name', 'email')->get();

자랑은 아니지만 저희 회사에서 가장 자주 사용되는 테이블의 경우에는 컬럼의 수가 굉장히 많은데, 게다가 조인까지 여러번 하는 쿼리를 쿨하게 *로 가져오는 코드가 있었습니다. 저도 변태같이 이렇게 한번에 가져올 때 컬럼 수를 확인해 봤는데, 무려 241개의 컬럼을 가져오고 있었지만, 필요한 컬럼은 단 20여개 정도만이었습니다. 컬럼만으로 최대 10% 정도 빨라지는 것을 경험하긴 했는데, 상황에 따라 30%까지도 차이날 수 있다고 합니다.

캐싱 사용

1. 데이터 캐싱

많은 트래픽을 처리하는 서비스에서는 DB에 접근하는 대신 캐시를 활용하는 것도 좋은 방법인 것 같습니다.
여러분도 아시듯이 프로그래밍에서 캐시는 빠르게 접근 가능한 저장소로, 한 번 읽은 데이터를 임시로 저장해 두어 다음에 찾았을 때 아주 빠르게 찾을 수 있는 방법입니다.

// 캐시를 활용한 데이터 조회
$users = Cache::remember('active_users', 60, function () {
    return User::where('status', 'active')->get();
});

// 모델 저장 시 캐시 삭제
public function boot()
{
    User::saved(function ($user) {
        Cache::forget('user_' . $user->id);
        Cache::forget('users_list');
    });
}

위와 같이 캐시를 그룹화하고, 데이터 변경 시 해당 태그의 모든 캐시를 일괄 삭제하며 사용합니다.
주의할 점은, 캐시 무효화(Invalidation) 라고 하여 생성자 등에 forgot을 넣어 이전 캐시를 제거합니다. 이 전략을 제대로 사용하지 않으면 데이터가 새로 create/update/delete되었음에도 다음 조회 시 반영이 안될 수 있습니다.
또한, 지나치게 빈번하게 crete/update/delete되는 경우에는 forgot함수를 많이 사용할 때에도 오버헤드가 발생할 수 있기에 주의해야 합니다.

2. Redis 활용하기

Laravel에서 캐싱을 위해 가장 많이 사용되는 것은 바로 Redis입니다. Redis는 메모리 기반 데이터 저장소로, 빠른 읽기/쓰기 성능을 제공하며, 간단한 캐싱은 Cache로도 해도 무방하나, 복잡한 데이터 구조를 다루거나 분산 환경 등등 더 활발하게 사용할 수 있습니다. 코드 줄 수도 확연히 적고요.

// Redis 캐시 드라이버 설정 (.env 파일)
CACHE_DRIVER=redis

// 데이터 저장 (10분간 유효)
Cache::store('redis')->put('key', 'value', 600);

// 데이터 가져오기
$value = Cache::store('redis')->get('key');

// 데이터 삭제
Cache::store('redis')->forget('key');

사용 방식은 비슷합니다. 시간을 정해 저장하고, 가져오고, 삭제할 수 있는 기능을 제공하는데 코드 줄 수가 확연히 줄었으니 기분이 좋죠.

비동기 (Queue 활용)

대량의 이메일 발송, 파일 처리, 외부 API 호출 등 시간이 오래 걸리는 작업은 사용자에게 많은 지연이 걸릴 수 있습니다. Laravel Queue를 활용하여 이러한 작업을 백그라운드에서 비동기적으로 처리할 수 있습니다.

// Queue 작업 정의
php artisan make:job ProcessPodcast

// 작업 디스패치
ProcessPodcast::dispatch($podcast);

제가 실제 비동기를 활용한 예시를 들어드리자면,
제가 현재 부동산 관련 스타트업을 다니고 있는데, 일반 개인 유저가 접수를 통해 최대 100명의 중개사무소를 요청하여 100명의 중개사분들께 알림톡을 전송해야 하는 작업이 있었고, 이를 비동기로 처리했습니다.

또한 대용량이 아니더라도 속도가 중요한 경우 일부를 비동기로 처리할 수도 있습니다.
매물을 찜하는 기능이 있었는데, 어느 날은 QA팀에서 모니터링 중, 매물에 대해 제공하는 찜하기/찜해제 기능이 왜 이렇게 오래 걸리냐고 묻더라고요. 그래서 저도 찜하기가 느려봤자 얼마나 느릴까 하는 마음으로 눌러봤는데, API 응답이 무려 1.3초나 걸렸습니다.
처음에는 테이블 구조를 의심했었으나, 코드를 확인해보니 찜 테이블에 데이터를 추가하고 삭제하는 작업 외에도 이벤트 로그나, 찜하기 정보가 REDIS로 저장되는 부분도 있고, RDS로 저장되는 부분도 있고, 로그 테이블에도 남기는 등등 많은 작업들이 있더라고요.
그래서 당장 찜하기 성공/실패나 찜하기 갯수를 보여주기 등등 API 응답에 필요한 코드는 남겨두고, 이벤트 로그 등등 동기식으로 바로 처리하지 않아도 되는 작업들은 Queue를 통해 비동기로 처리했습니다. 그리고 위풍당당하게 다시 QA팀에 보여드리니 흡족해주셨던 기억이 나네요.

대용량 조회 시 청크 처리

대용량 데이터를 처리할 때는 전체 데이터를 한 번에 처리하는 대신, 청크(chunk)로 나누어 처리하는 것이 효율적입니다.

User::chunk(100, function ($users) {
    foreach ($users as $user) {
        // 각 사용자 처리
    }
});

만약 10,000개의 데이터를 조회하고 foreach로 값을 변경해야 하는 API나 커맨드가 있다고 가정해 봅시다.
이 때, 한 번에 10,000개를 조회하고 foreach를 돌리는 경우에는 중간에 다른 API 등에서 변경된 데이터가 반영이 되지 않을 수도 있고, 어쩌면 lock이 걸릴 수도 있습니다.
청크를 통해 10개, 100개씩 조회하고 처리하여 메모리 사용량을 크게 줄이고, 대량 데이터 처리 시 안정성을 향상시킬 수 있습니다.
데이터 10,000개씩이나 조회하고 업데이트하는 로직을 언제 쓰나 싶으실 수 있지만, 저같은 경우에는 이전 데이터 정합성을 체크할 때 사용했습니다. 제가 현재 다니고 있는 회사에서는 안심번호를 사용하고 있는데, 이전 안심번호 관리가 개판이어서 저희 안심번호 테이블에 매핑된 사용자의 번호와 다른 경우들이 있어서 CS가 많이 인입돼었었습니다. 몇 번은 그냥 CS가 들어올 때마다 따로따로 처리를 해주었으나, 너무 많이 들어와서 그냥 모든 안심번호를 검증하고 제대로 매핑되지 않은 안심번호가 있으면 회원의 정보를 변경하거나 안심번호를 제거하는 커맨드를 만들었습니다. 그때 제가 검증한 데이터는 200,000만개였습니다.
한 번에 하나씩 조회를 할 때는 cursor라는 기법이 문법이 더 간단하긴 합니다. foreach (User::cursor() as $user) {~]처럼 간단하게 사용할 수 있으니 참고 부탁드려요.

3. Laravel Horizon 활용하기

Laravel HorizonRedis Queue를 위한 모니터링 대시보드를 제공합니다. Horizon을 사용하면 Queue의 처리량, 처리 시간, 실패한 작업 등을 한눈에 볼 수 있습니다.
하지만 죄송하게도 이건 아직 사용 안해봤습니다. 다음에 사용해보면 업데이트할게요....

마무리

지금까지 제가 직접 라라벨을 활용하면서 라라벨에서 DB를 다룰 때나 대용량 데이터를 다룰 때 활용하면 좋을만한 것들을 적어봤습니다.
도움이 되시면 좋겠습니다.
읽어주셔서 감사합니다.!

profile
Back-end Developer

0개의 댓글