- 발행일
없는 pk로 들어가면 404가 아니라 500이 났다 — get_object를 덮으면 예외 처리도 같이 덮인다
없는 pk로 들어가면 404가 아니라 500이 났다
관리자 조직 상세 주소에 없는 번호가 들어오면 "페이지를 찾을 수 없습니다"가 아니라 500이 났습니다. 조직·조직 구성원·권한 그룹·사용자의 상세와 수정, 여덟 개 화면이 전부 같았습니다.
원인은 이렇게 생긴 코드였습니다.
def get_object(self):
logger.info(f'OrganizationDetailView.get_object()')
# 최적화된 쿼리: 관련 객체들을 미리 로드
organization = Organization.objects.prefetch_related(
'organizationmember_set__user', # OrganizationMember -> User
).get(pk=self.kwargs.get('pk'))
logger.info(f"Organization object loaded with optimized prefetch_related")
return organization
의도는 분명합니다. 상세 화면이 구성원 목록을 그리니 prefetch_related 로 쿼리를 줄이고 싶었던 거예요. 그 의도 자체는 맞습니다.
문제는 그걸 얹으려고 get_object() 를 통째로 덮었다는 데 있습니다.
덮으면 같이 없어지는 것들
Django 제네릭 뷰의 get_object() 는 원래 이런 일을 합니다.
get_queryset()을 불러 대상 쿼리셋을 정한다- URL의
pk나slug로 좁힌다 - 없으면
Http404를 던진다
세 번째가 핵심이에요. 안에서 queryset.get() 이 던지는 DoesNotExist 를 받아 Http404 로 바꾸기 때문에 없는 pk는 404가 됩니다. 그런데 위 코드는 .get(pk=...) 를 직접 부릅니다. 매니저의 .get() 은 없으면 DoesNotExist 를 던지고, 뷰가 그걸 안 받으니 그대로 500으로 나갑니다.
즉 가져오는 방법을 바꾸려고 덮었는데, 같이 딸려 있던 예외 처리까지 덮었습니다. 겸사겸사 get_queryset() 도 안 거치게 됩니다. 지금 이 뷰들은 쿼리셋에 걸린 조건이 없어서 결과가 같았지만, 나중에 쿼리셋 단에 조건이 하나라도 붙으면 이 여덟 화면만 그 조건을 안 타는 상태가 됩니다.
덮어야 했던 건 다른 자리였다
하고 싶었던 일은 "무엇을 가져오나"가 아니라 "어떤 쿼리셋에서 가져오나"입니다. 그건 get_queryset() 자리예요.
def get_queryset(self):
return super().get_queryset().prefetch_related("organizationmember_set__user")
super() 가 준 쿼리셋에 prefetch_related 만 얹고 돌려줍니다. pk로 좁히는 것도, 없을 때 404를 내는 것도 제네릭 뷰가 원래 하던 대로 합니다. 성능 의도는 그대로 살아 있고요.
여덟 뷰를 같은 모양으로 옮기니 78줄이 26줄이 됐습니다. 줄어든 52줄의 상당 부분은 요청마다 찍히던 logger.info 와, "최적화된 쿼리"라고만 적혀 있어 무엇이 왜 최적화됐는지는 알려 주지 않는 주석이었습니다.
옮기면서 중복도 하나 걷어냈습니다.
# 전
prefetch_related(
'user_permissions',
'groups',
'user_permissions__content_type',
'groups__permissions__content_type',
)
# 후
prefetch_related(
"user_permissions__content_type",
"groups__permissions__content_type",
)
user_permissions__content_type 를 prefetch 하면 user_permissions 는 어차피 같이 가져옵니다. 앞의 두 줄은 같은 걸 한 번 더 적은 것이었어요.
버린 대안
get_object() 안에서 get_object_or_404 만 쓰기. 한 줄만 바꾸면 되니 제일 짧습니다. 안 골랐어요. 500은 사라지지만 get_queryset() 을 건너뛰는 구조는 그대로 남습니다. 여덟 벌의 비슷한 코드도 그대로고요. 증상만 막고 같은 형태를 남기면 다음 사람이 이 모양을 보고 새 뷰에 복사합니다. 실제로 이 여덟 벌이 그렇게 늘어난 모양이었습니다.
검증
- 없는 pk로 여덟 화면에 들어가 404가 뜨는 것을 확인했습니다.
- 정상 pk로 상세·수정을 열어 화면이 같고, 구성원·권한 목록이 그대로 그려지는지 봤습니다.
회귀 테스트는 안 붙였습니다. 이게 이 작업의 가장 약한 부분이에요. "없는 pk면 404"는 뷰 여덟 개에 대해 한 줄짜리 assertEqual(response.status_code, 404) 로 잠글 수 있는 조건인데 안 했습니다. 지금 상태는 같은 실수가 다시 들어와도 아무것도 빨개지지 않아요.
남은 것 · 한계
- 같은 함정이 네 곳 남아 있습니다. 물리 아키텍처 서비스·시나리오의 상세·수정 뷰가 아직
get_object()안에서.get(pk=self.kwargs["pk"])를 직접 부릅니다. 이번 커밋은 사용자·조직 묶음만 훑었고 거기서 멈췄어요. 찾는 건 검색 한 번이면 되는데 범위를 넓히지 않았습니다. get_object()를 덮는 게 늘 틀린 건 아닙니다. 이 저장소에도get_object()override가 수십 곳 있고, 대부분은 pk가 아닌 다른 키로 찾거나 여러 모델 중에 고르는 등 제네릭 뷰가 못 하는 일을 합니다. 이번에 문제였던 건 override 자체가 아니라, 제네릭 뷰가 이미 하던 일을 다시 하면서 일부를 빠뜨린 override 입니다.- 쿼리 수를 재지 않았습니다. 원래 주석이 "최적화된 쿼리"라고 주장하고 있었는데, 그게 실제로 몇 개를 줄였는지는 그때도 지금도 숫자로 남아 있지 않습니다. prefetch 대상을 그대로 옮겼으니 나빠지진 않았겠지만, 옮기면서
assertNumQueries로 한 번 박아 뒀으면 다음에 이 줄을 지워도 되는지 판단할 근거가 생겼을 겁니다.
관련 글: 관리자 화면 12개가 익명에 열려 있었다 · 없는 관계를 prefetch하고 있었다 · Django ORM 정리 — 그리고 실무에서 안 통했던 지점 · 같은 표준데이터를 두 화면이 198건과 134건으로 세고 있었다