From 1b6d2eba3732758ce2af42c72475b81d2ef59e37 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 3 Sep 2026 04:13:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(=EB=8C=80=ED=99=94?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=C2=B7?= =?UTF-8?q?=EB=B6=80=EA=B0=80=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EC=8A=A4=EB=83=85=EC=83=B7=EC=9C=BC=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 릴리즈 PR #272 Codex P2 반영: myConversations의 페이지 조회·건수·부가 정보(마지막 메시지 미리보기·안읽음 수)가 독립 조회로 쪼개져 있어, 사이에 커밋된 메시지가 미리보기에만 반영되고 정렬 기준·커서(lastMessageAt)는 과거 값으로 남는 혼합 상태가 나갈 수 있었다. - getConversationPageWithExtras: 세 조회를 한 트랜잭션(단일 REPEATABLE READ 스냅샷)으로 통합, 부가 정보는 페이지 항목(limit)만 조회 - 기존 세 메서드는 tx 스코프 private으로 전환(외부 사용처 없음) --- .../repositories/conversation.repository.ts | 58 +++++++++++++++---- .../services/conversation-center.service.ts | 16 +++-- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 36028b2..a28085f 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -129,20 +129,52 @@ export class ConversationRepository { } /** - * 구매자 대화 목록 페이지. (last_message_at, id) desc 키셋. - * 대화는 첫 메시지 전송 시에만 생성되지만, 방어적으로 메시지 없는 - * 대화(last_message_at null)는 목록에서 제외한다 — 커서 정렬 키가 없다. + * 구매자 대화 목록 페이지 + 부가 정보(마지막 메시지·안읽음 수)를 한 + * 트랜잭션(단일 REPEATABLE READ 스냅샷)으로 읽는다 — 조회를 쪼개면 + * 사이에 커밋된 메시지가 미리보기/안읽음 수에만 반영되고 정렬 기준· + * 커서(lastMessageAt)는 과거 값으로 남는 혼합 상태가 나갈 수 있다 + * (릴리즈 리뷰 반영). */ - async listConversationsByAccount(args: { + async getConversationPageWithExtras(args: { accountId: bigint; limit: number; cursor?: { lastMessageAt: Date; id: bigint }; }) { + return this.prisma.$transaction(async (tx) => { + const [rows, totalCount] = await Promise.all([ + this.listConversationsByAccount(tx, args), + this.countConversationsByAccount(tx, args.accountId), + ]); + // 초과분(limit+1)은 hasMore 판정용 — 부가 정보는 페이지 항목만 조회 + const extras = await this.getConversationListExtras( + tx, + rows.slice(0, args.limit).map((row) => ({ + id: row.id, + last_read_at: row.last_read_at, + })), + ); + return { rows, totalCount, extras }; + }); + } + + /** + * 구매자 대화 목록 페이지. (last_message_at, id) desc 키셋. + * 대화는 첫 메시지 전송 시에만 생성되지만, 방어적으로 메시지 없는 + * 대화(last_message_at null)는 목록에서 제외한다 — 커서 정렬 키가 없다. + */ + private async listConversationsByAccount( + tx: Prisma.TransactionClient, + args: { + accountId: bigint; + limit: number; + cursor?: { lastMessageAt: Date; id: bigint }; + }, + ) { const where: Prisma.StoreConversationWhereInput = { account_id: args.accountId, last_message_at: { not: null }, }; - return this.prisma.storeConversation.findMany({ + return tx.storeConversation.findMany({ where: args.cursor ? { AND: [ @@ -167,8 +199,11 @@ export class ConversationRepository { }); } - async countConversationsByAccount(accountId: bigint): Promise { - return this.prisma.storeConversation.count({ + private async countConversationsByAccount( + tx: Prisma.TransactionClient, + accountId: bigint, + ): Promise { + return tx.storeConversation.count({ where: { account_id: accountId, last_message_at: { not: null } }, }); } @@ -180,13 +215,14 @@ export class ConversationRepository { * 최신 메시지 id 집계 → 본문 일괄 조회 → 안읽음 OR-분기 groupBy의 * 고정 3쿼리로 배치한다. */ - async getConversationListExtras( + private async getConversationListExtras( + tx: Prisma.TransactionClient, rows: { id: bigint; last_read_at: Date | null }[], ) { if (rows.length === 0) return []; const ids = rows.map((row) => row.id); - const latestIdRows = await this.prisma.storeConversationMessage.groupBy({ + const latestIdRows = await tx.storeConversationMessage.groupBy({ by: ['conversation_id'], where: { conversation_id: { in: ids } }, _max: { id: true }, @@ -197,7 +233,7 @@ export class ConversationRepository { const [latestMessages, unreadGroups] = await Promise.all([ latestIds.length > 0 - ? this.prisma.storeConversationMessage.findMany({ + ? tx.storeConversationMessage.findMany({ where: { id: { in: latestIds } }, select: { conversation_id: true, @@ -207,7 +243,7 @@ export class ConversationRepository { }, }) : Promise.resolve([]), - this.prisma.storeConversationMessage.groupBy({ + tx.storeConversationMessage.groupBy({ by: ['conversation_id'], where: { sender_type: { not: ConversationSenderType.USER }, diff --git a/src/features/conversation/services/conversation-center.service.ts b/src/features/conversation/services/conversation-center.service.ts index 7f8996b..b5d1496 100644 --- a/src/features/conversation/services/conversation-center.service.ts +++ b/src/features/conversation/services/conversation-center.service.ts @@ -40,27 +40,25 @@ export class ConversationCenterService extends ConversationBaseService { ? parseTimestampIdCursor(input.cursor, CONVERSATION_ERRORS.INVALID_CURSOR) : undefined; - const [rows, totalCount] = await Promise.all([ - this.repo.listConversationsByAccount({ + // 페이지·건수·부가 정보는 repository가 한 트랜잭션(단일 스냅샷)으로 + // 읽는다 — 조회 사이에 커밋된 메시지로 미리보기와 정렬 기준·커서가 + // 어긋나는 혼합 상태 방지(릴리즈 리뷰 반영). + const { rows, totalCount, extras } = + await this.repo.getConversationPageWithExtras({ accountId, limit, cursor: cursor ? { lastMessageAt: cursor.timestamp, id: cursor.id } : undefined, - }), - this.repo.countConversationsByAccount(accountId), - ]); + }); // last_message_at desc 정렬과 결합된 커서 — 새 메시지 도착으로 대화가 // 위로 떠오르면 다음 페이지에 다시 나타날 수 있다(목록 새로고침 전제). const page = sliceCursorPage(rows, limit, (last) => - // listConversationsByAccount가 last_message_at null을 제외하므로 항상 존재 + // 목록 조회가 last_message_at null을 제외하므로 항상 존재 buildTimestampIdCursor(last.last_message_at!, last.id), ); - const extras = await this.repo.getConversationListExtras( - page.items.map((row) => ({ id: row.id, last_read_at: row.last_read_at })), - ); const extraById = new Map( extras.map((e) => [e.conversationId.toString(), e]), ); From 6db8b6f71f9a3c62e34d01f72d5384646e5cd3a0 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 3 Sep 2026 04:18:44 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=8A=A4=EB=83=85=EC=83=B7=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EA=B2=A9=EB=A6=AC=20=EC=88=98=EC=A4=80=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #276 Codex P2 반영: 단일 스냅샷 보장이 서버/세션 기본 격리 수준에 의존하지 않도록 REPEATABLE READ를 명시(READ COMMITTED 환경에서는 문장마다 새 스냅샷이라 혼합 상태 레이스가 재발). --- .../repositories/conversation.repository.ts | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index a28085f..cff8ead 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -140,21 +140,26 @@ export class ConversationRepository { limit: number; cursor?: { lastMessageAt: Date; id: bigint }; }) { - return this.prisma.$transaction(async (tx) => { - const [rows, totalCount] = await Promise.all([ - this.listConversationsByAccount(tx, args), - this.countConversationsByAccount(tx, args.accountId), - ]); - // 초과분(limit+1)은 hasMore 판정용 — 부가 정보는 페이지 항목만 조회 - const extras = await this.getConversationListExtras( - tx, - rows.slice(0, args.limit).map((row) => ({ - id: row.id, - last_read_at: row.last_read_at, - })), - ); - return { rows, totalCount, extras }; - }); + return this.prisma.$transaction( + async (tx) => { + const [rows, totalCount] = await Promise.all([ + this.listConversationsByAccount(tx, args), + this.countConversationsByAccount(tx, args.accountId), + ]); + // 초과분(limit+1)은 hasMore 판정용 — 부가 정보는 페이지 항목만 조회 + const extras = await this.getConversationListExtras( + tx, + rows.slice(0, args.limit).map((row) => ({ + id: row.id, + last_read_at: row.last_read_at, + })), + ); + return { rows, totalCount, extras }; + }, + // 단일 스냅샷 보장은 REPEATABLE READ 전제 — 서버/세션 기본값이 + // READ COMMITTED면 문장마다 새 스냅샷이라 명시로 고정한다(리뷰 반영) + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); } /**