728x90

Laravel에서 데이터 삽입하기: Bulk Insert vs 반복문 Insert 완벽 비교

이 글에서 다룰 내용

  • Bulk Insert와 반복문 Insert의 기본 개념과 차이점
  • 각 방식의 구체적인 코드 예제와 사용법
  • 성능 차이가 발생하는 이유와 실제 벤치마크 결과
  • 실무에서의 선택 기준과 주의사항
  • 각 방식의 장단점과 트레이드오프

두 방식의 기본 개념: 택시 vs 버스의 비유

Laravel에서 여러 개의 데이터를 데이터베이스에 삽입할 때는 크게 두 가지 방식을 사용할 수 있습니다. 이를 일상생활의 교통수단에 비유해보겠습니다.

반복문 Insert는 마치 여러 명의 승객을 각각 다른 택시로 목적지에 보내는 것과 같습니다. 한 명씩 개별적으로 처리하기 때문에 안전하고 확실하지만, 비용과 시간이 많이 듭니다.

Bulk Insert는 여러 명의 승객을 한 대의 버스에 태워 한 번에 목적지로 보내는 것과 같습니다. 효율적이고 빠르지만, 한 명이라도 문제가 생기면 전체에 영향을 줄 수 있습니다.

그렇다면 언제 어떤 방식을 써야 할까요?

이는 데이터의 양, 처리 속도 요구사항, 그리고 오류 처리 방식에 따라 달라집니다. 먼저 각 방식의 구체적인 코드를 살펴보겠습니다.


코드로 보는 두 방식의 차이

실제 사용자 데이터를 1000개 삽입하는 상황을 가정해보겠습니다. 각 방식이 어떻게 다른지 코드를 통해 확인해보세요.

반복문을 이용한 Insert 방식

// 반복문 Insert - 하나씩 처리하는 방식
$users = [
    ['name' => '김철수', 'email' => 'kim@example.com'],
    ['name' => '이영희', 'email' => 'lee@example.com'],
    // ... 1000개의 데이터
];

$startTime = microtime(true);

foreach ($users as $userData) {
    User::create($userData);
}

$endTime = microtime(true);
echo "반복문 Insert 소요 시간: " . ($endTime - $startTime) . "초";

이 방식은 각 데이터마다 별도의 SQL 쿼리를 실행합니다. 즉, 1000개의 데이터라면 1000번의 INSERT 쿼리가 실행되는 것이죠.

Bulk Insert 방식

// Bulk Insert - 한 번에 처리하는 방식
$users = [
    ['name' => '김철수', 'email' => 'kim@example.com', 'created_at' => now(), 'updated_at' => now()],
    ['name' => '이영희', 'email' => 'lee@example.com', 'created_at' => now(), 'updated_at' => now()],
    // ... 1000개의 데이터
];

$startTime = microtime(true);

User::insert($users);

$endTime = microtime(true);
echo "Bulk Insert 소요 시간: " . ($endTime - $startTime) . "초";

Bulk Insert는 모든 데이터를 하나의 SQL 쿼리로 처리합니다. 1000개의 데이터라도 단 1번의 INSERT 쿼리만 실행됩니다.

실제 실행되는 SQL 쿼리 확인하기

두 방식이 실제로 어떤 SQL을 생성하는지 Laravel의 쿼리 로그를 통해 확인해볼 수 있습니다.

// 쿼리 로그 활성화
DB::enableQueryLog();

// 반복문 Insert 실행
foreach ($users as $userData) {
    User::create($userData);
}

// 실행된 쿼리 확인
$queries = DB::getQueryLog();
echo "실행된 쿼리 수: " . count($queries);

반복문 방식에서는 데이터 개수만큼의 쿼리가 실행되지만, Bulk Insert에서는 단 하나의 쿼리만 실행됩니다.


성능 차이는 얼마나 날까?

실제로 두 방식의 성능 차이를 측정해보면 놀라운 결과를 확인할 수 있습니다.

벤치마크 테스트 코드

