-
-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(backend): DataLoader 타입 안전성 개선 및 구조 분리 #245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,122 +1,44 @@ | ||
| import _ from "lodash"; | ||
| import DataLoader from "dataloader"; | ||
| import { Injectable, Scope } from "@nestjs/common"; | ||
| import { Prisma } from "@prisma/client"; | ||
| import { PrismaService } from "src/prisma"; | ||
| import { ContentCategory, Item } from "@prisma/client"; | ||
| import { ItemSortOrder } from "src/content/shared/constants"; | ||
| import { | ||
| ContentRewardWithItem, | ||
| ContentSeeMoreRewardWithItem, | ||
| ManyLoader, | ||
| UniqueLoader, | ||
| } from "./data-loader.types"; | ||
| import { createManyLoader, createUniqueLoader } from "./data-loader.utils"; | ||
|
|
||
| @Injectable({ scope: Scope.REQUEST }) | ||
| export class DataLoaderService { | ||
| readonly contentCategory = this.createContentCategoryLoader(); | ||
| readonly contentRewards = this.createContentRewardsLoader(); | ||
| readonly contentSeeMoreRewards = this.createContentSeeMoreRewardsLoader(); | ||
| readonly item = this.createItemLoader(); | ||
|
|
||
| constructor(private prisma: PrismaService) {} | ||
|
|
||
| private createContentCategoryLoader() { | ||
| const contentCategoryLoader = new DataLoader<number, ContentCategory>(async (categoryIds) => { | ||
| const categories = await this.prisma.contentCategory.findMany({ | ||
| where: { | ||
| id: { in: categoryIds as number[] }, | ||
| }, | ||
| }); | ||
|
|
||
| const categoriesMap = _.keyBy(categories, "id"); | ||
|
|
||
| return categoryIds.map((id) => categoriesMap[id]); | ||
| }); | ||
|
|
||
| return { | ||
| findUniqueOrThrowById: async (categoryId: number) => { | ||
| const result = await contentCategoryLoader.load(categoryId); | ||
| if (!result) { | ||
| throw new Error(`ContentCategory with id ${categoryId} not found`); | ||
| } | ||
| return result; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| private createContentRewardsLoader() { | ||
| const contentRewardsLoader = new DataLoader<number, any[]>(async (contentIds) => { | ||
| const rewards = await this.prisma.contentReward.findMany({ | ||
| include: { | ||
| item: true, | ||
| }, | ||
| where: { | ||
| contentId: { in: contentIds as number[] }, | ||
| }, | ||
| }); | ||
|
|
||
| const sortedRewards = _.cloneDeep(rewards).sort((a, b) => { | ||
| const aOrder = ItemSortOrder[a.item.name] || 999; | ||
| const bOrder = ItemSortOrder[b.item.name] || 999; | ||
| return aOrder - bOrder; | ||
| }); | ||
|
|
||
| const rewardsGrouped = _.groupBy(sortedRewards, "contentId"); | ||
|
|
||
| return contentIds.map((id) => rewardsGrouped[id] || []); | ||
| }); | ||
|
|
||
| return { | ||
| findManyByContentId: async (contentId: number) => { | ||
| return await contentRewardsLoader.load(contentId); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| private createContentSeeMoreRewardsLoader() { | ||
| const contentSeeMoreRewardsLoader = new DataLoader<number, any[]>(async (contentIds) => { | ||
| const rewards = await this.prisma.contentSeeMoreReward.findMany({ | ||
| include: { | ||
| item: true, | ||
| }, | ||
| where: { | ||
| contentId: { in: contentIds as number[] }, | ||
| }, | ||
| }); | ||
|
|
||
| const sortedRewards = _.cloneDeep(rewards).sort((a, b) => { | ||
| const aOrder = ItemSortOrder[a.item.name] || 999; | ||
| const bOrder = ItemSortOrder[b.item.name] || 999; | ||
| return aOrder - bOrder; | ||
| }); | ||
|
|
||
| const rewardsGrouped = _.groupBy(sortedRewards, "contentId"); | ||
|
|
||
| return contentIds.map((id) => rewardsGrouped[id] || []); | ||
| }); | ||
|
|
||
| return { | ||
| findManyByContentId: async (contentId: number) => { | ||
| return await contentSeeMoreRewardsLoader.load(contentId); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| private createItemLoader() { | ||
| const itemLoader = new DataLoader<number, Item>(async (itemIds) => { | ||
| const items = await this.prisma.item.findMany({ | ||
| where: { | ||
| id: { in: itemIds as number[] }, | ||
| }, | ||
| }); | ||
|
|
||
| const itemsMap = _.keyBy(items, "id"); | ||
|
|
||
| return itemIds.map((id) => itemsMap[id]); | ||
| }); | ||
|
|
||
| return { | ||
| findUniqueOrThrowById: async (itemId: number) => { | ||
| const result = await itemLoader.load(itemId); | ||
| if (!result) { | ||
| throw new Error(`Item with id ${itemId} not found`); | ||
| } | ||
| return result; | ||
| }, | ||
| }; | ||
| readonly contentCategory: UniqueLoader<Prisma.ContentCategoryGetPayload<object>>; | ||
| readonly contentRewards: ManyLoader<ContentRewardWithItem>; | ||
| readonly contentSeeMoreRewards: ManyLoader<ContentSeeMoreRewardWithItem>; | ||
| readonly item: UniqueLoader<Prisma.ItemGetPayload<object>>; | ||
|
|
||
| constructor(private prisma: PrismaService) { | ||
| this.contentCategory = createUniqueLoader( | ||
| (ids) => this.prisma.contentCategory.findMany({ where: { id: { in: [...ids] } } }), | ||
| "ContentCategory" | ||
| ); | ||
|
|
||
| this.contentRewards = createManyLoader<ContentRewardWithItem>((ids) => | ||
| this.prisma.contentReward.findMany({ | ||
| include: { item: true }, | ||
| where: { contentId: { in: [...ids] } }, | ||
| }) | ||
| ); | ||
|
|
||
| this.contentSeeMoreRewards = createManyLoader<ContentSeeMoreRewardWithItem>((ids) => | ||
| this.prisma.contentSeeMoreReward.findMany({ | ||
| include: { item: true }, | ||
| where: { contentId: { in: [...ids] } }, | ||
| }) | ||
| ); | ||
|
|
||
| this.item = createUniqueLoader( | ||
| (ids) => this.prisma.item.findMany({ where: { id: { in: [...ids] } } }), | ||
| "Item" | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { Prisma } from "@prisma/client"; | ||
|
|
||
| export type ManyLoader<T> = { | ||
| findManyByContentId: (contentId: number) => Promise<T[]>; | ||
| }; | ||
|
|
||
| export type UniqueLoader<T> = { | ||
| findUniqueOrThrowById: (id: number) => Promise<T>; | ||
| }; | ||
|
|
||
| export type ContentRewardWithItem = Prisma.ContentRewardGetPayload<{ | ||
| include: { item: true }; | ||
| }>; | ||
|
|
||
| export type ContentSeeMoreRewardWithItem = Prisma.ContentSeeMoreRewardGetPayload<{ | ||
| include: { item: true }; | ||
| }>; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import { createManyLoader, createUniqueLoader } from "./data-loader.utils"; | ||
|
|
||
| describe("createUniqueLoader", () => { | ||
| it("존재하는 엔티티 로드 시 정상 반환", async () => { | ||
| const mockData = [ | ||
| { id: 1, name: "Item1" }, | ||
| { id: 2, name: "Item2" }, | ||
| ]; | ||
| const batchFn = jest.fn().mockResolvedValue(mockData); | ||
|
|
||
| const loader = createUniqueLoader(batchFn, "Item"); | ||
| const result = await loader.findUniqueOrThrowById(1); | ||
|
|
||
| expect(result).toEqual({ id: 1, name: "Item1" }); | ||
| }); | ||
|
|
||
| it("존재하지 않는 ID 로드 시 에러 throw", async () => { | ||
| const batchFn = jest.fn().mockResolvedValue([]); | ||
|
|
||
| const loader = createUniqueLoader(batchFn, "Item"); | ||
|
|
||
| await expect(loader.findUniqueOrThrowById(999)).rejects.toThrow("Item with id 999 not found"); | ||
| }); | ||
|
|
||
| it("여러 ID 동시 요청 시 배칭 동작", async () => { | ||
| const mockData = [ | ||
| { id: 1, name: "Item1" }, | ||
| { id: 2, name: "Item2" }, | ||
| { id: 3, name: "Item3" }, | ||
| ]; | ||
| const batchFn = jest.fn().mockResolvedValue(mockData); | ||
|
|
||
| const loader = createUniqueLoader(batchFn, "Item"); | ||
|
|
||
| const [result1, result2, result3] = await Promise.all([ | ||
| loader.findUniqueOrThrowById(1), | ||
| loader.findUniqueOrThrowById(2), | ||
| loader.findUniqueOrThrowById(3), | ||
| ]); | ||
|
|
||
| expect(batchFn).toHaveBeenCalledTimes(1); | ||
| expect(batchFn).toHaveBeenCalledWith([1, 2, 3]); | ||
| expect(result1).toEqual({ id: 1, name: "Item1" }); | ||
| expect(result2).toEqual({ id: 2, name: "Item2" }); | ||
| expect(result3).toEqual({ id: 3, name: "Item3" }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("createManyLoader", () => { | ||
| it("정상적으로 다중 엔티티 로드", async () => { | ||
| const mockData = [ | ||
| { contentId: 1, item: { name: "골드" } }, | ||
| { contentId: 1, item: { name: "실링" } }, | ||
| ]; | ||
| const batchFn = jest.fn().mockResolvedValue(mockData); | ||
|
|
||
| const loader = createManyLoader(batchFn); | ||
| const result = await loader.findManyByContentId(1); | ||
|
|
||
| expect(result).toHaveLength(2); | ||
| }); | ||
|
|
||
| it("ItemSortOrder에 따른 정렬", async () => { | ||
| const mockData = [ | ||
| { contentId: 1, item: { name: "실링" } }, // order: 10 | ||
| { contentId: 1, item: { name: "골드" } }, // order: 1 | ||
| { contentId: 1, item: { name: "운명의 파편" } }, // order: 3 | ||
| ]; | ||
| const batchFn = jest.fn().mockResolvedValue(mockData); | ||
|
|
||
| const loader = createManyLoader(batchFn); | ||
| const result = await loader.findManyByContentId(1); | ||
|
|
||
| expect(result[0].item.name).toBe("골드"); | ||
| expect(result[1].item.name).toBe("운명의 파편"); | ||
| expect(result[2].item.name).toBe("실링"); | ||
| }); | ||
|
|
||
| it("존재하지 않는 contentId에 대해 빈 배열 반환", async () => { | ||
| const batchFn = jest.fn().mockResolvedValue([]); | ||
|
|
||
| const loader = createManyLoader(batchFn); | ||
| const result = await loader.findManyByContentId(999); | ||
|
|
||
| expect(result).toEqual([]); | ||
| }); | ||
|
|
||
| it("여러 contentId 동시 요청 시 배칭 동작", async () => { | ||
| const mockData = [ | ||
| { contentId: 1, item: { name: "골드" } }, | ||
| { contentId: 2, item: { name: "실링" } }, | ||
| ]; | ||
| const batchFn = jest.fn().mockResolvedValue(mockData); | ||
|
|
||
| const loader = createManyLoader(batchFn); | ||
|
|
||
| const [result1, result2] = await Promise.all([ | ||
| loader.findManyByContentId(1), | ||
| loader.findManyByContentId(2), | ||
| ]); | ||
|
|
||
| expect(batchFn).toHaveBeenCalledTimes(1); | ||
| expect(batchFn).toHaveBeenCalledWith([1, 2]); | ||
| expect(result1).toHaveLength(1); | ||
| expect(result2).toHaveLength(1); | ||
| }); | ||
|
|
||
| it("ItemSortOrder에 없는 아이템은 마지막 정렬", async () => { | ||
| const mockData = [ | ||
| { contentId: 1, item: { name: "알 수 없는 아이템" } }, // order: 999 | ||
| { contentId: 1, item: { name: "골드" } }, // order: 1 | ||
| ]; | ||
| const batchFn = jest.fn().mockResolvedValue(mockData); | ||
|
|
||
| const loader = createManyLoader(batchFn); | ||
| const result = await loader.findManyByContentId(1); | ||
|
|
||
| expect(result[0].item.name).toBe("골드"); | ||
| expect(result[1].item.name).toBe("알 수 없는 아이템"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import _ from "lodash"; | ||
| import DataLoader from "dataloader"; | ||
| import { ItemSortOrder } from "src/content/shared/constants"; | ||
| import { ManyLoader, UniqueLoader } from "./data-loader.types"; | ||
|
|
||
| export const createUniqueLoader = <T extends { id: number }>( | ||
| batchFn: (ids: readonly number[]) => Promise<T[]>, | ||
| entityName: string | ||
| ): UniqueLoader<T> => { | ||
| const loader = new DataLoader<number, T>(async (ids) => { | ||
| const items = await batchFn(ids); | ||
| const itemsMap = _.keyBy(items, "id"); | ||
| return ids.map((id) => itemsMap[id]); | ||
| }); | ||
|
|
||
| return { | ||
| findUniqueOrThrowById: async (id: number) => { | ||
| const result = await loader.load(id); | ||
| if (!result) { | ||
| throw new Error(`${entityName} with id ${id} not found`); | ||
| } | ||
| return result; | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| export const createManyLoader = <T extends { contentId: number; item: { name: string } }>( | ||
| batchFn: (ids: readonly number[]) => Promise<T[]> | ||
| ): ManyLoader<T> => { | ||
| const loader = new DataLoader<number, T[]>(async (contentIds) => { | ||
| const items = await batchFn(contentIds); | ||
|
|
||
| const sortedItems = _.cloneDeep(items).sort((a, b) => { | ||
| const aOrder = ItemSortOrder[a.item.name] || 999; | ||
| const bOrder = ItemSortOrder[b.item.name] || 999; | ||
| return aOrder - bOrder; | ||
| }); | ||
kubrickcode marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const grouped = _.groupBy(sortedItems, "contentId"); | ||
| return contentIds.map((id) => grouped[id] || []); | ||
| }); | ||
|
|
||
| return { | ||
| findManyByContentId: async (contentId: number) => loader.load(contentId), | ||
| }; | ||
| }; | ||
kubrickcode marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.