Django Prefetch와 비교해보는 GORM Preload에 대한 고찰

2020. 7. 5. 19:08Tutorial & Training/Go

 

Django ORM은 Django 프레임워크에 포함된 ORM이고, GORM은 Go언어의 ORM 라이브러리입니다.

 

Django Framework 또는 DRF(Django Rest Framework)를 사용해보신 분들이라면 희귀하게 동작하기 때문에...

데이터베이스 (Database)에서  Query Performance(퍼포먼스) 개선에 대해 많은 고민을 해보실 거라고 생각되는데,

 

Django에서는 자주 사용되는 방식으로 select_related, prefetch_related 라는 친구가 존재합니다.

GORM에서는 Joins, Related, Preload 라는 친구들이 있습니다.

 

 

 

 

 

Django의 Join

 

Select_releated는 어떠한 상황에서 사용할까?

  • 1:1 관계 (OneToOne)
  • N:1 관계(ManyToOne)

Django는 select_related를 사용할 경우 스키마에 명시된 외래키 필드타입을 보고서, 자동으로 Join을 해주는

굉장히 오래된 연륜에서 보여주는 스마트(똑똑)한 ORM입니다. 과연 웹에 있어서 다방면의 많은 역사를 이뤄내면서 산증인입니다.

 

 

Prefetch_related는 어떠한 상황에서 사용할까?

  • 1:N(OneToMany)
  • N:M(ManyToMany)

Django의 prefetch_related 또한 굉장히 스마트 합니다.

2번의 쿼리로 하위 테이블의 데이터를 로드하고 Python Object로 구현하여 선행 쿼리에 매칭시켜 결과를 만들어줍니다.

 

 

Django ORM이 그렇다고 단점이 없는 것은 아닙니다.

  • .all() 아무 생각없이 사용하면 관계 필드를 모두 계속해서 Select 합니다... (ㄹㅇ 적폐)
  • Raw Query Type의 부재 (Python은 모든 것이 객체이고, Raw쿼리는 스키마를 명시한거와 다르게 알 수가 없어서인지 Raw 라는 별도의 Object로 뱉어내서 수동 맵핑을 해야합니다.)
  • MultiDB Join은... (다중 데이터베이스에서 Join할 때는 어느 ORM이나 마찬가지로 Raw 쿼리를 사용해야합니다. 이때 위의 단점이 다시 부각됩니다.)
  • Annotate의 문제 (GroupBy가 강제 이행되서 이걸 제거할 방법이 없습니다... 그냥 원시쿼리 쓰세요)

 

 

 

 

GORM의 Join

 

Joins

Gorm은 Joins은 공식문서를 살펴보면 여러 방법을 제시하는데, 사실 Joins 말고는 join이 아니라 Django의 Prefetch 처럼

별도의 쿼리셋으로 모으는 행위처럼 동작하여 쿼리가 2번 생깁니다.

 

Gorm의 Joins는 대체로 Raw Query(원시 쿼리)로 작성하게 되는데, 이때 중요시 해야하는 부분이 있습니다.

바로 중복된 컬럼명인데, 이는 Order by, Group by, Having, Pagination 등을 만들어야 하는 경우

mysql 1052 column 'id' in where clause is ambiguous

위 오류 처럼 충돌이 나는 상황입니다. 적절하게 `as`와 구조체 태그를 이용하여 탈출할 수 있습니다.

Joins를 사용하면 Django의 select_realted와 마찬가지로 inner join 말그대로 ㄹㅇ 조인을 이용하여 싱글쿼리를 만들 수 있습니다.

 

다음과 같이 말이죠

db.Unscoped().Table("user u").Joins("left join profile p on u.id = p.user_id").select("*")
// SELECT * FROM user u left join profile p on u.id = p.user_id

 

 

 

Related

Gorm은 Joins 이외에도 Related가 존재하는데, Gorm 공식문서에서 보이는 belong to, has one, has many 섹션을 보시면 전부 Related를 사용합니다.

하지만 이것의 치명적인 단점은 싱글쿼리가 불가능합니다. (ㅋㅋ루삥뽕)

애초에 사용방법 부터가 안될 것 처럼 생겼습니다...

 

var users []*User
var profile Profile

db.Unscoped().Find(&users)
db.Model(&users[0]).Related(&profile)
// SELECT * FROM profile where user_id = 1

네 만약 users가 다중 레코드(결과 열)를 소유하고 있고, 모든 profile이 필요로 할 경우

