[DB] friendship 모델링에 대해 고민하기.

ARA JO·2021년 3월 10일
0

Simtime 개발일지

목록 보기
6/7

simtime을 개발하다가 고민거리가 생겨서 기록하는 글. 친구와 그룹에 대한 것이다.

문제발생

친구를 삭제했지만, 그룹내에서 해당 친구가 삭제되지 않고 멤버로 남아있는다. 즉, cascading이 제대로 되고 있지 않다. 아니, 애초에 될 수 없었다!

원인

우선, 친구 관계 테이블에 대한 짧고 비루한 역사를 살펴보자.

초기

class Relationship(models.Model):
    account = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='friends')
    friend = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='friendOf')
    # friends can block the user. (=수신동의)
    subscribe = models.BooleanField(null=False, default=True)   # 수신여부
    dispatch = models.BooleanField(
        null=False, default=True)    # 발신여부 (false면 보내지않음)
    is_friend = models.BooleanField(null=False, default=True)
    created_at = models.DateTimeField(auto_now_add=True)
            
class FriendGroup(models.Model):
    account = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='FriendGroups')
    groupname = models.CharField(max_length=16, null=False, blank=False)
    is_default = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    profile_image = models.ImageField(
        upload_to=user_group_path, default='group-basic.png')
    thumbnail_image = ImageSpecField(  # CACHE에 저장된다. (object_create시가 아니라 필요할 때)
        source='profile_image',
        processors=[Thumbnail(100, 100)],  # 처리할 작업 목룍
        format='JPEG',					# 최종 저장 포맷
        options={'quality': 60}
    )  		# 저장 옵션

    class Meta:
        constraints = [models.UniqueConstraint(
            fields=['account', 'groupname'], name='group_name_unique')]
        
class Relationship_FriendGroup_MAP(models.Model):  # Which Group
     group = models.ForeignKey(
         FriendGroup, on_delete=models.CASCADE, related_name='friendships')
     relationship = models.ForeignKey(
         Relationship, on_delete=models.CASCADE, related_name='groups')
     created_at = models.DateTimeField(auto_now_add=True)

     class Meta:
         constraints = [models.UniqueConstraint(
             fields=['group', 'relationship'], name='gr_compositeKey')]
  • Relationship 은 단방향이다. 즉 하나의 레코드에서 내(user, account)가 friend에 대해 어떤 속성을 부여했는지만 나타낸다. 상대도 나를 친구로 등록했다면 둘 사이에 두개의 레코드가 생성되는 것이다.

    • 1이 2를 친구로 등록했다. {id:0, account:1, friend:2}
    • 2가 1을 친구로 등록했다. {id:1, account:2, friend:1}
  • 그룹의 멤버를 저장하는 Relationship_FriendGroup_MAP 에 Relationship의 id를 외래키로 사용한다.

이 모델에서는 친구를 삭제하면 그룹에서도 자연스럽게 친구가 삭제된다. Cascading에 문제가 없고 조회시에도 매우 간단하다.

수정

현재 사용하고 있는 모델이다.

class Friendship(models.Model):
    # account_A가 account_B보다 항상 작은 ID를 갖는다.
    account_A = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='friendship_A')
    account_B = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='friendship_B')
    status = models.IntegerField(default=999, null=False)

    # A-side(수신/발신/Block)
    # A는 B의 초대장을 수신 or not.
    A_subscribe_B = models.BooleanField(null=True, default=True)
    # A가 B에게 초대장을 발신 or not. (false면 보내지않음)
    A_dispatch_B = models.BooleanField(null=True, default=True)
    # A가 B를 Block or not.
    A_block_B = models.BooleanField(null=True, default=False)

    # B-side(수신/발신/Block)
    # B는 A를 수신 or not.
    B_subscribe_A = models.BooleanField(null=True, default=True)
    # B가 A에게 발신 or not (false면 보내지않음)
    B_dispatch_A = models.BooleanField(null=True, default=True)
    B_block_A = models.BooleanField(
        null=True, default=False)         # B가 A를 Block or not

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        constraints = [models.UniqueConstraint(
            fields=['account_A', 'account_B'], name='friendship_compositeKey')]
            

class FriendGroup(models.Model):
    account = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='FriendGroups')
    groupname = models.CharField(max_length=16, null=False, blank=False)
    is_default = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    profile_image = models.ImageField(
        upload_to=user_group_path, default='group-basic.png')
    thumbnail_image = ImageSpecField(  # CACHE에 저장된다. (object_create시가 아니라 필요할 때)
        source='profile_image',
        processors=[Thumbnail(100, 100)],  # 처리할 작업 목룍
        format='JPEG',					# 최종 저장 포맷
        options={'quality': 60}
    )  		# 저장 옵션

    class Meta:
        constraints = [models.UniqueConstraint(
            fields=['account', 'groupname'], name='group_name_unique')]



