Python 초간단 웹서버

웹서비스를 하고 싶은 폴더를 만들어 들어갑니다.

$ cd ~/webroot

서비스하고 싶은 프로그램을 작성합니다.

$ vi index.html
Hello, World!

저는 index.html안에 Hello, world라는 문자열을 저장했습니다.
index.html이 저장된 폴더에서 PORT번호를 지정하여 웹서비스를 실행합니다.

$ python -m http.server

자 그러면 서버가 잘 돌아가는지 확인해볼까요?

$ curl http://localhost:8000
Hello, world

Hello, world라는 문자열을 웹서버로 부터 반환받았습니다.

SimpleHTTPServer가 없는 Python버전의 경우 아래의 명령어로 서버를 띄울수 있습니다.

$ python -m http.server 8000

Locustfile 작성하기

본문은 locustio, 즉 locust 1.0이하를 기준으로 작성되었습니다. 새로운 버젼에서는 호환이 되지 않음을 알려드립니다. 현재기준 최신버젼, locust 1.0.3을 소개한 문서를 참고하세요.

Locustfile은 일반 파이썬 프로그램 파일입니다. 특별히 다른점 하나는 최소 하나이상의 class가 정의 되어있어야한다는 점이에요. 앞으로 이 class를 locust class라고 부를게요. 이 class는 Locust라는 class를 상속받아서 만들어져야합니다.

Locust class

여러분이 만드실, 이 locust class는요, 하나의 사용자를 통해서 호출되어 지는 명령함수들을 모아놓은거라고 보시면 되는데요. 사실 Locust 실행전에 동시접속자수를 여러명으로 설정해서 traffic을 조정할수 있는데 굳이 하나의 사용자라는 용어를 사용한 이유는 바로, Locust가 이 class를 호출 하는 과정에서 사용자 한명당, 하나의객체(instance)를 만들어서 실행을 하기 때문에 그렇게 말한거에요. 자 그러면 이 Locust Class에 어떤 것들이 보통 정의가 되는지 알아보자구요.

여러분이 만드실, 이 locust class는요, 하나의 사용자를 통해서 호출되어 지는 명령함수들을 모아놓은거라고 보시면 되는데요. 사실 Locust 실행전에 동시접속자수를 여러명으로 설정해서 traffic을 조정할수 있는데 굳이 하나의 사용자라는 용어를 사용한 이유는 바로, Locust가 이 class를 호출 하는 과정에서 사용자 한명당, 하나의객체(instance)를 만들어서 실행을 하기 때문에 그렇게 말한거에요. 자 그러면 이 Locust Class에 어떤 것들이 보통 정의가 되는지 알아보자구요.

task_set

클래스 변수로 task_set이란게 있는데요. 얘는 TaskSet class를 가지고 있는 애에요. TaskSet은 앞으로 이야기할건데요, 간단하게 말하자면 사용자가 어떤 행동을 하도록 할것인지에 대한 자세한 내용들을 담은 클래스에요.

wait_time

wait_time은 Locust class의 클래스 함수에요. 이건요 task하나 하고나서 다음 task하기 전에 얼마나 기다릴지를 알려주는 함수인데요, Locust에 정의된 함수들 중에는 wait_time함수를 반환해 주는 애들도 있어요. 그중에 대표적인게 between이라는 함순데요. 얘는 task를 하나 실행하고나서 넘겨받은 min하고 max시간 사이에서 시간을 random으로 가져와서 다음 task시작하기 전에 그만큼 기다려주는 애에요. 시간을 정해서 wait_time으로 만들어주면 Locust class가 알아서 중간에 그만큼 쉬어줘요. 그밖에 기다려주는 함수로는 constantconstant_pacing이 있어요.

아래의 예제를 보시면, 각 user별로 task하나가 끝날때마다 5에서 15초를 random으로 기다리게 될거에요.

from locust import Locust, TaskSet, task, between

class MyTaskSet(TaskSet):
    @task
    def my_task(self):
        print("executing my_task")

class User(Locust):
    task_set = MyTaskSet
    wait_time = between(5, 15)

wait_time 함수는요 숫자를 반환하는데요, 바로 “초”단위 숫자입니다. 이 함수는 TaskSet 클래스에서도 정의할수 있는데요, 단 그 경우에는 해당 task에만 적용된답니다.

wait_time함수를 Locust나 TaskSet클래스에 직접 만들어서 구현할수도 있는데요, 아래에 처음에는 1초쉬고, 그다음엔 2초, 3초 이렇게 1초씩 늘어나면서 쉬도록 하는 wait_time함수를 만들어봤어요.

class MyLocust(Locust):
    task_set = MyTaskSet
    last_wait_time = 0
    def wait_time(self):
        self.last_wait_time += 1
        return self.last_wait_time

weight

만약에 locustfile.py안에 Locust 클래스가 하나 이상 정의가 되어있는데, 명령어를 실행할때 어떤걸 쓸지 locusts를 지정하지 않았다면, 기존에 정의된 클래수중에 하나를 random으로 하나 선택해서 실행하게 됩니다. 그게 싫으면 명령어를 날릴때 어떤 locusts를 사용할지 골라서 명시하면됩니다. 아래와 같이 하면 두개중에 하나만 랜덤으로 실행하겠죠?

$ locust -f locustfile.py WebUserLocust MobileUserLocust

그런데 여러분이 두개중에 어떤 특정 클래스를 좀더 자주 호출하고 싶다 그러면요. 그때 사용되어지는게 바로 Locust 클래스 변수, weight이에요. 아래의 예제를 실행하면 WebUserLocust가 MobileUserLocust보다 3배 더 많이 실행하도록 선택될거에요.

class WebUserLocust(Locust):
    weight = 3
    ...
class MobileUserLocust(Locust):
    weight = 1
    ...

host

host는 URL prefix를 저장하는데 사용되는 클래스 변수에요. 보통 실행할때 Locust에서 제공하는 Web UI를 사용해서 설정하기도 하고요, command line으로 실행할때 --host옵션으로 받아다가 Locust.host에 저장하기도 해요.

이게 Locust클래스에 한번 셋팅이 되면요, –host옵션 안주고 다시 호출하거나, 아니면 Web UI에서 없애라는 요청이 올때까지 계속 URL앞에 prefix로 붙여서 작업을 수행합니다.

TaskSet class

만약에 Locust클래스가 한명의 사용자가 아니라, 동시에 여러명이 실행하도록 설정을 했다면, TaskSet이 locust의 두뇌를 대표한다고 볼수 있습니다. 각 Locust클래스는 반드시 task_set 에 가지고 있어야하고, 그값은 TaskSet클래스 여야합니다.

하나의 TaskSet클래스는 이름에서 알수있다시피, task들의 집합이라고 할수 있어요. 이 task들은 일반적으로 python callable함수들이고, 예를 들어 경매사이트를 Load test한다고 치면, 첫페이지를 로딩하고, 그다음에 물건을 찾고, 그다음에 bidding을 하는것과 같은거죠.

Load test가 시작이 되면, 각 instance들은 동시다발적 Locust클래스에 의해서 그들이 원하는 TaskSet을 실행하기 시작하는것이죠. 각 task를 실행하고 나서는, 아까 배웠듯이 Locust.wait_time을 실행해줘서 중간에 쉬는시간을 갖습니다. 기억나시죠? TaskSet.wait_time이 있으면 task수행후에 Locust.wait_time대신에 TaskSet.wait_time가 실행이 된다고 아까 말씀드렸잖아요. 그리고 다 기다리고 나면 다음 task을 찾아서 실행하고, 기다리고를 계속 반복합니다.

Declaring tasks

TastSet에 task들을 정의하는 가장 전형적인 방법이 바로 task 데코레이터를 이용하는 방법입니다. 아래 예제를 보시면요,

from locust import Locust, TaskSet,task
class MyTaskSet(TaskSet):
    @task
    def my_task(self):
        print("Locust instance (%r) executing my_task" % (self.locust))

class MyLocust(Locust):
    task_set = MyTaskSet

데코레이션 @task에 weight을 지정할수도 있습니다. 아래의 예제는 task2를 task1보다 두배 더 돌리는 코드입니다.

from locust import Locust, TaskSet, task
from locust.wait_time import between

class MyTaskSet(TaskSet):
    wait_time = between(5, 15)
    @task(3)
    def task(self):
       pass
    @task(6)
    def task2(self):
        pass

class MyLocust(Locust):
    task_set = MyTaskSet

tasks

