콘텐츠로 이동

중첩 구조 응답#

2024 iThome 철인 대회

이 글은 Django Ninja 입문 가이드의 14번째 글이에요.

API 개발 시, 관계된 모델들의 데이터를 동시에 반환해야 하는 상황에 자주 직면하게 돼요.

특히 "일대일(One-to-One)"이나 "일대다(One-to-Many)" 관계를 다룰 때는 다층 구조(Multi-level Structure)가 흔히 필요하죠.

우리는 이러한 데이터를 중첩 구조(Nested Objects)로 반환하여, API 사용자가 여러 번 요청할 필요 없이 필요한 정보를 한 번에 얻도록 하려고 해요.

이번 글에서는 기존의 "단일 글 정보" API 예제를 확장하며, Django Ninja에서 중첩 구조 응답을 어떻게 구현하여 우리의 API 응답을 더 풍부하고 체계적으로 만들 수 있는지 설명해 드릴게요.

이번 글의 모든 코드 변경 사항은 이 PR에서 확인하실 수 있어요.

GitHub 예제 프로젝트#

👉 Django-Ninja-Tutorial


1. 문제 배경#

이전의 API 설계에서 "단일 글 정보 가져오기"의 응답에는 글 정보와 작성자의 id가 포함되어 있었어요:

class PostResponse(Schema):
    id: int
    title: str
    content: str
    author_id: int
    created_at: datetime
    updated_at: datetime

경험이 많은 개발자라면 idauthor_id 같은 정보는 보통 서비스 사용자에게 바로 보여주기 위한 것이 아니라, 프론트엔드 개발자가 유연하게 활용하기 위해 존재한다는 것을 알 거예요.

예를 들어 시스템 화면에 작성자의 개인정보 링크가 글과 함께 제공되어, 클릭 시 작성자 정보를 볼 수 있도록 구현될 수 있죠. 이때 프론트엔드는 추가적인 정보를 얻기 위해 id를 통해 "사용자 정보 가져오기"라는 별도의 API를 다시 호출해야 해요.

부가적인 정보가 아주 많다면, 이러한 "디커플링(Decoupling)" 설계가 매우 합리적이에요. 하지만 작성자의 "필수 정보"를 함께 보여주고 싶다면, 호출을 여러 번으로 나누는 설계는 조금 거추장스럽게 느껴질 수 있어요.

그래서 우리는 중첩 구조가 필요해요!

API가 응답 내에 작성자의 "필수 정보"를 바로 포함시켜 주면, 사용자는 더 이상 추가적인 요청을 보낼 필요가 없게 돼요. 여기서는 그 예로 작성자의 "이름"과 "이메일"을 함께 표시해 볼게요.


2. API 개선: Schema 재정의#

응답 내용과 구조를 다르게 만들기 위해서는 딱 한 가지 일만 하면 돼요. 바로 PostResponse를 재정의하는 것이죠:

from ninja import Schema
from datetime import datetime

class _AuthorInfo(Schema):
    id: int
    username: str
    email: str

class PostResponse(Schema):
    id: int
    title: str
    content: str
    author: _AuthorInfo  # 작성자 정보를 포함하는 중첩 구조
    created_at: datetime
    updated_at: datetime

_AuthorInfo에는 작성자의 id, name, email이 포함되어 있으며, 이 구조를 PostResponseauthor 필드(정보의 의미가 달라졌으므로 author_id에서 이름을 바꿨어요)에 포함(embed)시켰어요.

여기서 주목할 점은, 우리는 오직 PostResponse만 수정했을 뿐 뷰 함수는 이전과 완전히 똑같이, 어떠한 변경도 하지 않았다는 거예요:

@router.get(path='/posts/{int:post_id}/', response=PostResponse)
def get_post(request: HttpRequest, post_id: int) -> Post:
    """
    단일 글 가져오기
    """
    post = Post.objects.get(id=post_id)
    return post

이렇게 하면, 글과 작성자의 필수 정보를 한 번에 동시에 얻을 수 있게 돼요.

