Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 37 additions & 115 deletions src/backend/src/dataloader/data-loader.service.ts
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"
);
}
}
17 changes: 17 additions & 0 deletions src/backend/src/dataloader/data-loader.types.ts
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 };
}>;
121 changes: 121 additions & 0 deletions src/backend/src/dataloader/data-loader.utils.spec.ts
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("알 수 없는 아이템");
});
});
46 changes: 46 additions & 0 deletions src/backend/src/dataloader/data-loader.utils.ts
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;
});

const grouped = _.groupBy(sortedItems, "contentId");
return contentIds.map((id) => grouped[id] || []);
});

return {
findManyByContentId: async (contentId: number) => loader.load(contentId),
};
};
Loading