// 성능 테스트를 위한 데이터 준비
$testData = [];
for ($i = 1; $i <= 1000; $i++) {
    $testData[] = [
        'name' => 'User ' . $i,
        'email' => 'user' . $i . '@example.com',
        'created_at' => now(),
        'updated_at' => now(),
    ];
}

// 반복문 Insert 테스트
$start = microtime(true);
foreach ($testData as $data) {
    User::create($data);
}
$loopTime = microtime(true) - $start;

// 테이블 초기화
User::truncate();

// Bulk Insert 테스트
$start = microtime(true);
User::insert($testData);
$bulkTime = microtime(true) - $start;

echo "반복문 Insert: {$loopTime}초\n";
echo "Bulk Insert: {$bulkTime}초\n";
echo "성능 차이: " . round($loopTime / $bulkTime) . "배";

실제 성능 비교 결과

데이터 수 반복문 Insert Bulk Insert 성능 차이
100개 0.8초 0.05초 16배 ⚡
1,000개 8.2초 0.12초 68배 ⚡
10,000개 85초 1.1초 77배 ⚡

왜 이렇게 큰 차이가 날까요?

성능 차이가 발생하는 주요 원인들을 살펴보겠습니다.

1. 네트워크 통신 횟수
반복문 Insert는 각 데이터마다 데이터베이스와 별도의 통신을 합니다. 1000개 데이터라면 1000번의 네트워크 왕복이 필요하죠. 반면 Bulk Insert는 단 한 번의 통신으로 모든 데이터를 전송합니다.

2. 트랜잭션 처리
각 INSERT마다 별도의 트랜잭션이 시작되고 커밋되는 과정이 반복됩니다. 이 오버헤드가 누적되면 상당한 시간 손실이 발생합니다.

3. 인덱스 업데이트
데이터베이스는 각 INSERT 후마다 관련 인덱스를 업데이트해야 합니다. Bulk Insert에서는 이 과정을 일괄적으로 최적화할 수 있습니다.


하지만 Bulk Insert도 만능은 아닙니다

성능상 압도적인 Bulk Insert지만, 몇 가지 중요한 단점들이 있습니다.

Eloquent 이벤트가 작동하지 않음

// User 모델에 이벤트가 정의되어 있다면
class User extends Model
{
    protected static function booted()
    {
        static::creating(function ($user) {
            // 사용자 생성 시 로그 기록
            Log::info('새 사용자 생성: ' . $user->email);
        });
    }
}

// 반복문 Insert - 이벤트가 정상 실행됨
foreach ($users as $userData) {
    User::create($userData); // creating 이벤트 발생
}

// Bulk Insert - 이벤트가 실행되지 않음
User::insert($users); // creating 이벤트 발생하지 않음

Bulk Insert는 Eloquent 모델을 거치지 않고 직접 데이터베이스에 쿼리를 실행하기 때문에, 모델에 정의된 이벤트(creating, created, updating 등)가 작동하지 않습니다.

Mutator와 Accessor 무시

class User extends Model
{
    // 비밀번호를 자동으로 해시화하는 Mutator
    public function setPasswordAttribute($value)
    {
        $this->attributes['password'] = bcrypt($value);
    }
}

// 반복문 Insert - Mutator 적용됨
User::create(['password' => 'plain-password']); // 자동으로 해시화됨

// Bulk Insert - Mutator 적용되지 않음
User::insert([['password' => 'plain-password']]); // 평문으로 저장됨 (위험!)

트랜잭션 실패 시 전체 롤백

try {
    $users = [
        ['name' => '정상 사용자', 'email' => 'normal@example.com'],
        ['name' => '중복 사용자', 'email' => 'duplicate@example.com'], // 이미 존재하는 이메일
        ['name' => '또 다른 사용자', 'email' => 'another@example.com'],
    ];

    User::insert($users);
} catch (Exception $e) {
    // 중간에 하나만 실패해도 전체가 실패함
    echo "전체 삽입 실패: " . $e->getMessage();
}

