- Published on
Django 5.2 업그레이드에서 만난 django-filter의 silent failure
- Authors

- Name
- hongreat
- ✉️hongreat95@gmail.com
최근 운영중인 프로젝트를 Django 4.x에서 5.2 LTS로 전환했습니다.
Django 버전만 올리는게 아니라 의존성들도 같이 정리했는데, 이 과정에서 django-filter가 2.4.0에서 24.3으로 크게 점프했습니다.
마이그레이션도 문제없었고 테스트도 통과해서 배포했는데, 며칠 뒤 일부 어드민 화면에서 "필터가 안먹는다"는 제보를 받았습니다.
에러 로그는 조용했습니다. 이게 이번 글의 핵심입니다.
- 1. 증상: 에러 없이 필터만 무시된다
- 2. 원인: filter_fields는 22.1에서 제거되었다
- 3. 왜 위험했나
- 4. 전수 조사와 수정
- 5. 재발 방지를 위해 정리한 것들
- 참고 자료
1. 증상: 에러 없이 필터만 무시된다
증상을 최소한으로 재현하면 이렇습니다.
class TicketListView(generics.ListAPIView):
queryset = Ticket.objects.all()
serializer_class = TicketSerializer
filter_backends = [DjangoFilterBackend]
filter_fields = ("place_id",) # 구버전(2.x) 시절의 속성명
?place_id=1로 요청하면 place_id가 1인 데이터만 와야 하는데, 전체 데이터가 응답됩니다.
직접 재현해본 결과입니다. (Django 5.2 + django-filter 24.3)
GET /tickets/?place_id=1
filter_fields (구) -> 200 OK, 3건 (전체. 필터 무시됨)
filterset_fields (신) -> 200 OK, 1건 (정상)
500도 아니고 400도 아닙니다. 경고 로그조차 없습니다. 정상 응답처럼 보이는 200에 필터만 조용히 빠져있습니다.
이런 유형을 silent failure라고 부르는데, exception은 시끄럽게 죽기라도 하지만 이건 조용히 틀린 데이터를 계속 내보냅니다.
2. 원인: filter_fields는 22.1에서 제거되었다
django-filter 2.0에서 DRF 연동 속성들의 이름이 변경되었습니다.
filter_fields→filterset_fieldsfilter_class→filterset_class
2.x 시절에는 옛 이름을 써도 동작했습니다. 백엔드에 이런 호환 코드가 있었기 때문입니다.
# django-filter 2.4.0의 rest_framework/backends.py
if filterset_fields is None and hasattr(view, 'filter_fields'):
message = (
"`%s.filter_fields` attribute should be renamed `filterset_fields`."
)
filterset_fields = getattr(view, 'filter_fields', None)
deprecation warning을 내면서 옛 이름을 새 이름으로 매핑해주고 있었습니다.
그리고 이 호환 코드는 django-filter 22.1에서 제거되었습니다. (2.4.0과 22.1 소스를 직접 받아서 비교해보니 22.1부터 backends.py에서 filter_fields 관련 코드가 완전히 사라졌습니다)
제거된 이후의 동작을 따라가보면 이렇습니다.
DjangoFilterBackend는 view에서filterset_fields(또는filterset_class)를 찾는다- 없으면 "이 view에는 필터 정의가 없다"고 판단한다
- 필터 정의가 없는 view는 정상 케이스이므로, queryset을 그대로 반환한다
view에 남아있는 filter_fields는 이제 백엔드가 쳐다보지도 않는, 아무 의미 없는 클래스 속성일 뿐입니다. 파이썬 입장에서는 멀쩡한 코드라서 어떤 단계에서도 에러가 날 이유가 없습니다.
참고로 알 수 없는 쿼리 파라미터를 무시하는 것은 DRF와 django-filter의 기본 동작이라, ?place_id=1이 무시되는 것도 프레임워크 입장에서는 지극히 정상인 상황입니다.
3. 왜 위험했나
단순 검색 필터라면 "필터가 안먹네요" 수준의 불편으로 끝납니다.
문제는 필터를 데이터 스코프 제한 용도로 쓰고 있던 엔드포인트들입니다.
지점 관리자용 API에서 ?place=로 자기 지점 데이터만 조회하는 구조였다면, 필터가 무시되는 순간 전 지점의 데이터가 응답에 실려나갑니다.
물론 이런 권한성 필터는 쿼리 파라미터가 아니라 서버에서 유저 기준으로 강제하는 것이 원칙이지만, 오래된 코드에는 이 원칙이 지켜지지 않은 엔드포인트가 섞여있기 마련입니다.
그리고 페이지네이션이 증상을 가립니다. 첫 페이지만 보면 데이터가 그럴듯하게 보이기 때문에, "결과 개수가 이상하다"는 것을 한눈에 알기 어렵습니다. 저희도 배포 직후가 아니라 며칠 지나서야 제보로 알게 되었습니다.
4. 전수 조사와 수정
원인을 알고나면 수정 자체는 단순합니다. 프로젝트 전체를 grep으로 전수 조사했습니다.
grep -rn "filter_fields" apps/ --include="*.py" | grep -v "filterset_fields"
13개 파일이 나왔고, 전부 한 줄씩 rename 했습니다.
- filter_fields = ("place", "provider")
+ filterset_fields = ("place", "provider")
filter_class를 쓰는 곳이 있다면 filterset_class로 같이 바꿔야 합니다. 같은 시점에 같이 제거되었습니다.
수정보다 오래 걸린 것은 "필터가 무시되던 기간 동안 문제가 된 조회가 있었는지" 확인하는 일이었습니다. 이런 사후 확인 비용까지가 silent failure의 비용이라고 생각합니다.
5. 재발 방지를 위해 정리한 것들
돌아보면 이 문제는 업그레이드 전에 잡을 수 있는 기회가 두번 있었습니다.
첫번째는 deprecation warning 입니다. 2.4.0을 쓰던 시절에 이미 경고가 출력되고 있었는데, 아무도 경고를 읽지 않았습니다. 테스트를 돌릴때 warning을 에러로 승격시키면 이런 유형을 강제로 마주하게 됩니다.
# pytest.ini 또는 pyproject.toml
[tool.pytest.ini_options]
filterwarnings = [
"error::DeprecationWarning",
]
물론 서드파티가 내는 warning까지 전부 에러로 만들면 시끄러워지므로, ignore 목록과 함께 운영해야 현실적입니다.
두번째는 changelog의 Removed 섹션 입니다. 메이저 버전을 여러개 건너뛰는 업그레이드라면, 중간 버전들의 Removed 항목을 모두 훑어야 합니다. 2.4에서 24.3으로 올리면 그 사이의 21.x, 22.x, 23.x 변경사항을 전부 통과하는 것이기 때문입니다.
마지막으로 필터 동작 자체를 검증하는 테스트를 추가했습니다.
def test_place_filter_applied(self):
response = self.client.get('/api/tickets/', {'place_id': self.place_a.id})
# 필터가 무시되면 전체 개수가 나오므로 개수를 직접 검증한다
self.assertEqual(len(response.data), 1)
"필터가 있다"가 아니라 "필터가 실제로 걸러낸다"를 검증하는 것이 포인트입니다. status code 200만 확인하는 테스트였다면 이번 문제를 절대 잡지 못했을 것 입니다.
업그레이드 작업에서 제일 무서운 것은 빌드가 깨지는 변경이 아니라, 조용히 동작이 바뀌는 변경이라는 것을 체감한 경험이었습니다.
exception은 발생한 그 자리에서 알려주지만, silent failure는 언제부터 틀렸는지를 역추적하는 비용까지 청구합니다.
비슷한 업그레이드를 앞두고 있다면 filter_fields부터 grep 해보는 것을 추천합니다.
