데이터베이스의 Many-to-Many relationship(이하 다대다 관계)는 처음 접하는 사람들을 힘들게 한다. 한 테이블의 여러 레코드가 다른 테이블의 여러 레코드와 연결되어 있는 관계. 말만 들어서는 감이 오지 않으니 예를 들자면, 피자와 토핑 사이의 관계라고 할 수 있을 것이다. '피자'라는 테이블의 피자(페퍼로니피자, 치즈피자 등)는 '토핑' 테이블의 토핑(치즈, 페퍼로니, 올리브, 양파 등)을 여러 개 가질 수 있고, 그 반대도 마찬가지이다.
장고에서는 이러한 데이터들의 관계를 ManyToManyField에서 정의할 수 있다.
class Pizza(models.Model):
name = models.CharField(max_length=20)
toppings = models.ManyToManyField('Topping')
class Topping(models.Model):
name = models.CharField(max_length=20)
위의 예시처럼 피자 테이블과 토핑 테이블을 만들었다면, 둘 중 하나에서 상대방을 참조하는 필드를 만들고 ManyToManyField를 정의하면 된다. 여기서는 피자 테이블에서 토핑 테이블을 참조하는 toppings 필드를 생성했다.
장고 공식문서에서는 ManyToManyField를 정의하면, 자동으로 두 테이블 사이의 관계를 관리해주는 중간 테이블을 생성한다고 되어 있다. 이 테이블은 우리가 작성한 모델에는 없지만, 데이터베이스를 확인하면 다대다관계의 두 테이블 이름을 _로 이어준 별도의 테이블이 생성된 것을 볼 수 있다.
이 중간 테이블은 두 테이블의 id를 각각 필드로 가지고 있다. 그렇기 때문에 각 테이블에 데이터가 존재하지 않으면 중간 테이블에도 primary key가 존재하지 않는다.
위에서 작성한 예시를 보면, 피자 테이블에 토핑 테이블을 참조하는 필드가 존재한다. 그래서 한 피자에 있는 토핑들을 가져오는 것은 크게 문제가 없다. 하지만 토핑이 들어간 피자들을 가져오기 위해
cheeze.pizzas.all()
을 하면, AttributeError: 'Topping' object has no attribute 'pizzas'
와 같이 '그런 속성 없다' 는 에러가 뜬다. 사실 조금 생각해보면 토핑 테이블에는 피자와 관련된 데이터는 아무것도 없다. 그렇기 때문에 직접적으로 가져올 수 없는 것이다.
그래서 참조되는 테이블에서 참조하는 테이블의 데이터를 가져오기 위해, 장고에서는 참조하는 테이블의 이름 뒤에 _set를 붙여주어야 한다고 명시했다.
>> pineapple.pizza_set.all()
<QuerySet [<Pizza: Hawaiian>]>
그런데 _set을 붙여주기 싫다면? 해결책은 있다. 바로 필드 속성에 related_name을 아래와 같이 정의해 주는 것이다.
class Pizza(models.Model):
...
toppings = models.ManyToManyField('Topping', related_name='pizzas')
처음에 related_name을 보면 뭔가 의아할 것이다. 필드명은 토핑인데 왜 이름을 'pizzas'라고 붙였을까? 그 이유는 related_name이 '참조되는 테이블이 참조하는 테이블의 데이터를 가져오고 싶을 때 사용하는 이름'을 정의하는 것이기 때문이다. 이 경우 토핑이 피자의 데이터를 가져오고 싶을 때 pizza_set 대신 pizzas를 쓸 수 있다는 것이다.
pineapple.pizzas.all()
<QuerySet [<Pizza: Hawaiian>]>
related_name을 정의해 주니 _set 없이도 토핑에서 피자의 데이터에 접근한 것을 볼 수 있다.
데이터 간의 관계성을 가지게 하기 위해 hawaiian_pizza.toppings.add(pineapple)
피자 테이블에서 토핑을 추가할 수도 있지만, 반대로 아래와 같이 토핑 테이블에서 토핑이 들어간 피자를 추가할 수도 있다.
mozzarella = Topping.objects.create(name='mozzarella')
>> mozzarella.pizzas.add(cheese_pizza)
데이터를 쿼리할 때도 한 테이블에서 상대 테이블에 있는 데이터를 연관시켜 조회할 수 있다. 다음과 같이 피자 테이블에서 필터로 토핑 테이블의 데이터 중 p로 시작하는 피자들만 추출할 수도 있다.
>> Pizza.objects.filter(toppings__name__startswith='p')
<QuerySet [<Pizza: Pepperoni Pizza>, <Pizza: Hawaiian Pizza>]>
# pepperoni, pineapple
마찬가지로 토핑 테이블에서 필터로 피자 테이블의 데이터 중 이름에 'Hawaiian'이 들어가는 피자의 토핑만 추출할 수 있다.
>> Topping.objects.filter(pizzas__name__contains='Hawaiian')
<QuerySet [<Topping: pineapple>, <Topping: Canadian bacon>]>
위에서도 언급했지만, 장고에서는 ManyToManyField로 데이터를 정의하면 자동으로 두 테이블의 관계를 관리해주는 테이블을 생성한다고 했다. 이것을 through model 이라고 하는데, 개발자가 직접 through model을 정의하면 필드를 추가한 중간 테이블을 생성할 수 있다.(자동 생성되는 테이블에는 테이블의 고유id와 두 테이블의 id만 존재한다)
그냥 장고가 알아서 만들어준거 쓰면 되지 왜 귀찮게 개발자가 직접 정의해야 하냐고 묻는다면, 더욱 자세한 데이터를 구축할 수 있기 때문이다. 피자의 예로 돌아가서, 우리는 지금까지 피자와 토핑 테이블을 생성했다. 만약 페퍼로니 피자에 페퍼로니를 더 추가한 더블메뉴라 존재한다면 어떻게 해야 할까? 존재하는 페퍼로니 피자에 토핑 pepperoni를 한번 더 추가하면 될까?
>> pepperoni_pizza.toppings.all()
<QuerySet [<Topping: pepperoni>]>
>> pepperoni_pizza.toppings.add(pepperoni)
>> pepperoni_pizza.toppings.all()
<QuerySet [<Topping: pepperoni>]>
이처럼 페퍼로니를 백번 추가해도 토핑은 한 번밖에 들어가지 않는다. 그렇기 때문에 토핑의 양을 정의한 필드를 중간 테이블에 추가함으로써 토핑의 양이 더블, 트리플인 더블 페퍼로니 피자, 트리플 페퍼로니 피자를 만들 수 있다.
다만, through model을 정의할 때는 모델을 처음 생성할 때부터 through model을 정의해 주거나 데이터베이스를 전부 drop하고 다시 시작해야 한다.
class ToppingAmount(models.Model):
REGULAR = 1
DOUBLE = 2
TRIPLE = 3
AMOUNT_CHOICES = (
(REGULAR, 'Regular'),
(DOUBLE, 'Double'),
(TRIPLE, 'Triple'),
)
pizza = models.ForeignKey('Pizza', related_name='topping_amounts', on_delete=models.SET_NULL, null=True)
topping = models.ForeignKey('Topping', related_name='topping_amounts', on_delete=models.SET_NULL, null=True, blank=True)
amount = models.IntegerField(choices=AMOUNT_CHOICES, default=REGULAR)
class Pizza(models.Model):
...
toppings = models.ManyToManyField('Topping', through='ToppingAmount', related_name='pizzas')
이처럼 중간테이블인 ToppingAmount 모델을 새로 정의하고, 이를 기존 toppings필드의 through에 넣어 주었다. 이제 토핑의 양을 구체적으로 명시해 줄 수 있게 되었다.
한 가지 주의할 점은, through model을 생성했다면 데이터를 추가할 때는 중간 테이블에 직접적으로 데이터를 넣어 주어야 한다는 점이다. 자동 생성되었을 때처럼 추가하면 에러를 뱉는다.
중간 테이블에 추가하려면 아래와 같이 추가한다.
super_pep_amount = ToppingAmount.objects.create(pizza=super_pep, topping=pepperoni, amount=ToppingAmount.DOUBLE)
중간 테이블에 직접 추가해 주어야 한다는 점이 귀찮아 보일 수 있지만, 덕분에 우리는 추가적인 정보를 중간 테이블에 넣을 수 있게 되었다. 예를 들어 super_pep에 페퍼로니 토핑이 얼마나 들어가는지도 확인할 수 있다.
for top_amt in ToppingAmount.objects.filter(pizza=super_pep):
print(top_amt.topping.name, top_amt.get_amount_display())
# pepperoni Double
(Model.get_필드명_display()는 choice 필드가 존재할 경우 사용할 수 있는데, 선택한 값을 보여준다)
지금까지 장고의 ManyToManyField에 대해 알아보았다. 이번에 소개한 정보는 장고가 제공하는 ManyToManyField관련 기능의 극히 일부분에 불과하다. 어려운 다대다 관계지만 잘 쓰면 강력한 데이터베이스 구축에 도움이 될 것이다.
감사합니다