왓챠피디아 클론 WatchB 개발기: 영화 데이터 수집 (백엔드 3-1편)

mynghn·2022년 10월 13일
1

지난 포스팅 이후 시간이 꽤 흘렀다. 나머지 백엔드 작업을 하느라 시간을 많이 썼는데 이렇게 된 이상 백엔드 쪽 포스팅을 먼저 완성한 후 프론트엔드 쪽으로 넘어 가도 될 것 같다.

이번 작업 기간 중 가장 많은 시간을 할애한(잡아먹은🥲) 영화 데이터 수집 파트의 작업기를 한번 적어보겠다.

🌐 Open API 선정

다른 작업을 시작하기에 앞서,
먼저 영화 데이터를 수집할 수 있는 경로를 확보하고 넘어갈 필요가 있었다.

크게 Open API 활용과 웹 스크래핑의 두 가지 가능성을 두고 있었는데,
다행히도 조사 결과 API들이 제공하는 데이터의 범위가 충분히 넓어 Open API만을 활용해서 서비스에 들어갈 영화 데이터를 수집하기로 결정하였다.

한 군데만으로는 서비스에서 필요한 모든 정보를 얻을 수는 없었고, 해외의 TMDB(The Movie Database)와 국내의 KMDb(한국영화데이터베이스) 두 군데를 활용하기로 했다.

🤖 Agent 구현

그리고 TMDB와 KMDb API 각각에 대해,
먼저 앞으로 사용할 API 요청에 대응하는 메소드들을 미리 정의해둔 Agent 클래스를 만들어 가장 로우 레벨에서 API 요청을 담당할 레이어를 마련했다.

from ..crawlers import custom_types as T
from .mixins.requests import RequestPaginateMixin, SingletonRequestSessionMixin


class TMDBAPIAgent(
	RequestPaginateMixin, SingletonRequestSessionMixin
):
    ...
    
    def top_rated_movies(
        self, max_count: Optional[int] = None
    ) -> list[T.SimpleMovieFromTMDB]:
        method = "GET"
        uri = "/movie/top_rated"

        return [
            T.SimpleMovieFromTMDB(**m)
            for m in self.paginate_request(
                method, 
                self.base_url + uri, 
                max_count=max_count
            )
        ]


class KMDbAPIAgent(
	RequestPaginateMixin, SingletonRequestSessionMixin
):   
   	...
    
    def search_movies(
        self, 
        max_count: Optional[int] = None, 
        **search_kwargs
    ) -> list[T.MovieFromKMDb]:
        method = "GET"
        uri = "/search_api/search_json2.jsp"

        return [
            T.MovieFromKMDb(**m)
            for m in self.paginate_request(
                method, 
                self.base_url + uri, 
                params=search_kwargs, 
                max_count=max_count
            )
        ]

Agent에게 모두 필요한 공통 동작은 Mixin 클래스를 별도로 정의해 상속 받는 방식으로 구현했는데

  • SingletonRequestSessionMixin
    : 상속되는 클래스의 네임스페이스 안에서 requests 라이브러리의 Session 객체를 싱글톤 패턴으로 관리
  • RequestPaginateMixin
    : 외부 API 서버 응답에 페이징 처리가 되어있을 때 여러 페이지 응답을 모두 반환

의 역할을 하는 두 가지 클래스를 따로 작성해두고 상속 받아 사용했다.

🗂 데이터클래스 커스텀

그리고 또 하나,
API 응답으로 넘어오는 모든 데이터가 필요한 게 아니기 때문에 이를 효율적으로 선별할 방법이 필요했다.

필요할 때마다 문자열 키로 응답 JSON에 접근해 데이터를 골라내는 방식은 일관성 있는 데이터 스펙 관리가 안 된다는 측면에서 좋은 방법이 아니었는데,

그래서 일단 파이썬의 데이터클래스를 활용해 하드 코딩된 문자열을 배제하고 필요한 데이터 스펙의 정의를 일원화하고자 했다.

flexible_dataclass

여기서 끝은 아니었고 특별히 프로젝트의 요구 사항에 맞게 데이터클래스 구현을 몇몇 부분 커스텀해 활용했는데,

먼저 응답 JSON을 통째로 unpack해서 데이터클래스 생성자로 넘기면 그 안에 필요한 데이터들만 남아있도록 해서,
데이터클래스를 정의만 해두면 그 안에 선언된 필드 목록을 들여다보는 등 하는 별도의 데이터 선별 코드는 작성하지 않아도 되도록 만들고 싶었다.

그런데 기본 데이터클래스 구현 상으로는 필드로 정의하지 않은 키워드 인자를 데이터클래스 생성자에 넘기면 에러가 발생한다.

