/ PROJECT, REFACTORING

기사 스크래핑 프로젝트 회고 및 리팩토링 후기


프로젝트가 끝난지 한달 반 정도 되었다. 처음으로 동료와 협업하여 기획부터 설계, 모델링, 배포까지 진행한 프로젝트였다. 기획 당시 나는 스크래핑에 관심이 있었으며 스크래핑을 활용해 장고 프로젝트를 진행해보면 재밌을 것 같다는 생각을 했다. 동료는 OAuth를 이용한 소셜 로그인과 대댓글의 기능을 구현해보고 싶다는 의견이 있었다. 이를 토대로 주제는 기사 스크래핑을 통해 회원이 원하는 기사를 제공해 줄 수 있는 사이트를 기획하게 되었다.

목표했던 기능은 대부분 구현했지만 더 깊게 생각하지 못한 부분들로 인해 아쉽게 마무리 된 기능이 2개정도 생겼다. 기능을 업데이트 하기 전에 해야 할 일은 내가 구현한 코드를 정리하는 것이였다.


  • 기능 구현에만 집중되었던 과거

정해진 기간이 있었고 기간안에 최대한 기능을 구현하고 싶었다. 그래서 코드의 중복이나 흐름을 생각하지 않고 기능 구현에만 초점을 맞추어 진행하게 되었다. 그 결과 내가 짠 코드임에도 불구하고 알아보기 쉽지 않았다. 하나의 view안에서 모든 비즈니스 로직이 구현되어 있어 view자체가 너무 무거워진 느낌이 들었고 해당 로직이 어떤 역할을 하는지 한번에 알아보기 어려웠다. 초반에 비즈니스 로직을 어떻게 다룰지에 대한 고민이 있었다면 훨씬 일관성 있는 로직으로 작성 할 수 있었겠다라는 아쉬움이 남았다.


  • 유추할 수 없는 네이밍

기능이 많아질수록 함수나 클래스의 네이밍을 생각해내는게 생각보다 많이 힘들었다. 개발자가 어려워하는 업무 베스트 중에 작명이 왜 들어가 있는지 이번 프로젝트를 통해서 몸소 깨닫게 되었다..😂 특히 CRUD의 경우 REST API 설계를 통해 url을 최대한 활용할 수 있지만 이번 프로젝트의 경우에는 Get과 Post만으로 프로젝틀 진행했기 때문에 url도 전부 따로 만들어주어야 했다. 동시에 네이밍의 작업도 배로 늘어나게 되었다.
네이밍이 잘 되어 있으면 대충 로직을 이해할 수 있다. 이게 굉장히 중요했다. 네이밍을 보고 유추되지 않으면 해당 로직을 하나하나 다시 둘러봐야 한다. 이게 생각보다 아주 많은 시간이 걸린다.. 이번 프로젝트가 그랬다.. ^^ 진짜 앞으로는 머리에 쥐가나도 최대한 기능을 유추할 수 있게 네이밍을 짓겠다는 다짐을 했다.. :)


  • 아쉬운 협업 방향

프로젝트를 진행하면서 동료와 굉장히 적극적으로 커뮤니케이션을 했다. 노션과 git project를 굉장히 잘 활용했다. 서로 도움이 될 만한 내용은 슬랙을 통해 공유하면서 진행했다. 하지만 초반에 고려하지 못한 부분이 뒤로 갈수록 문제가 됐다. 우리만의 코드 컨벤션과 깃 컨벤션을 정하지 못했다. 그리고 서로의 코드에 대한 리뷰를 하는 시간도 갖지 못했다. 그러다 보니 하나의 프로젝트이지만 각자의 스타일대로 코드를 짜다보니 코드에 일관성이 없었다. 누가봐도 다른 사람이 짠 느낌이 강했다.

