2025.7.17: 미니 해커톤 회고록 (2)

jiyongg·2025년 7월 17일

TIL: Today I Learned

목록 보기
2/30

오늘은 어제 쓴 회고록에 이어 두번째 편을 써보고자 한다.

원래는 둘째 날의 이야기부터 시작하고 싶었지만, 어제 첫째 날의 이야기를 마저 마무리 짓지 못했기 때문에 첫째 날의 리팩토링부터 시작해 보고자 한다.

1편은 링크에서 볼 수 있다.

어제 급하게 마무리하느라 못 쓴 이야기가 있었는데, 내가 기능을 구현하는 동안에 파트너 A가 원래 구현할 유저 구현을 끝내서, A가 코멘트도 이어받아 구현해 주었다. 그래서 원래 계획과는 다르게, 영화 관련 메인 페이지와 세부 페이지 기능에 집중하게 되었다.

⚙️ 개발 과정 (이어서)

6. 🪛 리팩토링 및 추가 기능 구현

첫째 날에 저녁을 먹은 뒤쯤에, 리팩토링 및 추가 기능 구현을 시작했다.

현재까지 짠 프로젝트의 코드를 보면서 대략적인 목표를 세워보았다.

  • 페이지네이션을 메인 페이지뿐만 아니라, 검색 페이지, 코멘트, 출연진 정보 등에 대해서도 적용시켜 보고 싶다.
  • detail_url의 하드코딩한 서버 주소를 수정하고 싶다.
  • 이미 데이터베이스에 데이터가 존재하는 상황에서 데이터를 불러왔을 때, 데이터가 중복으로 등록되는 것을 막고 싶다.
  • 데이터베이스에 데이터를 등록할 때, 진행 상황을 알 수 있도록 하고 싶다.
  • 프론트에서 맨 앞에는 인기 영화를 노출시키고, 그 후 ID 순서대로 영화를 노출시키고 싶다고 하여 이를 반영하고자 한다.

다른 곳에 페이지네이션 추가

먼저 시작한 작업은 페이지네이션을 다른 곳에도 추가하는 작업이었다.

다만 메인 페이지와 달리, 페이지네이션을 적용할 때 추가적으로 고려해야 하는 것들이 있었다.

  • 먼저 검색 페이지는, 메인 페이지와 다르게 필터를 적용시켜야 한다. 즉, 메인 페이지와 다르게 모든 영화 (Movie.objects.all())의 정보가 아니라 필터링된 영화들의 정보를 반환해야 한다.
  • 코멘트를 반환하는 뷰는 코멘트 모델의 영화 ID 필드 (movie_id)를 바탕으로 필터링된 코멘트를 반환해야 한다.
    • 코멘트 뷰는 파트너 A가 작성한 뷰라서, A의 기분이 나쁘지 않게 최대한 기존 코드를 유지하고 싶다.
  • 출연진 정보만을 담당해서 반환하는 뷰가 있는 것이 아니기 때문에, 위의 두 상황보다 구현이 복잡해진다.
검색 페이지: list 메소드 오버라이딩

ListAPIView가 상속하는 ListModelMixin에서 list 메소드는 아래와 같이 정의되어 있다.

def list(self, request, *args, **kwargs):
    queryset = self.filter_queryset(self.get_queryset())

    page = self.paginate_queryset(queryset)
    if page is not None:
        serializer = self.get_serializer(page, many=True)
        return self.get_paginated_response(serializer.data)

    serializer = self.get_serializer(queryset, many=True)
    return Response(serializer.data)
  • 클래스에서 지정된 queryset을 가지고, filter_queryset을 실행한 결과를 queryset으로 지정한다.
  • queryset을 가지고 페이지네이션을 실행(paginate_queryset)하여 페이지네이션된 결과를 반환한다.