사용희 편리성을 위해 @task 데코레이터를 이용해서 task를 정의하는게 편리하기도 하고, 가장 많이 사용되는 방법이기도 합니다. 또다른 방법으로는 TaskSet 클래스안에 tasks를 정의하는 방법도 있습니다. 사실 @task 데코레이터를 이용한 방법은 바로 TaskSet클래스안의 tasks를 가져오는거에요.

tasks변수의 값으로는 하나의 호출가능한 형태의 함수들을 모아놓은 list가 될수도 있구요, 또는 {callsble: int}형태의 dict가 들어갈수 도 있어요. task들 하나의 인자를 받는 호출가능한 형태의 함수입니다. TaskSet클래스의 instance가 그 task를 실행합니다. 아래에 간단한 예제를 봐주세요. 사실 이 locustfile은 아무것도 실행하지 않을거에요.

from locust import Locust, TastSet

def my_task(l):
    pass

class MyTaskSet(TaskSet):
    tasks = [my_task]

class MyLocust(Locust):
    task_set = MyTaskSet

만약 tasks가 list로 정의가 되었다면, 그 안의 각 task가 실행이 될것이구요, 어떤 것이 실행될지는 tasks에 의해서 random하게 선택될거에요. 근데 만약에 tasks가 dict로 정의가 되어있다면 (callable함수를 key로 갖고, 정수를 값으로 갖는 형태), 역시나 task는 random하게 선택이 되겠지만, 값으로 설정된 ratio에 의해 실행되는 빈도수가 달라집니다. 예를들어,

{my_task: 3, another_task: 1}

위와 같이 선언이 되었다고 하면, my_task가 another_task보다 3배정도 더 많이 실행되게 됩니다.

TaskSets can be nested

TaskSet에서 매우 중요한 속성중의 하나가 바로 TaskSet이 nested형태로 제공될수 있다는 점인데요, 사실 실제 웹사이트들은 단편적으로 만들어진게 아니라 단계별로 실행되어져야 하는 경우가 대부분입니다. 예를 한번 들어보자면,

  • 메인 사용자 행동
    • 첫페이지
    • 포럼페이지
      • 포럼 읽기
        • 답변하기
      • 새로운 포럼 작성
      • 다음 페이지 보기
    • 카테고리 페이지
      • 동영상 시청
      • 동영상 검색
    • 소개 페이지

위에서 보시다시피, 어떤 특정 task가 실행된 후에 그 다음 task를 명시해야할 경우가 있는데 그럴때 사용할수 있는 방법이 tasks변수의 호출할수 있는 함수넣을 자리에 함수대신 또다른 TaskSet을 넣는거에요.

class ForumPage(TaskSet):
    @task(20)
    def read_thread(self):
        pass

    @task(1)
    def new_thread(self):
        pass

    @task(5)
    def stop(self):
        self.interrupt()

class UserBehaviour(TaskSet):
    tasks = {ForumPage:10}

    @task
    def index(self):
        pass

위의 예제에서 보시면, UserBehaviour의 tasks가 task를 선택할때, 또다른 TaskSet인 ForumPage를 선택하게 되겠죠, 그러면 그때 ForumPage가 실행되고 다시 ForumPage.tasks가 그 안의 함수들을 실행하게 되는 형태가 되는거에요.

여기서 중요한거 한가지, 위의 코드에서 마지막에 보시면 interrupt라는 함수가 사용된 stop이라는 task가 있죠? 이게 뭐냐면요, 이제 ForumPage.tasks가 그 안의 함수들을 랜덤으로 돌아가면서 실행을 하다가, stop을 실행하게 되면, 그때 바로 ForumPage실행을 멈추고 UserBehaviour로 돌아가서 그 다음 task를 실행하라는 거에요. 만약에 ForumPage어디에도 interrupt()함수가 호출되는곳이 없다면, Locust는 ForumPage task들을 멈추지 않고 영원히 돌리게 될거에요. 그래서 대강 ForumPage에서 사용자들을 얼마정도 있다가 돌아가겠거니 싶을때, task 데코레이션에 각 함수들의 호출 빈도를 지정함으로써 어느정도 포럼에서 머물다가 나와서 다른데도 구경하고 하게끔 interrupt()를 불러주는게 중요해요.

그리고, nested형태의 TaskSet은 @task데코러에터를 이용해서 클래스 안에서 inline으로 구현할수도 있어요. 일단 task를 정의하듯이 그렇게 말에요.

class MyTaskSet(TaskSet):
    @task
    class SubTaskSet(TaskSet):
        @task
        def my_task(self):
            pass

Locust 객체나 TaskSet의 부모객체에 접근하기

TaskSet객체 안에는 locust라는 포인트랑 parent라는 포인트가 있는데요, 얘네들을 이용해서 부모객체나 TaskSet을 호출한 Locust객체에 용이하게 접근할수가 있어요.

TaskSequence클래스

TaskSequence클래스는 일종의 TaskSet이에요. 근데 얘는 특이한점이 task들이 빈도수에 따라 random으로 실행되는게 아니라, 순차적으로 실행을 한다는 점이 일반 TaskSet이랑은 다른점이에요. 구현은 아래와 같이 TaskSequence를 상속받아서 클래스를 정의하고 @seq_task로 각 task함수들을 정의합니다.

class MyTaskSequence(TaskSequence):
    @seq_task(1)
    def first_task(self):
        pass
    @seq_task(2)
    def second_task(self):
        pass
    @seq_task(3)
    @task(10)
    def third_task(self):
        pass

이렇게 정의하면, 위에서 부터 @seq_task순서대로 차례로 실행이 되는데, 추가로 세번째 함수에 @task데코레이터의 값이 10이니까 마지막 함수는 10번 실행합니다. 보시다시피, 하나의 함수에 @seq_task@task 막 섞어서 쓰실수 있으시구요. 또 TaskSets의 nested형태를 구현할때도 TaskSequences랑 위아래 막 섞어서 정의하실수 있으세요.

Setups, Teardowns, on_start, 그리고 on_stop

Locust는 추가적으로 Locust 레벨에서는 setup그리고 teardown을 제공하고, TaskSet 레벨에서는 setup, teardown, on_start 그리고 on_stop을 제공합니다.

Setup과 Teardowns

Locust나 TaskSet의 setupteardown은 오직 한번만 실행이 됩니다. setup은 task들이 실행하기 전에 우선적으로 실행이 되고요, teardown은 모든 task가 전부다 실행되고 나가기 전에 마지막에 실행이 됩니다. 이 함수들을 재정의함으로써 작업이 실행하기 전에 준비작업을 할수도 있고, 작업을 최종적으로 종료하기 전에 실행하면서 어질러놨던거를 정리를 하고 나갈수도 있게 됩니다.

on_start와 on_stop

on_starton_stop 메써드는 TaskSet 클래스에서만 정의할수 있는데요. on_start는 어떤 사용자가 어떤 특정 TaskSet클래스를 실행했을때 호출되고, on_stop메써드는 TaskSet이 종료될때 실행이 됩니다.

각 함수가 호출되는 순서

아래는 위에서 설명한 함수들이 호출되는 순서입니다:

  1. Locust setup
  2. TaskSet setup
  3. TaskSet on_start
  4. TaskSet tasks….
  5. TaskSet on_stop
  6. TaskSet teardown
  7. Locust teardown

보통 setup이랑 teardown이 쌍으로 움직입니다.

HTTP요청하기

지금까지 task들을 어떻게 커버할지 Locust사용자 입장에서 계획만 짰자나요. 어떤 서비스의 실제 load test를 하려면 HTTP요청이 빠질수가 없죠. 이걸 손쉽게 하도록 생겨난것이 바로 HttpLocust라는 클래스에요. 이 클래스를 이용하면요, 각 instance가 HttpSession 클래스의 객체를 저장하는 client를 가지게 되고, 이것을 통해서 HTTP요청이 가능해지는거에요.

class HttpLocust

이 클래스의 instance하나가 HTTP 사용자 한명을 대변하고, 여러개의 instance를 통해 시스템을 공격하게 되는데 그게 바로 load test가 되는거죠.

이 사용자가 뭘할지는 해당 클래스안에 task_set라는 변수에 TaskSet을 상속받아 정의한 클래스를 할당함으로써 정의가 됩니다.

이 클래스는 client라는 변수를 만들고, 그 안에 session을 보유한 HTTP client를 갖고 있게 됩니다.

client=None

Locust가 초기화를 하면서 HttpSession의 instance를 만들어서 client에 저장합니다. 이 client는 쿠키저장도 가능하고, HTTP request간에 session도 유지도 합니다.

