장고를 사용해 개발하다 보면 여러 개의 모델이 같은 필드를 공유하는 경우를 자주 마주하게 된다. 가령, 발행일과 수정일을 여러 모델이 공유하게 되는 것은 흔한 일이며, 여러 비즈니스 로직을 공유하게 되는 경우도 잦다. 붕어빵처럼 비슷비슷한 코드들이 이름과 일부 필드만 살짝 바뀌어 존재하는 것이다.
다음 두 모델을 살펴보자.
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_date
와 last_modified
를 이용하는 다른 함수, foo
를 만들어야 한다면 어떻게 될까?
위에 써 놓은 수많은 모델들을 일일히 수정해야 할 것이다. 하지만 처음에 작성한 것처럼 함수를 외부로 추출하는 것은 예쁘지가 않다. 어떤 것이 답일까?
trait
을 빌려오기이 문제를 해결하기 위해, 파이썬보다 좀 더 현대적인 구석들을 가지고 있는 언어들을 살펴보자. 그 대상은 rust
와 scala
다. 이 두 언어는 모두 trait
이라는 언어 명세를 가지고 있다. 이 글에서는 scala
의 trait
에 초점을 맞추어 살펴보자. 대략 다음과 같은 측면을 가지고 있다.
Monad
라는 객체는 FlatMap
과 Applicative
라는 두 객체의 속성을 모두 갖고 있다고 하자. 이 때 스칼라는 다음과 같이 Monad
를 구현할 수 있다.
trait Monad[F[_]] extends FlatMap[F] with Applicative[F] {
...
}
조금 와닿지 않겠지만, 파이썬이라면 다음과 같이 구현하는 것이다. 리스트와 딕셔너리, 집합을 구현한다고 생각해보자. 이것들이 갖고 있는 공통적인 특성은 다음이 있다.
리스트+리스트
, 집합+집합
등등...)이 때 각각의 성격을 따로 구현하고, 리스트, 딕셔너리 등등 각각의 개체는 각각 성질에 대한 특수한 동작을 정의하는 것이다. 어떤가. 조금 더 품이 덜 들고 유지보수에 편해질 것이라는 게 와닿는가?
java8
이전의 interface
는 기본 구현을 가질 수 없었다. 하지만 java8
부터는 기본 구현을 가질 수 있게 되었다. interface
가 일반적인 구현을 제공하고 특정한 구현체에 대해서만 특수한 동작을 기대하는 것이 훨씬 합리적이기 때문이다.
scala
의 trait
은 자바보다 좀 더 앞서 이런 동작을 가능하게 했다. scala
로 짜여진 거대한 라이브러리 중 하나인 cats
를 살펴보자.
cats
의 Monad
를 상속받은 객체는 오직 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)
}
pure
과 flatMap
등의 다른 함수들을 이용하여 함수가 작성되어 있는 것을 확인할 수 있다. 그래서 일부 메소드만 구현하면 공짜로 다른 함수들을 얻을 수 있는 것이다.
Mixin
구현하기그래서 이제 파이썬으로 위 동작들을 따라해보자. django
의 모델은 Meta
태그로 abstract
여부를 지정해 줄 수 있다. 이 때 abstract
를 True
로 설정해주면 실제로 데이터베이스에 저장되지는 않고, 상속받은 모델만 데이터베이스에 생기는 것을 만들 수 있다. 파이썬은 다중상속을 지원하므로 우리가 기대하는 동작을 쉽게 흉내낼 수 있다.
우선 다음과 같은 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_date
와 last_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
만큼의 품을 들여 구현할 수 있다. 또한 유지보수에 좀 더 편리한 코드를 구현할 수 있다.
망치를 들면 모든 것이 못으로 보인다. 만병통치약이란 없는 법이다. 위의 구현 방식은
이라는 장점을 가진다. 하지만 다음과 같은 문제가 생긴다.
모든 것은 등가교환이다. 이 방법으로 인한 장점과 단점을 잘 저울질하여 올바른 방법으로 서버를 구성하자.