서버 문제찾기부터 Django 최적화하는 방법까지
"내게 문제를 해결하는 데에 1시간을 주면 50분을 문제 정의에 쓰고 10분을 해결하는 데에 쓰겠다."
- 아인슈타인
라이브 서버에서 문제가 발생하면 발생하는 이유와 지점을 명확히 정의할 필요가 있다. '일반적으로 이렇게 한다더라'라는 사고의 단점은 근본적인 문제가 사라지지 않을 뿐더러, 서버에서 정의한 제한값이나 aws 서비스들의 클래스(등의 사양들)를 올리자는 생각을 먼저 하게된다. 구체적인 문제 정의없이 이러한 사양들을 먼저 올려도 사용자는 계속 늘어나고 자연스럽게 활동도 사용자수 비례만큼 늘어나 동일한 문제가 발생할 것이다.
그래서 위의 아인슈타인이 했던 말을 생각하면서 문제 정의를 해보자.
“푸시를 날리는 등 고객들이 몰리면 서버가 다운된다”
라이브 서비스를 운영한다면 충분히 겪을 수 있는 문제이다. 그렇다면 서버는 왜 다운될까? 라는 이유로부터 문제 정의가 시작된다.
"요청이 처리되는 시간보다 들어오는 양이 더 많아서 처리되지 못하는 요청이 많아진다." 가 다운되는 이유이다. 그렇다면 처리하는 데에 얼마나 오래 걸릴까? 라는 질문이 나오게 된다.
nginx 로그를 보면 알 수 있다.