여담: 네이밍에 대한 소소한 조언#

눈치채셨을지 모르지만, 제가 _AuthorInfo의 이름을 지을 때 "밑줄(underscore)로 시작"하는 원칙을 사용했어요. Python에서 이는 이 속성, 함수, 클래스가 주로 내부적인 용도로 쓰인다는 것을 나타내는 관례(convention)예요.

이른바 "내부적"이라는 말은 여러 가지로 해석될 수 있지만, 여기서 제 의도는 다음과 같아요: 이것은 특정한 하나 또는 여러 Schema의 일부분일 뿐이며, 뷰 함수에서 직접 호출하여 사용하기 위한 것이 아니에요.

이런 사소한 네이밍의 디테일을 가볍게 넘기지 마세요. Schema의 개수가 늘어나고 새로운 API를 개발해야 할 때, 기존 Schema들을 먼저 훑어보면서 새로 정의할지 기존 것을 재사용할지 결정해야 하는 순간이 꼭 오거든요.

그럴 때 이러한 네이밍 규칙은 정말 "세심한 배려"로 다가와요. 크고 작은 수많은 Schema들 사이에서 눈이 빠지도록 헤맬 필요가 없어지니까요.

실무를 하다 보면 중첩 Schema를 작성할 기회가 꽤 많으므로, 저는 이런 좋은 습관을 들이는 것이 가치 있다고 생각해요.

업데이트된 응답: Nested Response#

이제 API 응답이 어떻게 변했는지 확인해 볼게요:

// http://127.0.0.1:8000/posts/2/
{
    "id": 2,
    "title": "Alice's Django Ninja Post 1",
    "content": "Alice's Django Ninja Post 1 content",
    "author": {
        "id": 1,
        "username": "Alice",
        "email": "[email protected]"
    },
    "created_at": "2024-09-12T02:28:16.801Z",
    "updated_at": "2024-09-12T02:28:16.801Z"
}

새롭게 추가된 author 필드의 내용을 보세요. 아름다운 중첩 구조가 완성되었어요!

사용자는 즉시 글 작성자의 이름과 이메일을 확인할 수 있고, 만약 더 많은 작성자 정보가 필요하다면 여전히 id 필드를 통해 프론트엔드가 다른 API를 호출하게끔 할 수 있어요.

이것은 매우 이상적인 절충안이라고 할 수 있어요.


3. 중첩 정보 "펼치기" (Flatten)#

앞서 본 "절충안"도 꽤 훌륭했어요. 하지만 때로는 우리의 요구사항이 더 단순할 때가 있어요.

예를 들어 "글 목록 조회" API에서도 작성자의 정보가 필요할 수 있는데, 이럴 때는 단순히 이름만 있으면 충분할 수 있어요.

작성자 id도 필요 없고 이메일도 필요 없으며, 오직 이름만 필요한 경우 말이죠.

그렇다면 왜 이를 가리켜 "중첩 정보를 펼친다"고 표현할까요? 작성자의 이름은 Post 모델의 직접적인 속성이 아니라, 실제로는 연결된 모델인 User에서 유래된 것이기 때문이에요.

우리는 작성자에 관한 중첩 정보를 더욱 간소화해야 해요.

원래는 이랬어요:

"author": {
    "id": 1,
    "username": "Alice",
    "email": "[email protected]"
},

이제 이렇게 바뀔 거예요:

"author_name": "Alice",

두 단계(layer)에서 한 단계로 되돌아왔으니(비록 작성자 id 대신 이름이 되었지만요), 그래서 "펼치다"(flatten)라고 부르는 거예요.

Schema 분리 (Decoupling)#

혹시 기억하시나요? "글 목록 조회" API의 응답 구조는 사실 "단일 글 정보 조회" API와 공유되어 쓰이고 있었어요:

@router.get(path='/posts/', response=list[PostResponse])
def get_posts(...) -> QuerySet[Post]:
    """
    글 목록 가져오기
    """
    ...

