DotFriends

📌 쿼리 파라미터 입력값에 따른 500에러 막기

King of Silicon Valley 2021. 9. 24. 21:57
728x90

상품리스트 뷰는 쿼리파라미터 입력값이 많다.  

 

new      = int(request.GET.get('new', 0))
sale     = int(request.GET.get('sale', 0))
offset   = int(request.GET.get('offset', 0))
limit    = int(request.GET.get('limit', 10))
order    = request.GET.get('order', 'id')
search   = request.GET.get('search', None)
category = request.GET.get('category', 0)

 

입력값이 많으므로 에러가 날 변수가 많다. 

 

프론트단에서는 우리가 유도하는 값만 쿼리 파라미터에 실어서 보내지만 

 

API주소는 노출되어 있으므로 악의적인 의도로 쿼리 파라미터에 이상한 값을 보낼 경우를 대비 해야한다.

 

본 포스팅은 쿼리 파라미터 입력값에 대한 500에러를 막기 위한 과정이다. 


쿼리 파라미터 값이 어디에 사용되는지 살펴보자

(쿼리 파라미터와 쿼리셋을 짜는 부분까지의 코드) 

class PublicProductsView(View):
    def get(self, request):
        new      = request.GET.get('new', 0)
        sale     = request.GET.get('sale', 0)
        order    = request.GET.get('order', 'id')
        search   = request.GET.get('search', None)
        category = request.GET.get('category', 0)
        offset   = int(request.GET.get('offset', 0))
        limit    = int(request.GET.get('limit', 10))
        
        filter_set = {
            "search"   : "name__icontains",
            "new"      : "is_new",
            "sale"     : "discount_percent__gte",
            "category" : "category_id"
        }               

        q = {filter_set.get(i):v for (i,v) in request.GET.items() if filter_set.get(i)}
                          
        products = Product.objects.filter(**q).prefetch_related('image_set')\
            .annotate(avg_rate=Avg('comment__rate'),popular=Count("userproductlike", distinct=True),review_count=Count('comment',distinct=True))\
            .order_by(order)[offset:offset+limit]

쿼리파라미터 값으로 

 

new: 신상 여부

sale: 세일 여부

order: 정렬 옵션

search: 검색어

category: 카테고리 

offset: 조회 시작 인덱스

limit: 조회 개수 

 

총 7가지를 받는다. 

입력 받은 쿼리파라미터 값으로 조건에 맞는 쿼리셋을 짠다. 


쿼리 파라미터를 아예 안주는 경우를 대비했다. 

http://127.0.0.1:8000/public/product

이럴 경우를 대비하기 위해서 

filter_set = {
            "search"   : "name__icontains",
            "new"      : "is_new",
            "sale"     : "discount_percent__gte",
            "category" : "category_id"
        }               

 q = {filter_set.get(i):v for (i,v) in request.GET.items() if filter_set.get(i)}

search, new, sale, category는 쿼리 파라미터에 아무값도 안 넣으면 해당하는 값이 쿼리 셋을 짜는데 영향이 없도록 했다. 

 

order의 경우 

order    = request.GET.get('order', 'id')

값을 안주면 기본 값으로 id를 기준으로 오름차순으로 정렬해서 쿼리셋을 짠다. 

 

offset과 limit의 경우 

offset   = int(request.GET.get('offset', 0))
limit    = int(request.GET.get('limit', 10))

각각 0, 10을 기본값으로 주어서 10개를 조회하도록 했다. 

 

이제 나머지의 경우 오류가 날 경우를 체크해보자 


new: 신상여부를 선택하는 옵션이다. 1이면 신상, 0이면 신상이 아니다. 

 

에러가 날 수 있는 경우의수는 3가지다. 

 

1. 입력값에 0이나 1이 아닌 다른 숫자를 넣을 때

http://127.0.0.1:8000/public/product?new=2

2. 숫자가 아닌 문자열을 집어넣을 경우 

http://127.0.0.1:8000/public/product?new=fwenwg

3. 쿼리파라미터에 빈 문자열을 줄 경우  

http://127.0.0.1:8000/public/product?new=

결국 이 세가지의 오류를 막으려면 0과 1이 아닌 값을 주면 흐름을 끊어주면 된다. 

if new != 0 and new != 1: 
    return HttpResponse(status=401)

new의 값이 0도 1도 아니면 401코드를 반환 해준다. 

그리고 아무값도 안주었을 때는 

new = request.GET.get('new', 0)

기본값을 0으로 주어서 위 if문을 건너 뛸 수 있다.

 

이렇게 해서 총 3가지의 오류가 날 경우를 막을 수 있다. 

 

+ 추가 

리팩토링을 하던 중 더 좋은 방법을 찾았다. 

 

new에 0과 1이 아닌 다른 값을 주면 ValidationError가 난다.

 

기존 처럼 if문으로 조건을 나누지 않고 예외로 잡아 주었다. 

 

