ポスト一覧取得次にいいね判定で Exists, OuterRefを使う方法

# はじめに
皆さんこんにちは！Yuyaです。
現在、私はXのクローンアプリを開発しています。

投稿一覧を出力する際、同時にその投稿が「いいね」されているかを判定する必要がありました。調べていくと `annotate`+ `Exists, OuterRef`の2つを使って実装できると判明したのでアウトプットしていきます。

# 前提
前提となるファイルのコードです。

```apps/posts/models.py
from django.db import models

class Post(models.Model):
    user = models.ForeignKey(
        "accounts.CustomUser",
        on_delete=models.CASCADE,
        related_name="posts"
    )
    message = models.CharField(max_length=140, null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    # 他のフィールドは省略
```
```apps/likes/models.py
class Like(models.Model):
    user = models.ForeignKey(
        "accounts.CustomUser",
        on_delete=models.CASCADE,
        related_name="likes"
    )
    post = models.ForeignKey(
        "posts.Post",
        on_delete=models.CASCADE,
        related_name="likes"
    )
    # 他のフィールドは省略
    
    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=['user', 'post'],
                name='unique_user_post_like'
            )
        ]
```
```apps/posts/services.py
from apps.posts.models import Post
from django.db.models import Count, Exists, OuterRef
from apps.likes.models import Like

class PostService:
    @staticmethod
    def get_post_list(user):
        """ ポスト一覧を取得（いいね状態付き） """
        return Post.objects.select_related('user') \
            .prefetch_related('comments') \
            .annotate(
                total_comments=Count('comments', distinct=True),
                total_likes=Count('likes', distinct=True),
                is_liked=Exists(
                    Like.objects.filter(
                        post_id=OuterRef('id'),
                        user_id=user.id
                    ))) \
            .order_by('-created_at', '-id')
```
```apps/accounts/views.py
from django.views.generic import TemplateView
from django.contrib.auth.mixins import LoginRequiredMixin
from apps.posts.services import PostService
from django.core.paginator import Paginator

class HomePageView(LoginRequiredMixin, TemplateView):
    template_name = "home.html"
    paginate_by = 5

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        user = self.request.user
        
        # PostServiceからいいね状態付きの投稿一覧を取得
        post_list = PostService.get_post_list(user)
        
        # ページネーション
        paginator = Paginator(post_list, self.paginate_by)
        page_number = self.request.GET.get("page")
        page_obj = paginator.get_page(page_number)

        context.update({
            'user': user,
            'page_obj': page_obj,
            'posts': page_obj.object_list
        })
        return context
```

# 今回の肝
今回大事なのは`services.py`にあります。
```python
Post.objects.select_related('user') \
            .prefetch_related('comments') \
            .annotate(
                total_comments=Count('comments', distinct=True),
                total_likes=Count('likes', distinct=True),
                # ---- 重要なコード -----
                is_liked=Exists(
                    Like.objects.filter(
                        post_id=OuterRef('id'),
                        user_id=user.id
                # ---- 重要なコード -----
                    ))) \
            .order_by('-created_at', '-id')
```
## コード説明
#### `.select_related('user')`
投稿に関連するユーザー情報を同時に取得
#### `.prefetch_related('comments')`
投稿に関連するコメントを事前に一括で取得
select_relatedとは異なり、prefetch_relatedは別のクエリを実行
#### `.annotate(total_comments=Count('comments', distinct=True),`
各投稿に対するコメント数を集計し、`total_comments`という名前の属性として付与。`distinct=True`を指定することで、重複カウントを防止
#### `total_likes=Count('likes', distinct=True),`
同様に、各投稿の「いいね」数を集計し、`total_likes`属性として付与。こちらも`distinct=True`と指定することで、重複カウントを防止
#### `is_liked=Exists(Like.objects.filter(post_id=OuterRef('id'), user_id=user.id))`
各投稿に対して、現在のユーザーがいいねしているかどうかをブール値として計算
- `Exists() `
  - サブクエリの結果が存在するかどうかを判定
- `OuterRef('id')`
  - 外側のクエリ（`Post`）の`id`フィールドを参照します
- この組み合わせにより、各投稿ごとにいいね状態を効率的に判定できます
#### `.order_by('-created_at', '-id')`
取得した投稿を作成日時の降順でソートし、同じ日時の場合はIDの降順でソート

詳しくは公式ドキュメントをご覧ください。

https://docs.djangoproject.com/ja/5.1/ref/models/querysets/

https://docs.djangoproject.com/ja/5.2/ref/models/expressions/#exists-subqueries