class FriendshipGroupMap(models.Model):  # Which Group
    group = models.ForeignKey(
        FriendGroup, on_delete=models.CASCADE, related_name='friendships')
    friend = models.ForeignKey(Account, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        constraints = [models.UniqueConstraint(
            fields=['group', 'friend'], name='gf_compositeKey')]

이렇게 변경한 이유는 아래와 같은 장점이 있기 때문이다.

  • 참신해보였... 🤐

  • 두 사람의 관계가 하나의 레코드로만 기록된다. -> 유저가 많아질 수록 효과가 크다.

  • 두 사람의 서로에게 어떤 속성을 부여했는지(어떤 관계인지) 파악하기 쉽다.

반면 미리 예측했던 단점으로는 아래와 같다.

  • 매번 user와 friend가 A인지 B인지 선별하는 추가 로직이 필요하다. (Account id가 작은쪽이 A이다.)

첫번째 장점이 주는 효과가 꽤 크지 않을까 하는 기대가 있었기 때문에 코드가 아주 살짝 복잡해지는 정도는 감수할만하다고 생각했다.

하지만!

테스트를 하다보니 아주 치명적인 점을 발견했다. 바로 데이터 정합성이 깨진다는 것.

멤버로 친구관계가 아니라 친구(account)그 자체를 외래키로 갖고 있으니, 당연히 친구 관계에 따라 Cascading이 될 수 없는 구조이다. 황당하게도 오직 친구가 탈퇴했을때만 멤버에서 지워질 수 있도록 두고 추가 로직 구현을 안해뒀던 것!

T.T

해결

해결은 간단하다.

친구를 삭제할 때(delete view) 직접 FriendshipGroupMap에서도 관련 레코드를 삭제하는 로직을 추가해줬다.

def delete(self, request, pk):
	friendship = self.get_object(pk)
	friend = friendship.account_B if friendship.account_A == self.request.user else friendship.account_A
	friendshipStatus = friendship.status
        
	# 1. group에서 친구 삭제
	groups = FriendshipGroupMap.objects.select_related('group')\
	.filter(Q(group__account=request.user.id) & Q(friend=friend))

    def do_delete(friendship, groups):
        try:
            friendship.delete()
            groups.delete()
            return Response(status=status.HTTP_204_NO_CONTENT)
        except:
            print('delete err')
            return Response(status=status.HTTP_400_BAD_REQUEST)

    def do_update(friendship, groups, newStatus):
        try:
            # update
            friendship.status = newStatus
            friendship.save()
            groups.delete()
            return Response(status=status.HTTP_204_NO_CONTENT)
        except:
            print('update err')
            return Response(status=status.HTTP_400_BAD_REQUEST)

#. 친구 삭제 or state 변경
	if(friendship.account_A == self.request.user):
		if(friendshipStatus == 0):
			# update (status 0 ==>2) : (mutual ==> b_only')
			return do_update(friendship, groups, 2)
		elif(friendshipStatus == 1 or friendshipStatus == 3):
			return do_delete(friendship, groups)  # 'a_only인 경우'
		else:
			return Response(status=status.HTTP_400_BAD_REQUEST)
	else:
		if(friendshipStatus == 0):
			# update (status 0 ==>1) : (mutual ==> a_only')
			return do_update(friendship, groups, 1)
		elif(friendshipStatus == 2 or friendshipStatus == 4):
			return do_delete(friendship, groups)  # 'b_only인 경우'
		else:
			return Response(status=status.HTTP_400_BAD_REQUEST)

풀리지 않는 의문

이제와서 다시 예전 모델로 돌리는 것은 꽤 많은 비용이 드는 것에 비해, 반드시 그래야한다는 확신이 100% 들지가 않기 때문에 간단히 로직을 추가하는 방안으로 해결했다. 추가 로직이 필요하다는 것은 처음부터 감안했던 부분이기 때문이다.

하지만 여전히 풀리지 않는 의문으로 남는 것이 있다.

  • 친구관계를 하나의 레코드로 기록하는 것과, 두개의 레코드로 나누어 기록하는 것은 추가로직과 정합성을 감안할 정도로 성능, 비용적인 측면에서 정말 유리한 것일까?
  • 현재 simtime은 간단한 서비스니까, 친구관계는 기껏해야 유저 두 명 (동시에 친구관계에 대해 변경 요청을 하는 경우), 그룹는 유저 본인 한 명이 컨트롤하는 부분이라 크게 문제가 되지는 않을것이다.

    하지만 이 관계를 이용해 서비스를 확장할 일이 있다고 가정했을 때에도 안정적인 서비스를 만드는 것에 문제가 없는 선택일까..?

고견과 조언이 있으시다면 거침없이 댓글을 남겨주세요..ㅠㅠ👏
정말 감사하겠습니다♥ 🙆‍♀️

profile
Sin prisa pero sin pausa (서두르지 말되, 멈추지도 말라)

0개의 댓글