본문 바로가기

백엔드/Prisma + GraphQL

[인스타그램 클론코딩] GraphQL 데이터 모델링

GraphQL을 아직 잘 모르겠다면?

나의 첫번째 GraphQL서버 만들기

model.graphql

인스타그램 클론코딩에 사용된 GraphQL 모델입니다.

참조1: [인스타그램 클론코딩] Prisma2 데이터 모델링

참조2: GraphQL with Prisma2 설계 구조

 

Prisma2부터는 id 타입이 Int입니다. 그래서 GraphQL에서도 ID(String) 타입이 아닌 Int 타입을 반환합니다.

scalar DateTime

type User {
  id: Int!
  userName: String!
  avatar: String
  email: String!
  firstName: String
  lastName: String
  bio: String
  posts: [Post!]
  followers:   [User!]
  following:   [User!]
  likes:       [Like!]
  comments:    [Comment!]
  createdAt:   DateTime
  updatedAt:   DateTime
  fullName: String
  isFollowing: Boolean
  isSelf: Boolean
  postsCount: Int,
  likesCount: Int
  commentsCount: Int,
  followingCount: Int,
  followersCount: Int
}

type Post {
  id:          Int!
  user:        User
  userId:      Int
  location:    String
  caption:     String
  files:       [File!]
  comments:    [Comment!]
  likes:       [Like!]
  createdAt:   DateTime
  updatedAt:   DateTime
  isLiked: Boolean
  likesCount: Int
  commentsCount: Int
}

type Like {
  id:      Int!
  userId:  Int
  postId:  Int
  createdAt:   DateTime
  updatedAt:   DateTime
}

type Comment {
  id:      Int!
  text:    String!
  user:    User!
  userId:  Int
  postId:  Int
  createdAt:   DateTime
  updatedAt:   DateTime
}

type File {
  id:         Int!
  url:        String!
  postId:     Int
  createdAt:  DateTime
  updatedAt:  DateTime
}

scalar DataTime은 다음 포스트에서 설명하겠습니다.

 

GraphQL with Prisma2 설계 구조 포스트에서 Prisma 모델과 타입이 대응해야 한다고 했었습니다.

그런데 Prisma 모델에는 없는 필드가 있네요.

  • User의 fullName, isFollowing, isSelf, postsCount, likesCount, commentsCount, followingCount, followersCount
  • Post의 isLiked, likesCount, commentsCount

클라이언트에서 User 타입의 fullName 필드를 요청했고, Resolver에서 반환한 User 객체에 해당 필드가 없으면, GraphQL 서버는 Computed 필드를 검색합니다.

src/api/User/computed.js

User 타입의 Computed 필드의 Resolvers입니다.

인증 방식이 노마드 코더님의 인스타그램 클론코딩 강의와 다르기 때문에 Resolvers도 약간 다를 것입니다.

parent 매개변수는 User 객체입니다.

예를 들어, searchUser Resolver에서 Prisma 클라이언트를 사용하여 fullName 필드가 없는 User 객체를 반환했다고 해 봅시다.

그런데 GraphQL 서버는 fullName이 필요합니다. 그럼 Computed 필드에서 User 타입의 fullName 필드를 검색합니다. 이때 Computed 필드의 Resolver에 fullName이 없는 미완성의 User 객체를 parent 매개변수로 넘겨줍니다.

