Django/DRF pk vs id 파라미터 불일치로 인한 500 오류 — 원인과 해결
프론트 저장(복사) 버튼 클릭 시 500 발생 → 원인: URL 파라미터명(pk)과 뷰 시그니처(id) 불일치.
TL;DR
- 증상: 프론트에서 AxiosError: 500, 백엔드에서 TypeError: PostCopyAPIView.post() got an unexpected keyword argument 'pk'.
- 원인: urls.py에서 posts/<int:pk>/copy/로 등록 → resolver가 {'pk': 376}을 전달. 그러나 뷰는 def post(self, request, id)만 받음 → 이름 불일치(TypeError)로 500.
- 해결:
- URL 통일: posts/<int:id>/copy/로 되돌림.
- 방어 패치(선택): 뷰에서 id/pk 모두 수용(kwargs).
- 검증: resolve/inspect로 최종 매칭 파라미터와 로딩된 시그니처 확인.
- 재발 방지: 파라미터명 일관 가이드, 단위 테스트/CI 가드, 중복 경로 제거, 캐시/오토리로드 주의.
배경 & 환경
- 프론트: 저장(=복사) 버튼 클릭 시 백엔드 /api/posts/<post_id>/copy/ 호출.
- 백엔드: Django/DRF, 함수/클래스 혼용. 일부 경로에서 <int:id>/<int:pk>가 혼재.
- 관찰 로그(요약):
- 초기 401(비로그인 상태의 POST) → 로그인 후 재호출은 200.
- 복사 시도 시 500: TypeError: ... unexpected keyword argument 'pk'.
에러 로그 스냅샷
AxiosError: Request failed with status code 500
...
TypeError: PostCopyAPIView.post() got an unexpected keyword argument 'pk'
Django resolve 결과(로컬/배포 동일):
resolve('/api/posts/376/copy/').kwargs == {'pk': 376}
실행 중 뷰 시그니처(초기 상태):
PostCopyAPIView.post -> (self, request, id)
원인 분석 (한 줄 요약)
- URL은 pk 라벨로 값을 전달 ({'pk': 376})
- 뷰는 id 라벨만 받도록 구현 (def post(..., id))
- 파라미터 이름 불일치로 Python이 unexpected keyword argument 'pk' 예외 발생 → DRF에서 500으로 응답.
참고: <int:...>에서 int는 타입이고, 진짜 중요한 건 **라벨 이름(pk/id)**입니다.
재현 절차
- urls.py에 다음 경로가 존재:
- path("posts/<int:pk>/copy/", PostCopyAPIView.as_view(), name="post-copy")
- 뷰 시그니처가 다음과 같음:
- def post(self, request, id): ...
- /api/posts/376/copy/ 호출 시 resolver가 {'pk': 376} 전달 → 뷰는 id만 받음 → TypeError.
해결 전략
A) URL 통일(권장)
urls.py 수정:
- path("posts/<int:pk>/copy/", PostCopyAPIView.as_view(), name="post-copy"),
+ path("posts/<int:id>/copy/", PostCopyAPIView.as_view(), name="post-copy"),
B) 뷰 방어 패치(선택, 환경 무관 안정성)
class PostCopyAPIView(APIView):
permission_classes = [IsUser]
serializer_class = CopyLogSerializer
def post(self, request, *args, **kwargs):
post_id = kwargs.get("id") or kwargs.get("pk") \
or request.data.get("id") or request.query_params.get("id")
try:
post_id = int(post_id)
except (TypeError, ValueError):
return Response({"detail": "post id missing"}, status=400)
user = request.user
post = get_object_or_404(GeneratedPost, id=post_id)
# 1회만 복사 로그 생성 (중복 방지하려면 get_or_create 권장)
copy_log = CopyLog.objects.create(user=user, post=post)
# DB 레벨에서 copy_count +1
GeneratedPost.objects.filter(id=post.id).update(copy_count=F("copy_count") + 1)
# 필요 시 serializer.data 반환 가능
return Response({"message": "복사 기록이 저장되었습니다."}, status=200)
실무 팁: 팀원이 다시 pk로 바꿔도 안 터지도록, 방어 패치는 유지해도 좋습니다.
적용 코드(최종 상태)
- urls.py
- path("posts/<int:id>/copy/", PostCopyAPIView.as_view(), name="post-copy"),
- views.py (방어형 그대로 유지)
- def post(self, request, *args, **kwargs): post_id = kwargs.get("id") or kwargs.get("pk") \ or request.data.get("id") or request.query_params.get("id") ...
검증 방법
- 파라미터 매칭 확인
python manage.py shell -c "from django.urls import resolve; print(resolve('/api/posts/376/copy/').kwargs)"
# 기대: {'id': 376}
- 실행 중 시그니처/소스 확인
python manage.py shell -c "import inspect; from django.urls import resolve; VC=resolve('/api/posts/376/copy/').func.view_class; print(inspect.getfile(VC)); print(inspect.signature(VC.post))"
# 기대: (self, request, *args, **kwargs)
- 캐시 정리 & 완전 재기동(필수)
find . -name "__pycache__" -type d -exec rm -rf {} +
find . -name "*.pyc" -delete
# runserver 재시작 또는 컨테이너 재기동
- 엔드포인트 호출 체크(로그인 필요)
# 예: curl -H "Authorization: Bearer <token>" -X POST http://localhost:8000/api/posts/376/copy/
왜 어떤 때는 서버에서 안 터졌나? (FAQ)
- URL 중복/등록 순서: 동일 패턴이 id/pk 두 버전으로 정의되어 있거나, DRF Router 포함 순서에 따라 먼저 매칭되는 쪽이 달라질 수 있음.
- 캐시/오토리로드: 개발 서버/컨테이너가 이전 아티팩트를 들고 있어 일시적으로 다른 경로가 적용된 상태였을 가능성.
- 결론: 구조적으로 잠복 버그였고, 이번에 URL 통일 + 방어 패치로 영구 해소.
재발 방지 체크리스트
- urls.py 전체에서 파라미터명 일관성 유지 (<int:id> 권장)
- DRF Router(detail=True) 사용 시, 뷰에서 *args, **kwargs 패턴으로 수용 후 내부에서 pk/id 정규화
- 중복 경로 제거 (같은 URL 패턴이 2번 등록되지 않도록)
- 단위 테스트 추가
- from django.urls import resolve def test_post_copy_uses_id_kwarg(): match = resolve('/api/posts/123/copy/') assert match.kwargs.get('id') == 123
- CI 가드(선택):
- # pk 버전 사용 금지 가드 예시 grep -R "posts/<int:pk>/copy/" -n django_app && { echo "Do not use pk for copy URL"; exit 1; } || true
- 코드 주석으로 팀 합의 명시: "게시글 라우트 파라미터는 id로 통일"
부록: 관련 커맨드 모음
# 현재 매칭 파라미터 확인
python manage.py shell -c "from django.urls import resolve; print(resolve('/api/posts/376/copy/').kwargs)"
# 실행 중 시그니처/소스 경로 확인
python manage.py shell -c "import inspect; from django.urls import resolve; VC=resolve('/api/posts/376/copy/').func.view_class; print(inspect.getfile(VC)); print(inspect.signature(VC.post))"
# 중복 클래스 탐색
grep -R "class PostCopyAPIView" -n django_app/apps
# 중복 경로 탐색
grep -R "posts/<int:" -n django_app/apps | grep "/copy"
# 캐시 삭제
find . -name "__pycache__" -type d -exec rm -rf {} +
find . -name "*.pyc" -delete
마무리
이번 이슈는 파라미터 이름 불일치라는 작지만 치명적인 설정 차이에서 시작됐습니다.
URL 통일 + 방어 패치 + 검증/가드 절차로 동일 유형의 문제를 확실하게 차단할 수 있습니다. 팀 규칙에 “게시글 라우트 파라미터는 id로 통일”을 명문화해 주세요.