HttpLocust클래스를 상속해서 나만의 클래스를 정의할때, client를 이용해서 아래와 같이 HTTP요청을 만들수가 있어요.

from locust import HttpLocust, TaskSet, task, between

class MyTaskSet(TaskSet):
    @tast(2)
    def index(self):
        self.client.get("/")
    @task(1)
    def about(self):
        self.client.get("/about/")

class MyLocust(HttpLocust):
    task_set = MyTaskSet
    wait_time = between(5, 15)

위의 코드를 해석하자면, Locust클래스는 HttpLocust클래스를 상속받아 구현했기 때문에 session정보를 저장한 client를 가지게 되고요, task에서 URI를 호출하여 HTTP요청을 할수 있는데, index를 about보다 2배 정도 많이 호출하고, 각 task를 실행한 뒤에는 5초에서 15초정도 쉬어준뒤 다음 task를 실행합니다.

예리한 분들은 눈치채셨겠지만, HttpLocust는 부모 클래스로 정의가 되었는데 의외로 task에서 client에 접근할때 self.locust.client.get()이 아니라 self.client.get()으로 client에 접근하고 있어요. 이거는 사용자들의 편의를 위해서 self.client.get()를 실행하면 self.locust.client.get()가 호출되도록 뒷단에서 그렇게 만들어 놓은거에요.

HTTP client사용하기

HTTPLocust의 각 객체들은 HttpSession객체를 client에 저장하고 있어요. HttpSession클래스는요 사실 requests.Session의 서브클래스에요. 그래서 HTTP요청이 가능한건데요. HTTP요청이 get, post, put, delete, head, patch 그리고 options등의 다양한 형태로 요청이 되는데, 그 통계자료를 Locust에서 취합하게 되요. 하나의 HttpSession객체는 쿠키도 가질수 있고, 세션도 공유하기 때문에 웹사이트에서 로그로 사용될수도 있고, 요청간의 연관관계를 이용해서 뭔가 더 다양한 테스트를 할수 있겠죠. client는 위에서 말씀드린대로 TaskSet에서도 바로 접근하실수 있으십니다.

아래는 client를 통해서 GET으로 /about페이지에서 결과를 가져다가 화면에 보여주는 간단한 예제입니다.

response = self.client.get("/about")
print("Response status code:", response.status_code)
print("Response content:", response.text)

POST로 요청할때는 이렇게:

response = self.client.post("/login", {"username":"testuser", "password":"secret"})

Safe mode

HTTP client를 safe_mode로 돌리는 방법이 있습니다. 이게 뭐하는거냐면요, 만약에 어떤 요청이 connection에러가 났다거나, timeout났거나, 그밖에 어떤 에러상황에 처해서 처리에 실패를 했을때, 에러코드 대신에 그냥 빈 dummy response를 object에 넣어서 반환해주는 기능이에요. 해당 요청은 Locust통계에 실패로 보고가 되겠지만 사용자는 그냥 텅빈화면을 받아보게 되기 때문에 별다른 에러처리를 안해도 되는거죠. 대신에 response안에 status_code는 200이 아니라 0을 받게 될거에요.

요청의 성공, 실패 여부 조작하기

기본적으로 요청들은 처음에 failed상태로 요청을 시작했다가 요청이 끝나면 상태를 결과에 맞게 갱신해주게 되어있는데요. 대부분의 경우 이렇게 하면 만사오케이 거든요. 그런데 가끔은 테스트코드를 너무 막짜는 바람에 결과를 200만 받도록 했으면 좋겠는거에요. 예를 들어 404보다 큰 network status를 가지는 결과에 대해서 그냥 200으로 받고 싶은 경우에, 수동으로 결과를 조작하는 기능이 있습니다.

with self.client.get("/", catch_response=True) as response:
    if response.content != b"Success":
        response.failure("Got wrong response")

catch_response를 True로 요청을 하면 결과를 가져오는데 실패한 경우에 실패한 결과값 대신에 캐싱된 데이타를 보여주게 되는데 이렇게 하면, 아래 예제와 같이 최종 사용자에게 404를 넘겨주더라도 Locust통계에는 성공이라고 집계가 됩니다.

with self.client.get("/does_not_exist/", catch_response=True) as response:
    if response.status_code == 404:
        response.success()

URL과 파라메터별로 요청 묶기

어떤 특정 요청들을 묶어서 보고 싶을때, 요청에 name인자를 추가함으로써 통계를 볼때 해당 name으로 모아서 볼수가 있습니다.

# Statistics for these requests will be grouped under: /blog/?id=[id]
for i in range(10):
    self.client.get("/blog?id=%i" % i, name="/blog?id=[id]")

Common libraries

종종 사람들은 여러개의 locustfile들을 common libraries에 넣고 함께 공유하고 싶어 합니다. 그런 경우 우선 project root를 정의하고 모든 locustfile들은 프로젝트 root아래에 있게 하는것이 중요합니다.

아래는 단순한 형태의 구조입니다.

  • project root
    • commonlib_config.py
    • commonlib_auth.py
    • locustfile_web_app.py
    • locustfile_api.py
    • locustfile_ecommerce.py

위의 파일들이 서브구조를 가지면 좀더 깔끔하게 정리가 될것 같죠?

project root

  • __init__.py
  • common/
    • __init__.py
    • config.py
    • auth.py
  • locustfiles/
    • __init__.py
    • web_app.py
    • api.py
    • ecommerce.py

위의 구조로 설계를 한뒤에 아래와 같이 접근을 하실수있습니다

sys.path.append(os.getcwd())
import common.auth

Source: https://docs.locust.io/en/latest/writing-a-locustfile.html

SQLAlchemy Relationship Loading 완전정복

안녕하세요. 이번 시간에는 SQLAlchemy의 relationship()에서 제공하는 loading옵션에 대해서 한번 자세히 파헤쳐 보려고 합니다. 이 글은 SQLAlchemy를 기본 이상으로 사용하고 계신분들을 위해 좀더 심도있는 내용을 다루고자 제작되었음을 미리 양해부탁드립니다.

자, 우리가 mapper에서 relationship을 정의하면요, 연관된 다른 테이블의 데이타 collections이나 scalar를 참조할수 있게 되는데요. 사실 두개의 테이블간의 연관 관계는요 두가지 방법으로 명시를 할수가 있습니다. 한가지는, 방금 말씀드린대로 mapper에서 relationship함수를 써서 relationship.lazy 파라메터를 이용하는 방법이 있고요, 다른 한가지는 Query object를 사용함으로써 관계를 명시할수도 있습니다.

자, 우리가 mapper에서 relationship을 정의하면요, 연관된 다른 테이블의 데이타 collections이나 scalar를 참조할수 있게 되는데요. 사실 두개의 테이블간의 연관 관계는요 두가지 방법으로 명시를 할수가 있습니다. 한가지는, 방금 말씀드린대로 mapper에서 relationship함수를 써서 relationship.lazy 파라메터를 이용하는 방법이 있고요, 다른 한가지는 Query object를 사용함으로써 관계를 명시할수도 있습니다.

Loading relationship의 종류는 세가지로 나뉘는데요, lazy로딩, eager로딩, 그리고 마지막으로 no로딩이 있습니다. 간단하게 설명을 드리자면요,

첫째, lazy로딩은 처음에 SELECT를 날릴때는 related 객체를 로딩하지 않은 상태로 결과값을 반환했다가 누군가 related 객체에 접근을 시도할때 추가로 SELECT를 날려서 데이타를 채운뒤에 해당 collection을 제공하는 식으로 로딩을 하는 방식이 바로 lazy 로딩입니다.
두번째, eager로딩은요 처음부터 아예 relationship에 명시된 애들을 전부다 쿼리해오는 방식입니다. Query가 실행될때 eager로딩이 설정되어있다 그러면, SELECT문을 확장해서 related객체를 가져오는데요 보통 JOIN을 통해서 다른 테이블의 연관된 레코드를 한번에 가져오거나, 아니면 메인 SELECT문 일단 날리고, 추가로 SELECT문을 또 날려서 필요한 데이타를 로딩해서 어쩄든 처음에 다 가져오는 방식입니다.
마지막으로, no로딩은요, 해당 relationship의 로딩을 아예 안하는거에요. 누군가 정의된 related객체에 접근을 하면 그 해당 객체는 비어있거나, 로딩이 안된상태 즉 여러분이 초기값을 먹여놓은 상태겠죠. 때론 해당 객체에 접근을 하려는 시도가 발생할시 에러를 raise하는 경우도 있습니다. 이것은 모두 원치 않는 lazy로딩을 보호하기 위한 것이에요. 아래에서 더욱 자세히 설명드리겠습니다.