협업을 진행하면서 기대했던 목표 중 하나는 git을 통한 협업을 하는것이였다. pull request를 올리면 다른 팀원이 코드를 보고 확인하는 시간이 최소한이라도 필요했지만 이번 프로젝트는 pull request올리고 바로 main에 merge해버렸다. 그리고 충돌을 최대한 피하기 위해서 merge를 하면 슬랙으로 merge했다고 서로 알려주었다.. :-) git을 사용했지만 협업을 위한 git사용은 아니였던 것이 참 아쉬웠다. 충돌을 두려워해서 충돌을 해결하는 쪽보다는 충돌을 최대한 피하는 쪽을 선택했다.


리팩토링을 진행


  • 비즈니스 로직을 어떻게 관리할것인가

코드가 길어지고 많아질수록 체계적으로 일관성 있게 로직을 관리하고 싶었다. 그래서 나는 service layer 방식을 택했다. view는 요청에 맞게 데이터를 가공해서 전송하는 역할을 한다. 나는 view에서는 요청이 들어온 데이터를 받아 요청에 맞게 데이터를 가공해주는 service로 ㄴ데이터를 보낸후 가공된 데이터를 전달 받아 반환하는 역할 만을 할 수 있게 수정하고 싶었다.
그래서 4단계로 나누어 로직을 분리했다

  1. view에서 받은 데이터는 dto라는 곳에서 데이터의 타입을 정해 놓는다
  2. view에서 비즈니스 로직을 service라는 곳으로 옮긴다
  3. view에서 dto를 통해 데이터를 받은 후 요청에 맞게 데이터를 가공할 수 있도록 해당 데이터를 service로 전달한다
  4. service에서 가공된 데이터를 받아 반환해준다

프로젝트의 기능 중 유저가 선택한 언론사와 선택하지 않은 언론사를 불러와 해당 데이터를 프론트에 보내주는 기능이 있다.

view안에서 직접 모든 데이터를 가공하고 있었고 페이징처리에 대한 로직도 함께 들어가 있었다. 당연히 가독성은 좋지 못했다. 네이밍도 press_list, press와 같이 각 변수명이 무엇을 뜻하는지 로직을 읽어보지 않는 이상 유추하기 힘들었다.

# 유저가 선택한 언론사 보여주는 view

class NewsInforEditView(LoginRequiredMixin, View):
    login_url = 'user/login/'
    redirect_field_name='/'
    
    def get(self, request, **kwargs):
        userpress = UserPress.objects.filter(user__pk = request.user.pk).first()
        press_list = Press.objects.all().order_by('name')
        non_press = userpress.non_press.all()
        press = userpress.press.all()
        if non_press is not None:
          non_press = userpress.non_press.all()
          press = userpress.press.all()
        if press is not None:
            press = userpress.press.all()


가장 먼저 네이밍을 좀 더 의미있게 나타내기 위해 몇몇개의 변수를 수정했다. press_list -> presses, press -> presses_in_userpress 네이밍을 보고도 이전보다는 어느정도 유추가 가능해졌다.

DB에 접근해야 되는 로직은 serivces.py에서 메서드로 불러와 가공되어진 데이터를 받아왔다. non_presses = PressService.get_non_presses(userpress) 이렇게 함수로 작성하는 경우 다른곳에서도 해당 함수를 필요할때마다 불러와 사용할 수 있다.

class NewsInforEditPressView(LoginRequiredMixin, View):
    login_url = 'user/login/'
    redirect_field_name='/'
    
    def get(self, request, **kwargs):
        data = self._build_article_pk(request)

        presses = PressService.get_presses()
        non_presses = PressService.get_non_presses(userpress)
        userpress = UserPressService.get_userpress(data.pk)
        presses_in_userpress = PressService.get_presses_in_userpress(userpress)

    def _build_article_pk(self, request):
        return ArticlePkDto(
            pk = request.user.pk
        )


