
항상 큰 규모의 DRF 프로젝트에서는 어떻게 하면 효율적인 코드 분리가 될 수 있을까?
고민을 했었다. 그러다 이번에 어느정도 프로젝트 복잡도가 큰 훕치치 어드민 백엔드를 맡게되면서, 이번 프로젝트는 계층 분리도 철저히 하고, 각 계층의 역할도 확실히 해주고 싶었다.
스프링 프레임워크를 어느정도 공부하게 되면서, DRF에서도 적용이 되었으면 좋겠다! 라는 생각이 든 것 중에 하나가 바로 Serializer의 DTO + 검증 역할화이다.
기존의 나의 serializer에서는 정말 많은 로직이 담겨있었다. 예를 들면 아래는 OSOD에서 사용했던 회원가입 시리얼라이저이다.
class UserSerializer(RegisterSerializer):
password1 = serializers.CharField(error_messages={'blank': '비밀번호를 설정하세요.'})
password2 = serializers.CharField(error_messages={'blank': '비밀번호를 설정하세요.'})
nickname = serializers.CharField(max_length=50, validators=[UniqueValidator(queryset=User.objects.all(), message='닉네임이 이미 사용중입니다.')]
,error_messages={'blank': '닉네임을 입력하세요.'})
name = serializers.CharField(max_length=50, error_messages={'blank': '이름을 입력하세요.'})
subscription = serializers.BooleanField(default=False)
email = serializers.EmailField(required=allauth_settings.EMAIL_REQUIRED, error_messages={'blank': '이메일을 입력하세요.'})
class Meta:
model = User
fields = ['email', 'password', 'nickname', 'name', 'subscription']
def get_cleaned_data(self):
super(UserSerializer, self).get_cleaned_data()
return {
'email': self.validated_data.get('email', ''),
'password1': self.validated_data.get('password1', ''),
'password2': self.validated_data.get('password2', ''),
'nickname': self.validated_data.get('nickname', ''),
'name': self.validated_data.get('name', ''),
'subscription': self.validated_data.get('subscription', ''),
}
def save(self, request):
adapter = get_adapter()
user = adapter.new_user(request)
self.cleaned_data = self.get_cleaned_data()
user.nickname = self.cleaned_data.get('nickname')
user.name = self.cleaned_data.get('name')
user.subscription = self.cleaned_data.get('subscription')
user.save()
adapter.save_user(request, user, self)
return user
얼핏봐도 엄청나게 길다. 해당 시리얼라이저가 길어지게 진 이유는 다음과 같다. 이 시리얼라이저는 dj_rest_auth 라이브러리에서 회원가입위한 시리얼라이저로, 회원가입을 커스터마이징 하려면 'RegisterSerializer' 를 상속하여 사용해야된다.
기본으로 제공하는 필드보다 더 많은 정보를 입력해야 하는 소요가 생긴다면 저렇게 오버라이딩을 해야했다. 시리얼라이저에서 데이터를 추출하고, 해당 데이터를 가지고 유저를 저장하는 로직까지 담겨있어 너무 많은 책임을 지고 있다.
그렇다면 원래 설계된 시리얼라이저이 역할을 지키면서, DTO의 역할까지 수행할 수 있는 방법을 고민했다.
그래서 생각해낸 것이 바로 데이터 변환과 유효성 검사의 역할만 지게 하는 것이었다.
본래 Serializer의 역할은 다음과 같다.
여기에 우리가 흔히 아는 DTO의 역할을 더했다. 그런데 원래도 serialzier는 DTO의 역할을 어느정도 수행하지 않느냐? 라는 질문을 할 수 있다. 사실 DTO라는 것은 데이터 전송 객체라는 뜻으로, '계층간 데이터 교환을 위한 자바 객체' 가 통상적인 의미이다. 따라서 데이터 전송 역할 이외의 로직을 가지고 있지 않으며, getter/setter만 가지고 있다.
Fastapi의 pydantic의 모델객체도 이를 수행해주고, DRF의 serialzier도 비슷한 역할을 수행해준다. 그런데 생각없이 serialzier를 쓰면 이런 곤란한 상황에 마주칠 수 있다.
class RecordRequestSerializer(serializers.ModelSerializer):
class Meta:
model = Record
fields = ('game_team_id', 'game_team_player_id', 'score', 'quarter_id')
위의 Record 시리얼라이저는 게임 기록(타임 라인)을 만들어주는 Record 모델의 시리얼라이저이며, 모델 인스턴스를 직렬화하여 이와 같은 json으로 만들어준다.
{
"game_team_id": 1,
"game_team_player_id": 2,
"score": 1,
"quarter_id": 1
}
와! 역시 시리얼라이저는 DTO 맞네요! 별 문제 없잖아요? 라고 할 수 있을 만큼 여기까지만 해도 훌륭하다.
하지만, 생각해보자. 갑자기 클라이언트 쪽에서 요청 사항으로
"누가 스네이크 써요 ㅋ 카멜식으로 데이터 줄게요?"
라고 말했다. 하지만 괜찮다. 당황하지 않고 시리얼라이저만 수정하면 되니깐!
class RecordRequestSerializer(serializers.Serializer):
gameTeamId = serializers.IntegerField()
gameTeamPlayerId = serializers.IntegerField()
score = serializers.IntegerField()
quarterId = serializers.IntegerField()
자 이렇게 수정하면 카멜식으로 데이터를 받을 수 있다. 다 OK 인 줄 알았는데 문제점이 존재했다. 바로, 내부 코드까지 변경이 된다는 것이다.
def create_record(self, game_id: int, request_data: dict):
record_request_serializer = RecordRequestSerializer(data=request_data)
record_request_serializer.is_valid(raise_exception=True)
record_data: dict = record_request_serializer.validated_data
game_team_id: int = record_data.get('game_team_id')
game_team: GameTeam = self._game_repository.find_game_team_by_id(game_team_id)
game_team.score += record_data.get('score')
self._game_repository.save_game_team(game_team)
만약 코드가 이렇게 되어있다면
record_data.get('game_team_id')
이 부분 또한 아래처럼 변경되어야 한다.
record_data.get('gameTeamId')
뭔가 번거롭고 이상하지 않는가?
어째서 API 스펙이 변경 되었는데 수정할 것이 serializer과 더불어 service 로직까지 손을 대야하는 것이지?
이러한 문제는 시리얼라이저를 쓰지 않을 때 더욱이 심각해진다.
만약에 댓글의 정보 아래와 같이 response 해야한다고 가정해보자.
{
"commentId": 1,
"content": "댓글이오",
"createdAt": "2023-12-25"
}
만약에 serializer를 사용하지 않고 반환한다면 어떻게 코드를 적어야 할까?
# 기타 로직 ,,,
res = {
"commentId": commend_id,
"content": content,
"createdAt": content.created_at
}
Response(res, status=status.HTTP_200_OK)
대충 이렇게 코드가 짜여져 있을 때, 이러한 요청이 들어온다고 하자.
"뭔가 스네이크가 좋네요. 다시 스네이크로 주세요. 😅"
헉! 이러면.. 직접 서비스 로직에 들어가서 commentId -> comment_id로 바꿔주고.. 세가지 코드를 바꿔주면 될 것이다.
얼핏보면 문제가 없는데, 만약에 저 댓글 정보를 반환하는 로직이 여러 군데에 있다면? 일일이 다 찾아서 저 세가지를 바꿔줘여한다! 만약 현업에서 이랬다면 api 스펙이 바뀔 때 마다 밤을 세야할 것이다.
그래서 기존의 serializer의 역할만을 수행하고, api 스펙 변경에 다른 계층 코드도 바뀌지 않는 방법이 있을까? 를 생각해본 결과 정답은 "source"에 있었다.
source는 필드의 데이터를 어디에서 가져올지를 지정하는 역할을 한다. 또한 해당 시리얼라이저 필드가 실제 파이썬 코드에서 어떤 변수명으로 사용 될지도 고정시켜 줄 수 있다.
문제 상황 1 을 타계할 시리얼라이저 구조를 보자.
class RecordRequestSerializer(serializers.ModelSerializer):
gameTeamId = serializers.IntegerField(source='game_team_id')
gameTeamPlayerId = serializers.IntegerField(source='game_team_player_id')
score = serializers.IntegerField()
quarterId = serializers.IntegerField(source='quarter_id')
class Meta:
model = Record
fields = ('gameTeamId', 'gameTeamPlayerId', 'score', 'quarterId')
클라이언트에서 데이터를 받아올 때 게임 팀 아이디는 "gameTeamId" 로 받되, 실제 파이썬 코드에서는 "game_team_id"로 고정시켜준다. 이렇게 되면 API스펙이 변경되어도 서비스단의 코드를 변경하지 않아도 된다.
만약에 API 스펙이 score에서 raiseScore로 변경이 되어야 한다면? 이렇게만 바꿔주면 된다.
raiseScore = serializers.IntegerField(source='score')
quarterId = serializers.IntegerField(source='quarter_id')
class Meta:
model = Record
fields = ('gameTeamId', 'gameTeamPlayerId', 'raiseScore', 'quarterId')
이렇게 되면, 실제 파이썬 코드에서는 raiseScore는 score로 동작하며 시리얼라이저만으로도 API 스펙 변경을 할 수 있게된다. 문제상황 2 또한 밑과 같은 serialzier면 문제가 해결이 된다.
class _CommentInfoSerializer(serializers.ModelSerializer):
comment_id = serializers.IntegerField(source='id')
created_at = serializers.DateTimeField()
class Meta:
model = Comment
fields = ('commentId', 'content', 'createdAt',)
여기서 확인할 수 있는 source의 주의점은 다음과 같다.
created_at = serializers.DateTimeField(source='created_at') 과 같은 코드는 불가능하다.comment_id = serializers.IntegerField(source='comment_id') 는 불가능하다.(Comment 모델에는 comment_id가 존재하지 않는다.)이렇게 깔끔한 코드를 위한 serialzier의 역할과, 방법에 대해서 알아보았다. 이번 프로젝트 및 향후 개발에서 나는 serialzier를 이렇게 사용할 것이다. 이렇게 사용한다면 얻는 좋은 점을 정리해보겠다.