const _default = {
    User: {
        fullName: async (parent) => {
            return `${parent.firstName} ${parent.lastName}`;
        },
        isFollowing: async (parent, _, { prisma, me, isValid }) => {
            if(isValid !== true) throw Error("NotAuthenticated")

            const { id: parentId } = parent; // another user or me
            try {
                const exist = await prisma.user.findMany({
                    where: {
                        AND: [
                            {
                                id: parentId
                            },
                            {
                                followers: {
                                    some: {
                                        id: me.id
                                    }
                                    
                                }
                            }
                        ]
                    },
                    select: {
                        id: true
                    }
                });

                if(exist.length > 0) {
                    return true;
                } else {
                    return false
                }
            } catch(error) {
                console.log(error);
                return false;
            }
        },
        isSelf: (parent, _, { me, isValid }) => {
            if(isValid !== true) throw Error("NotAuthenticated")

            const { id: parentId } = parent;
            return me.id === parentId;
        },
        posts: async (parent, _, {prisma, isValid}) => {
            if(isValid !== true) throw Error("NotAuthenticated")
            try {
                return await prisma.post.findMany({
                    where: {
                        userId: parent.id
                    }
                });
            } catch (e) {
                console.log(e);
            }
        },
        postsCount: async (parent, _, {prisma, isValid}) => {
            if(isValid !== true) throw Error("NotAuthenticated")
            try {
                const posts = await prisma.post.findMany({
                    where: {
                        userId: parent.id
                    },
                    select: {
                        id: true
                    }
                });

                return posts.length;
            } catch (e) {
                console.log(e);
            }
        },
        likesCount: async (parent, _, {prisma, isValid}) => {
            if(isValid !== true) throw Error("NotAuthenticated")

            try {
                const likes = await prisma.like.findMany({
                    where: {
                        userId: parent.id
                    },
                    select: {
                        id: true
                    }
                })
    
                return likes.length;
            } catch (e) {
                console.log(e);
            }
        },
        commentsCount: async (parent, _, {prisma, isValid}) => {
            if(isValid !== true) throw Error("NotAuthenticated")

            try {
                const comments = await prisma.comment.findMany({
                    where: {
                        userId: parent.id
                    },
                    select: {
                        id: true
                    }
                })
    
                return comments.length;
            } catch (e) {
                console.log(e);
            }
        },
        followingCount: async (parent, _, {prisma, isValid}) => {
            if(isValid !== true) throw Error("NotAuthenticated")

            try {
                const users = await prisma.user.findMany({
                    where: {
                        followers: {
                            some: {
                                id: parent.id
                            }
                        }
                    },
                    select: {
                        id: true
                    }
                });

                return users.length;
            } catch (e) {
                console.log(e);
            }
        },
        followersCount: async (parent, _, {prisma, isValid}) => {
            if(isValid !== true) throw Error("NotAuthenticated")

            try {
                const users = await prisma.user.findMany({
                    where: {
                        following: {
                            some: {
                                id: parent.id
                            }
                        }
                    },
                    select: {
                        id: true
                    }
                });

                return users.length;
            } catch (e) {
                console.log(e);
            }
        },
    }
}

exports.default = _default;

위의 Computed 필드 Resolvers가 항상 실행되는 것은 아닙니다. 클라이언트에서 해당 타입의 필드를 요청한 경우에만 실행됩니다.

 

참고로 User 타입의 Computed 필드의 목적은 다음과 같습니다.

  • fullName
    어떤 Resolver에서 반환한 User 객체의 fisrtName과 lastName을 사용해서 사용자의 풀네임을 구하기위해 사용됩니다. 데이터베이스의 리소스를 절약할 수 있습니다. 
  • isFollowing
    어떤 Resolver에서 반환한 User 객체를 인증된 사용자(요청자)가 팔로잉하고 있는지를 나타냅니다. 이것은 클라이언트마다, 그리고 반환된 User 객체마다 달라지기 때문에 GraphQL 모듈에서 처리할 수 밖에 없습니다.
  • isSelf
    어떤 Resolver에서 반환한 User 객체가 요청자(자기자신)인지를 나타냅니다.
    isFollowing과 동일한 이유로 GraphQL 모듈에서 처리해야 합니다.
  • postsCount
  • likesCount
  • commentsCount,
  • followingCount,
  • followersCount
    1:N Relation이 설정된 필드 역시 요청자에 따라 달라질 수 있기 때문에 GraphQL에서 처리해야 합니다.

그런데 Prisma 모델에는 posts가 있는데 Computed 필드에도 posts가 있는 이유는 무엇인가요?

Prisma에서는 깊은 연결을 제한합니다.

예를 들면, 다음과 같은 해커의 공격을 차단하기 위해서입니다.

searchUser(id: userId) {
    id
    posts {
    	user {
            posts {
            	user {
                    posts {
                        user {
                        	...
                        }
                    }
                }
            }
        }
    }
}

그래서 Prisma 클라이언트는 다른 레코드와 연결된 필드는 기본적으로 반환하지 않습니다.

다음과 같이 include를 사용하는 방법도 있습니다만, User 객체의 posts 필드를 반환할 필요가 없는 Resolver를 위해서 (posts 등의 반환 여부를 클라이언트에게 맡기기위해서) Cmputed 필드로 만들었습니다.

prisma.user.findOne({
    where: { id },
    include: { posts: true }
})

참조: https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/relation-queries#nested-reads