그래서,
다음과 같이 미리 정의한 필드에 해당하지 않는 것들은 필터링하도록 dataclass 데코레이터를 수정해 flexible_dataclass 데코레이터를 만들어봤다.

from dataclasses import dataclass, fields


def flexible_dataclass(cls=None, /, **kwargs):
    def decorator(cls):
        cls = dataclass(cls, **kwargs)
        cls.__init__.__qualname__ = f"{cls.__qualname__}.__default_init__"
        setattr(cls, "__default_init__", cls.__init__)

        def __flexible_init__(self, *args, **kwargs):
            filtered_kwargs = {
                f.name: kwargs[f.name] for f in fields(cls) if f.name in kwargs.keys()
            }
            self.__default_init__(*args, **filtered_kwargs)

        __flexible_init__.__qualname__ = f"{cls.__qualname__}.__init__"
        setattr(cls, "__init__", __flexible_init__)

        return cls

    if cls is None:
        return decorator

    return decorator(cls)

NestedInitMixin

그리고 API 응답이 플랫하지 않고 중첩된 구조로 오는 경우가 있었다. 이런 경우 중첩된 내부 JSON 구조 역시 미리 정의해둔 데이터클래스 스펙으로 역직렬화하고 싶었는데,

이를 위한 로직을 NestedInitMixin 클래스에 미리 정의해두고 중첩 구조가 있는 데이터클래스에서 상속 받아 활용했다.

TMDB와 KMDb API의 여러 응답으로부터 필요한 데이터 스펙을 정의한 데이터클래스들의 세부 구현은 movies/crawlers/custom_types 모듈에서 확인할 수 있다.

🎞 모델 설계

이제 수집한 데이터를 담을 모델이 필요하다.

결국 필요한 데이터의 종류가 여기서 결정되기 때문에 앞서 언급한 데이터클래스들의 스펙 역시 모델 단의 요구사항에 따라 정해진다.

결과적으로 영화 도메인 쪽에서 구현한 모델 클래스들은 총 8가지.

  • Movie: 영화
  • Person: 영화인
  • Credit: 영화의 감독/작가/캐스팅을 기록하는 Person과 Movie 사이 중간 테이블
  • Country: 제작 국가
  • Genre: 장르
  • Poster: 포스터
  • Still: 스틸컷
  • Video: 예고편 등 영화 관련 영상

Credit 모델의 경우,
영화와 참여한 영화인을 단순히 매핑하는 것 이외에 직업이나 역할명 등 크레딧에 대한 추가 정보가 더 필요했기 때문에 확장된 중간 테이블로 따로 정의했고

CountryGenre 모델의 경우
레코드가 지속적으로 추가될 일도 없고 추가 정보를 담기 위한 필드들이 필요한 것도 아니지만,
가능한 옵션을 명확히 정의해두고, 이후 서비스 내 분류 기준으로도 신뢰성 있게 활용하기 위해 별도의 모델로 선언해 관리하기로 했다.

이외에도 이번 작업을 통해 쟝고의 모델 도메인에서 학습한 내용들을 아래에서 자세히 정리해보겠다.

null vs. blank

쟝고의 모델 필드 옵션 중 nullblank는 둘 다 빈 값에 대한 옵션들이고 특히 문자열 필드와 함께 사용할 땐 개발하는 입장에서 그 효과가 상당히 헷갈린다. 이번 기회에 확실히 정리하고 넘어갈 수 있으면 좋을 것.

우선 null은 확실히 짚고 넘어갈 게 DB에 영향을 미치는 옵션이라는 것이다.
True로 지정하면 해당 필드가 비어있을 경우 DB에 쓸 때 쟝고가 NULL 값을 줄 수 있다.

반면 blank유효성 검증 쪽에서 기능하는 옵션이다.
기본적으로 파이썬 런타임 안에서, 쟝고 모델을 활용하는 다른 레이어의 작업에서 해당 필드가 비어있어도 된다는 플래그를 세워두는 역할이라고 볼 수 있겠다.
대표적으로 쟝고의 Form 단에서 작용하고, DRF의 ModelSerializer를 사용해서 쟝고 모델로부터 Serializer 필드를 자동으로 추출할 때도 영향을 미친다.

ModelSerializer는 필드 추출 과정에서 쟝고 모델의 필드에

  • 기본값이 있거나
  • blank=True 거나
  • null=True 일 때,

Serializer 필드의 blank 옵션에 해당하는 required 옵션을 False로 지정해 대응하는 필드를 생성한다.