Bulk Insert에서는 중간에 하나의 데이터라도 실패하면 전체 삽입이 실패합니다. 반면 반복문 Insert에서는 실패한 데이터만 건너뛰고 나머지는 계속 처리할 수 있습니다.


언제 어떤 방식을 선택해야 할까요?

실무에서 두 방식 중 어떤 것을 선택할지는 다음 기준들을 고려해야 합니다.

Bulk Insert를 사용하는 것이 좋은 경우 ✅

// 대용량 데이터 일괄 처리
// 예: CSV 파일 임포트, 데이터 마이그레이션
$csvData = array_map('str_getcsv', file('large_dataset.csv'));
$insertData = [];

foreach ($csvData as $row) {
    $insertData[] = [
        'name' => $row[0],
        'email' => $row[1],
        'created_at' => now(),
        'updated_at' => now(),
    ];
}

User::insert($insertData); // 빠른 일괄 처리

추천 상황:

  • 1000개 이상의 대용량 데이터 처리
  • 단순한 데이터 구조 (Mutator나 이벤트가 필요 없음)
  • 성능이 최우선인 경우
  • 배치 작업이나 데이터 마이그레이션

반복문 Insert를 사용하는 것이 좋은 경우 ✅

// 복잡한 비즈니스 로직이 필요한 경우
foreach ($userData as $data) {
    $user = User::create($data); // Eloquent 이벤트 활용

    // 각 사용자별로 개별 처리 로직
    $user->assignRole('user');
    Mail::to($user->email)->send(new WelcomeEmail($user));

    // 실패해도 다른 사용자 처리에 영향 없음
}

추천 상황:

  • 소량 데이터 (수백 개 이하)
  • Eloquent 이벤트나 Mutator가 중요한 경우
  • 개별 에러 처리가 필요한 경우
  • 복잡한 비즈니스 로직이 포함된 경우

성능 비교표

기준 반복문 Insert Bulk Insert 추천
성능 느림 (데이터 많을수록) 빠름 🚀 Bulk
Eloquent 이벤트 지원 미지원 ⚡ 반복문
에러 처리 개별 처리 가능 전체 실패 ⚡ 반복문
메모리 사용 적음 많음 (대용량 시) ⚡ 반복문
코드 복잡도 간단 간단 🤝 동등

최적의 조합: 청크(Chunk) 방식

때로는 두 방식의 장점을 결합한 접근법이 가장 효과적일 수 있습니다.

// 대용량 데이터를 작은 단위로 나누어 처리
$allUsers = collect($largeUserData);

$allUsers->chunk(500)->each(function ($chunk) {
    try {
        User::insert($chunk->toArray());
        Log::info('500개 사용자 추가 완료');
    } catch (Exception $e) {
        // 실패한 청크만 개별 처리로 전환
        foreach ($chunk as $userData) {
            try {
                User::create($userData);
            } catch (Exception $e) {
                Log::error('사용자 생성 실패: ' . $e->getMessage());
            }
        }
    }
});

이 방식은 대부분의 데이터는 빠른 Bulk Insert로 처리하면서도, 문제가 발생한 부분만 안전한 개별 처리로 전환하는 전략입니다.


실전에서 주의해야 할 점들

1. created_at, updated_at 타임스탬프 처리

// Bulk Insert 시 타임스탬프를 수동으로 추가해야 함
$users = collect($rawData)->map(function ($user) {
    return array_merge($user, [
        'created_at' => now(),
        'updated_at' => now(),
    ]);
})->toArray();

User::insert($users);

2. 메모리 사용량 모니터링

// 대용량 데이터 처리 시 메모리 체크
echo "시작 메모리: " . memory_get_usage(true) / 1024 / 1024 . "MB\n";

User::insert($largeDataSet);

echo "종료 메모리: " . memory_get_usage(true) / 1024 / 1024 . "MB\n";
echo "최대 메모리: " . memory_get_peak_usage(true) / 1024 / 1024 . "MB\n";

3. 데이터 검증 전략

// Bulk Insert 전 데이터 유효성 검사
$validator = Validator::make($users, [
    '*.name' => 'required|string|max:255',
    '*.email' => 'required|email|unique:users,email',
]);