ValidationError를 잡으면 앞으로 다른 곳에서도 잡을 수 있는것이 많아서 예외를 잡는것으로 선택했다. 

 

except ValidationError:
    return JsonResponse({'MESSAGE' : 'UNEXPECTED_VALUE'},status=401)

 


sale: 숫자를 넣으면 할인율이 지정된 숫자 이상인 조건을 지정한다. 

 

오류가 날 경우의수는 2가지다. 

 

1. 입력값에 숫자가 아닌 값을 줄 경우 

127.0.0.1:8000/public/product?sale=fefweg

 

2. 빈문자열을 줄 경우 

127.0.0.1:8000/public/product?sale=

 

이번에는 쿼리파라미터 입력값을 바로 float형으로 변환시켜준다. 

sale = float(request.GET.get('sale', 0))

이렇게 하면 빈문자열이나 숫자가 아닌값을 float로 형변환을 하면ValueError가 나게 된다. 

 

앞으로 숫자형으로 형변환 하는 경우가 많으므로 ValueError를 잡아주도록 한다. 

 

except ValueError:
    return HttpResponse(status=400)

이렇게 해서 2가지의 오류가 날 경우를 막을 수 있다. 

 

+추가

sale도 마찬가지로 ValidationError를 잡으면 문자열을 주던 빈문자열을 주던 다 잡을 수 있게 됐다.

 

더 이상 float로 형변환을 할 필요가 없게 됐다. 

 


category: 제품의 카테고리를 지정한다. 

 

오류가 날 경우의 수는 3가지다.

 

1. 없는 카테고리 아이디 값을 줄 때

127.0.0.1:8000/public/product?category=846

 

2. 문자열을 줄 경우 

127.0.0.1:8000/public/product?category=dgwegw

 

3. 빈 문자열을 줄 경우  

127.0.0.1:8000/public/product?category=

 

이런 경우는 sale때와 같이 숫자형으로 바꿔준다. 

category = int(request.GET.get('category', 0))

이렇게 하면 문자열을 주거나 빈 문자열을 줄 경우를 잡을 수 있다. 

 

그리고 카테고리 아이디가 있는지 조회를 해야하므로 

if category and not (Category.objects.filter(id=category).exists()):
    return HttpResponse(status=404)

받은 카테고리 아이디가 있는지 조회해보는 if문을 넣어준다. 

 

category값이 있는지 확인하고 조회를 하는 이유는 카테고리에 아무값을 안주면 0을 넣어주게 되는데 

cayegory값이 있는지 확인을 안하면 바로 0을 가지고 카테고리 아이디를 조회하므로 404에러코드를 반환하게 된다. 

그래서 일단 카테고리에 값이 있는지 확인하고 조회를 한다. 

카테고리에 값이 없으면 조건문을 지나치게 된다. 

 

다만 카테고리에 0을 직접 넣으면 위 조건식을 피하게 되고 쿼리셋에 카테고리가0인 경우를 집어넣는다. 

그래도 에러가 나지 않으므로 이 경우는 따로 처리를 하지 않았다. 

 

int로 형변환을 하면서 생기는 ValueError는 아까 sale할 때 잡아줬으므로 따로 추가 하지 않는다. 

이렇게 해서 총 2가지의 에러가 날 경우를 막을 수 있다. 

 

+ 추가

category의 경우도 int형으로 변환할 필요가 없었다. 

 

카테고리 아이디에 해당하지 않는 아이디를 주면 빈 쿼리셋이 반환되고 에러가 나지 않으므로

(Category.objects.filter(id=category).exists()):

위와 같은 조회를 할 필요가 없으므로 데이터베이스에 쿼리를 날릴 필요가 없어졌다. 

 

빈문자열이나 문자열을 주게 되면 ValueError로 잡을 수 있어서 ValueError예외처리를 해주었다. 

 

ValueError는 또 쓰임새가 있으므로 조건문을 주기보다 예외처리로 잡는 방법을 선택했다. 

 


order: 정렬 기준을 지정한다. 

 

order의 경우는 좀 특별하다. 

 

if not (order == 'id' or order == '-id' or order == '?'\
                or order=='-popular' or order=='-updated_at' or order =='price' or order =='-price'):

7개의 정렬기준이 있는데 이 7가지에 해당하지 않으면 401에러를 반환하게 분기 처리를 해야할지

아니면 예외로 잡아줄지 2가지의 선택이 있다. 

 

기존에는 input_validator라는 데코레이터를 만들어서 입력값 검증을 미리 해주는 게 있어서 데코레이터에 분기 처리를 넣어주었지만 리팩토링을 하면서 데코레이터의 필요성이 없어지게 되었고 

 

view함수에서 예외처리로 잡아주기로 결정했다. 

 

except FieldError:
    return JsonResponse({'MESSAGE' : 'UNEXPECTED_VALUE'},status=401)

 

FieldError의 경우는 order옵션에서만 생기는 예외여서 order만을 위해서 예외를 잡을지 깊게 고민해본 결과