반복문으로 모든 Related 요청시 user 수 만큼 쿼리가 발생합니다. (Django의 .all()과 같은 적폐 방식)

단일 쿼리면 편한데, 사실 쓸일이 있을까 싶습니다...

 

 

var users []*User
var profile *Profile

db.Unscoped().Find(&users)

for x := range users{
    db.Model(&users[x]).Related(&profile)
    // do Something
}

 

상황 예시)

  • AWS Aurora(서버리스 DB, 100만 쿼리 요청당 0.24$)을 사용
  • 사용자데이터가 100만건 이상 보유중
  • API 요청시 마다 모든 데이터를 조회 후 추출해서 반환해야함
  • 하루에 API 요청이 대략 1000~5000건 사이

위 같은 상황이라면???? 요금 폭탄에 맞을 위기에도 처할 수 있습니다...

하루 최대 약 1,439,400 원 이라는 요금 폭탄을 볼 수 있습니다.

요청 요금만 계산한 것이고... 아마 스토리지나 예약 등등 이상한거 포함하면 하루에 200만원씩 나갈듯 하군요

 

 

 

 

Preload

드디어 여러분이 찾던 1:N 데이터를 Load하는 방법입니다. Gorm의 Preload는 사실 Django의 Prefetch와 같은 개념으로써 2번의 쿼리로 선행 쿼리 결과값에 매칭시켜줍니다.

 

type User struct {
    Id int
    LastLogin *time.time `gorm:"Column:last_login"`
    CreatedAt time.time `gorm:"Column:created_at"`
    Profile Profile
    Items []Items
}

type Items struct {
    id int
    UserId int
    User User
    Test string
}

var users []*User

db.Unscoped().Preload("Items").Scan(&users)
// SELECT * FROM user
// SELECT * FROM items where user_id in (1,2,3 ... 9923)

fmt.Println(users[0].Items) // 이렇게 하위에 로드됨

위 처럼 각 유저 레코드마다, Items가 있다면 Items에 데이터가 탑재될 수 있습니다.

Django에서 select_related(1:1)를 Prefetch_related로 사용할 수도 있는데 GORM 또한 마찬가지로 킹능합니다.

 

 

type User struct {
    Id int
    LastLogin *time.time `gorm:"Column:last_login"`
    CreatedAt time.time `gorm:"Column:created_at"`
    Profile Profile
    Items []Items
}

type Profile struct {
    Id int
    UserId int
    User User
    Name string
}

type Items struct {
    id int
    UserId int
    User User
    Test string
}

var users []*User

db.Unscoped().Preload("Items").Preload("Profile").Scan(&users)
// SELECT * FROM user
// SELECT * FROM items WHERE user_id in (1,2,3 ... 9923)
// SELECT * FROM profile WHERE user_id in (1,2,3 ... 9923)

fmt.Println(users[0].Items) // 이렇게 하위에 로드됨
fmt.Println(users[0].Profile)

 비록 쿼리가 3줄이 되는 단점이 있죠.

 

 

 

 

 

 

Prefetch Preload 그리고 결론

Django는 Prefetch, GORM은 Preload 라고 지칭하는데 사실 정의로 치면...

 

Prefetch는 음... 쓸거같은데?

Preload는 이건 반드시 쓸거야!

 

이러한 차이가 존재하지만, 두 ORM의 역할은 사실 관계형 데이터를 미리 로드한다 입니다.

Django나 GORM 둘 다 Prefetch, Preload를 사용 시에는 매칭 되는 고유키 또는 지정한 외래 키를 기준으로 미리 로드한 뒤 내부적(DB가 아닌 코드상에서)으로 매칭 시켜 데이터를 합쳐버립니다.

(당연하겠지만, Python은 별도의 처리를 해주지 않는이상, 컴파일언어인 Go가 이부분에선 훨씬 빠릅니다.)

 

 

이 글을 쓴 계기는 Django에서 맨날 쿼리수 줄이느라 급급해서 코드를 늘리는 거보다 쿼리수를 줄이는게 빠르다고 착각속에 빠져있던 탈출구가 되었군요

 

실제로 쿼리수가 너무 방대할 경우엔 손해이긴한데...

 

Go는 joins를 사용하는 것도 좋지만 구조체 고치고, 늘리고, 반복문 돌리고, 결과 구조체에 다시 매핑하고 하는 소모 비용 대비 차라리 때깔에 티도안나는 차이니깐 그냥 1:1 관계도 Preload 쓰는게 정신건강과 속도에 더 이롭습니다.