Mixin으로 장고 개발 속도 높이기

구경회·2020년 12월 16일
6
post-thumbnail

평범한 개발 루틴


장고를 사용해 개발하다 보면 여러 개의 모델이 같은 필드를 공유하는 경우를 자주 마주하게 된다. 가령, 발행일과 수정일을 여러 모델이 공유하게 되는 것은 흔한 일이며, 여러 비즈니스 로직을 공유하게 되는 경우도 잦다. 붕어빵처럼 비슷비슷한 코드들이 이름과 일부 필드만 살짝 바뀌어 존재하는 것이다.
다음 두 모델을 살펴보자.

class Post(models.Model):
    title = models.CharField(blank=False, null=False, max_length=50)
    content = models.TextField(blank=True, null=False)
    issued_date = models.DateTimeField(auto_now_add=True)
    last_modified = models.DateTimeField(auto_now=True)

    def __str__(self):
        return f"{self.title}, {self.content}"


class Comment(models.Model):
    post = models.ForeignKey(Post, related_name='comments', on_delete=models.CASCADE)
    content = models.TextField(blank=True)
    issued_date = models.DateTimeField(auto_now_add=True)
    last_modified = models.DateTimeField(auto_now=True)
    
    def __str__(self):
    	return f"{self.content} at {self.post}"

두 모델에서 중복되는 부분은 content, issued_date, last_modified이다. 이 때, 만약 이 모델의 수정 여부를 확인하고 싶어지게 된다면 어떻게 할까? 다음과 같은 함수를 짜서 문제를 해결할 수 있을 것이다.

from datetime import timedelta

def is_modified(x):
        return abs(x.issued_date - x.last_modified) > timedelta(seconds=1)

지금은 잘 동작하는 것 같다. 하지만 불편함이 남아 있다. is_modified가 모델에 마치 존재하는 속성인 것처럼 작동했으면 좋겠기 때문이다. 그러면 다음과 같이 리팩토링 할 수 있다.

class Post(models.Model):
    title = models.CharField(blank=False, null=False, max_length=50)
    content = models.TextField(blank=True, null=False)
    issued_date = models.DateTimeField(auto_now_add=True)
    last_modified = models.DateTimeField(auto_now=True)
    
    def is_modified(self):
        return abs(self.issued_date - self.last_modified) > timedelta(seconds=1)

    def __str__(self):
        return f"{self.title}, {self.content}"


class Comment(models.Model):
    post = models.ForeignKey(Post, related_name='comments', on_delete=models.CASCADE)
    content = models.TextField(blank=True)
    issued_date = models.DateTimeField(auto_now_add=True)
    last_modified = models.DateTimeField(auto_now=True)
    
    def is_modified(self):
        return abs(self.issued_date - self.last_modified) > timedelta(seconds=1)
    
    def __str__(self):
    	return f"{self.content} at {self.post}"

이제 Post.objects.get(id=2).is_modified()와 같이 호출할 수 있다. 조금 더 나아가, 정말 메소드의 속성처럼 접근하게 하려면 다음과 같이 property 데코레이터를 붙여 호출할 수 있다.

class Post(models.Model):
    title = models.CharField(blank=False, null=False, max_length=50)
    content = models.TextField(blank=True, null=False)
    issued_date = models.DateTimeField(auto_now_add=True)
    last_modified = models.DateTimeField(auto_now=True)

    @property
    def is_modified(self):
        return abs(self.issued_date - self.last_modified) > timedelta(seconds=1)

    def __str__(self):
        return f"{self.title}, {self.content}"

이제 Post.objects.get(id=2).is_modified와 같이 호출할 수 있다. 마치 원래 존재하는 필드처럼 자연스럽다. 이제 새로 만드는 모델마다 위와 같은 로직을 적용해서 다음과 같은 상황이 되었다.

class Post(models.Model):
    ...
    issued_date = models.DateTimeField(auto_now_add=True)
    last_modified = models.DateTimeField(auto_now=True)
    
    @property
    def is_modified(self):
        return abs(self.issued_date - self.last_modified) > timedelta(seconds=1)

class Comment(models.Model):
    ...
    issued_date = models.DateTimeField(auto_now_add=True)
    last_modified = models.DateTimeField(auto_now=True)
    
    @property
    def is_modified(self):
        return abs(self.issued_date - self.last_modified) > timedelta(seconds=1)
        
class Image(models.Model):
    ...
    issued_date = models.DateTimeField(auto_now_add=True)
    last_modified = models.DateTimeField(auto_now=True)
    
    @property
    def is_modified(self):
        return abs(self.issued_date - self.last_modified) > timedelta(seconds=1)

수정 요구 발생

하지만, 만약 is_modified의 로직이 수정되거나, issued_datelast_modified를 이용하는 다른 함수, foo를 만들어야 한다면 어떻게 될까?

위에 써 놓은 수많은 모델들을 일일히 수정해야 할 것이다. 하지만 처음에 작성한 것처럼 함수를 외부로 추출하는 것은 예쁘지가 않다. 어떤 것이 답일까?

현대적인 해결법: trait을 빌려오기

이 문제를 해결하기 위해, 파이썬보다 좀 더 현대적인 구석들을 가지고 있는 언어들을 살펴보자. 그 대상은 rustscala다. 이 두 언어는 모두 trait이라는 언어 명세를 가지고 있다. 이 글에서는 scalatrait에 초점을 맞추어 살펴보자. 대략 다음과 같은 측면을 가지고 있다.