내용을 보면 xx bytes in xx msecs 몇 바이트를 몇 밀리세턴즈 안에 처리했는지 정보가 담겨있다.
그래서 이 로그파일을 scp로 복사해서 컴퓨터로 옮긴 다음에 파일 각 라인의 url당 msecs의 평균을 구하면 시간이 많이 걸리는 api를 알 수 있게 된다.
→ url의 오른쪽 숫자가 처리하는 데에 걸린 평균 ms 다. (1s = 1000ms)
즉, 앱에 접속하면 꼭 부르게되는 /v1/a/b 가 요청 하나당 평균 927ms(약 1초) 걸리는 데, 이로인해 bottle neck이 걸려서 제일 서버부하가 심하다. (특히 알람푸시를 보낼 때)
그래서 많은 사용자가 접속하면 서버가 다운되는 현상을 겪는 사용자가 나올 수 밖에 없다. 이제 많이 걸리는 api부터 하나씩 해결해나가면 처리시간이 줄어들 것이고 더 많은 요청을 처리할 수 있게 될것이다.
Refactoring, API 최적화
그래서 각 API들의 코드 정리와 리팩토링이 필요한 시점이라고 생각된다.
1) Keypoint
장고서버 리팩토링에 있어서 DB hit 수를 줄이고 response의 속도도 줄이는 코드를 쓰는 것이 무엇보다 중요하다.
키포인트는 DB hit 수를 줄이기위해 prefetch 를 사용하겠지만, 가져올 DB가 방대할 경우 response 속도가 느려진다. 무슨 데이터를 어떻게 가공하여 내려줄지에 따라서 prefetch 를 써야할지, 쿼리를 더 만들어야할지를 결정하는 것이 중요하다. 게다가 효율적인 코드, 즉 pythonic하면서 더 좋은 ORM 코드로 작성하자.
2) 원칙을 세우자
- 사용하지 않는 코드는 제거한다. (주석처리된 코드도 지우는 것이 좋다)
- 사용할 related db를 prefetch한다 (하지만 데이터를 어떻게 쓰이는 지에 따라 prefetch없이 속도를 개선시킬 수 있다)
- DB hit 수와 코드 실행 시간을 print하여 비교 및 코드 수정한다.
→ time.time() 으로 현재 코드가 도달한 시간, 처리될 코드 이후에도 시간을 print하여 비교, len(connection.queries)를 print하여 print가 찍힌 코드까지의 DB hit 수를 확인할 수 있다. - Pythonic한 코드로 작성한다. (파이썬은 같은 기능이라도 내장함수와 메소드들을 잘 활용하면 퍼포먼스가 소폭 향상된다)
- sql문이 어떻게 작동되는지 모니터링하여(connection.queries) DB hit를 더 줄일 수 있는 ORM 코드로 개선한다.
- 변경 전 코드
# page 를 정의하는 코드가 너무 비효율적이다
page = int(request.GET.get('page', 1)) if int(request.GET.get('page', 1)) > 0 else 1
# 불필요하게 PlaceCategory를 hit한다. prefetch하여 한번에 부르게하고 results for문에서 작성하는 것이 낫다.
category_map = {
c.id: c.image.url for c in PlaceCategory.objects.using('replica_1').exclude(image='').exclude(image__isnull=True)
}
# 사용안하는 장문의 주석코드는 삭제한다.
# exposures = {
# str(e.place_id): e
# for e in PlaceExposureCount.objects.filter(place__in=[str(p.id) for p in places], date=datetime.date.today())
# }
# new_exposures = []
# for p in places:
# e = exposures.get(str(p.id))
# if not e:
# new_exposures.append(PlaceExposureCount(place=p, date=datetime.date.today(), count=1))
# continue
# e.count += 1
#
# PlaceExposureCount.objects.bulk_create(new_exposures)
# PlaceExposureCount.objects.bulk_update(exposures.values(), fields=['count'])
# 위 PlaceCategory처럼 불필요하게 hit되며 prefetch하여 results에 다시 쓰는게 낫다.
ownerd = {str(o.place_id): o for o in OwnerInformation.objects.using('replica_1').filter(place__in=[p.id for p in places])}
results = [{
'id': str(p.id),
'name': p.name,
# 아래 이미지 부분의 코드에서 많은 hit가 일어나고 있고, 상당히 비효율적인 코드(특히 계절 이미지)로 새로 써야한다.
# sorted 함수로 더 깔끔하게 쓸 수 있다.
'images': ['https://.cloudfront.net/' + p.thumbnail.url[32:]] if p.thumbnail else
[i.image_url for i in PlaceImage.objects.using('replica_1').filter(place_id=p.id).annotate(
custom_order=Case(When(season=current_season, then=Value(True)), default=False, output_field=BooleanField())
).order_by('-custom_order', 'order')] if p.images.filter(season__isnull=False) else [i.image_url for i in p.images.all()[:1]]
or
[category_map[c.id] for c in p.categories.all() if category_map.get(c.id)],
'distance': p.distance.m if p.distance else None,
'address': p.address,
'lat': p.lat,
'lng': p.lng,
'holiday_starts_at': None,
'holiday_ends_at': p.holiday_ends_at,
'score': 0 if p.score is None else p.score,
# 아래 review_count, likers_count에 의해 무려 128번이 넘게 hit가 일어난다. 긴 request 시간의 주범이다.
# prefetch해서 hit를 안하거나 따로 빼서 한 쿼리로 작성해야한다.
# * 하지만 prefetch하면 연결된 likers(User), reviews(PlaceReview) 모두 가져오기때문에 처리 시간이 훨씬 길어질 위험이 있어서
# hit 한번 치는 쿼리로 만드는 것이 더 낫다.
'review_count': p.reviews.count(),
'likers_count': p.likers_count if p.likers_count > 0 else 0,
# liked는 위 likers_count 내용과 거의 동일하지만 앱에서 안쓰기때문에 제거한다.
'liked': True if self.request.user in p.likers.all() else False,
'is_advertising': p.is_advertising,
# ownerd를 정의한 코드의 내용대로 prefetch하고 다시 써야한다.
'owner_validation': [{
'agree_verify': ownerd[str(p.id)].agree_verify,
'verify_benefit': ownerd[str(p.id)].verify_benefit,
'is_licensed': ownerd[str(p.id)].is_licensed,
}] if ownerd.get(str(p.id)) else [],
} for p in places
]
return JsonResponse(data={
'places': results
})
- 변경된 코드
기존 코드의 코멘트대로 수정되었으며 결과적으로 128 hit에서 7hit로 줄이고 코드실행시간을 단축시켰다.
page = max(int(request.GET.get('page', 1)), 1)
reviews_count = Counter([r.place_id for r in PlaceReview.objects.using('replica_1').filter(place__in=places)])
connection = connections['replica_1']
if places:
with connection.cursor() as cursor:
p_ids = ','.join([f"'{str(p.id)}'" for p in places])
sql = f"""
select place_id, user_id
from service_place_likers
where place_id in ({p_ids})
"""
cursor.execute(sql)
rows = cursor.fetchall()
likers_count = Counter([r[0] for r in rows])
results = [{
'id': str(p.id),
'name': p.name,
'images': ['https://.cloudfront.net/' + p.thumbnail.url[32:]] if p.thumbnail else
[x.image_url for x in sorted(p.images.all(), key=lambda x: (x.season == current_season, x.season is None), reverse=True)[:1]] or
[c.image.url for c in p.categories.all() if c.image],
'distance': p.distance.m if p.distance else None,
'address': p.address,
'lat': p.lat,
'lng': p.lng,
'holiday_starts_at': None,
'holiday_ends_at': p.holiday_ends_at,
'score': 0 if p.score is None else p.score,
'review_count': reviews_count.get(p.id, 0),
'likers_count': likers_count.get(p.id, 0) if likers_count else 0,
'is_advertising': p.is_advertising,
'owner_validation': [{
'agree_verify': owner.agree_verify,
'verify_benefit': owner.verify_benefit,
'is_licensed': owner.is_licensed,
} for owner in p.owners.all()],
} for p in places
]
return JsonResponse(data={
'places': results
})
Refactoring 결과
평균 927ms에서 평균 361ms 로 축소시켜서 2/3 빨라졌는데(약 61퍼센트 감소) 이는 평소보다 사용자를 약 2.6배 더 받을 수 있는 퍼포먼스로 향상시킨 것과 같다.
서버업데이트 이후, 푸시알림 약 1.5만명에게 전송하고 앱에 접속한 결과, 앱 느려짐 현상이 줄어들었다.
앞으로
모든 문제에는 원인이 있고 그 원인에도 깊이가 존재한다. 가장 깊게 도달하면 도달할 수록 그 문제에 대해 정확히 이해할 수 있기 때문에 계속 탐구해야하며, 문제해결방법을 강구해야한다. "다른 곳이 이렇게 하니까", "이 방법이 일반적이니까" 라는 사고로 서버를 증설 및 개선하기보다는 지금 자신에게 있는 진짜 문제점을 찾아보는 것이야말로 최적화인 것이다.