페이징 처리에 대한 로직은 사실 다른 view에서도 필요한 로직이였기 때문에 코드가 중복되는 상황이였다. 아래와 같은 로직이 페이징이 필요한 view마다 존재하고 있었다.

        page = request.GET.get('page','1')
        paginator = Paginator(press_list, 10)
        press_obj = paginator.page(page)
        index = press_obj.number
        max_index = len(paginator.page_range)
        page_size = 10
        current_page = int(index) if index else 1
        start_index = int((current_page - 1) / page_size) * page_size
        end_index = start_index + page_size
        if end_index >= max_index:
            end_index = max_index
        page_range = paginator.page_range[start_index:end_index]
        
        context = context_infor(press_list=press_obj, 
                                page_range=page_range,
                                in_press=press,
                                non_press=non_press,
                                )    
        return render(request, 'infor-edit.html',context)


페이징처리에 대한 로직을 utils라는 곳에 따로 함수로 작성하여 view마다 필요할때 해당 함수를 호출해 사용할 수 있게 처리하여 중복을 최소화 해주었다.


    page_range, press_obj = paging(request.GET.get('page','1'), presses, 10)
    
    context = context_infor(presses=press_obj, 
                            page_range=page_range,
                            in_press=presses_in_userpress,
                            non_press=non_presses,
                            )    
        
    return render(request, 'infor-edit.html',context)


대부분 view의 로직을 위와 같은 형식으로 수정했다. 시간이 꽤 많이 걸렸지만 기존의 코드보다는 확실히 가독성이 좋아진것에 만족스러웠다. 동시에 로직 자체에 대한 효율성을 생각해보게 되었다. 장고에서 제공해주는 페이지네이션을 사용하여 장고 템플릿을 이용해 화면의 페이징 처리를 구현했다. 찾아보니 페이징 처리 방법이 꽤 여러개 존재했다. 이후에 더 좋은 방법을 고려해 로직을 수정해보고 싶다는 생각을 했다


  • 회원가입시 이메일 인증과 재인증 로직에 대한 mixin class 활용

초반에 기능을 구현할때 Mixin을 이용해 상속받아 사용함으로써 이메일 인증과 이메일 재인증 로직의 중복을 최소화 시키는게 목표였다. 그리고 내가 생각한 Mixin의 기능은 아래와 같았다. 인자로 재인증인지 인증인지를 new, again이라는 단어로 구별을 했다

class VerifyEmailMixin():
    def send_verify_email(self, request, dto, form, email):
        try:
            if form.is_valid():
                if email == 'new':
                        user = UserService.create(dto)
                        mail = dto.email

                elif email == 'again':      
                    user = User.objects.get(email=dto.email)
                    resend_email_user = User.objects.get(email=dto.resend_email)
                    
                    if user != resend_email_user:
                        context = context_info(msg='이메일을 확인해주세요 !', error=True)
                        
                        return context

                    user = UserService.update(dto.email, dto.resend_email)
                    mail = dto.resend_email


이후 에러가 존재할 경우 에러메시지를 반환해주었고 에러가 없을 경우 인증/재인증 링크를 이메일로 전송하는 로직을 작성했다.

error = form.non_field_errors()

if error:
    print(error)

    context = context_info(msg=error, error=True)
    
    return context

mail_title, message_data, mail_to = UserEmailVerifyService.verify_email_user(request, user.pk, mail)

send_email.delay(mail_title, message_data, mail_to)


context = context_info(msg='이메일을 인증해 회원가입을 완료하세요!', error=False)

return context

except User.DoesNotExist:
context = context_info(msg='회원가입부터 해주세요', error=True)
            
return context


구현 후 동작에는 문제가 없었지만 이게 mixin이라는 기능을 잘 활용 한 것인가 라는 의문이 강하게 들었다. 이런 생각이 든 후 mixin의 개념에 대해 찾아보았다. mixin은 중복을 최소화하고 클래스를 활용해 확장성을 극대화 시켜주는 역할을 한다. 하지만 내가 짠 로직은 그냥 하나의 함수에 불과해보였다. 구현한 VerifyEmailMixin class를 사용하게 되면 특정 조건을 무조건 거쳐가게 된다. 조건을 거친후에 조건에 해당되는 로직이 동작된다.