그리고 문제의 문자열 필드의 경우,
쟝고에선 nullblank 옵션을 동시에 사용하는 것을 권장하지 않는다.
문자열 필드에 대해선 ""(empty string)으로 빈 값을 나타내는 것이 쟝고의 컨벤션이기 때문이다.
그래서 문자열 필드에 null 옵션을 주면 쟝고 입장에서 해당 필드는 빈 값을 표현하는 방법이 두 가지가 되고, 개발하는 입장에서 특별히 더 신경써야 하는 상황이 된다.

🎲 문자열 필드가 비어있을 때 null & blank 경우의 수

  • null=True & blank=True
    쟝고 Form과 DRF Serializer 모두에서,
    유효성 검사에 통과하고, 해당 필드를 DB에 NULL 값으로 저장한다.
  • null=True & blank=False
    • 쟝고 Form
      유효성 검사에 실패하고, 빈 값을 받았을 땐 받은 값 그대로(NoneNULL / """") DB에 저장한다.
    • DRF Serializer
      유효성 검사에 통과하고 해당 필드를 ""이 아닌 NULL값으로 DB에 저장한다.
      (대응하는 Serializer 필드에서 required=True)
  • null=False & blank=True
    쟝고 Form과 DRF Serializer 모두에서,
    유효성 검사에 통과하고 해당 필드를 DB에 ""으로 저장한다.
  • null=False & blank=False
    쟝고 Form과 DRF Serializer 모두에서,
    유효성 검사에 실패하고, 빈 값을 받았을 땐 받은 값 그대로 DB에 저장한다.

DRF Serializer의 경우 문자열 필드에 이미 null 옵션을 줬다면 blank 옵션에 따라선 동작이 달라지지 않는다.

문자열 필드에서 unique=True & blank=True

앞서 언급한 대로 쟝고의 문자열 필드 빈 값 컨벤션은 "" 인데 DB 입장에선 "" == "" 이므로,

문자열 필드에 uniqueblank 옵션을 모두 줬을 경우엔 빈 필드가 여러 번 들어왔을 때 유일성 조건에 문제가 생긴다.

하지만 DB 입장에서 NULL != NULL 이기 때문에,
uniqueblank 옵션이 같이 있는 문자열 필드의 경우 ""을 하나의 값으로 인정하고 싶은 게 아니면 null 옵션을 같이 줘서 빈 값을 NULL 값으로 표현할 수 있도록 해주면 된다.

모델 사이 관계 표현

아래에서 설명할 세 가지 타입의 필드를 사용하면 쟝고에서 테이블 간 관계를 선언할 수 있다.

1:N

ForeignKey를 사용해 N 사이드 모델 클래스에 외래키 필드를 선언하면 테이블 간 1:N 관계를 표현할 수 있다.
(unique 옵션을 주면 1:1 관계까지도 표현할 수 있다.)

DB 단에선 ForeignKey 필드를 선언한 해당 N 사이드 테이블에 필드명_id 필드가 만들어진다.

아래는 나머지 두 타입의 필드에서도 공통적으로 적용되는 관계형 필드 전용 옵션 중 몇 가지.

  • on_delete
    반대쪽 1 사이드 테이블의 연결된 레코드가 삭제되었을 때 참조 무결성을 위해 현재 레코드에 취할 액션을 결정. 다양한 옵션이 있고, 이 쪽 레코드도 같이 삭제하는 CASCADE 옵션이 기본값

  • related_name
    관계형 필드는 한 쪽 모델에서만 선언하지만 그 반대 쪽 모델 객체에서도 파이썬 런타임 안에서 이 관계에 접근할 방법이 있다. 특정한 이름의 어트리뷰트에 접근하면 되는데, related_name은 바로 그 어트리뷰트 이름을 지정하는 인자.
    related_name을 따로 지정하지 않을 경우 갖게 되는 기본 어트리뷰트명은 상대 쪽 모델이 1 사이드일 경우 소문자로 된 상대 모델명이고 상대 쪽 모델이 N 사이드일 경우엔 그 뒤에 _set을 붙인다.

    class Movie(models.Model):
         genres = models.ManyToManyField("Genre", ...)
         ...
    
     class Credit(models.Model):
         movie = models.ForeignKey(Movie, ..., related_name="credits")
         ...
    >>> Movie.objects.get(id=1).credits.all()
     <QuerySet [<Credit: ...>, ...]>
     >>> Genre.objects.get(id=1).movie_set.all()
     <QuerySet [<Movie: ...>, ...]>

1:1

1:1 관계를 표현하려면 OneToOneField 필드를 한 쪽 모델에 선언하면 된다.

ForeignKeyunique 옵션을 주는 것과 다른 점은 반대 쪽 모델에서 관계에 접근할 때 쟝고의 RelatedManager가 아니라 연결된 인스턴스 하나를 직접 돌려주는 방식으로 동작한다는 것.

DB 단에선 OneToOneField 필드를 선언한 모델의 테이블에 필드명_id 필드가 만들어진다.

M:N

한 쪽 모델에 ManyToManyField 필드를 선언하면 테이블 간 M:N 관계를 나타낼 수 있다.

DB 단에선 기본 적으로 양쪽 테이블의 외래키 쌍을 저장하는 별도의 중간 테이블이 만들어진다.

위에서 설명한 on_delete 옵션은 따로 설정하지 않고,
한 쪽 레코드가 삭제되면 중간 테이블의 매핑 레코드가 삭제된다.
그래서 반대 쪽 모델에서 연결된 이 쪽 레코드들에 접근하면, 삭제된 레코드가 빠진 결과를 얻을 수 있다.

그리고 WatchB의 Credit 모델과 같이
중간 테이블에 두 인스턴스 간 매핑 이외에 더 다양한 정보를 담아야 하는 등의 이유로 중간 테이블을 직접 구현하고 싶다면,

별도의 모델 클래스를 구현한 뒤 ManyToManyField를 선언할 때 through 옵션으로 넘겨주면 중간 테이블 지정이 가능하다.

class Person(models.Model):
	...
    filmography = models.ManyToManyField(Movie, through="Credit", ...)

(참조해야하는 테이블 Credit이 코드 위치 상 아래에 있다면 위의 예시처럼 문자열로 넘길 수 있다.)

through 이외에도 ManyToManyField 에서만 사용하는 몇 가지 인자가 있다.

  • symmetrical
    M:N 관계는 해당 모델의 인스턴스끼리도 정의할 수 있다. 첫 번째 인자로 문자열 "self"를 넘기면 선언 가능한데, 그런 경우 그 관계가 대칭적인지를 결정하는 옵션.
    즉, 한 인스턴스 쪽에서 다른 인스턴스를 참조하면 그 쪽에서도 다시 반대 쪽을 같은 관계로 참조할 것인지를 결정한다. 대칭적이지 않은 재귀적 M:N 관계는 대표적으로 유저 사이 팔로잉/팔로워 관계가 있다.
  • through_fields
    through 인자로 중간 테이블을 따로 지정했을 때 해당 모델 안에 정의된 외래키 필드들이 자동으로 특정되지 않을 때 외래키 필드 조합을 직접 넘겨줄 수 있는 인자.

추상 모델

PosterStill 모델을 정의할 때는 반복을 피하기 위해 공통 필드인 이미지 주소와 영화 외래키가 담긴 MovieImage 추상 모델을 정의해 상속받아 구현했다.

class MovieImage(models.Model):
    image_url = models.URLField()

    movie = models.ForeignKey(Movie, on_delete=models.CASCADE)

    class Meta:
        abstract = True

class Poster(MovieImage):
	...

class Still(MovieImage):
	...

Meta.abstract 속성을 True로 두면 쟝고가 해당 모델을 추상 모델로 인식해 실제 ORM 작업을 피해갈 수 있다.

쟝고의 모델 Constraints

쟝고에는 단순히 필드를 선언하는 것으로 표현할 수 없는 보다 복잡한 DB 단의 제약을 적용하기 위한 Meta.constraints 옵션이 있다.

작동하는 순간은 모델의 인스턴스에서 save() 메소드가 불렸는데 필드 값이 제약을 만족하지 못 했을 때. 그 때 쟝고의 IntegrityError를 뱉는다.

WatchB에선,
Movie를 참조하는 몇몇 모델에서 UniqueConstraint 객체를 활용해 특정 필드 값이 하나의 영화 안에서 유일하도록 강제해뒀다.

class Poster(MovieImage):
    ...
    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["movie", "image_url"],
                name="unique_poster_in_movie"
            )
        ]

위의 예시를 생각해보면 대부분의 포스터 이미지 주소가 모든 영화에 대해 유일할 것이고,
UniqueConstraint를 설정해둔 다른 필드들 역시 대부분 실질적으로는 단순히 unique=True 옵션을 줘도 문제가 없었겠지만,
필드의 의미적인 부분까지만 고려해 보수적으로 설정해봤다.


지금까지

  • 외부 API에서 넘어온 데이터와
  • 그게 최종적으로 담길 그릇인 모델

의 스펙을 결정했으니

이제 그 사이를 이어줄 레이어를 설계해서 실제로 데이터를 모델에 담을 차례다.

근데 이미 글이 또 꽤나 길어진 관계로...
이번 글은 여기서 마무리하고 다음 포스팅에 바로 이어서 작성해보겠다.

0개의 댓글