졸업프로젝트에서 프론트는 리액트와 타입스크립트, 백엔드는 장고를 사용해 풀스택 개발을 하게 되었다. 우리 프로젝트는 은퇴한 시니어 전문가가 다시 일할 수 있도록 돕는 채용 플랫폼인데, 핵심 기능은 이력서 작성과 인재 추천(검색)이고 그 외에도 여러 부가 기능들이 있다. 프론트는 이력서 작성과 인재 추천 페이지, 백은 이력서와 관련된 전반적인 API 개발을 담당했다.
ERD를 만들고 API 문서를 작성하다보니 이력서가 여러 테이블과 관계를 맺으면서 점점 뚱뚱해졌는데, 한 API로 이력서가 담고 있는 모든 정보를 반환해야 해서 nested serializer를 사용하게 되었다. nested serializer를 이용해 종속된 테이블들의 어트리뷰트를 하나의 객체로 합쳐 반환하는 방법에 대해 적어보겠다.
Model / Serializer
nested serializer를 사용하기에 앞서 장고에서 model과 serializer가 무엇이고 왜 사용되는지 알아야 한다. 장고는 웹 서비스에서 사용할 정보를 model이라는 객체로 관리하는데, 여기에 저장한 내용은 데이터베이스와 연동되어 보관된다. ERD에 정의한대로 모델을 만들고 migrate를 하면 변경 사항이 자동으로 SQL 쿼리문으로 바뀌어 데이터베이스 테이블에 반영된다. 이렇게 model 객체를 이용하여 데이터를 저장했다가 API 요청이 오면 응답으로 객체 정보를 반환해야 하는데, 이때 객체 정보를 JSON으로 변환해주는 것이 Serializer다. 각 API에서 요구하는 정보에 맞게 특정 테이블에서 특정 어트리뷰트만을 골라 Serializer로 묶은 후 CRUD에 활용할 수 있다.
Nested Serializer
그렇다면 nested serializer는 무엇일까? 말 그대로 Serializer 안에 Serializer가 들어가도록 중첩된 형태의 Serializer다. 객체의 프로퍼티로 다른 객체가 포함되는 경우 이를 표현하기 위해 사용할 수 있다.
구현해야 하는 사항: 이력서 객체 정보 반환
ERD 중 이력서와 관련된 부분만 보면 이 정도인데, 이력서의 전반적인 정보를 담고 있는 resumes 테이블이 있고 이 테이블의 primary key를 foreign key로 갖는 1:N 관계의 경력사항(careers), 학력사항(educations), 프로젝트 이력(projects), 포트폴리오 파일(portfolios), 기존 이력서 파일(prior_resumes) 테이블이 있다. 실제 개발 시 기존 이력서 파일은 따로 서버에 저장하지 않고 OCR 추출에만 활용했으니 제외하고, 경력사항 테이블과 1:N 관계를 맺고 있는 주요 성과(performances) 테이블까지 고려했을 때 이력서 객체의 전체적인 구조는 다음과 같다.
- 이력서
- 경력사항
- 주요 성과
- 학력사항
- 프로젝트 이력
- 포트폴리오
주요 성과를 표현하려면 삼중 중첩까지 표현해야 해서 처음엔 막막했지만 한번 방법을 알고 나니 응용하는 건 괜찮았다.
어떻게 Nested Serializer를 작성하면 되는지 먼저 알아보고, 이 Serializer를 각 API에 어떻게 활용했는지 살펴보겠다.
Nested Serializer 만들기
Nested Serializer를 만드는 방법은 크게 2가지가 있다.
- 1 : N 관계에서 1에 해당하는 model 기준으로 nested serializer 생성 (Reverse relations)
- 1에 해당하는 model의 primary key를 N에 해당하는 모델에서 foreign key로 사용
- 1:n 관계에서 부모가 1, 자식이 n일 때 사용
- ex. album:track이 1:n이므로 track model이 자신이 포함된 앨범 정보를 어트리뷰트로 가짐
- 1 : N 관계에서 N에 해당하는 model 기준으로 nested serializer 생성
- N에 해당하는 model의 primary key를 1에 해당하는 모델에서 foreign key로 사용
- 1:n 관계에서 부모가 n, 자식이 1일 때 사용
- ex. 1명의 child가 2명의 부모를 가지므로 parent가 child 어트리뷰트를 포함
나의 경우 하나의 이력서에 여러 학력, 경력사항 등의 정보가 포함되므로 전자의 방법으로 작성했다. 작성 방법은 다음과 같다.
1. 1:n에서 n이 되는 자식 객체의 Serializer를 먼저 선언 - 해당 자식 객체의 model명을 적고 포함하고 싶은 fields 나열
2. 1:n에서 1이 되는 부모 객체의 Serializer 안에 자식 Serializer 포함 후 meta class 안의 fields에 자식 Serializer 어트리뷰트명 추가 - 자식 여러 개가 부모 하나에 종속되므로 Serializer(many=True)로 작성
class PerformanceSerializer(serializers.ModelSerializer):
class Meta:
model = Performance
fields = ['id', 'start_year_month', 'end_year_month', 'performance_name', 'performance_detail']
class CareerSerializer(serializers.ModelSerializer):
performances = PerformanceSerializer(many=True)
class Meta:
model = Career
fields = ['id', 'start_year_month', 'end_year_month', 'company_name', 'job_name', 'performances']
class EducationSerializer(serializers.ModelSerializer):
class Meta:
model = Education
fields = ['id', 'start_year_month', 'end_year_month', 'education_name', 'education_info']
class ProjectSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = ['id', 'start_year_month', 'end_year_month', 'project_name', 'project_detail']
class PortfolioSerializer(serializers.ModelSerializer):
class Meta:
model = Portfolio
fields = ['id', 'portfolio_name', 'portfolio_file']
class ResumeSerializer(serializers.ModelSerializer):
careers = CareerSerializer(many=True)
educations = EducationSerializer(many=True)
projects = ProjectSerializer(many=True)
portfolios = PortfolioSerializer(many=True)
class Meta:
model = Resume
fields = ['keyword', 'introduction', 'job_group', 'job_role', 'career_year', 'skills', 'careers', 'educations', 'projects', 'portfolios', 'duration_start', 'duration_end', 'min_month_pay', 'max_month_pay', 'commute_type']
완성한 nested serializer는 위와 같다. ResumeSerialzer 안에 CareerSerailzer, EducationSerialzer, ProjectSerializer, PortfolioSerializer가 포함되어 있고, CareerSerializer 안에는 PerformanceSerializer가 들어있다.
이렇게 정의한 Serializer를 response body에 넣어 resume 객체를 출력해보면 다음과 같다.
{
"resume_id": 17,
"resume": {
"keyword": "멀티플레이어 보드게임 서버 엔지니어, 혁신적 경험으로 게임 업계를 뒤흔든 전문가",
"introduction": "멀티플레이어 보드게임 서버 엔지니어입니다. 15년 이상의 경력을 가지고 있으며, 다양한 보드게임 프로젝트에서 서버 아키텍처를 설계하고 구현한 경험이 있습니다. 업계를 주도하는 리더로서, 게임 서버의 성능과 효율성을 높이는데 열정적으로 기여하고 있습니다.",
"job_group": "IT",
"job_role": "네트워크 개발자",
"career_year": 25,
"skills": "[Unity, Unreal,WebSocket, AWS, Azure, MySQL, MongoDB, Redis, OAuth, JWT]",
"careers": [
{
"id": 3,
"start_year_month": "202401",
"end_year_month": "202403",
"company_name": "감자도리",
"job_name": "대장",
"performances": [
{
"id": 4,
"start_year_month": "202401",
"end_year_month": "202403",
"performance_name": "감자먹기",
"performance_detail": "감자 누가누가 빨리 먹나 대결하기"
}
]
},
{
"id": 4,
"start_year_month": "202001",
"end_year_month": "202201",
"company_name": "이화여자대학교",
"job_name": "전산직",
"performances": []
}
],
"educations": [
{
"id": 10,
"start_year_month": "201401",
"end_year_month": "201801",
"education_name": "이화여자대학교",
"education_info": "컴퓨터공학전공"
}
],
"projects": [
{
"id": 3,
"start_year_month": "201401",
"end_year_month": "201601",
"project_name": "졸업 프로젝트",
"project_detail": "시니어 전문가를 위한 긱 워킹 플랫폼"
}
],
"portfolios": [],
"duration_start": 11,
"duration_end": 12,
"min_month_pay": 800,
"max_month_pay": 900,
"commute_type": "원격 근무"
},
"message": "이력서를 성공적으로 조회했습니다."
}
Nested Serializer를 활용하여 REST API 개발하기
성공적으로 nested serializer를 만들었지만 여기서 끝이 아니었다. 이력서 CRUD를 위한 API들이 필요했다.
이력서와 관련된 전체 API 목록은 이 정도다. 대략적인 CRUD 흐름을 정리해보자면 다음과 같다.
1. 시니어 사용자가 이력서 생성 (POST) -> user id를 받아 빈 이력서 객체 생성 후 이력서 id 반환
2. 시니어 사용자가 이력서 내용 업데이트 (PUT) -> 각 attribute + 자식 객체(경력, 학력 등)의 내용 갱신
2-1. 경력사항 등에 대해 새로운 항목 추가 (POST) -> 빈 객체 생성 후 id 반환
2-2. 경력사항 등에 대해 기존 항목 삭제 (DELETE) -> user id, resume id와 삭제할 객체 id 받은 후 삭제
2-3. 기존 이력서 파일 업로드 (POST) -> 업로드한 파일을 CLOVA OCR 서버로 전송 후 텍스트 추출 및 포맷팅, 이후 해당 정보로 경력사항/학력 객체 생성 후 해당 정보 반환
2-4. 지금까지 작성한 이력서 내용으로 전문가 소개 생성 (POST) -> resume 객체 내용을 바탕으로 Open AI를 이용해 마크다운 형식의 전문가 소개 자동 생성 후 반환
원래는 2번 PUT API 하나에서 경력사항 등 자식 객체에 대한 CRUD도 함께 관리하려고 했다. 그런데 따로 Create/Delete API를 두지 않으면 객체 id 관리가 어렵고, id 관리가 제대로 되지 않으면 update 시 내용이 바뀔 때마다 계속 새로운 객체로 추가되는 문제가 있어 별도의 API로 분리하였다.
이력서 nested serializer와 관련된 CRUD 코드를 보면서 글을 마무리하겠다.
POST
경력사항 등 중첩된 객체를 추가할 때는 user id과 resume id로 경력사항을 추가할 resume 객체를 선택한 후 Career.objects.create(resume=resume)와 같이 인자로 넘겨주면 된다.
이미 작성한 이력서를 복제하는 API 구현이 까다로웠는데, 원래 장고에서 객체 복제는 복제하려는 객체의 pk(primary key)를 None으로 설정한 후 save()를 하면 된다. 하지만 이 경우 이력서에 종속된 경력사항 등의 객체는 함께 복제되지 않으므로 따로 찾아서 복제해줘야 했다. 그래서 일단 기존 이력서를 복제한 새 이력서를 만든 후, 기존 이력서의 경력사항 등 객체들을 찾아 for문으로 돌면서 새로 만든 이력서 객체에 추가하는 식으로 작성했다.
def copyDetails(objects, resume_id, isCareer=False):
if isCareer:
for o in objects:
original_career = o.pk
o.resume_id = resume_id
o.pk = None
o.save()
new_career = Career.objects.last()
performances = Performance.objects.filter(career_id=original_career)
for p in performances:
p.career_id = new_career.pk
p.pk = None
p.save()
else:
for o in objects:
o.resume_id = resume_id
o.pk = None
o.save()
class CopyResumeAPIView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(tags=['기존 이력서를 복제합니다.'], request_body=FindResumeSerializer)
def post(self, request):
user_id = request.data.get('user_id')
resume_id = request.data.get('resume_id')
resume = checkResumeExistence(user_id, resume_id)
if resume:
resume.pk = None
resume.is_default = False
resume.is_submitted = False
resume.title = resume.title + "의 사본"
resume.save()
new_resume = Resume.objects.last()
careers = Career.objects.filter(resume_id=resume_id)
educations = Education.objects.filter(resume_id=resume_id)
projects = Project.objects.filter(resume_id=resume_id)
portfolios = Portfolio.objects.filter(resume_id=resume_id)
copyDetails(careers, new_resume.pk, True)
copyDetails(educations, new_resume.pk)
copyDetails(projects, new_resume.pk)
copyDetails(portfolios, new_resume.pk)
serializer = ResumeCardSerializer(new_resume, data=request.data)
if serializer.is_valid():
res = Response(
{
"user_id": user_id,
"resume_id": resume.id,
"resume": serializer.data,
"message": "이력서가 성공적으로 복제되었습니다."
},
status=status.HTTP_200_OK,
)
return res
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
GET
views.py에 작성한 get api 함수다. user id와 resume id로 resume 객체를 filter 후 해당 객체를 Serializer로 감싸 반환하기만 하면 된다.
class GetResumeAPIView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(tags=['이력서 상세 내용을 조회합니다.'])
def get(self, request, user_id, resume_id):
resume = checkResumeExistence(user_id, resume_id)
if resume:
serializer = ResumeSerializer(resume)
res = Response(
{
"resume_id": resume_id,
"is_submitted": resume.is_submitted,
"resume": serializer.data,
"message": "이력서를 성공적으로 조회했습니다."
},
status=status.HTTP_200_OK,
)
return res
return Response({"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND)
PUT
update 함수는 serializers.py에 함께 정의했다. request body에 담긴 careers 정보를 이용하여 객체 배열을 돌면서 해당 id의 객체에 대해 각 어트리뷰트를 업데이트 하는 식으로 구현했다. 객체명과 어트리뷰트명이 다 다르다 보니 비슷한 포맷의 반복문이 여러 개 쓰였는데, 객체명과 어트리뷰트명을 인자로 갖는 함수로 분리하면 더 깔끔하게 작성할 수 있을 것 같다.
class ResumeSerializer(serializers.ModelSerializer):
...
def update(self, resume, validated_data):
...
careers = validated_data.pop('careers')
educations = validated_data.get('educations')
...
for career in careers:
Career.objects.update_or_create(id=career['id'], defaults={
'start_year_month': career['start_year_month'],
'end_year_month': career['end_year_month'],
'company_name': career['company_name'],
'job_name': career['job_name'],
'resume': resume
})
performances = career['performances']
_career = Career.objects.get(id=career['id'])
for performance in performances:
Performance.objects.update_or_create(id=performance['id'], defaults={
'start_year_month': performance['start_year_month'],
'end_year_month': performance['end_year_month'],
'performance_name': performance['performance_name'],
'performance_detail': performance['performance_detail'],
'career': _career
})
for education in educations:
Education.objects.update_or_create(id=education['id'], defaults={
'start_year_month': education['start_year_month'],
'end_year_month': education['end_year_month'],
'education_name': education['education_name'],
'education_info': education['education_info'],
'resume': resume
})
...
return resume
이렇게 작성한 update 함수를 views.py에서 호출하면 된다.
class EditResumeAPIView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(tags=['이력서를 수정합니다.'], request_body=ResumeSerializer)
def put(self, request, user_id, resume_id):
resume = checkResumeExistence(user_id, resume_id)
if resume:
serializer = ResumeSerializer(resume, data=request.data)
if serializer.is_valid():
resume = serializer.update(resume, validated_data=request.data)
resume.save()
res = Response(
{
"resume_id": resume_id,
"resume": serializer.data,
"message": "이력서가 성공적으로 수정되었습니다."
},
status=status.HTTP_200_OK,
)
return res
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response({"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND)
PATCH
중첩된 객체와 관련된 부분은 아니지만, 어트리뷰트 일부만 변경하고 싶을 경우 patch를 사용하면 된다. 정확히는 serializer 생성 시 partial=True로 선언하면 된다.
class ChangeResumeTitleAPIView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(tags=['이력서 제목을 변경합니다.'], request_body=ChangeResumeTitleSerializer)
def patch(self, request):
user_id = request.data.get('user_id')
resume_id = request.data.get('resume_id')
resume = checkResumeExistence(user_id, resume_id)
if resume:
serializer = ChangeResumeTitleSerializer(resume, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
res = Response(
{
"resume_id": resume.id,
"title": resume.title,
"message": "이력서 제목이 성공적으로 변경되었습니다."
},
status=status.HTTP_200_OK,
)
return res
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response({"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND)
DELETE
제일 단순하다. 그냥 삭제하고 싶은 객체를 id로 찾은 후 delete()를 호출하면 된다. 경력사항 등 종속되는 객체도 동일하다.
class DeleteResumeAPIView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(tags=['이력서를 삭제합니다.'])
def delete(self, request, user_id, resume_id):
resume = checkResumeExistence(user_id, resume_id)
if resume:
resume.delete()
res = Response(
{
"message": "이력서가 성공적으로 삭제되었습니다."
},
status=status.HTTP_200_OK,
)
return res
return Response({"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND)
References