두 API 모두 PostResponse를 사용하고 있죠.

만약 이번 글 전반부에서 했던 것처럼 "단일 글 정보" 응답을 수정하게 되면, 그 변경 사항이 "글 목록 조회"에도 영향을 미치게 될 텐데, 보통 이는 우리가 원하는 결과가 아니에요.

그래서 우리는 "글 목록 조회" API를 위해 독립적인 응답 Schema를 새롭게 만들고, 앞서 설명한 요구에 맞게 정보를 단순화해 볼 거예요!

제가 생각한 계획은 이래요:

  1. 목록에서는 필요 없는 글 내용(content)과 수정 시간(updated_at) 필드를 생략할게요.
  2. 작성자 정보는 "이름"만 남길 거예요.

4. 중첩 정보 펼치기 구현하기 — @property 사용#

먼저 새로운 Schema가 어떻게 정의되었는지 살펴볼까요:

class PostListResponse(Schema):
    id: int
    title: str
    created_at: datetime
    author_name: str

'이상하다, Post 모델에는 author_name이라는 속성이 없는데?'라고 생각하셨을 수도 있어요.

네, 맞아요! 왜냐하면 우리가 직접 정의할 거거든요. @property를 이용해서요:

# post/models.py
class Post(models.Model):
    ...

    @property
    def author_name(self) -> str:
        return self.author.username

이렇게 하면, 이제 여러분의 Post 모델 객체는 author_name이라는 속성을 가지게 돼요.

하지만 주의하세요. 이 속성을 호출한다는 것은 종종 두 번째 쿼리를 발생시킨다는 의미(이 속성이 관계 모델의 속성이기 때문이에요)이므로, 뷰 함수에서 Django QuerySet의 select_related 메서드와 함께 사용해야 해요:

posts.filter(title__icontains=title).select_related('author')

이것이 바로 Django ORM에서 흔히 겪는 "N+1 문제"인데, 여기서는 더 깊게 다루지 않을게요.

더 나은 방법#

어쩌면 이 방법이 별로 우아하지 않다고 느끼셨을 수도 있어요 (적어도 저는 처음 봤을 때 그렇게 생각했어요!) — 특히 Django REST framework의 방식과 비교하면 더더욱 그렇죠.

Django REST framework라면 시리얼라이저(serializer)에서 다음과 같이 작성했을 거예요:

author_name = serializers.CharField(source="author.name")
훨씬 깔끔하지 않나요?

하지만 이 @property 방식이 바로 Django Ninja 개발자가 초창기에 추천했던 방식이었답니다.

걱정 마세요. 16편에서 더 좋고 현대적인 방법을 소개해 드릴 테니까요. 그렇지만 @property 방식 역시 특정 상황에서는 꽤 유용하게 쓰일 수 있어요.

펼친 후의 응답 모습#

마지막으로, "글 목록 조회" API의 새로운 응답을 확인해 볼게요:

// http://127.0.0.1:8000/posts/
[
    {
        "id": 1,
        "title": "Alice's Django Ninja Post 1",
        "created_at": "2024-09-12T02:28:16.801Z",
        "author_name": "Alice"  // 평탄화된 작성자 이름
    },
    {
        "id": 2,
        "title": "Alice's Django Ninja Post 2",
        // ...(이하 생략)
    },
    // ...(이하 생략)
]
완벽하네요!


소결#

이번 글에서는 Django Ninja에서 Schema를 활용해 중첩 구조의 응답을 구현하는 방법을 시연해 드렸어요.

그리고 이어서 이 중첩 구조를 어떻게 "펼치고(flatten)", 기존의 작성자 id 대신 이름 필드로 대체하는지도 소개했어요.

이러한 방법들을 통해 API 응답의 유연성을 크게 높일 수 있답니다.

다음 글에서는 직렬화 및 응답 구조 처리와 관련하여 Django Ninja와 Django REST framework의 설계 철학 차이를 알아보고, 두 프레임워크의 장단점을 비교해 볼게요.