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
8 changes: 0 additions & 8 deletions src/features/store/repositories/store-wishlist.repository.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';

import { WISHLISTED_STORE_IMAGE_LIMIT } from '@/features/store/constants/store-wishlist.constants';
import { PrismaService } from '@/prisma';

/** 찜한 매장 목록 조회 결과 row. myWishlistedStores 매퍼 입력. */
Expand All @@ -14,7 +13,6 @@ export interface WishlistedStoreRow {
address_city: string | null;
address_neighborhood: string | null;
region: { name: string } | null;
store_images: { image_url: string }[];
};
}

Expand Down Expand Up @@ -129,12 +127,6 @@ export class StoreWishlistRepository {
address_city: true,
address_neighborhood: true,
region: { select: { name: true } },
store_images: {
where: { deleted_at: null },
orderBy: { sort_order: 'asc' },
take: WISHLISTED_STORE_IMAGE_LIMIT,
select: { image_url: true },
},
},
},
},
Expand Down
3 changes: 2 additions & 1 deletion src/features/store/repositories/store.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ export class StoreRepository {
/** 페이지 매장들의 대표 케이크 이미지(매장당 최대 N장, 활성 상품 1장씩). */
async findStoreCakeImages(
storeIds: bigint[],
limit: number = POPULAR_STORE_CAKE_IMAGE_LIMIT,
): Promise<Map<bigint, string[]>> {
if (storeIds.length === 0) return new Map();

Expand All @@ -396,7 +397,7 @@ export class StoreRepository {
images: { some: { deleted_at: null } },
},
orderBy: { id: 'desc' },
take: POPULAR_STORE_CAKE_IMAGE_LIMIT,
take: limit,
select: {
images: {
where: { deleted_at: null },
Expand Down
74 changes: 61 additions & 13 deletions src/features/store/services/store-wishlist.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { closeTruncateConnection, truncateAll } from '@/test/db/truncate';
import {
createAccount,
createOrderItem,
createProduct,
createReview,
createStore,
createStoreWishlist,
Expand Down Expand Up @@ -142,13 +143,14 @@ describe('StoreWishlistService (real DB)', () => {
});

describe('myWishlistedStores', () => {
async function addImages(storeId: bigint, urls: string[]): Promise<void> {
await prisma.storeImage.createMany({
data: urls.map((url, index) => ({
store_id: storeId,
image_url: url,
sort_order: index,
})),
/** 상품 1개 + 대표 이미지 1장 생성(카드 이미지 소스). */
async function addProductWithImage(
storeId: bigint,
imageUrl: string,
): Promise<void> {
const product = await createProduct(prisma, { store_id: storeId });
await prisma.productImage.create({
data: { product_id: product.id, image_url: imageUrl, sort_order: 0 },
});
}

Expand Down Expand Up @@ -186,13 +188,59 @@ describe('StoreWishlistService (real DB)', () => {
expect(haz.addedAt).toBeInstanceOf(Date);
});

it('대표 이미지는 sort_order asc 최대 3장, 삭제된 이미지는 제외한다', async () => {
it('카드 이미지는 상품 대표 이미지 최대 3장(최신 상품 우선), 이미지 없는 매장은 빈 배열이다', async () => {
const account = await createAccount(prisma, { account_type: 'USER' });
const store = await createStore(prisma);
await addImages(store.id, ['u0', 'u1', 'u2', 'u3']);
await prisma.storeImage.updateMany({
where: { store_id: store.id, image_url: 'u1' },
data: { deleted_at: new Date() },
const empty = await createStore(prisma);
// 상품 4개 → 최신(id desc) 3개의 대표 이미지만 포함
await addProductWithImage(store.id, 'u0');
await addProductWithImage(store.id, 'u1');
await addProductWithImage(store.id, 'u2');
await addProductWithImage(store.id, 'u3');
await createStoreWishlist(prisma, {
account_id: account.id,
store_id: store.id,
});
await createStoreWishlist(prisma, {
account_id: account.id,
store_id: empty.id,
});

const result = await service.myWishlistedStores(account.id);

const withImages = result.items.find(
(i) => i.storeId === store.id.toString(),
);
const withoutImages = result.items.find(
(i) => i.storeId === empty.id.toString(),
);
expect(withImages?.imageUrls).toEqual(['u3', 'u2', 'u1']);
expect(withoutImages?.imageUrls).toEqual([]);
});

it('삭제된 이미지·비활성 상품은 카드 이미지에서 제외한다', async () => {
const account = await createAccount(prisma, { account_type: 'USER' });
const store = await createStore(prisma);
await addProductWithImage(store.id, 'kept');
// 이미지가 soft-delete된 상품 → 이미지 보유 상품이 아니므로 제외
const deletedImageProduct = await createProduct(prisma, {
store_id: store.id,
});
await prisma.productImage.create({
data: {
product_id: deletedImageProduct.id,
image_url: 'deleted',
sort_order: 0,
deleted_at: new Date(),
},
});
// 비활성 상품 → 제외
const inactive = await createProduct(prisma, {
store_id: store.id,
is_active: false,
});
await prisma.productImage.create({
data: { product_id: inactive.id, image_url: 'inactive', sort_order: 0 },
});
await createStoreWishlist(prisma, {
account_id: account.id,
Expand All @@ -201,7 +249,7 @@ describe('StoreWishlistService (real DB)', () => {

const result = await service.myWishlistedStores(account.id);

expect(result.items[0].imageUrls).toEqual(['u0', 'u2', 'u3']);
expect(result.items[0].imageUrls).toEqual(['kept']);
});

it('평점은 소수 첫째 자리 반올림, 리뷰 없으면 0.0/0건이다', async () => {
Expand Down
21 changes: 15 additions & 6 deletions src/features/store/services/store-wishlist.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {

import { parseId } from '@/common/utils/id-parser';
import { STORE_WISHLIST_ERRORS } from '@/features/store/constants/store-wishlist-error-messages';
import { DEFAULT_WISHLISTED_STORES_LIMIT } from '@/features/store/constants/store-wishlist.constants';
import {
DEFAULT_WISHLISTED_STORES_LIMIT,
WISHLISTED_STORE_IMAGE_LIMIT,
} from '@/features/store/constants/store-wishlist.constants';
import type { MyWishlistedStoresInput } from '@/features/store/dto/inputs/my-wishlisted-stores.input';
import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository';
import { StoreRepository } from '@/features/store/repositories/store.repository';
Expand Down Expand Up @@ -60,10 +63,16 @@ export class StoreWishlistService {
limit,
});

// 평점은 페이지 매장들만 단일 groupBy로 집계(N+1 회피). 랭킹과 동일 소스.
const reviewStats = await this.storeRepo.aggregateReviewStats(
items.map((row) => row.store.id),
);
const storeIds = items.map((row) => row.store.id);
// 평점·이미지는 페이지 매장들만 집계(N+1 회피).
// 카드 이미지는 인기 매장 카드(PopularStore)와 동일하게 상품 대표 이미지를 쓴다(#216).
const [reviewStats, cakeImages] = await Promise.all([
this.storeRepo.aggregateReviewStats(storeIds),
this.storeRepo.findStoreCakeImages(
storeIds,
WISHLISTED_STORE_IMAGE_LIMIT,
),
Comment on lines +71 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Batch image queries for wishlist pages

When a caller requests the allowed 50-item wishlist page, this call passes all 50 IDs to findStoreCakeImages, whose storeIds.map executes a separate product.findMany for every store. The endpoint therefore adds up to 50 concurrent product queries per request, whereas the previous nested relation selection could batch image loading; under concurrent traffic this increases database load and can cause connection-pool waits or timeouts. Fetch and group the product images in a bounded number of queries instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

미반영(기존 결정 유지): findStoreCakeImages의 매장당 병렬 조회는 popularStores·todayPickupStores가 이미 운영 중인 의도된 트레이드오프(전체 상품 materialize 대신 take 제한, 레포 주석 참조). 병렬도는 페이지 상한(50)으로 바운드되고 Prisma 커넥션 풀 큐잉으로 흡수됨. 트래픽 증가로 실측 문제가 생기면 윈도우 함수 기반 배치 조회로 세 호출부를 한꺼번에 최적화하는 편이 맞다고 판단.

]);

return {
items: items.map((row) => {
Expand All @@ -76,7 +85,7 @@ export class StoreWishlistService {
ratingAverage: Math.round((stat?.average ?? 0) * 10) / 10,
reviewCount: stat?.count ?? 0,
regionLabel: buildRegionLabel(row.store),
imageUrls: row.store.store_images.map((image) => image.image_url),
imageUrls: cakeImages.get(row.store.id) ?? [],
addedAt: row.created_at,
};
}),
Expand Down
2 changes: 1 addition & 1 deletion src/features/store/store-wishlist.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type WishlistedStoreSummary {
reviewCount: Int!
"""매장 위치 표기(예: 인천 청라동)."""
regionLabel: String
"""매장 대표 이미지(최대 3장, sort_order asc)."""
"""대표 케이크 이미지(활성 상품 대표 이미지, 최대 3장). 인기 매장 카드와 동일 소스."""
imageUrls: [String!]!
"""찜한 시각."""
addedAt: DateTime!
Expand Down
Loading