# なぜExists, OuterRefがいいね判定に適しているのか
今回 `Exists, OuterRef` を使用した理由は以下です。
- いいね判定はいいねを「している/していない」のどちらかであるため「**True / False**」で判定することが適している
- いいねは1人のユーザーが1つの投稿に対して1回だけであり、単純な存在確認だけであるため `Exists`が最適
- `OuterRef` を使用することで外部クエリとの関連をシンプルにすることができる
- `annotate`と併用することで、1回のクエリで全投稿と各投稿のいいね状態を同時に取得できる

# パフォーマンス度外視したら...
何も考えずに書くと以下のようにserviceなんて使わず、for文を使って各投稿にいいねがされているかを確認することができます。

```python
posts = Post.objects.all()

# 各投稿に対していいね状態を判定
for post in posts:
    post.is_liked = Like.objects.filter(post=post, user=request.user).exists()
```

結論、これでも問題なくコードは動きます。

ただ、このコードの欠点は
**N+1問題**が起きてしまうことです。

:::note warn
N+1問題についての説明は割愛します。

以下の記事はdjangoのN+1問題について詳しく書かれているので、もしよかったらご覧ください。
https://qiita.com/eiji-noguchi/items/08c751dcb7a476ea7485
https://selfs-ryo.com/detail/django_nplusone
https://zenn.dev/shimakaze_soft/articles/99452bc12af6b0
:::

# SQLを確認
先ほどのfor文を使ったコードではどんなクエリが発行されているか確認しましょう。
100件投稿があり、それぞれの投稿に対していいね確認を行うようにします。

## for文を使った場合

まず、投稿を全件取得します。

```sql
SELECT * FROM posts;
```

その後、各投稿に対して100件いいね確認のクエリが実行される
```sql
-- 投稿ID=1のいいね確認
SELECT EXISTS(SELECT 1 FROM likes WHERE post_id = 1 AND user_id = 123);
-- 投稿ID=2のいいね確認
SELECT EXISTS(SELECT 1 FROM likes WHERE post_id = 2 AND user_id = 123);

-- 以降も同じようなSQLが繰り返し続く...

-- 投稿ID=99のいいね確認
SELECT EXISTS(SELECT 1 FROM likes WHERE post_id = 99 AND user_id = 123);
-- 投稿ID=100のいいね確認
SELECT EXISTS(SELECT 1 FROM likes WHERE post_id = 100 AND user_id = 123);
```

このようにfor文を使って100件の投稿に対していいね確認をすると
- 投稿全権取得
- いいね確認 × 100

合計で101回のクエリが発生します。

もし、投稿が1000件, 1万件となるとその分だけクエリの回数が増えます。

## `annotate, Exists, OuterRef`を使った場合

一方で `annotate, Exists, OuterRef` を使うと以下のクエリが発行されます。
```python
# views.py
post_list = PostService.get_post_list(user)

# services.py
class PostService:
    @staticmethod
    def get_post_list(user):
        """ ポスト一覧を取得（いいね状態付き） """
        return Post.objects.select_related('user') \
            .prefetch_related('comments') \
            .annotate(
                total_comments=Count('comments', distinct=True),
                total_likes=Count('likes', distinct=True),
                is_liked=Exists(
                    Like.objects.filter(
                        post_id=OuterRef('id'),
                        user_id=user.id
                    ))) \
            .order_by('-created_at', '-id')
```

```sql
SELECT 
    posts.*,
    EXISTS(
        SELECT 1 
        FROM likes 
        WHERE likes.post_id = posts.id AND likes.user_id = 123
    ) AS is_liked
FROM posts;
```
なんとクエリの発行はたった1回だけです。
100件でも1000件でもたった1回だけで、各投稿がいいねされているか取得できます。

# 最後に
今回は、Djangoにおける投稿の「いいね」状態を効率的に取得する実装方法について解説しました。

多くの開発者が陥りがちなN+1問題を回避するため、`annotate` と `Exists`、`OuterRef` を組み合わせたアプローチを紹介しています。

一般的なfor文による実装では、投稿件数に比例してクエリが発行され（100件の投稿なら101回のクエリ）、アプリケーションのパフォーマンスが著しく低下します。特に大規模なデータセットを扱う実運用環境では致命的な問題となります。今回紹介した手法では、サブクエリを活用して単一のSQLで全ての情報を取得するため、データ量に関わらずクエリ発行回数を1回に抑えられます。

ぜひプロジェクトでの実装時に参考にしてください。