분기처리를 줄이는 것이 더 중요하다 생각했다. 

 


offset과 limit 

offset과 limit은 무조건 int형이 되어야만 했다. 

 

쿼리파라미터로 숫자값을 넣어주어도 

 

products = Product.objects.filter(**q).prefetch_related('image_set')\
                .annotate(avg_rate=Avg('comment__rate'),popular=Count("userproductlike", distinct=True),review_count=Count('comment',distinct=True))\
                .order_by(order)[offset:offset+limit]

int형으로 변환을 안하게 되면 에러가 나기 때문에 

int형으로 변환 하는게 필수 였는데 

이 과정 속에서 에러를 쉽게 잡을 수 있었다. 

 

빈 문자열, 문자열을 주면 int로 형변환을 하면서 

offset   = int(request.GET.get('offset', 0))
limit    = int(request.GET.get('limit', 10))

 

except ValueError:
    return JsonResponse({'MESSAGE' : 'NOT_INT'},status=401)

ValueError로 잡을 수 있다. 

sale에서 ValueError를 잡을 때 쓰였으므로 코드를 추가할 필요가 없다. 

 


search

search에는 아무 값이나 주어도 에러가 나지 않기 때문에 따로 처리 하지 않았다.

 


최종 코드

class PublicProductsView(View):
    def get(self, request):
        try:
            new      = request.GET.get('new', 0)
            sale     = request.GET.get('sale', 0)
            order    = request.GET.get('order', 'id')
            search   = request.GET.get('search', None)
            category = request.GET.get('category', 0)
            offset   = int(request.GET.get('offset', 0))
            limit    = int(request.GET.get('limit', 10))

            filter_set = {
                "search"   : "name__icontains",
                "new"      : "is_new",
                "sale"     : "discount_percent__gte",
                "category" : "category_id"
            }               

            q = {filter_set.get(i):v for (i,v) in request.GET.items() if filter_set.get(i)}
                            
            products = Product.objects.filter(**q).prefetch_related('image_set')\
                .annotate(avg_rate=Avg('comment__rate'),popular=Count("userproductlike", distinct=True),review_count=Count('comment',distinct=True))\
                .order_by(order)[offset:offset+limit]

            total_count = Product.objects.filter(**q).count()

            results = [{
                'id'               : product.id,
                'name'             : product.name,
                'price'            : int(product.price),
                'updated_at'       : product.updated_at.date(),
                'popular'          : product.popular,
                'avg_rate'         : round(product.avg_rate, 2) if product.avg_rate else product.avg_rate,
                'review_count'     : product.review_count,
                'is_new'           : product.is_new,
                'discount_percent' : int(product.discount_percent),
                'discounted_price' : int(product.price*(100-product.discount_percent)/100),
                'image'            : [image.url for image in product.image_set.all()],
            }for product in products]

            return JsonResponse({'results': results, 'count': total_count}, status=200)
        except ValueError:
            return JsonResponse({'MESSAGE' : 'NOT_INT'},status=401)
        except FieldError:
            return JsonResponse({'MESSAGE' : 'UNEXPECTED_VALUE1'},status=401)
        except ValidationError:
            return JsonResponse({'MESSAGE' : 'UNEXPECTED_VALUE'},status=401)

최종 코드 입니다. 

if분기는 1개도 없고 예외처리가 3개다. 

 

원래코드를 살펴보면 

 

def input_validator(func):
    def wraper(self, request, *args, **kwargs):
        try:
            offset   = int(request.GET.get('offset', 1))
            limit    = int(request.GET.get('limit', 12))
            order    = request.GET.get('order', 'id')
            category = int(request.GET.get('category', 0))
            sale     = float(request.GET.get('sale', 0))
            new      = request.GET.get('new', 0)
           
            if new != 0 and new != 1: 
                return HttpResponse(status=401)
                
            if category and not (Category.objects.filter(id=category).exists()):
                return HttpResponse(status=404)

            if not (order == 'id' or order == '-id' or order == '?'\
                or order=='-popular' or order=='-updated_at' or order =='price' or order =='-price'):
                return HttpResponse(status=400) 
                
        except ValueError:
            return HttpResponse(status=400)
        return func(self, request, *args, **kwargs)
    return wraper

이런 데코레이터를 만들어서 입력값 검증을 해주었는데 

리팩토링을 하면서 필요가 없어지게 됐다. 

 


후기 

 

리팩토링을 하면서 정말 심혈을 기울여 생각한 input_validator가 의미가 없단걸 알았을 때 허무함이 들었다. 

하지만 이렇게 프로젝트가 끝나고 내가 짠 코드를 깊게 분석하면서 더 좋은 방법이 있단 걸 알게 됐고 배운게 더 많아서 허무함은 새로운 배움으로 채워졌다. 

 

아쉬운 점은 프로젝트가 끝나서 바뀐로직을 프론트엔드에 적용해볼 수 없는 점이다.

하루빨리 나만의 프로젝트를 만들고 싶다.