if ($validator->fails()) {
    // 검증 실패한 데이터 처리
    return response()->json(['errors' => $validator->errors()], 422);
}

User::insert($users);

요약

Laravel에서 대량의 데이터를 삽입할 때는 다음과 같은 기준으로 방식을 선택하세요:

Bulk Insert 선택 기준:

  • 1000개 이상의 대량 데이터
  • 단순한 데이터 구조
  • 성능이 최우선
  • Eloquent 이벤트가 불필요

반복문 Insert 선택 기준:

  • 수백 개 이하의 소량 데이터
  • Eloquent 이벤트/Mutator 필요
  • 개별 에러 처리 중요
  • 복잡한 비즈니스 로직 포함

성능 차이: Bulk Insert가 반복문 Insert보다 10배~100배 빠름


실전 팁

  1. 메모리 관리: 10,000개 이상의 데이터는 청크 단위로 분할 처리
  2. 타임스탬프: Bulk Insert 시 created_at, updated_at 수동 추가 필수
  3. 에러 처리: Bulk Insert 실패 시 개별 처리로 폴백하는 로직 구현
  4. 테스트: 실제 운영 환경과 유사한 데이터 양으로 성능 테스트 필수
  5. 모니터링: 대량 작업 시 메모리 사용량과 실행 시간 추적

올바른 방식을 선택하면 애플리케이션의 성능을 크게 향상시킬 수 있습니다. 상황에 맞는 최적의 선택이 중요합니다!

728x90

'PHP > Laravel' 카테고리의 다른 글

Laravel hasManyThrough 관계 완벽 이해하기  (0) 2025.06.10
728x90

Laravel hasManyThrough 관계 완벽 이해하기

🎯 들어가며

Laravel의 Eloquent 관계 중에서 hasManyThrough는 초보 개발자들이 가장 헷갈려하는 관계 중 하나입니다. 하지만 일단 이해하고 나면 복잡한 데이터베이스 쿼리를 놀라울 정도로 간단하게 만들어주는 강력한 도구라는 것을 알게 될 거예요.

오늘은 국가-주/도-도시 시스템을 예제로 들어 hasManyThrough 관계를 차근차근 분해해보겠습니다.

📊 데이터베이스 구조 이해하기

먼저 우리가 다룰 테이블 구조를 살펴보겠습니다:

countries (국가)
├── id
├── name
├── code
└── created_at

states (주/도)
├── id
├── country_id (countries.id 참조)
├── name
├── code
└── created_at

cities (도시)
├── id
├── state_id (states.id 참조)
├── name
├── population
└── created_at

🏗️ 관계도 시각화

Country (국가)
    │
    │ hasMany
    ▼
State (주/도)
    │
    │ hasMany
    ▼
City (도시)

실제 데이터 예시:

  • Country #1: "미국"
    • State #1: "캘리포니아"
      • City #1: "로스앤젤레스"
      • City #2: "샌프란시스코"
    • State #2: "텍사스"
      • City #3: "휴스턴"
      • City #4: "댈러스"

🤔 문제 상황: 특정 국가의 모든 도시를 가져오고 싶다면?

일반적인 접근 방법으로는 이렇게 할 수 있습니다:

// 방법 1: 중첩 반복문 (비효율적)
$country = Country::find(1); // 미국
$allCities = [];

foreach ($country->states as $state) {
    foreach ($state->cities as $city) {
        $allCities[] = $city;
    }
}

하지만 이 방법은 N+1 쿼리 문제를 일으킬 수 있고, 코드도 복잡해집니다.

✨ hasManyThrough의 마법

hasManyThrough를 사용하면 중간 모델을 "통과해서" 최종 모델에 직접 접근할 수 있습니다:

class Country extends Model 
{
    // 직접적인 관계: Country → State
    public function states() 
    {
        return $this->hasMany(State::class);
    }