relationship 로딩에서 자주 쓰이는 방식들을 옵션별로 간략하게 정리하자면 아래와 같습니다:

  • lazy loading – 이 방식은 lazy=select나 lazyload()옵션을 주었을때 사용되는 방식이구요, 이게 바로 누군가 해당 정보에 접근하고자 할때 그제서야 SELECT문을 날려서 정보를 충당하는 방식입니다. 그때그때 필요한 정보만 가져오기 때문에 미리 쿼리를 하지 않는 다는 특징이 있습니다.
  • joined loading – 이 방식은 lazy=joined나 joinedload()옵션을 주었을때 사용되는 방식이구요. 이 방식은 eager loading의 일종으로 Joined eager loading이라고 하고요. 이 방식은 메인 SELECT문에 relationship에 명시된 테이블을 JOIN하여 모든 테이블의 데이타들을 한꺼번에 가져오는 방식으로 related정보들이 메인테이블과 같은 결과 set에 포함되어 로딩이 됩니다.
  • subquery loading – 이 방식은 lazy=subquery나 subqueryload()옵션을 주었을때 사용되어지는 방식이구요. 마찬가지로 초반에 쿼리를 날리는 형태라서 eager loading의 일종으로 Subquery eager loading이라고 부릅니다. 그냥 joined loading과 다른점은 SELECT문을 재작성할때, 테이블을 바로 join하지 않고, related table을 subquery로 SELECT문을 작성하여 해당 subquery를 메인 table과 조인하는 방식입니다. 그렇게 하면 subquery를 통해서 related table의 데이타가 먼저 로딩을 하게 되고, 후에 main table과 join함으로써 데이타를 추려내는 방식입니다.
  • select IN loading – 이 방식은 lazy=selectin이나 selectinload()옵션을 주었을때 사용되는 방식이구요. 두번째 SELECT문, 즉 related table을 쿼리하는 SELECT문에 parent table의 primary key를 IN 조건문에 추가하는 방식으로, 테이블을 통째로 들었다놨다 하는게 아니라 데이타베이스에서 가져올때 primary key로 간추려서 selectively가져오는 방식입니다.
  • raise loading – 이 방식은 lazy=raise나 lazy=raise_on_sql 혹은 raiseload()옵션을 주었을때 사용되는 방식이구요. lazy load에 대한 요청이 왔을때 Error를 raise해서 원치 않는 lazy loading을 막아줍니다.
  • no loading – 이 방식은 lazy=noload나 noload()옵션을 주었을때 사용되어지는 방식이구요. 이 로딩방식은 related된 객체를 비워놓고 그냥 비워둔채로 놔두고 절대로 loading을 하지 않는것입니다. 이 방식은 loading옵션중에서 많이 사용되어지는 방식은 아닙니다.

Mapping시 Loader설정 전략

어떤 특정 relationship을 설정하기 위한 loader 전략이 세워졌다면 테이블들을 mapping할때 이것들을 적용해야해요. 이 설정은 relationship()함수의 lazy인자를 통해서 이루어 집니다. 아까 말씀드린대로 lazy인자에 값으로 들어갈수 있는 애들은 select, joined, subquery, selectin등이 있습니다.

