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: "댈러스"
- State #1: "캘리포니아"
🤔 문제 상황: 특정 국가의 모든 도시를 가져오고 싶다면?
일반적인 접근 방법으로는 이렇게 할 수 있습니다:
// 방법 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는 복잡해 보이지만, 한 번 이해하고 나면 데이터베이스 관계를 매우 우아하게 표현할 수 있는 도구입니다.
핵심 포인트 요약:
- 목적: 중간 모델을 거쳐 최종 모델에 접근
- 구조: 시작 모델 → 중간 모델 → 최종 모델
- 장점: 복잡한 쿼리를 간단한 관계로 표현
- 주의: 중간 모델의 속성에는 직접 접근할 수 없음
실무 활용 팁:
- 항상 실제 SQL을 확인하여 성능을 검토하세요
- 복잡한 조건이 필요하다면
whereHas나 별도 쿼리를 고려하세요 - Eager Loading을 활용하여 N+1 문제를 방지하세요
- 대용량 데이터에서는 페이지네이션을 함께 사용하세요
이런 지리적 관계는 특히 이커머스, 배송 서비스, 지역 기반 서비스에서 매우 유용합니다.
'PHP > Laravel' 카테고리의 다른 글
| Laravel에서 데이터 삽입하기: Bulk Insert vs 반복문 Insert 완벽 비교 (0) | 2025.06.11 |
|---|