다중 상속이 가능하다

Monad라는 객체는 FlatMapApplicative라는 두 객체의 속성을 모두 갖고 있다고 하자. 이 때 스칼라는 다음과 같이 Monad를 구현할 수 있다.

trait Monad[F[_]] extends FlatMap[F] with Applicative[F] {
  ...
}

조금 와닿지 않겠지만, 파이썬이라면 다음과 같이 구현하는 것이다. 리스트와 딕셔너리, 집합을 구현한다고 생각해보자. 이것들이 갖고 있는 공통적인 특성은 다음이 있다.

  • 인덱스 기반 추출이 가능하다.
  • 합성이 가능하다. (리스트+리스트, 집합+집합 등등...)
  • 순회할 수 있다.

이 때 각각의 성격을 따로 구현하고, 리스트, 딕셔너리 등등 각각의 개체는 각각 성질에 대한 특수한 동작을 정의하는 것이다. 어떤가. 조금 더 품이 덜 들고 유지보수에 편해질 것이라는 게 와닿는가?

구현이 가능하다

java8 이전의 interface는 기본 구현을 가질 수 없었다. 하지만 java8부터는 기본 구현을 가질 수 있게 되었다. interface가 일반적인 구현을 제공하고 특정한 구현체에 대해서만 특수한 동작을 기대하는 것이 훨씬 합리적이기 때문이다.

scalatrait은 자바보다 좀 더 앞서 이런 동작을 가능하게 했다. scala로 짜여진 거대한 라이브러리 중 하나인 cats를 살펴보자.

catsMonad를 상속받은 객체는 오직 pure, flatMap, tailRecM만 구현하면 whileM, untilM, map2, map3, ..., traverse, sequence등의 함수를 공짜로 얻을 수 있다. 어떻게 이런 일들이 가능할까? 답은 다음과 같은 코드에서 알 수 있다. 다음은 cats의 일부이다.

  @noop
  def whileM_[A](p: F[Boolean])(body: => F[A]): F[Unit] = {
    val continue: Either[Unit, Unit] = Left(())
    val stop: F[Either[Unit, Unit]] = pure(Right(()))
    val b = Eval.later(body)
    tailRecM(())(_ =>
      ifM(p)(
        ifTrue = as(b.value, continue),
        ifFalse = stop
      )
    )
  }
  
  def untilM[G[_], A](f: F[A])(cond: => F[Boolean])(implicit G: Alternative[G]): F[G[A]] = {
    val p = Eval.later(cond)
    flatMap(f)(x => map(whileM(map(p.value)(!_))(f))(xs => G.combineK(G.pure(x), xs)))
  }
  
def iterateWhile[A](f: F[A])(p: A => Boolean): F[A] =
  flatMap(f) { i =>
    iterateWhileM(i)(_ => f)(p)
  }

pureflatMap등의 다른 함수들을 이용하여 함수가 작성되어 있는 것을 확인할 수 있다. 그래서 일부 메소드만 구현하면 공짜로 다른 함수들을 얻을 수 있는 것이다.

파이썬으로 Mixin 구현하기

그래서 이제 파이썬으로 위 동작들을 따라해보자. django의 모델은 Meta 태그로 abstract 여부를 지정해 줄 수 있다. 이 때 abstractTrue로 설정해주면 실제로 데이터베이스에 저장되지는 않고, 상속받은 모델만 데이터베이스에 생기는 것을 만들 수 있다. 파이썬은 다중상속을 지원하므로 우리가 기대하는 동작을 쉽게 흉내낼 수 있다.
우선 다음과 같은 Model 클래스를 만들어보자.

class TimeMixin(models.Model):
    issued_date = models.DateTimeField(auto_now_add=True)
    last_modified = models.DateTimeField(auto_now=True)
    
    class Meta:
        abstract = True

이제 이 TimeMixin을 상속받아 모델의 보일러플레이트를 상당히 줄일 수 있다. 그리고 파이썬의 정상적인 상속관계처럼 두 필드(issued_datelast_modified)를 이용하여 계산 결과로 나온 값들을 필드처럼 자연스럽게 이용할 수 있다.

class TimeMixin(models.Model):
    issued_date = models.DateTimeField(auto_now_add=True)
    last_modified = models.DateTimeField(auto_now=True)

    @property
    def is_modified(self):
        return abs(self.issued_date - self.last_modified) > timedelta(seconds=1)

    class Meta:
        abstract = True

단순하게 생각해보면, 대략 m개의 특질을 가진 n개의 모델을 구현하려면, 원래는 m * n만큼의 노력을 들여야겠지만 이제는 m + n만큼의 품을 들여 구현할 수 있다. 또한 유지보수에 좀 더 편리한 코드를 구현할 수 있다.

단점

망치를 들면 모든 것이 못으로 보인다. 만병통치약이란 없는 법이다. 위의 구현 방식은

  • 개발 속도
  • 유지보수의 용이성

이라는 장점을 가진다. 하지만 다음과 같은 문제가 생긴다.

  • 지나치게 잦은 DB 쿼리의 실행
  • 여러 방법을 통한 성능 향상에 문제가 생길 수 있음

모든 것은 등가교환이다. 이 방법으로 인한 장점과 단점을 잘 저울질하여 올바른 방법으로 서버를 구성하자.

profile
즐기는 거야

0개의 댓글