예를 들어보면요, 위에서 언급한 joined eager loading을 한번 parent object에 설정해볼게요.

 class Parent(Base):
    __tablename__ = 'parent'

    id = Column(Integer, primary_key=True)
    children = relationship("Child", lazy='joined' 

위의 코드는 언제든지 Parent object가 loading될때 Children collection이 추가적으로 로딩 될것입니다. 이것은 추가된 JOIN쿼리에 의해서 Parent object에 Children row가 fetch되었기 때문이죠.
lazy인자의 기본값은 select입니다. 처음엔 부르지 않다가, 나중에 related 객체에 접근이 일어나면 loading이 이루어지는 방식말이죠.

Loader Option을 사용한 Relationship Loading

Mapping당시 Lazy인자를 사용하는것 이외에 loading설정을 하는 방법이 있는데요. 바로 loader option, 즉 Query.options()를 사용하는 방법입니다. 아마도 이 방법이 mapper에서 설정하는 방법보다 더 흔하게 사용되고 있을지도 모를것 같기도 한데요. Loader option을 이용하면 보다 정교하게 설정이 가능해 집니다. 가장 흔하게 쓰이는 loader option으로는 joinedload(), subqueryload(), selectinload(), 그리고 lazyload()가 있습니다. 위의 함수들에 related객체를 연결하는 방법은 두가지, parent객체 안에 선언된 child객체의 이름을 string으로 그냥 보내도 되구요, 아니면 child node객체 자체를 보내도 됩니다. 몇가지 예를 들어볼까요?

# parent객체에 child node의 이름을 넘겨서 lazy load할때는 이렇게
session.query(Parent).options(lazyload('children')).all()

# parent객체에 선언한 child node 자체를 넘겨줄수도 있습니다.
session.query(Parent).options(lazyload(Parent.children)).all()

# 다른 함수도 마찬가지로 이렇게
session.query(Parent).options(joinedload('children')).all()

Loader option들은 쭉 연결해서 여러번 호출할 수도 있는데, 이걸 method chaining이라고 합니다. 아래의 코드를 보시면 Parent에 children은 JOIN으로 묶어서 함께 호출되고, 두번째 loader option은 자세히 보시면 Child니까 children의 item이 되겠죠? 이때, 그 JOIN된 쿼리에 subelements를 함께 불러올 subquery가 추가적으로 만들어진다는 겁니다.

session.query(Parent).options(
    joinedload(Parent.children).
    subqueryload(Child.subelements)).all()

이런 chaning형태는 lazy loding에서도 사용할수가 있는데요. 아래 코드를 보시면요. Join대신에 lazyload를 썼는데요. 그 밑에 또 subquery loading을 하도록 설정했어요. 이 경우에는 Parent의 children collection에 가장 처음 access를 했을때 일단 childred collection을 가져오는 쿼리를 날리는데요, 이때 해당 쿼리에 subquery를 붙여서 subelements도 함께 가져오라는 뜻입니다. 막연하게 subquery는 eager loading이니까 Parent를 쿼리해올때 SELECT문에 추가적으로 붙여서 갖고 오겠지 하고 추측하시면 안된다는 거죠.

session.query(Parent).options(
    lazyload(Parent.children).
    subqueryload(Child.subelements)).all()

method chaining에서 각 단계의 loader style은 함수명을 통해 확실하게 명시가 되어집니다. 그런데 어떤 특정한 arrtibute의 기존 loader style을 변경하지 않고 각 chain의 구조만 정의하고 싶다면 그때는, defaultload()를 사용할수 있습니다.

session.query(A).options(
    defaultload(A.atob).
    joinedload(B.btoc)).all()

이와 비슷하게 chain의 구조를 좀더 여러개의 depth로 만들고 싶을때, 물론 loader option을 여러번 정의하는 방법도 있지만 아래 코드와 같이 defaultload()를 사용해서 한번에 sub option을 정의할수 있습니다.

session.query(A).options(
    defaultload(A.atob).options(
      joinedload(B.btoc),
      joinedload(B.btod)
    )).all()

참고로, lazyload()로 한번 로딩된 collection은 해당 데이타가 memory에 존재하는한 계속 접근해서 사용할수 있지만요, query가 expired된경우, 즉 Session이 commit이나 rolled back된 경우나, 혹은 Session.expire_all()함수를 호출해서 임의로 모든 session들을 expire시켜버린 경우에는 방금 보여드렸던 아래의 query의 경우 누군가 Parent.children에 접근을 시도하면 lazyload()가 다시 실행되고, 마찬가지로 이때 sub-query로 다시 loading이 됩니다.

session.query(Parent).options(
    lazyload(Parent.children).
    subqueryload(Child.subelements)).all()

이때, 나는 children이 호출될때 subelements가 자동으로 끌려나오는게 싫다 하시는분은 Parent를 로딩한 이후에 loader option을 변경할수가 있습니다. 아래와 같이 populate_existing()을 사용하면 이미 로딩된 객체를 다시 로딩하지 않고도 설정을 변경할수가 있어요.

# change the options on Parent objects that were already loaded
session.query(Parent).populate_existing().options(
    lazyload(Parent.children).
    lazyload(Child.subelements)).all()

이렇게 해주면, garbage collection이 임의로 Session을 clear하거나 Session.expunge_all()등을 실행해서 Session이 없어진 경우에 기존의 객체는 사라지고 새로운 객체를 만들어지기 때문에 다시 정의한 loader option이 적용되게 됩니다.

앞으로 SQLAlchemy는 이미 로딩된 객체의 loader option을 변경하는데 필요한 더 많은 기능을 추가적으로 출시할 계획이에요.

Lazy Loading

기본적으로 내부 객체의 relationship들은 lazy loading입니다. 위에서도 말씀드렸다시피 lazy loading은 해당 attribute에 접근이 시도될때 필요한 collection이나 scalar가 relationship()을 통해서 연계가 되는거죠. At the point of access에 related object나 object들을 연계시키기 위해서 일반적으로 SQL문을 날리게 되는것이에요.

>>> jack.addresses
SELECT
    addresses.id AS addresses_id,
    addresses.email_address AS addresses_email_address,
    addresses.user_id AS addresses_user_id
FROM addresses
WHERE ? = addresses.user_id
[5]
[<Address(u'jack@google.com')>, <Address(u'j25@yahoo.com')>

SQL문을 날리지 않는 경우가 한가지 있는데 바로 부모와 자식테이블의 관계가 many-to-one 일경우 입니다. 이미 related 정보가 현재 Session에 불려와 있을때는 다시 쿼리를 실행하지 않고 있는 데이터를 활용하는 것이지요. 그런 이유에서 lazy loading이 때로는 너무 여러번의 쿼리를 날리는 바람에 자원이 낭비되는가 싶다가도, 하나의 related 데이타가 여러 다른 object들에 의해 호출이 될때는 새로 호출을 안하고 로컬에 있는 데이터를 사용하기 때문에 살짝 절약이 될수도 있다는 거죠.

이렇게 attribute에 접근을 할때 loading을 하는 방식을 lazy또는 select로딩이라고하는데 select라는 단어의 어원은 객체에 접근이 있을때 SELECT문을 날린다 그래서 select라고 합니다.
Lazy loading은 lazyloading() loader option을 통해서도 query에 직접 사용할수 있습니다.

from sqlalchemy.orm import lazyload
session.query(User).options(lazyload(User.addresses))

Raiseload를 이용해서 원치 않는 lazy loading막기

lazyloading()의 가장큰 폐해는 바로 N + 1이슈인데요. 이게 뭐냐면요 예를들어 related 된 객체의 개수가 N개라고 할때 해당 객체를 모두 읽어서 결과를 보여주고 싶은경우에 각 객체별로 N번의 추가 쿼리를 날려야하기 때문에 parent객체를 위한 쿼리까지 더해서 총 N + 1개의 쿼리를 날리게 된다는 말이죠. SQLAlchemy에서 이 문제를 해결하기 위해 사용하는 방법은 eager load를 사용해서 초반에 한꺼번에 가져오도록 쿼리를 변경하는거에요. 여기서 또다른 문제가 생기는데 어떤 attribute들이 eager loading에 의해 로딩이 안되었을때, 접근을 시도하면 lazy loading이 발생할수 있는데 때로는 데이타가 없으면 없었지, 차후 loading자체를 허용하기 싫은 경우가 있을수 있잖아요. 그럴때 바로 raiseload()를 사용해서 차후 loading을 아래와 같이 차단할수 있어요.

from sqlalchemy.orm import raiseload
session.query(User).options(raiseload(User.addresses))

위의 코드가 실행된 후에는 .addresses에 접근했을때 쿼리를 날리기는 커녕, 오히려 ORM Exception error를 raise하게 됩니다.

raiseload()의 인자로 wildcard를 사용하면 해당 parent에 설정된 모든 relationship에 대해 raiseload()를 적용하라는 말이 됩니다.

session.query(Order).options(
    joinedload(Order.items), raiseload('*'))

위의 코드는 처음에 한번 eager loading을 수행하고, 그 이후로 부터는 모든 relationship에 대해 Exception error를 raise하게 됩니다. 그런데 여기서 주의하셔야하실 부분은 raiseload('*')Order쿼리 안에서 사용되었음에도 불구하고, raiseload('*')는 Order객체에만 국한 된것이 아니라 모든 relationship에 적용이 된다는거에요. 굳이 Order객체안의 relationship만 제한하고 싶다면 반드시 Full path로 아래와 같이 명시해주셔야해요.

from sqlalchemy.orm import Load

session.query(Order).options(
    joinedload(Order.items), Load(Order).raiseload('*'))

그리고 부득이 Order객체 안에 있는 여러개 relationship중에 단하나 items만 차후 로딩을 제한하고 싶으시다면, 아래와 같이 .으로 표기해서 적용되는 scope을 좁히실수도 있어요.

session.query(Order).options(
    joinedload(Order.items).raiseload('*'))

Joined Eager Loading

joined eager로딩은 eager loading중에 가장 기본적인 방식입니다. SELECT문에 JOIN을 추가해서 쿼리를 날리는데 이때 별다른 설정이 없다면 LEFT OUTER JOIN으로 join하게 됩니다. Query()가 실행될때 Parent obejct와 함께 related collection이나 scalar가 같은 result set에 저장되는 형태죠.
Mapping시 설정하시려면 다음과 같습니다:

class Address(Base):
    # ...

    user = relationship(User, lazy="joined")

joined eager로딩은 loading옵션의 기본값은 아니고, 보통 쿼리에 추가 옵션으로 적용이 되는데요, 특히 Parent객체와 추가로 불러오는 데이타와의 관계가 Many-to-one이 아니고, one-to-many인경우에 많이 쓰입니다. 코드는 joinedload()옵션을 줌으로써 아래와 같이 구현할수 있습니다.

>>> jack = session.query(User).\
... options(joinedload(User.addresses)).\
... filter_by(name='jack').all()
SELECT
    addresses_1.id AS addresses_1_id,
    addresses_1.email_address AS addresses_1_email_address,
    addresses_1.user_id AS addresses_1_user_id,
    users.id AS users_id, users.name AS users_name,
    users.fullname AS users_fullname,
    users.nickname AS users_nickname
FROM users
LEFT OUTER JOIN addresses AS addresses_1
    ON users.id = addresses_1.user_id
WHERE users.name = ?
['jack']

JOIN은 기본적으로 LEFT OUTER JOIN이기 때문에 related row가 없는 경우에도 lead object는 result set에 포함이 됩니다. 만약 이거를 허용하고 싶지 않다, 나는 releted row가 없는 lead object는 결과에 포함시키고 싶지 않다 하시는 분은 relationship.innerjoin플래그를 사용하셔서 related row가 있는 데이타만 가져오도록 쿼리를 변형할수 있습니다.

class Address(Base):
    # ...

    user_id = Column(ForeignKey('users.id'), nullable=False)
    user = relationship(User, lazy="joined", innerjoin=True)

그리고 query option에서 쓰고자할때는 joinedload.innerjoin플래그를 사용하시면 됩니다.

session.query(Address).options(
    joinedload(Address.user, innerjoin=True))

chain에 outer join과 inner join이 함께 쓰인경우에는 inner join이 오른쪽에 nested형태로 아래 쿼리와 같이 만들집니다.

>>> session.query(User).options(
...     joinedload(User.addresses).
...     joinedload(Address.widgets, innerjoin=True)).all()
SELECT
    widgets_1.id AS widgets_1_id,
    widgets_1.name AS widgets_1_name,
    addresses_1.id AS addresses_1_id,
    addresses_1.email_address AS addresses_1_email_address,
    addresses_1.user_id AS addresses_1_user_id,
    users.id AS users_id, users.name AS users_name,
    users.fullname AS users_fullname,
    users.nickname AS users_nickname
FROM users
LEFT OUTER JOIN (
    addresses AS addresses_1 JOIN widgets AS widgets_1 ON
    addresses_1.widget_id = widgets_1.id
) ON users.id = addresses_1.user_id

JOIN문에서 INNER는 기본값이기 때문에 생략된거에요. 옛날 버젼의 SQLite나 SQLAlchemy에서는 오른쪽의 nested join을 subquery로 바꾸도록 설계했었으니까 참고하세요.

Joined eager loading and result set batching

joined eager loading에서 가장 중점을 두어야하는 부분은 바로 leading entry의 결과가 중첩되지 않아야 한다는 것입니다. 예를 들어 위의 예시에서 User가 3개의 Address를 가지고 있다면 조인했을때 User는 3개의 row에 중첩된 채로 반환이 될것입니다. User데이타가 전에 받은거랑 똑같은게 나왔을때, Address object에 해당하는 칼럼의 데이타로 구성된 객체가 User.addresses에 추가적으로 들어가게 됩니다.

이 과정은 매우 투명하죠, 그런데 여기서 궁금증이 한가지 생깁니다. 과연 joined eager loading은 “batched” query의 결과와 양립할수 있을까요? Collection을 로딩할때 batched 쿼리는 Query.yield_per()함수를 통해서 제공됩니다. lead object와 related object가 1:1관계라면 문제없을거에요 하지만 Query.yield_per()이 collection based joined eager loader에서 실행되면는 Exception을 throw할겁니다.

collection-based joined eager loading을 할때 큰 result set을 가지는 “batch”쿼리는 다중의 SELECT문을 날리는데 이때 row를 묶어서 추가로 날리는 쿼리의 WHERE문을 조작해서 subset을 가져오도록합니다. 그렇게 실제 날리는 쿼리의 숫자를 줄이도록 하는 것이지요.
이를 보완하기 위해서 “select IN” eager loading을 사용할것을 고려해보세요. 얘는 잠재적으로 Query.yield_per()와 양립할수 있거든요. 왜냐면 DB driver가 multiple, simultaneous쿼리를 제공하기 때문인데요, 단 SQLite, PostgreSQL은 지원을 하는데 MySQL이나 SQL Server ODBC driver는 지원을 안한다는 점 알아두세요.

The Zen of Joined Eager Loading

지금까지의 이야기를 들어보면 joined eager loading이 Query.join()이랑 참 많이 닮은것 같죠? 근데 이 두가지가 굉장히 헷갈릴수가 있어요. 이 두가지 기능의 차이점을 분명히 아셔야하는데요. 그 차이점은 Query.join()은 퀴리결과를 갱신할때 사용되는 것이고, joinedload()는 lead object의 결과값은 결코 건드리지 않고 오직 related object를 가져오는데만 사용되는 join이라는거죠. loader 전략의 기본 철학은 어떤 종류의 scheme든 추가로 로딩할수 있지만, 결과값은 절대 바꾸지 않는다에요. 오직 데이타베이스에 날리는 SQL쿼리문의 개수에는 변동이 있을수 있지요. related object를 추가로 가져와야하니까요. 처음에는 일단 all lazy load로 관계를 설정하세요 그리고 추후에 쿼리를 보고 어떻게 하면 보다 효과적일지를 고민하시면 됩니다. Loader전략은 쿼리를 변경하지 않고도 이루어 질수 있어요. 결과값은 동일할거란 말이죠. 하지만 전략적으로 개선을 해서 더 적은 숫자의 SQL문을 날린다면 더 효율적인 시스템이 될거에요. 이론적으로, loader 전략을 변경하더라고 사실상 여러분들이 Query에 직접적으로 전혀 다른 primary result set이나 related objects를 가져오게 만드는건 못할거에요.

joinedload()가 어떻게 entity row에 영향을 받지 않은 동일한 결과를 그 어떤 경우에도 변함없이 반환하는지는 바로 내부적으로 임의의 join에 대한 alias를 생성한뒤에 그것을 당신의 쿼리에 추가하기 때문에 그 쿼리가 기본쿼리의 어떤 부분에서도 참조될수 없도록 원천봉쇄를 하기 때문입니다. 예를 들면, 아래의 query는 users의 addesses로부터 LEFT OUTER JOIN을 하기위해 joinedload()를 사용합니다. 그런데 여기서 Address.email_address로 ORDER BY를 하라고 추가하는것은 불가능합니다. 왜냐하면 Address라는 entiry는 쿼리안에 명시되어 있지 않기 때문입니다.

... options(joinedload(User.addresses)).\
... filter(User.name=='jack').\
... order_by(Address.email_address).all()
SELECT
    addresses_1.id AS addresses_1_id,
    addresses_1.email_address AS addresses_1_email_address,
    addresses_1.user_id AS addresses_1_user_id,
    users.id AS users_id,
    users.name AS users_name,
    users.fullname AS users_fullname,
    users.nickname AS users_nickname
FROM users
LEFT OUTER JOIN addresses AS addresses_1
    ON users.id = addresses_1.user_id
WHERE users.name = ?
ORDER BY addresses.email_address   <-- this part is wrong !

위의 쿼리에 addresses라는 테이블은 FROM문에 없죠? 그런데 그냥 사용하는것은 오류죠. 진심으로 addresses.email_address로 ORDER BY를 하고 싶으시다면 Query.join()에 addresses테이블을 추가하신후에 아래와 같이 쓰실수 있으세요.

... join(User.addresses).\
... filter(User.name=='jack').\
... order_by(Address.email_address).all()

SELECT
    users.id AS users_id,
    users.name AS users_name,
    users.fullname AS users_fullname,
    users.nickname AS users_nickname
FROM users
JOIN addresses ON users.id = addresses.user_id
WHERE users.name = ?
ORDER BY addresses.email_address
['jack']

위의 쿼리결과는 이제 더이상 첫번째 쿼리와 같지 않을거에요. 그럼 여기에다 joinedload()를 한번 추가해 볼까요? 그래서 쿼리에 두개의 join이 하나는 ordering을 위해 하나는 related rows를 가져오기 위해 존재하도록 말이죠.

>>> jack = session.query(User).\
... join(User.addresses).\
... options(joinedload(User.addresses)).\
... filter(User.name=='jack').\
... order_by(Address.email_address).all()
SELECT
    addresses_1.id AS addresses_1_id,
    addresses_1.email_address AS addresses_1_email_address,
    addresses_1.user_id AS addresses_1_user_id,
    users.id AS users_id, users.name AS users_name,
    users.fullname AS users_fullname,
    users.nickname AS users_nickname
FROM users JOIN addresses
    ON users.id = addresses.user_id
LEFT OUTER JOIN addresses AS addresses_1
    ON users.id = addresses_1.user_id
WHERE users.name = ?
ORDER BY addresses.email_address
['jack']

위의 쿼리에서 보시면 이제 Query.join()은 JOIN을 만들어서 이후에 필요한 작업에 사용되도록 했구요, joinedload()는 오직 쿼리결과의 각 User에 User.addresses collections을 가져오는데만 쓰이게 됩니다. 그런데 왠지, 쿼리가 중복된다는 느낌을 지울수가 없네요. 사실 중복되고 있는게 맞죠. 만약 우리가 단 하나의 JOIN으로 collection도 로딩을 하고, ordering도 할수 있다면 참 좋겠는데 말이죠. 방법이 있어요 바로 contains_eager()로딩을 사용하면 됩니다. contains_eager()에 대해서는 아래에서 더 자세히 설명 드릴게요. joinedload()가 왜 이렇게 써야하는지를 설명하기 위해서 Address를 가지고 filtering을 한번 해볼게요.

>>> jack = session.query(User).\
... join(User.addresses).\
... options(joinedload(User.addresses)).\
... filter(User.name=='jack').\
... filter(Address.email_address=='someaddress@foo.com').\
... all()
SELECT
    addresses_1.id AS addresses_1_id,
    addresses_1.email_address AS addresses_1_email_address,
    addresses_1.user_id AS addresses_1_user_id,
    users.id AS users_id, users.name AS users_name,
    users.fullname AS users_fullname,
    users.nickname AS users_nickname
FROM users JOIN addresses
    ON users.id = addresses.user_id
LEFT OUTER JOIN addresses AS addresses_1
    ON users.id = addresses_1.user_id
WHERE users.name = ? AND addresses.email_address = ?
['jack', 'someaddress@foo.com']

위에서 보시다시피, 두개의 JOIN은 매우 다른 역할을 하고 있어요. Query.join() 즉, INNER JOIN으로 추가된 addresses는 정확하게 하나의 row를 반환할것이고, joinedload(), 즉 OUTER JOIN으로 추가된 addresses는 여전히 해당 사용자의 email address 3개 모두를 가져오는데 사용될 것입니다.

joinedload()을 다른 loading 스타일로 변경함으로 인해서, collection을 완전히 독립적으로 따로 쿼리를 해서 불러오도록 변형 시킬수 있습니다. 아래의 코드는 joinedload()를 subqueryload()로 바꿔서 구현한 쿼리입니다.

... join(User.addresses).\
... options(subqueryload(User.addresses)).\
... filter(User.name=='jack').\
... filter(Address.email_address=='someaddress@foo.com').\
... all()
SELECT
    users.id AS users_id,
    users.name AS users_name,
    users.fullname AS users_fullname,
    users.nickname AS users_nickname
FROM users
JOIN addresses ON users.id = addresses.user_id
WHERE
    users.name = ?
    AND addresses.email_address = ?
['jack', 'someaddress@foo.com']

# ... subqueryload() emits a SELECT in order
# to load all address records ...

joined eager loading을 이용했을때 쿼리안에 쿼리결과에 영향을 주는 modifier(DISTINCT, LIMIT, OFFSET…)가 있으면, 메인쿼리를 subquery로 감싸고, 그 subquery에 related 객체를 가져오는데 필요한 JOIN을 추가하도록합니다. SQLAlchemy는 loader option들이 최종결과에 어떠한 영향도 미치지 못하도록 하기위해 신중에 신중을 기하여 어떤 형태의 query라도 반드시 오직 related objects만을 로딩되어지도록 만반의 태세를 갖추고 있습니다.

Subquery Eager Loading

Subquery eager loading은 joined eager load와 같은 방법으로 설정이 됩니다. 물론 relationship.lazy에는 “joined”대신 “subquery”가 들어가야겠죠. 그리고 Query에서 option으로 사용될때는 joinedload()옵션 대신에 subqueryload()옵션을 사용하셔야 하구요.

Subquery eager loading은요, lead쿼리를 날린후에 모든 relationship에 대해서 새로운 SELECT쿼리를 추가적으로 날려서 collection을 받아다 결과객체에 추가하는데요, 이때 날리는 SELECT문 안에 original SELECT문이 subquery로 들어가게됩니다. Lead SELECT문에 날렸던 조건과 동일한 조건으로 SELECT문을 만든후에 칼럼은 primary key만 가져오도록해서 해당 subquery를 related table과 Join을 해서 related rows를 가져오는 방식이죠.

>>> jack = session.query(User).\
... options(subqueryload(User.addresses)).\
... filter_by(name='jack').all()
SELECT
    users.id AS users_id,
    users.name AS users_name,
    users.fullname AS users_fullname,
    users.nickname AS users_nickname
FROM users
WHERE users.name = ?
('jack',)
SELECT
    addresses.id AS addresses_id,
    addresses.email_address AS addresses_email_address,
    addresses.user_id AS addresses_user_id,
    anon_1.users_id AS anon_1_users_id
FROM (
    SELECT users.id AS users_id
    FROM users
    WHERE users.name = ?) AS anon_1
JOIN addresses ON anon_1.users_id = addresses.user_id
ORDER BY anon_1.users_id, addresses.id
('jack',)

subqueryload전략은 collection을 로딩할때 joined eager loading과 비교하여 이점이 매우 많습니다. 첫째, original 쿼리를 전혀 건드리지않고 그대로 날린다는 것인데, LEFT OUTER JOIN을 하면 쿼리 효율이 살짝 떨어진다는 점에서 효과적이고, 둘째, related table이 많을때 하나의 쿼리에서 처리해야한다면 해당 쿼리가 너무 비대해져서 마찬가지로 효율이 떨어지게 됩니다. 마지막으로, 추가적으로 실행되는 쿼리는 오직 collection에 item을 채우기 위한 것이므로, 이경우 INNER JOIN을 날리는게 효율적이기 때문입니다.

subqueryload 를 이용할때 단점은 original query가 복잡한 경우에 그 복잡한 쿼리의 조건문을 related item을 가져오는 쿼리에 그대로 사용해야한다는 점입니다. 때때로 이런 복잡한 쿼리는 어떤 경우에 (보통 MySQL) 쿼리 엄청나게 느려지는 상황이 생기기도 합니다. 추가로 subqueryload전략은 오직 한번에 모든 collection들을 load하기 때문에 Query.yield_per()를 이용한 “batched” loading과 양립할수 없다는 단점이 있습니다.

이러한 subqueryload()의 단점을 보완하기 위하여 새롭게 만들어진 loading style이 바로 selectinload() 입니다.

The Importance of Ordering

subqueryload()를 연결방식으로 사용하는 Query문에서 Query.first(), Query.limit(), 혹은 Query.offset()과 같은 limiting modifier를 사용하는경우에는 반드시 unique column으로 정렬을 하도록 Query.order_by()를 함께 사용해야합니다. 그래야 subqueryload()에 의해 쿼리를 날릴때, parent query와 동일한 정렬기준을 사용할수 있게 되니까요. 그렇지 않으면, subqueryload()로 실행한 쿼리가 잘못된 결과를 가져오는 사태가 발생할수도 있습니다.

# 틀렸음, ORDER BY를 반드시 사용해야함
session.query(User).options(
    subqueryload(User.addresses)).first()

# 틀렸음, User.name은 고유하지 않을수 있음
session.query(User).options(
    subqueryload(User.addresses)
).order_by(User.name).first()

# 맞았음. 반드시 고유한 ID로 order by를 해야함.
session.query(User).options(
    subqueryload(User.addresses)
).order_by(User.name, User.id).first()

Select IN loading

select IN loading은 subquery eager loading과 매우 흡사하지만, 두번째 SELECT문이 보다 간결해 진다는 점에서 subquery eager loading과 구분이 됩니다. 추가적으로 select IN loading은 load result를 그룹으로 뭉뚱그려서 한번에 쿼리하기도 합니다. joined나 subquery eager loading은 Query.yield_per()를 사용한 batch쿼리와 양립할수 없는데 반해 select IN loading은 batch쿼리와도 양립이 가능하고, database driver에 동시 다발적으로 쿼리를 날릴수 있는 기능도 제공을 합니다.

종합적으로, 특히 SQLAlchemy의 1.3시리즈에서는 selectin loadin이 대부분의 경우 collection을 로딩하는데 가장 심플하고, 효율적인 eagerly load라고 할수 있습니다. 단, selectin eager loading이 불가능한 경우가 한가지 있는데, 바로 primary key가 여러개의 column들을 조합해서 만든경우에 backend database가 IN 조건문으로 tuple을 지원하지 않는 경우입니다. 지원하지 않는 데이타베이스로는 SQLite, Oracle 그리고 SQL Server가 있습니다.

Select IN eager loading은 mapping단계에서 relationship.lazy에 selectin를 값으로 주어 설정하거나, Query에 selectinload()옵션을 주어서 제공되어집니다. 이 방식은 이미 가져온 parent object에서 가져온 primary key들을 두번째 SELECT문의 조건문으로 사용하는 방식입니다. This also takes place for a non-collection, many-to-one load where the related column values are not loaded on the parent objects and would otherwise need to be loaded:

>>> jack = session.query(User).\
... options(selectinload('addresses')).\
... filter(or_(User.name == 'jack', User.name == 'ed')).all()
SELECT
    users.id AS users_id,
    users.name AS users_name,
    users.fullname AS users_fullname,
    users.nickname AS users_nickname
FROM users
WHERE users.name = ? OR users.name = ?
('jack', 'ed')
SELECT
    addresses.id AS addresses_id,
    addresses.email_address AS addresses_email_address,
    addresses.user_id AS addresses_user_id
FROM addresses
WHERE addresses.user_id IN (?, ?)
ORDER BY addresses.user_id, addresses.id
(5, 7)

위의 예제에서 addresses.user_id IN (5, 7)과 같이 이미 로딩된 User objects에서 primary key를 획득하여 두번째 SELECT쿼리의 조건으로 사용합니다. 다시 말해, parent objects의 batch가 완전히 로딩된 상태에서 그 안의 primary key값들을 가져다가 두번째 SELECT문의 IN안에 삽입하는거죠. 왜냐하면 위의 두 객체 User와 Address는 User의 primary key가 Address.user_id를 이끌어내는 관계에 있기 때문이죠. 그래서 두번째 SELECT문에서는 어떤 join이나 subquery도 필요하지 않고 primary key만 가지고 direct로 추가정보를 가져올수 있습니다.

Parent object에 primary key가 명시되어있지 않은 경우에 selectinload()를 사용하면, 그때는 selectin loading이 첫번째 SELECT문에 parent table을 추가로 join하여, primary key를 획득하도록 조작합니다. This also takes place for a non-collection, many-to-one load where the related column values are not loaded on the parent objects and would otherwise need to be loaded:

>>> session.query(Address).\
... options(selectinload('user')).all()
SELECT
    addresses.id AS addresses_id,
    addresses.email_address AS addresses_email_address,
    addresses.user_id AS addresses_user_id
    FROM addresses
SELECT
    addresses_1.id AS addresses_1_id,
    users.id AS users_id,
    users.name AS users_name,
    users.fullname AS users_fullname,
    users.nickname AS users_nickname
FROM addresses AS addresses_1
JOIN users ON users.id = addresses_1.user_id
WHERE addresses_1.id IN (?, ?)
ORDER BY addresses_1.id
(1, 2)

“Select IN” loading은 eager loading중에서도 가장 최근에 추가된 기능입니다. 아래는 알아두면 좋은 정보입니다.:

  • selectin loader를 통해 실행된 SELECT문은 subquery와 달리 subquery를 필요로 하지도 않고, original query에서 그 어떤 performance에 제약을 주는 조건문도 넣어줄 필요가 없습니다. 단순히 primary key를 통한 검색이기때문에 매우 높을 성능을 보입니다.
  • subqueryload에서는 필수적이었던 고유한 키로 정렬을 해야한다는 조건도 selectinload에서는 명시할 필요가 없습니다. selectin은 오직 primary key만 가지고 관계를 link하기 때문에 잘못된 결과를 가져올 가능성이 전혀 없습니다.
  • selectin load는 join이나 subquery와는 달리 parent objects가 로딩이 되자마자 바로 두번째 SELECT문을 날립니다. 그래서 eager loading이 여러개의 depth로 복잡하게 구성이 되어있다해도, selectin loading은 여전히 JOIN은 최대 한번만 이용하고 보통은 JOIN없이 여러번 쿼리를 해옵니다. joined이나 subquery eager loading과 비교하면 얘네들은 엄청나게 많은 다중 JOIN을 쿼리에 추가해서 가져오죠. 물론 original parent에 필요한 경우에 말이죠.
  • “selectin” loading은 original쿼리가 아무리 복잡해도 그와는 별개로 언제나 SELECT문이 예상가능한 심플한 형태입니다. 새로운 기능인 ColumnOperators.in_()을 이용하여, cached query를 허용하도록 할수도 있고, selectin loader는 한번 쿼리한 SQL을 캐시에 저장하기 위하여 sqlalchemy.ext.baked 확장자를 이용하기도 합니다. 그리고 저장된 캐시를 이용함으로 인해서 외부로 요청하는 요청횟수를 줄여줍니다.
  • 이 strategy는 한번에 최대 500개의 parent primary key를 두번째 쿼리에 명시하여 정보를 가져올수 있습니다. Oracle같은 데이타베이스는 IN 조건문에 넣을 수 있는 값의 제약이 많아요. 그리고 전체 SQL의 길이에도 제약이 있을수 있으니 값을 너무 많이 넣지는 않는게 좋기때문에 selectin loading은 넘길수 있는 최대 primary key의 값을 500개로 나눠서 여러번 쿼리를 함으로써 추가 정보를 획득합니다. These SELECT statements emit with minimal Python overhead due to the “baked” queries and also minimal SQL overhead as they query against primary key directly.
  • selectin loading은 eager loading입니다. 그래서 Query.yield_per()로 제공되는 batch와 함께 쓰일수 있어요. 그리고 데이타베이스 driver가 지원한다면 동시다발적으로 쿼리를 날릴수 있지요. 현재 simultaneous cursors를 지원하는 데이타베이스 driver는 SQLite, ProstgreSQL입니다. MySQL의 driver인 mysqlclient나 pymysql은 현재 simultaneous cursors를 지원하지 않고 있어요. 마찬가지로 SQL Server의 ODBC도 동시쿼리를 지원하지 않습니다.
  • selectin loading 은 primary key가 다중 컬럼으로 이루어 진 경우 IN조건문에서 tuple 데이타 타입을 허용하지 않는 데이타베이스라면 사용할수 없습니다. tuple이라 함은 WHERE (table.column_a, table.column_b) IN ((?, ?), (?, ?), (?, ?))와 같은 값의 그룹을 이야기하는데, 이 문법은 PostgreSQL, MySQL, SQLite의 최신버젼에서 지원이 되고 있습니다. SQLAlchemy는 여러분의 데이타베이스가 해당 기능을 지원하는지 검사하는 기능은 하지 않습니다. tuple을 포함한 쿼리를 날렸을때 에러를 반환한다면 지원하지 않는것으로 간주하시면 됩니다. SQLAlchemy는 데이타베이스가 tuple을 지원하든 안하든 안되는거만 쏙 빼고 문제없이 일을 처리할것입니다.

일반적으로 selectin loading은 대부분의 경우에서 subquery eager loading보다 우수한 성능을 보여줄 것입니다. 다시한번 말하지만, composite primary key로는 안되는 데이타베이스가 있다는것, 두번째 parent의 result set이 굉장히 큰 경우에 SELECT쿼리가 여러번 발생될수 있다는점. 그리고 언제나처럼, 개발자들은 쿼리문이 효율적으로 돌아가고 있는지를 확인하는데 시간을 할애하기를 바랍니다.

What Kind of Loading to Use ?

과연 어떤 loading타입을 이용하여 데이타베이스에 날리는 “Number of SQL execution”과 “complexity of SQL”, 그리고 “fetch해온 data의 amount” 간에 장점들을 tradeoff를 해서 optimizing을 해야할까요? 여기 두개의 예제가 있습니다. 하나는 collection을 가져오는 relationship()이고, 하나는 scalar를 가져오는데 parent테이블과의 관계는 many-to-one reference로 연결이 되어있습니다.

One to Many Collection

  • lazy loading을 이용하면, 당신이 만약 100개의 객체를 로딩해왔고 그후 각각의 collection을 불러온다면 총 101개의 쿼리를 하게 되는셈이에요. 비록 SELECT문이 비교적 간단하다지만 이 방법은 parent objects가 늘어날수록 SELECT문의 실행 횟수도 늘어난다는 단점이 있습니다.
  • joined loading을 이용하면, 100개의 objects와 각 object에 연계된 collection들은 단 한번의 쿼리로 가져오게 되는데요. fetch된 총 row의 개수는 모든 collections의 합과 비어있는 collection들의 합이 되겠죠. 각 row는 모든 테이블의 column들의 값을 full set으로 가져올것이고, collection item개수만큼 반복되어 보여질것이에요. 그래서 joined eager loading은 related collection들의 크기가 비교적 작을때 사용되어집니다. 추가로 joined loading이 사용하는 LEFT OUTER JOIN은 INNER JOIN과 비교할때 좀더 격렬(intensive)하기때문에 성능이 떨어질수 있어요.
  • subquery loading을 이용하면, 100개의 object들을 2개의 쿼리를 날려서 loading을 하는데요. 두번째 SELECT문에서 모든 related collection을 다 가져올거에요. 여기서는 INNER JOIN이 사용되고요, primary key이외에 추가로 가져오는 parent column도 없습니다. 따라서 collections의 크기가 큰 경우에는 subquery loading을 이용하는것이 바람직합니다.
  • 중첩으로 collections을 안에서 또 호출하고, 또 호출하고 하는 경우에는 joined나 subquery로딩을 사용하여 전부다 한번에 가져오면 collections-within-collections이 multiply되서 엄청나게 많은 row를 반환하게 될것 입니다. 게다가 joined나 subquery는 언제나 original parent class에서 비롯되므로, 만약 collection이 4 레벨로 구성되어있다면 original 쿼리는 4개의 JOIN을 가지게 될것입니다. 하지만, 그와는 달리 selectin loading을 이용한다면, depth에 상관없이 언제나 one JOIN의 쿼리를 날리게 될것 입니다.
  • selectin loading을 이용하면, 100개의 object를 가져올것이고 2개의 SQL문을 날릴것입니다. 두번째 쿼리는 가져온 100개의 primary key를 참고해서 보내므로 매우 빠르고 심플한 형태의 쿼리가 되겠죠. 만약 parent의 result set이 500개 이상인 경우에는 primary key를 500개씩 나눠서 쿼리를 필요한만큼 추가적으로 하게 될거에요.
  • 중첩된 level로 구성된 relationship에서 selectin loading을 사용할시 join이나 subquery에서 문제되었던, cartesian문제는 더이상 발생하지 않습니다. selectin loading은 가장 우수한 성능을 가진 loading방법이고, 가장 적은 수의 쿼리를 날립니다. 단 parent결과값이 너무 많을경우 나눠서 쿼리를 하기 때문에 추가적으로 날리는 쿼리가 1개 이상 될수 있습니다.

Sourse: https://docs.sqlalchemy.org/en/13/orm/loading_relationships.html