    // 간접적인 관계: Country → State → City
    public function cities() 
    {
        return $this->hasManyThrough(
            City::class,             // 🎯 최종 목표 모델
            State::class,            // 🔗 중간 연결 모델
            'country_id',            // 📌 중간모델의 외래키 (State.country_id)
            'state_id',              // 📌 최종모델의 외래키 (City.state_id)
            'id',                    // 🔑 시작모델의 로컬키 (Country.id)
            'id'                     // 🔑 중간모델의 로컬키 (State.id)
        );
    }
}

📝 매개변수 설명

순서 매개변수 설명 예시 값
1 최종 모델 최종적으로 가져오고 싶은 모델 City::class
2 중간 모델 거쳐가야 하는 중간 모델 State::class
3 중간모델 외래키 중간 테이블에서 현재 모델을 참조하는 키 'country_id'
4 최종모델 외래키 최종 테이블에서 중간 모델을 참조하는 키 'state_id'
5 현재모델 로컬키 현재 모델의 기본키 'id'
6 중간모델 로컬키 중간 모델의 기본키 'id'

💻 실제 사용 예시

// 이제 미국의 모든 도시를 한 번에 가져올 수 있습니다!
$usa = Country::where('code', 'US')->first();
$allUsCities = $usa->cities; // 🎉 간단하죠?

// 특정 조건을 추가할 수도 있습니다
$largeCities = $usa->cities()
    ->where('population', '>', 1000000)
    ->orderBy('population', 'desc')
    ->get();

// 개수도 쉽게 셀 수 있습니다
$cityCount = $usa->cities()->count();
echo "미국에는 총 {$cityCount}개의 도시가 있습니다.";

🔍 생성되는 SQL 쿼리 분석

Laravel이 hasManyThrough로 어떤 SQL을 생성하는지 살펴보겠습니다:

SELECT 
    cities.*, 
    states.country_id as laravel_through_key
FROM cities 
INNER JOIN states 
    ON states.id = cities.state_id 
WHERE states.country_id = 1

💡 : DB::enableQueryLog()를 사용하면 실제 실행되는 쿼리를 확인할 수 있습니다!

🛠️ 실무에서 활용하기

1. 이커머스 배송 시스템

// 특정 국가로 배송 가능한 모든 도시 조회
$korea = Country::where('code', 'KR')->first();
$deliverableCities = $korea->cities()
    ->where('is_delivery_available', true)
    ->orderBy('name')
    ->get();

// 배송비 계산을 위한 도시별 인구 정보
$populatedCities = $korea->cities()
    ->where('population', '>', 100000)
    ->select('name', 'population')
    ->get();

2. 집계 함수 활용

// 국가별 총 도시 개수
$countriesWithCityCounts = Country::withCount('cities')->get();

foreach ($countriesWithCityCounts as $country) {
    echo "{$country->name}: {$country->cities_count}개의 도시\n";
}

// 가장 많은 도시를 가진 국가 찾기
$countryWithMostCities = Country::withCount('cities')
    ->orderBy('cities_count', 'desc')
    ->first();

3. 지역별 통계 분석

// 국가별 평균 도시 인구
$avgPopulationByCountry = Country::join('states', 'countries.id', '=', 'states.country_id')
    ->join('cities', 'states.id', '=', 'cities.state_id')
    ->groupBy('countries.id', 'countries.name')
    ->selectRaw('countries.name, AVG(cities.population) as avg_population')
    ->get();

4. Eager Loading으로 성능 최적화

// N+1 쿼리 문제 방지
$countries = Country::with(['states.cities', 'cities'])->get();

// 특정 조건이 있는 관계도 미리 로드
$countries = Country::with([
    'cities' => function ($query) {
        $query->where('population', '>', 500000);
    }
])->get();

🔧 Laravel Tinker로 테스트하기

실제로 동작하는지 확인해보고 싶다면 Tinker를 사용해보세요:

php artisan tinker
// Tinker에서 실행
$usa = App\Models\Country::where('code', 'US')->first();

// 전통적인 방법
$usa->states->flatMap->cities;

// hasManyThrough 방법
$usa->cities;