mixin에 대한 이해를 제대로 하지 않은 상태로 로직을 구현했구나 라는 생각을 하게 되었다. 지나가듯이 봤었던 기능이였기에 제대로 알아보지 않고 사용한 것 같아 아쉬웠다. 그래서 mixin에 대한 공부를 진행 후 해당 로직을 수정해보기로 결정했다.

mixin class에 4개의 메서드로 로직을 분리했다. 회원가입의 경우 장고에서 제공해주는 form을 사용했다. form의 유효성 검증은 이메일 인증과 이메일 재인증 두개 다 필요하기 때문에 form의 유효성을 검증하는 verify_form 메서드를 생성했다

class VerifyEmailMixin:
    def verify_form(self, form):
        if form.is_valid():
            context = context_info(error=False)

            return context
        
        else:
            error = form.non_field_errors()
            context = context_info(msg=error, error=True)

            return context


email = new 로 인자를 받아서 이메일 인증을 판단하지 않고 이메일 인증의 유효성 검증을 해주는 verify_email 메서드를 추가해주었다.

    def verify_email(self, dto:SignupDto):
        try:
            user = UserService.create(dto)
            mail = dto.email

            return {"user": user, "mail": mail}

        except User.DoesNotExist:
            context = context_info(msg='회원가입부터 해주세요', error=True)

            return JsonResponse(context)


이메일 인증과 마찬가지로 이메일 재인증의 유효성을 검증해주는 verify_resend_email 메서드를 추가해주었다. 이렇게 각각의 메서드로 만들게 되면 해당 메서드만 호출하면 되기 때문에 훨씬 효율적이다

    def verify_resend_email(self, dto:ResendDto):
        try:
            user = User.objects.get(email=dto.email)
            resend_email_user = User.objects.get(email=dto.resend_email)

            if user != resend_email_user:
                context = context_info(msg='이메일을 확인해주세요 !', error=True)

                return JsonResponse(context)
            
            user = UserService.update(dto.email, dto.resend_email)
            mail = dto.resend_email

            return {"user": user, "mail": mail, "error":False}

        except User.DoesNotExist:
            context = context_info(msg="회원가입부터 해주세요", error=True)

            return context


send_user_email 은 유저의 이메일로 인증링크를 전송해주는 메서드이다. 이 메서드는 이메일 인증과 재인증 둘다 필요한 로직이기 때문에 각각의 view에서 해당 메서드를 호출해서 사용하면 된다

    def send_user_email(self, request, mail_info):
        mail_title, message_data, mail_to = UserEmailVerifyService.user_email(request, mail_info["user"].pk, mail_info["mail"])
        
        send_email.delay(mail_title, message_data, mail_to)
    
        context = context_info(msg='이메일을 인증해 회원가입을 완료하세요!', error=False)
        
        return context

이제 이메일 인증과 재인증에 기능이 추가되면 mixin class에 메서드를 추가해주면 간단하게 해결된다 :-) 확실히 view에서 사용하기 편해졌다. 필요한 메서드를 호출만 하면 되기 때문에 훨씬 효율적인다


Project Introduction

매일매일 쏟아지는 뉴스 기사 속에서 내가 원하는 기사의 카테고리를 직접 선택하고 관심 키워드를 직접 입력하여 오로지 기사만을 볼 수 있는 나만의 뉴스공간의 웹/앱사이트 프로젝트입니다

초기 프로젝트 진행 인원 수 : 2명

개발 기간 : v1.0 2021.07.01 ~ 2021.08.08


후기 리팩토링 프로젝트 진행 : 개인

개발 기간 : v2.0 2021.10.26
리팩토링 된 기능 : 서비스 레이어 로직으로 변경

개발 기간 : v2.1 2021.11.30
리팩토링 된 기능 : 이메일 인증/재인증, 비밀번호 찾기 로직

개발 기간 : v2.1 2021.12.07
리팩토링 된 기능 : 이메일 인증/재인증 mixin class

GitHub Respository
Github Repo