From 04b9e7bfc80035105b4547e7724a947d401fa31f Mon Sep 17 00:00:00 2001 From: kubrickcode Date: Tue, 25 Nov 2025 12:21:30 +0000 Subject: [PATCH] =?UTF-8?q?refactor(backend):=20DataLoader=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=95=88=EC=A0=84=EC=84=B1=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit any 타입과 as 타입 단언을 제거하고 제네릭 헬퍼 함수로 중복 코드 제거 - any[] → Prisma.XxxGetPayload 타입으로 교체 - as number[] → spread 연산자로 교체 - 4개 private 메서드 → 2개 제네릭 헬퍼 함수로 통합 - types, utils 파일 분리로 관심사 분리 fix #232 --- .../src/dataloader/data-loader.service.ts | 152 +++++------------- .../src/dataloader/data-loader.types.ts | 17 ++ .../src/dataloader/data-loader.utils.spec.ts | 121 ++++++++++++++ .../src/dataloader/data-loader.utils.ts | 46 ++++++ 4 files changed, 221 insertions(+), 115 deletions(-) create mode 100644 src/backend/src/dataloader/data-loader.types.ts create mode 100644 src/backend/src/dataloader/data-loader.utils.spec.ts create mode 100644 src/backend/src/dataloader/data-loader.utils.ts diff --git a/src/backend/src/dataloader/data-loader.service.ts b/src/backend/src/dataloader/data-loader.service.ts index af556565..a216d5b9 100644 --- a/src/backend/src/dataloader/data-loader.service.ts +++ b/src/backend/src/dataloader/data-loader.service.ts @@ -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(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(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(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(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>; + readonly contentRewards: ManyLoader; + readonly contentSeeMoreRewards: ManyLoader; + readonly item: UniqueLoader>; + + constructor(private prisma: PrismaService) { + this.contentCategory = createUniqueLoader( + (ids) => this.prisma.contentCategory.findMany({ where: { id: { in: [...ids] } } }), + "ContentCategory" + ); + + this.contentRewards = createManyLoader((ids) => + this.prisma.contentReward.findMany({ + include: { item: true }, + where: { contentId: { in: [...ids] } }, + }) + ); + + this.contentSeeMoreRewards = createManyLoader((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" + ); } } diff --git a/src/backend/src/dataloader/data-loader.types.ts b/src/backend/src/dataloader/data-loader.types.ts new file mode 100644 index 00000000..4cf7e867 --- /dev/null +++ b/src/backend/src/dataloader/data-loader.types.ts @@ -0,0 +1,17 @@ +import { Prisma } from "@prisma/client"; + +export type ManyLoader = { + findManyByContentId: (contentId: number) => Promise; +}; + +export type UniqueLoader = { + findUniqueOrThrowById: (id: number) => Promise; +}; + +export type ContentRewardWithItem = Prisma.ContentRewardGetPayload<{ + include: { item: true }; +}>; + +export type ContentSeeMoreRewardWithItem = Prisma.ContentSeeMoreRewardGetPayload<{ + include: { item: true }; +}>; diff --git a/src/backend/src/dataloader/data-loader.utils.spec.ts b/src/backend/src/dataloader/data-loader.utils.spec.ts new file mode 100644 index 00000000..1f5b09d5 --- /dev/null +++ b/src/backend/src/dataloader/data-loader.utils.spec.ts @@ -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("알 수 없는 아이템"); + }); +}); diff --git a/src/backend/src/dataloader/data-loader.utils.ts b/src/backend/src/dataloader/data-loader.utils.ts new file mode 100644 index 00000000..46045301 --- /dev/null +++ b/src/backend/src/dataloader/data-loader.utils.ts @@ -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 = ( + batchFn: (ids: readonly number[]) => Promise, + entityName: string +): UniqueLoader => { + const loader = new DataLoader(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 = ( + batchFn: (ids: readonly number[]) => Promise +): ManyLoader => { + const loader = new DataLoader(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; + }); + + const grouped = _.groupBy(sortedItems, "contentId"); + return contentIds.map((id) => grouped[id] || []); + }); + + return { + findManyByContentId: async (contentId: number) => loader.load(contentId), + }; +};