// 두 결과가 같은지 확인
$usa->states->flatMap->cities->pluck('id')->sort()->values() === 
$usa->cities->pluck('id')->sort()->values();

// 쿼리 로그 확인
DB::enableQueryLog();
$usa->cities;
dd(DB::getQueryLog());

⚠️ 주의사항과 제한사항

1. 중간 모델의 속성 접근 불가

// ❌ 이렇게 할 수 없습니다
foreach ($usa->cities as $city) {
    echo $city->state->name; // 중간 모델 정보 없음
}

// ✅ 대신 이렇게 해야 합니다
$usa->load('cities.state');
foreach ($usa->cities as $city) {
    echo $city->state->name;
}

2. 복잡한 조건 처리

// 중간 모델의 조건을 추가하고 싶을 때
$usa->cities()
    ->whereHas('state', function ($query) {
        $query->where('timezone', 'PST');
    })
    ->get();

// 또는 직접 조인 조건 추가
$usa->cities()
    ->join('states', 'cities.state_id', '=', 'states.id')
    ->where('states.timezone', 'PST')
    ->select('cities.*')
    ->get();

🌟 고급 활용 패턴

1. 동적 관계 구성

class Country extends Model 
{
    public function citiesByPopulation($minPopulation = 0)
    {
        return $this->hasManyThrough(City::class, State::class)
            ->where('population', '>=', $minPopulation);
    }

    public function majorCities() 
    {
        return $this->citiesByPopulation(1000000);
    }
}

2. 중간 테이블 정보 포함

// 중간 테이블 정보도 함께 가져오기
$usa->cities()
    ->join('states', 'cities.state_id', '=', 'states.id')
    ->select(
        'cities.*', 
        'states.name as state_name',
        'states.code as state_code'
    )
    ->get();

📚 다른 관계들과의 비교

관계 타입 사용 시기 예시
hasMany 직접적인 1:N 관계 Country → States
hasManyThrough 간접적인 1:N 관계 Country → Cities (via States)
belongsToMany N:M 관계 User ↔ Roles
hasOneThrough 간접적인 1:1 관계 Country → Capital (via States)

🌍 실제 사용 사례

1. 글로벌 서비스 지역 설정

// 사용자가 선택한 국가의 모든 배송 가능 도시
$selectedCountry = auth()->user()->country;
$availableCities = $selectedCountry->cities()
    ->where('shipping_available', true)
    ->orderBy('name')
    ->pluck('name', 'id');

2. 관리자 대시보드 통계

// 국가별 서비스 현황
$serviceStats = Country::withCount([
    'cities',
    'cities as active_cities_count' => function ($query) {
        $query->where('service_active', true);
    }
])->get();

3. API 응답 최적화

// JSON API에서 중첩 데이터 효율적으로 반환
return Country::with('cities:id,state_id,name,population')
    ->find($countryId)
    ->cities
    ->groupBy('state_id')
    ->map(function ($cities) {
        return $cities->sortByDesc('population')->values();
    });

🎉 정리하며

hasManyThrough는 복잡해 보이지만, 한 번 이해하고 나면 데이터베이스 관계를 매우 우아하게 표현할 수 있는 도구입니다.

핵심 포인트 요약:

  1. 목적: 중간 모델을 거쳐 최종 모델에 접근
  2. 구조: 시작 모델 → 중간 모델 → 최종 모델
  3. 장점: 복잡한 쿼리를 간단한 관계로 표현
  4. 주의: 중간 모델의 속성에는 직접 접근할 수 없음

실무 활용 팁:

  • 항상 실제 SQL을 확인하여 성능을 검토하세요
  • 복잡한 조건이 필요하다면 whereHas나 별도 쿼리를 고려하세요
  • Eager Loading을 활용하여 N+1 문제를 방지하세요
  • 대용량 데이터에서는 페이지네이션을 함께 사용하세요

이런 지리적 관계는 특히 이커머스, 배송 서비스, 지역 기반 서비스에서 매우 유용합니다.

728x90

+ Recent posts