N+1 Query 문제

2024년 9월 27일 금요일

Today I Learned

날짜

2024년 9월 27일 금요일

내용

joinedload 는 earger loading이다.

최근 새 서비스를 개발하면서, 써보고 싶은 코드를 써보고 있다. 그 중 자주 쓰는게 현재 hybrid property다. 연관된 테이블이 있는지를 나타내기 위해 쓰고 있다. 예를 들어, 각 스토어가 연결한 인스타그램 계정을 나타내기 위해 다음과 같이 해놨다.

1
코드 비공개

이런 식으로 데이터베이스 구조가 짜여 있다. 스토어를 가져와서 그 스토어가 연결된 인스타그램 계정이 있는지 확인해보려면 다음과 같이 써야한다.

1
2
3
4
5
6
7
8
9
10
11
def function(db):
	stmt = select(models.Store).where(models.Store.service_id == "test_id")
	store = db.execute(stmt).scalar()
	if store:
		print(store.instagram_account.name)
	else:
		print("no account!")
		
		
#이 함수를 실행했을 때, 스토어가 연결된 인스타그램 계정이 있다면 다음처럼 이름이 출력된다
// "Test Instagram ACcount name!"

만약 하이브리드 프로퍼티를 이용하지 않으면, store.instagram_accounts 라는 builtin_list 에서 is_selected 가 True인 것을 찾아야 하는데, 이게 참 귀찮았다.

하지만 내가 사용한 방식에서 N+1 Query 문제를 겪었다. 이게 뭔지 알기 위해, 모든 스토어를 조회했을떄 연결된 인스타그램 계정의 folowers_count가 100이 넘는 것만 찾아보자.

1
2
3
4
5
6
7
8
def get_stores_that_have_famous_instagram_account(db):
	cnt = 0
	stmt = select(models.Store)
	stores = db.execute(stmt).scalars().all()
	for store in stores:
		if store.instagram_account.followers_count >= 100:
			cnt += 1
	return cnt

여기서 모든 store의 갯수가 N개라고 가정하면, 총 실행되는 쿼리(데이터베이스에 접근)의 수는 몇일까?

우선 N개의 스토어를 가져오기 위한 쿼리가 1개다. 각 반복문때마다 스토어의 하이브리드 프로퍼티를 가져오기 위해 instagram_account 테이블에 접근하여 지금 주목하는 스토어와 페어링된 계정을 탐색하기 위한 쿼리가 실행된다. 반복문은 N번 반복되니, 쿼리는 총 N+1 번 실행된다.

만약 처음 1번의 쿼리로 N개의 스토어를 가져올때 페어링된 인스타그램 계정도 가져온다면 성능이 훨씬 수월할 것이다. 그럼 왜 안했을까? 저 하이브리드 프로퍼티가 Lazy loading이기 때문이다. lazy loading은 뒤늦게 필요할 때 데이터베이스에서 가져온다. 만약 내가 위 함수에서 instagram_account 라는 하이브리드 프로퍼티를 호출하지 않았다면, 애초에 가져오지 않았을 거다. 내가 호출해서 필요해지니 그때서야 쿼리를 실행해서 가져온 거다. 호출 할지 안할지 모르는 상황에선 좋다. store 데이터를 불러올 때마다 항상 연관된 인스타그램 계정이 필요한건 아니니까, 거시적으로 보면 효율적이다. 하지만 필요한게 확실한 상황에선 지금 처럼 N+1 쿼리문제 같은 비효율성을 야기시킨다.

해결책은 위에도 말했듯, store를 가져올 때 다 들고오게 해주면 된다. 그게 eager loading.

1
2
3
4
5
6
7
8
9
10
11
12
def get_stores_that_have_famous_instagram_account(db):
	cnt = 0
	
	# 쿼리문에 "올 때 꼭 챙겨와!" 라고 말해준다.
	stmt = select(models.Store)
				.options(joinedload(models.Store.instagram_accounts)
	
	stores = db.execute(stmt).scalars().all()
	for store in stores:
		if store.instagram_account.followers_count >= 100:
			cnt += 1
	return cnt

joinedload 를 쓰고있었는데 정 반대로 이게 그동안 레이지 로딩인줄 알았다.

예전 TIL에 써놓고 껄껄..

회고

아임리포트 약간의 참사 사건은 다음주에 써야겠다.