나의 상황에서는 paginate_queryset에 들어갈 querysetMovie.objects.filter(title_kor__icontains=keyword이어야 했다. 그래서 검색을 담당하는 MovieSearch 뷰의 list를 오버라이딩해서 아래와 같이 수정했다.

def list(self, request, *args, **kwargs):
    queryset = self.filter_queryset(self.get_queryset())
    keyword = request.query_params.get('title', '')
    queryset = queryset.filter(title_kor__icontains=keyword)

    page = self.paginate_queryset(queryset)
    if page is not None:
        serializer = self.get_serializer(page, many=True)
        return self.get_paginated_response(serializer.data)

    serializer = self.get_serializer(queryset, many=True)
    return Response(serializer.data)
  • 일단 클래스의 querysetMovie.objects.all()로 둔다.
  • get이 호출하는 list에서 filter_queryset 메소드를 실행시킨다.
  • 그 후, keyword에 이름이 title인 Query Parameter를 넣고
  • keyword를 바탕으로 영화를 필터링한 결과를 queryset으로 지정한다.
  • 이후의 로직(페이지네이션, 결과 반환)은 기존 list의 로직과 동일하다.
코멘트: 기존 코드를 최대한 유지하며 메소드 수정

코멘트의 경우는 영화 ID를 바탕으로 그에 해당하는 코멘트들을 가져와야 하고, 영화 검색과 다르게 Path Parameter로 영화 ID가 주어진다는 점을 고려해야 했다.

또한, 파트너 A가 짠 코드이기에, 최대한 기존 코드를 유지하며 필요한 페이지네이션을 추가하고 싶었다.

그래서 ListAPIViewCreateAPIView 또는 ListCreateAPIView를 상속하기보다는, GenericAPIView를 상속해서 기존 코드를 최대한 유지하는 방법을 시도해 보았다.

그리고 이 상황에서는 오히려 ListCreateAPIView를 상속하고 get, post, list, create를 오버라이딩하는 것보다는, GenericAPIView를 상속한 상황에서 getpost만을 구현하는 쪽이 덜 지저분할 것 같다는 생각이 들었다.

그래서 post는 A의 코드를 그대로 유지하고, get에 약간의 페이지네이션 기능만을 추가해 보았다. 페이지네이션 기능은 위의 ListModelMixinlist에 있던 paginated_queryset부터 결과 반환 부분까지를 그대로 옮겼다.

최종적인 코드는 아래와 같다.

def get(self, request, movie_id):
    try:
        movie = models.Movie.objects.get(id=movie_id)
        comment = models.Comment.objects.filter(movie_id=movie)

        page = self.paginate_queryset(comment)

        if page is not None:
            serializer = serializers.CommentResponseSerializer(page, many=True)
            return self.get_paginated_response(serializer.data)

        serializer = serializers.CommentResponseSerializer(comment, many=True)
        return Response(serializer.data)
    except models.Movie.DoesNotExist:
        return Response(status=status.HTTP_404_NOT_FOUND)

사실, try의 범위를 수정하고 싶다는 생각은 들었는데, A가 만든 구조를 최대한 유지하고자 그냥 수정하지 않았다.

출연진: 구현 포기

나의 상황과 유사한 스택 오버플로우 질문을 찾아보았는데, 사실 예전에는 PaginationSerializer가 있었다고 한다. 하지만 DRF 최신 버전에는 이것이 없어졌기 때문에, 직접 페이지네이션 클래스의 get_paginated_response 메소드를 오버라이드 해야 한다고 한다.

그래서 이와 관련된 방법들을 찾아 따라해봤는데 잘 되지 않았고, 몇시간 뒤면 첫째 날이 끝나버리기 때문에, 아쉬움을 뒤로 하고 정말 급한 불부터 끄자는 생각으로 이 부분은 구현을 포기했다.

detail_url 수정

detail_urlSerializerMethodField로 되어 있었고, get_detail_url은 다음과 같았다.

def get_detail_url(self, obj):
    return f'http://localhost:8000/movies/{obj.id}/'
    request = self.context['request']

위에서 얘기한 급한 불이 바로 이거였다.

보다시피, 서버 주소를 직접 하드코딩한 형태였기 때문에 서버 주소가 변하는 등의 상황에 대처하기 어렵게 된다.

그래서, 이 부분을 수정하기 위해 다시 DRF의 reverse를 사용하고자 하였다.

하지만 역시나 이번에도 NoReverseMatch 오류를 만나며 막막한 상황이었는데, 이번에는 reverse를 꼭 사용하고 싶어 검색해보니

https://xfrnk2.github.io/django/django_url_name_not_found/

이런 글을 발견했는데, 글의 내용은 urls.py에 어떤 앱의 URL들을 include 했을 때, reverse('app_name:url') 식으로 써야 한다는 것이었다.

블로그의 상황과 현재 나의 상황이 같았기 때문에, 나에게도 적용할 수 있을 것 같다고 생각했고, 실제로 적용해보니 reverse가 무사히 작동했다!

혹시 싶어서 HyperLinkedIdentityFieldview_name에도 이 방법을 적용시켜 봤지만 Could not resolve URL for hyperlinked relationship을 만나며 실패했다.

그래도, reverse를 사용하여 서버 주소의 하드코딩을 벗어난 것이 나에게는 이미 큰 성과였기 때문에, HyperLinkedIdentityField는 쿨하게 보내줬다.

결국, 리팩토링 후 아래와 같은 코드가 되었다.

def get_detail_url(self, obj):
    request = self.context['request']
    return reverse('movies:movie-detail', kwargs={'movie_id': obj.id}, request=request)

이전과 달리 서버 주소가 바뀌어도 걱정할 필요가 없게 되었다!

데이터베이스의 중복 방지

우리의 서비스에서는 가장 먼저 외부의 영화 데이터를 데이터베이스에 등록시키는 작업이 필요했다.

나는 이 부분을 init_db라는 함수를 작성했고, Django에서 제공하는 shell을 실행시킨 후 init_db를 실행시키면 데이터 등록 작업이 진행되게끔 하였다.

그런데, 이미 데이터가 등록된 상태에서 다시 init_db를 실행한다면, 같은 데이터가 또 등록되는 일이 벌어지고 말 것이다.

그래서, 이를 방지하기 위해 init_db를 수정할 필요성이 있었다.

exists()

QuerySet에는 exists라는 메소드가 존재한다. 이 메소드는, 해당 QuerySet에 데이터가 하나라도 존재한다면 True를 반환하고 그렇지 않다면 False를 반환한다.

따라서 이것을 이용한다면 데이터베이스에 이미 데이터가 등록되어 있는지 아닌지를 알 수 있게 된다.

init_db의 맨 앞에 다음과 같은 코드를 추가하였다.

if models.Movie.objects.exists():
    print('DB is already initialized!')
    return

데이터베이스 등록 진행 상황 표시

이 부분은 create 메소드 바로 다음 줄에 print로 인스턴스가 생성되었고, 어떤 인스턴스인지 알려주는 문자열을 출력하게끔 작성했다.

메인 페이지의 뷰 변경

프론트엔드로부터 맨 앞에 인기 영화가 보여지고, 더보기를 눌렀을 때, ID순으로 영화가 보여지게 하는 방식으로 구현해달라는 부탁을 받았다.

현재 메인 페이지는 단순히 ID순으로 10개씩 페이지네이션이 되어 있는데, 요구 사항에 맞추기 위해선 메인 페이지의 뷰를 바꾸어야 했다.

그런데 이걸 어떻게 구현해야 할까?

기준을 뭘로 하지?

그런데, 인기 영화의 기준이 뭘까? 제공받은 데이터에는 예매율이나 순위와 같은 정보는 없기 때문에, 인기 영화의 기준을 설정할 필요성이 있었다. 주어진 정보 중 그나마 인기와 연관이 있을 만한 정보가 평점이어서, 평점(rate)을 기준으로 잡아 보았다.

페이지네이션의 1페이지만 쿼리셋을 다르게 하기?

가장 먼저 떠오른 생각은 페이지네이션의 1페이지만 쿼리셋을 다르게 하는 방법이 있을까? 였다.

이와 관련된 정보를 찾아보고, 페이지네이터와 Generic View의 코드를 보았지만, 어떻게 하면 좋을지 도무지 감이 오지 않아서 다른 방법을 생각해 보았다.

paginated라는 Query Parameter 사용하기

그다음 떠오른 방법은 Query Parameter를 이용하는 방법이었다.

QuerySet에서 order_by 메소드를 이용하면 특정 기준으로 데이터들을 정렬할 수 있다. order_by('field1', 'field2', ...)으로 사용하는데, 필드 이름 앞에 -를 붙이면 내림차순 정렬을 할 수 있다. 그리고 QuerySet은 슬라이싱이 가능하기 때문에, 이 세가지 사실을 조합하여 가장 인기 있는 영화 10개를 반환하는 것이 가능할 것이라 생각했다.

paginated라는 Query Parameter를 받아서, paginated가 없을 때에는 querysetMovie.objects.order_by('-rate')[:10]으로 사용하고, paginated가 있을 때에는 querysetMovie.objects.all()로 사용하는 방법을 생각해 보았다.

  • Request.query_params에서 'paginated'를 키로 하고 기본값으로 ''을 주어 get을 실행한다.
  • paginated가 없다면 ''(False)이 될 것이고, 있다면 True가 될 것이다.
  • if문으로 paginated의 값에 따라 queryset을 다르게 사용한다.

정확한 코드는 내가 커밋하지 않고 아래의 방법으로 넘어가서 잘 기억나지 않지만, 대략 다음과 같은 코드였던 것 같다.

def list(self, request, *args, **kwargs):
    queryset = self.filter_queryset(self.get_queryset())
    paginated = request.query_params.get('paginated', '')
    
    if not paginated:
        queryset = queryset.order_by('-rate')[:10]
    
    page = self.paginate_queryset(queryset)
    if page is not None:
        serializer = self.get_serializer(page, many=True)
        return self.get_paginated_response(serializer.data)

    serializer = self.get_serializer(queryset, many=True)
    return Response(serializer.data)

보다시피 paginated Query Parameter가 없는 경우 queryset이 바뀌게 구현했다.

엔드포인트 분리 및 뷰 분리

하지만 위의 방법은 뭔가 찝찝함이 있었다.

진짜로 페이지네이션의 로직을 바꾼 것보다는, 쿼리셋을 바꾼 방식이었기 때문이다.

그래서 운영진에게 질문해보니 그냥 차라리 엔드포인트와 뷰를 분리하는 게 좋지 않겠냐는 답을 얻었다.

생각해보니, 어차피 메인에서 인기 영화 10개를 보여주고 더보기를 클릭했을 때 ID 순서대로 10개씩 보여줄 거라면, 엔드포인트를 분리하는 것도 나쁘진 않겠다고 생각했다.

그래서, 엔드포인트를 분리하고 뷰를 분리했다.

class MovieList(generics.ListAPIView):
    '''
    모든 영화 목록을 조회
    '''
    queryset = models.Movie.objects.all()
    serializer_class = serializers.MovieListResponseSerializer

class MovieListofTopTen(generics.ListAPIView):
    '''
    메인 페이지용 api로, 영화를 인기순으로 10개를 보여줌
    '''
    queryset = models.Movie.objects.order_by('-rate')[:10]
    serializer_class = serializers.MovieListResponseSerializer
    pagination_class = None

이렇게 작성하니 확실히 위 코드보다 깔끔해진 느낌이었다.

이렇게 리팩토링과 추가 구현 작업을 마치니 1일차 종료 시간이 되어 숙소로 가서 휴식을 취했다.

7. 📰 API 명세 작성

이제 백엔드에게 남은 것은 API 명세 작성과 배포 뿐이었다.

2일차가 되고, API 명세를 작성하는 일을 시작했다.

프론트엔드쪽에서 백엔드와 연결하려면 API 명세가 필요하다고 하였다. 마침 미니 해커톤 며칠전에 OpenAPI Specification에 대해 간단히 공부해 두었다. 그래서 이번에 이걸 활용해 볼 수 있지 않을까?라는 생각이 들었다. 나는 파트너 A에게 내가 API 명세 작성을 맡을테니 배포를 맡아달라고 부탁하고, API 명세를 작성하는 작업을 시작하였다.

Swagger Editor를 이용한 노가다

미리 다운로드 받은 Swagger Editor를 VS의 Live Server를 이용해서 실행하고, 거기에서 노가다로 직접 명세를 작성하였다.

사실 API 명세는 자동화된 툴로 작성한다는 내용을 보았었고, drf-spectacular의 존재를 알고 있기도 했다.

하지만, 내가 아직 drf-spectacular에 대해 정리해 본 적이 없어서 그냥 노가다로 작성하자고 생각하고 에디터에서 제공되는 몇가지 삽입 기능을 제외하면 나머지는 직접 작성했다.

그리고 작성한 명세를 yaml로 저장한 후, 내가 가지고 있던 Swagger UI의 html 파일과 함께, 열람법을 알려주며 전달하였다.

미리 drf-spectacular를 공부해 두었다면, 이런 노가다는 하지 않았어도 되었을 것이고, 서버 내에 명세도 같이 제공되어서 굳이 yaml을 전달할 필요도 없었을 텐데라는 아쉬움이 들었다.

8. 🛜 배포

세미콜론 하나에 식은땀 흘린 이야기

파트너 A의 EC2 인스턴스를 이용해 서버 배포를 하며 https 인증서를 얻기 위해 Let's Encrypt와 Certbot을 사용했는데 자꾸 Some challenges have failed라는 오류가 뜨며 인증서를 얻지 못하는 상황이 있었다.

파트너 A와 나는 도대체 무엇이 잘못된 것인지, nginx 구성과 Docker Compose 구성, init-letsencrypt.sh를 살펴보고, 기존 세션 자료도 몇시간째 살펴보았지만 답을 쉽사리 찾지 못했다.

그러던 중 파트너 A가 드디어 원인을 찾았다. 그건 바로 server_name Directive의 끝에 세미콜론이 빠져 있었다는 것이다.

nginx에서 어떤 Directive의 끝에 세미콜론이 빠져있으면 구문 오류가 난다고 한다.

그래서 이것 때문에 nginx의 구성이 제대로 로딩되지 못해 이러한 오류가 발생한 것 같다.

원인을 알게 되니 참으로 허탈했지만, 빠진 세미콜론은 쉽게 눈에 보이진 않았으니, 운이 안 좋았다며 서로를 위로했다.

ContainerConfig 오류

세미콜론 해프닝이 끝나고 인증서를 얻고 서버를 실행시키려 하는데, 이번엔 ContainerConfig 오류가 뜨던 것이다. 이걸 본 나는 Container가 들어가 있으니 Docker의 오류일 것 같다고 판단했다. 그래서 파트너에게 docker-compose ps를 시켜보니, 컨테이너 이름이 이상하게 나타나는 것이었다. 그래서 파트너에게 도커 이미지를 prune (docker image prune)하고 다시 이미지를 생성해보자고 제안했다.

정말로 도커 이미지를 prune하고 다시 이미지를 생성하고 서버를 실행시키니 잘 동작하였다.

이렇게 우리는 백엔드 서버 배포에 성공했다. 🥳

💬 소감

이틀동안 미니 해커톤을 진행하는 일은 꽤 힘든 일이긴 했지만, 보람찬 일이기도 했다. 그리고 코드를 작성하고, 포스트맨으로 요청을 보내고 돌아오는 응답을 보는 일이 재밌고 흥미로웠다. 프론트엔드를 위해 API 명세를 직접 작성해서 공유하는 것도 한 번 정도는 해볼 만 한 경험인 것 같다. 다음부턴 그냥 drf-spectacular를 쓸 것이다.

누구와 협업해본 경험이 처음이었고, 나도 이런 서비스를 만들어본 경험이 없었기 때문에, 협업을 경험하면서도 실사용 가능한 서비스를 만들어 볼 수 있는 소중한 행사였다. 완벽하지는 않지만 서비스 하나를 만들어 냈다는 점에서 뿌듯함을 느끼기도 하였다.

그리고, 공부한 내용이 이렇게 녹아들어가는구나 라는 것을 느낄 수 있었고, 실전의 과제를 해결하며 실무 능력과 프레임워크의 숙련도를 더 높일 수 있었던 것 같다.

하지만 아쉬운 점도 있었다.

  • git rebase를 이용해서 히스토리를 깔끔하게 정리하고 싶었다. 초반부에는 그래도 좀 활용해봤는데, rebase를 완전히 이해한 상태는 아니어서 히스토리가 역으로 망가지면 어쩌지라는 생각에 잘 활용하지 못했다.
  • 배포는 아무래도 EC2를 이용해야 하고, 다른 것에 비해 과정이 복잡하다 보니까 실습하지 않고 내용 정리정도만 해뒀는데, 이 실습 부족이 이번에 배포할 때 약간 발목을 잡은 것 같다.
  • API의 URI들을 리팩토링하고 싶었는데, 남아 있는 시간이 그걸 허락해주지 않았다. 조금더 RestFUL한 URI를 구성하고 싶었는데 말이다.
  • 2일차 새벽부터 컨디션이 좋지 않아서, 제일 어려운 배포를 맡고 있는 파트너를 잘 케어해주지 못한 것 같다. 약간 정신줄도 나가버려서 하고 싶은 대로 해보라고 해버렸다... 세세하게 신경을 잘 써줬으면 좋았을텐데 말이다.

사실 미니 해커톤은 아직 끝나지 않았다. 이틀 간 서비스가 완성되지 않았다. 아직 프론트엔드와 백엔드의 연결 작업이 남아 있고, 이 과정에서 해결해야 할 오류들이 있을 것으로 예상된다.

그렇지만, 이 오류들을 해결하는 과정에서 더 성장할 수 있을테니, 너무 겁먹지 않고 나의 파트너 A와 함께 차근차근 해내고자 한다.

profile
그냥 쓰고 싶은 것 쓰는 개발(?) 블로그

0개의 댓글