나는 이번 년도에 멋쟁이사자처럼 대학 흔히 줄여서 멋사라고 하는 대외활동에 참여하고 있다.
그리고 오늘부터 TIL을 쓰는 활동에도 참가하게 되었다.
이틀 전에 우리 학교 멋사에서 미니 해커톤이 이틀 간 열렸는데, 이때의 개발 과정과 소감을 TIL의 첫번째 주제로 써보고자 한다.
회고록은 총 이틀을 하루씩 나누어서 쓰고자 한다. 원래 한 번에 쓰고 싶었지만, 어제부터 몸의 컨디션이 좋지 않아서 불가피하게 쪼개게 되었다. 너무 하얗게 불태운걸까.. 😿
이번 글에서는 미니 해커톤을 준비하는 과정과 첫날의 이야기를 써보고자 한다.
그동안은 개발을 독학했었다. 그러다가, 올해부터는 다른 개발자들과 교류를 좀 해야겠다는 마음을 먹고 멋사에 가입했다. 그런 나에게 미니 해커톤은 멋사에서 첫번째로 맞이한 프로그래밍 행사였다.
멋사의 미니 해커톤은 교내 개발자들이 이틀 간 같은 장소에 모여서, 미리 지정된 팀원들과 협력하며 하나의 프로젝트를 완성하는 행사였다.
실제 해커톤보다 시간도 훨씬 짧고 규모도 작은 행사이지만, 나에게 있어서는 그동안 공부한 내용을 활용해보고 협업을 경험해볼 수 있는 뜻깊은 기회였다.
1학기 세션이 끝나고, 미니 해커톤이 열리기 전까지 세션 내용을 복습하며 최대한 정리하고, 다양한 자료를 찾아가며 Django와 DRF의 숙련도를 올리고자 하였다.
특히 DRF는 세션 내용 이외에도 튜토리얼을 따라가며 실습을 진행해보았고, 유용한 정보들도 같이 정리해 두었다.
혹시 이 글을 보고 있는 DRF 뉴비가 있다면, DRF 튜토리얼은 DRF의 전체적인 구조와 흐름을 파악하는 데에 도움이 될 것이라고 생각한다.
어쨌든, 내가 사용할 프레임워크에 대한 숙련도를 올려두어야 이틀이라는 빠듯한 시간 내에 프로젝트를 완성할 수 있을 것 같았고, 민폐가 되지 않을 것 같다고 느꼈다.
프론트엔드가 리액트를 사용하는 것으로 알고 있었다. 그래서 백엔드 입장에서 어떤 점을 알아두면 좋을까? 라는 관점으로 프론트엔드에 대해서도 아주 간략하게 공부해 보았다.
공부하면서 정리한 내용은, 리액트가 어떻게 동작하는 지 전반적인 구조와 배포한 리액트 앱을 어떻게 서버에서 배포해야 하는가?였다.
이 과정에서 알게 된 것을 정리해보면 다음과 같다.
index.html 페이지 위에서 상황에 따라 필요한 컴포넌트(html의 element를 자바스크립트로 작성한 UI 요소)를 불러오는 방식으로 동작한다.try_files: React는 index.html 페이지 위에서만 동작한다. 라우팅 상황에서 파일이 존재하지 않는 경로로 요청되어 404 오류가 날 수 있다. 그래서 이 Directive로 기본적으로 index.html에서 동작하되, URI에 해당하는 파일이 실제로 존재(이미지, js 등)하면 그것은 불러올 수 있게끔 구성해주어야 한다.이번 미니 해커톤의 주제는 영화 리뷰 사이트 만들기였다.
그리고 주제와 함께 필수 구현 사항과 선택 구현 사항이 적혀 있었다.
대략 필수 구현 사항을 정리하면
이정도가 있었다.
그러면 1일차 개발 과정을 전반적으로 정리해보고자 한다.
일단 당연히, 팀으로 하는 프로젝트니 분업을 해야 했다.
나는 백엔드였는데, 백엔드는 나와 다른 개발자 한명으로 구성되었다.
일단 이번 해커톤에 대한 전반적인 소개가 끝난 후 가장 먼저 역할을 분담하였다.
사실, 아침에 구현 사항들을 보았을 때에는 분담해야 할 정도인가? 싶었는데 막상 이틀 간 진행해보니 꽤나 빠듯하다는 것을 느낄 수 있었다.
여기서부터 신상 보호를 위해 내 백엔드 파트너를 A라고 칭하겠다.
우리는 다음과 같이 분담을 진행했다.
초기엔 이렇게 분담했는데, 하다보니 저 계획대로 진행되진 않았다..
일단 무턱대고 모델을 만드는 것보다는 먼저 전체적인 DB 관계를 설계하고 진행하는 편이 더 효율적으로 DB를 만들 수 있을 것 같았다.
DB 설계에는 ERD Cloud를 이용하였다.
DB를 설계하고 각 엔티티를 연결하면서 Non-identifying Relationship과 Identifying Relationship이라는 단어를 수도 없이 마주쳤는데, 이게 뭔지는 정확히 모르겠어서 나중에 한 번 자세히 알아봐야겠다라고 생각했다.
그리고 이 과정에서 꽤나 오랜 시간이 걸렸다.
어떻게 하면 조금 더 효율적이면서 만족할 수 있는 구조를 짤 수 있을까?라는 질문에 부딪히고 만 것이다.
먼저, 처음에 떠올린 방법은 영화(Movie), 코멘트(Comment), 유저(User), 인물(Person), 감독(Director) 5개의 엔티티를 만들고, N:M 관계인 영화와 인물을 매개해주는 Cast 엔티티를 통해 영화와 인물을 그곳에 담아 영화와 1:N으로 연결하는 방법이었다.
이렇게 설계한 이유는 기존 데이터를 고려하면서도 특수 상황을 고려했기 때문이다. 일단, 기존 데이터가 감독과 배우를 구분하고 있었다. 그렇지만, 특수 상황도 고려하고자 했다. 가끔 감독도 하고 배우도 하거나 본인이 연출하고 출연하는 멀티 플레이어들이 있지 않은가.
그리고 이렇게 하면 인물에 대한 정보가 따로 모여 있기 때문에 추후 서비스를 확장해서 인물의 정보를 보는 페이지를 만들거나 하는 경우에 좋을 것이라 생각하였다.
하지만 이렇게 설계했을 때, 좀 찝찝한 부분이 있었다.
Movie)와 인물(Person) 간에 묶을 수 있는 관계는 출연(Cast)으로, Cast는 출연 정보를 담게 된다. 그런데, 출연 ID라는 말이 좀 이상하게 들렸다.Cast라는 매개를 통해 연결되어 있으니, 이 부분이 매끄럽지 못하다고 느껴졌다. 그래서 이 케이스에 대해서 운영진에게 질문을 하였고, 아래와 같이 조언해 주었다.
Person을 만드는 것은 좋은 아이디어긴 하다.그래서 이 조언을 참고로 설계를 고쳐보았다.
그래서 수정된 설계는 영화(Movie), 코멘트(Comment), 유저(User), 출연(Cast)의 4개 엔티티를 사용하는 방식이었다.
기존과 달리 감독과 배우를 구분하지 않고, 출연에 감독과 배우 정보를 모두 넣는 식으로 설계했다.
기존 데이터의 정보를 보면 각 배우들은 배우진이라는 한 배열 내에 배우 정보와 영화 내 배역 정보가 있고 각 감독은 영화 객체 내에 영화 정보와 함께 감독 이름과 감독의 프로필 사진 정보가 있었다.
여기에서 배우 정보와 감독의 정보 구성이 모두 이름과 프로필 사진으로 구성되어 있었다.
그래서 이 점을 착안해서, Cast에 이름, 프로필 사진, 역할 필드를 넣어서 감독의 경우 역할을 감독으로, 배우의 경우 배우가 맡은 배역을 넣는 식으로 하면 되지 않을까 생각했다.
그리고, 이 Cast와 영화(Movie)의 관계를 N:1로 설계하는 식으로 처리하였다.
물론, 이렇게 하면 기존의 영화 정보에 같이 들어 있는 감독 정보와 영화 정보의 배우진 내에 있는 각 배우들 정보를 처리하는 작업이 필요하게 되지만, 그 작업은 충분히 구현할 수 있을 것이라고 생각했다.
그리고 이정도면 충분히 사용 가능한 DB 설계라고 생각했기에, 다음 과정으로 진행하였다.
설계한 DB를 바탕으로 모델을 작성하였다.
DB 설계에 좀 애를 먹긴 했지만, 확실히 미리 설계한 DB를 바탕으로 모델을 작성하니 작업이 수월했다. 설계한 DB의 필드를 Django 쪽의 모델 필드로 옮겨주기만 하면 되어서 금방 할 수 있었다.
만약 설계 없이 모델을 작성하려 했다면 아마 수없는 makemigrations와 migrate의 반복에, 어쩌면 DB를 날리고 다시 migrate해야 하는 번거로움까지 생기지 않았을까?
DB를 미리 설계하는 것의 중요성을 여기에서 느낄 수 있었다.
시리얼라이저는 세션 때 공부한 내용을 바탕으로 작성하였다.
기본적으로 응답과 요청에 대한 시리얼라이저를 분리하고, 필요한 필드가 다른 시리얼라이저는 분리하는 식으로 작성했다.
HyperLinkedModelSerializer와의 싸움메인 페이지에서 어떤 영화를 클릭하면 그 영화의 세부 정보 페이지로 이동되어야 하는 구현 사항이 있었다.
백엔드에서 세부 정보로 가는 url(detail_url)을 제공하면, 프론트 쪽에서 a를 이용해서 각 영화를 클릭했을 때 그 세부 정보 url로 이동되는 구조가 될 것 같다는 생각이 들었다.
위의 튜토리얼에는 HyperLinkedModelSerializer에 대한 내용이 있었는데 딱 지금이 이 시리얼라이저를 써보기 좋은 상황이다! 라고 느꼈다.
메인 페이지용 시리얼라이저(MovieListResponseSerializer)가 HyperLinkedModelSerializer를 상속하고, HyperLinkedIdentityField를 이용해서 세부 정보에 대한 URL을 그 detail_url에 담으면 되지 않을까? 라고 생각했다.
하지만, 누구나 그럴싸한 계획을 가지고 있다는 그 말처럼, 실제로 그 계획을 코드로 옮겨보려 하니 자꾸 오류가 발생하고 말았다.
오류는 Could not resolve URL for hyperlinked relationship이었던 것으로 기억한다.
몇 번이고 튜토리얼을 정리한 내용을 다시 보고, 스택 오버플로우를 뒤져보고, HyperLinkedModelSerializer와 HyperLinkedIdentityField에 대한 설명을 다시 읽어봤지만, 대체 이 오류가 발생하는지 감을 잡지 못했다.
앞에서 DB 설계하느라 시간을 좀 써버린 바람에, 오류 내용 내에 relationship이라는 단어가 있는 것을 보니 HyperLinkedModelSerializer는 관계가 있을 때 사용하는 시리얼라이저인가보다 생각하고 다른 방법으로의 전환을 시도했다.
HyperLinkedModelSerializer의 사용법에 대해서 더 자세히 공부할 필요성이 있겠다고 느꼈다.
그래서, 생각한 것이 detail_url 필드를 SerializerMethodField로 구성하고, get_detail_url에서 reverse라는 함수를 사용하는 방식이었다. 이 함수는 DRF에 있는 함수로, 상대적인 URL을 절대적인 URL로 바꿔서 반환해주는 함수이다.
공식 문서의 설명에 따라 URL 필드에 reverse를 적용시켜보았는데, 오류가 나는 것이다. 점점 시간은 촉박해지고, 이틀까지 어떻게든 완성해야 한다는 생각에 쫓기며 이 방법도 포기하고 말았다. (하지만, 리팩토링을 하면서 이 방식을 적용시키는 데에 성공했는데, 그건 리팩토링 부분에서 다루겠다.)
결국, detail_url을 고정된 서버 부분 문자열과 영화 id를 합친 문자열로 처리하는 방법을 선택하였다. 물론 알고 있었다. 이건 절대 좋은 방법이 아니라는 것을. 서버의 주소가 바뀌면 이를 반영하지 못하기 때문이다. 하지만 일단 API를 어떻게든 완성시키고, 나중에 시간이 나면 리팩토링해야겠다고 생각했다.
그래서 결국 이러한 코드가 되었다.
detail_url = serializers.SerializerMethodField()
def get_detail_url(self, obj):
return f'http://localhost:8000/movies/{obj.id}/'
이제 시리얼라이저를 작성했으니 기능을 구현하여야 했다.
어느 정도 가이드가 있어서 그 가이드를 보면서 기능들을 구현했다.
파트너 A도 기능을 구현하느라 고생이 많았지만, 일단 이 글은 나의 회고록이다 보니 내가 구현한 기능들에 대해서만 쓰고자 한다.
일단 외부 URL에서 영화 데이터를 불러와서 DB에 저장하는 작업부터 시작했다.
외부 URL에서 데이터를 불러오는 부분은 가이드가 있어 그 부분을 보며 따라했다.
Movie 구조위에서 언급했듯 원본 데이터의 구조는 우리 서비스의 Movie 모델의 구조와 달랐다.
원본 데이터에는 영화 정보와 감독 정보가 같이 movies라는 객체 내에 있고, 배우의 정보는 movies 내에 있는 actors라는 배열의 항목들이었다.
그래서 이 점을 고려해서 처리할 필요성이 있었다.
matches라는 딕셔너리를 만들어 원본 데이터와 영화(Movie) 모델 내의 필드명을 매칭시켜주는 작업을 진행하였다. 이때 감독에 대한 부분(director_name)과 (director_image_url)을 배우 정보 객체의 키와 맞춰서 매칭시켜 줬다. 이렇게 하면 추후 Cast에 감독의 데이터를 담기 용이해진다.data 딕셔너리를 만들어,matches에 따라 데이터를 넣었다. 그런데 평점과 상영 시간은 각각 float, int로 처리할 필요성이 있었다. 그래서 조건문을 이용해 평점과 상영 시간의 데이터 타입을 알맞게 바꿔서 넣어줬다.data에 들어있게 되는데, 이 부분은 Cast에 분리해서 담을 것이므로 딕셔너리에서 추출할 것이다. 그 전에 미리 감독의 정보를 담을 딕셔너리 (director_info)를 하나 만들어 character라는 키에 '감독'을 매핑시켜주는데, 이는 배우 정보 객체의 character라는 키에 배역이 있어, 이에 맞춰 감독 정보에 character 키를 추가하고 거기에 감독이라는 값을 줌으로써 배우 정보와 데이터 구조를 통일시켜준 것이다.data에서 감독의 정보를 뽑아낸다.data에서 director_info를 리스트에 넣고 data에서 actors에 해당하는 부분을 추출해 둘을 서로 합쳐준다. director_info라는 딕셔너리가 들어간 1차원 리스트와 배우진에 대한 정보 딕셔너리들이 들어 있는 1차원 리스트를 합치기 때문에, casts는 감독 정보가 0번 인덱스이고, 나머지 인덱스들에 배우 각각의 정보가 들어 있는 리스트가 된다.data를 바탕으로 영화(Movie) 정보를 DB에 저장한다.casts를 순회하여 출연(Cast) 정보를 DB에 저장한다.아마 줄글로 보면 정신이 아득해지겠지만, 아래와 같은 코드를 작성했다.
# json 내 key와 영화 모델의 key 매핑 딕셔너리
matches = {
'title_kor': 'title_kor',
'title_eng': 'title_ori',
'poster_url': 'poster_url',
'release_date': 'release_date',
'rating': 'rate',
'genre': 'genre',
'showtime': 'showtime',
'plot': 'plot',
'actors': 'actors',
# 감독을 미리 cast로 넣기 위한 처리
'director_name': 'name',
'director_image_url': 'image_url',
}
# 추가할 데이터 정보 담는 딕셔너리
data = dict()
for key in movie.keys():
if key == 'rating': # float 타입 rating 처리
data[matches[key]] = float(movie.get(key, ''))
elif key == 'showtime': # int 타입 showtime 처리
data[matches[key]] = int(movie.get(key, ''))
else: # 나머지 데이터 처리
data[matches[key]] = movie.get(key, '')
director_info = {'character': '감독'}
for key in ('name', 'image_url'):
director_info[key] = data.pop(key)
casts = [director_info] + data.pop('actors')
instance = models.Movie.objects.create(**data)
for cast in casts:
cast_info = dict()
cast_info['name'] = cast.get('name', '')
cast_info['profile_url'] = cast.get('image_url', '')
cast_info['role'] = cast.get('character', '')
cast_info['movie_id'] = instance
cast = models.Cast.objects.create(**cast_info)
페이지네이션은 데이터가 많을 때, 페이지를 나누어서 한 페이지에 일정 개수를 보여주고 다음 페이지에 이어서 일정 개수를 보여주고... 를 하는 방식이다.
DRF의 튜토리얼에 이 페이지네이션에 대한 설명이 있었어서 페이지네이션은 어렵지 않게 구현할 수 있었다. 운이 좋았다.
페이지네이션을 하려면 DRF의 Generic View를 이용하고 DRF의 설정에서 페이지 수를 지정해주면 된다.
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10
}
이제 검색 기능을 구현하여야 했는데, 검색 기능은 Django의 Field Lookup을 이용해서 구현하면 될 것 같다고 생각했다.
Query Parameter로 검색하고 싶은 내용을 받아서, 그 내용이 한국어 제목에 포함되어 있는 영화들을 필터링한 정보를 반환하는 식으로 구현했다.
class MovieSearch(APIView):
'''
한국어 제목을 바탕으로 영화 검색. 영화 제목 내 검색어 포함을 기준으로 검색됨
'''
def get(self, request):
keyword = request.query_params.get('title', '')
movie = models.Movie.objects.filter(title_kor__icontains=keyword)
serializer = serializers.MovieListResponseSerializer(movie, many=True)
return Response(serializer.data)
Request에는 query_params가 있어 Query Parameter를 불러올 수 있다.필드명__조건으로 구성되는데, icontains는 대소문자 구분하지 않고 포함되는 경우를 의미한다.사실, 1일차에 리팩토링도 했다. 그런데, TIL 활동에는 자정 전에 TIL을 제출하지 못하면 벌금을 내는 무시무시한 조건이 있다! 이 글을 쓰고 있는 지금, 시간이 매우 아슬아슬해서 리팩토링은 다음 글에서 2일차 내용과 같이 다루도록 하겠다.
다음 글에는 리팩토링, API 명세, 배포 중 있었던 해프닝에 대해서 정리해보고자 한다.