From a6580860f234c47070e7a46e46836eac71960bb5 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Thu, 26 Mar 2026 18:27:28 +0900 Subject: [PATCH] =?UTF-8?q?fix(bot):=20RSS=20=ED=8F=B4=EB=A7=81=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20+=20pino=20Error=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RSS 폴링 시 멤버별 배치 URL 중복 체크 (IN query 1개로 변경) - 기존: 피드 아이템별 개별 SELECT (~344개/cycle) - 개선: 멤버당 1개 IN 쿼리 (29개/cycle) - pino logger에 Error serializer 등록 (error: {} → 구조화된 출력) - 에러 로깅 시 raw Error 객체 직접 전달 (스택 트레이스 보존) Co-Authored-By: Claude --- packages/bot/src/lib/logger.ts | 5 ++++ packages/bot/src/schedulers/rss-poller.ts | 31 +++++++++++++++-------- packages/bot/src/services/post.service.ts | 18 ++++++++++++- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/bot/src/lib/logger.ts b/packages/bot/src/lib/logger.ts index 15edeb2..1543ad5 100644 --- a/packages/bot/src/lib/logger.ts +++ b/packages/bot/src/lib/logger.ts @@ -27,6 +27,11 @@ const logger = pino({ formatters: { level: (label) => ({ level: label }), }, + // Serialize Error objects properly (pino defaults to {} for non-'err' keys) + serializers: { + error: pino.stdSerializers.err, + err: pino.stdSerializers.err, + }, // ISO 8601 timestamp with timezone offset timestamp: pino.stdTimeFunctions.isoTime, // Development: pretty print diff --git a/packages/bot/src/schedulers/rss-poller.ts b/packages/bot/src/schedulers/rss-poller.ts index e816688..b0afe1e 100644 --- a/packages/bot/src/schedulers/rss-poller.ts +++ b/packages/bot/src/schedulers/rss-poller.ts @@ -5,6 +5,7 @@ */ import { getMemberService } from '../services/member.service'; +import { getPostService } from '../services/post.service'; import { getRssService, type PollResult, type RssFeedItem } from '../services/rss.service'; import { type Member, MemberStatus } from '@blog-study/shared/db'; import logger from '../lib/logger'; @@ -79,26 +80,36 @@ export class RssPoller { try { const rssService = getRssService(); + const postService = getPostService(); const items = await rssService.fetchFeed(member.rssUrl); - + + if (items.length === 0) { + return { memberId: member.id, success: true, newItems: [] }; + } + + // Batch duplicate check: 1 IN query instead of N individual SELECTs + // postService.create() has its own getByUrl guard as a write-time safety net + const feedUrls = items.map(item => item.link).filter(Boolean); + const existingUrls = await postService.getExistingUrls(feedUrls); + const newItems = items.filter(item => !existingUrls.has(item.link)); + return { memberId: member.id, success: true, - newItems: items, + newItems, }; } catch (error) { // Requirements: 6.5 - Log error and continue processing other feeds - const errorMessage = error instanceof Error ? error.message : String(error); logger.error({ member: member.discordUsername, - error: errorMessage, + error, }, '📡 [RSS] 멤버 피드 폴링 에러'); - + return { memberId: member.id, success: false, newItems: [], - error: errorMessage, + error: error instanceof Error ? error.message : String(error), }; } } @@ -141,13 +152,13 @@ export class RssPoller { try { await this.onNewPost(member, result.newItems); } catch (callbackError) { - const errorMsg = callbackError instanceof Error - ? callbackError.message - : String(callbackError); logger.error({ member: member.discordUsername, - error: errorMsg, + error: callbackError, }, '📡 [RSS] 콜백 에러'); + const errorMsg = callbackError instanceof Error + ? callbackError.message + : String(callbackError); errors.push(`Callback error for ${member.discordUsername}: ${errorMsg}`); } } diff --git a/packages/bot/src/services/post.service.ts b/packages/bot/src/services/post.service.ts index e0503c2..8903cdd 100644 --- a/packages/bot/src/services/post.service.ts +++ b/packages/bot/src/services/post.service.ts @@ -4,7 +4,7 @@ * Requirements: 6.3, 6.4, 6.6 */ -import { eq, and, count } from 'drizzle-orm'; +import { eq, and, count, inArray } from 'drizzle-orm'; import { getDb, posts, @@ -175,6 +175,22 @@ export class PostService { return post || null; } + /** + * Get existing URLs from a list of URLs (batch duplicate check) + * Note: URL is globally unique across all members (schema unique constraint), + * so no memberId scoping is needed here. + */ + async getExistingUrls(urls: string[]): Promise> { + if (urls.length === 0) return new Set(); + + const results = await this.db + .select({ url: posts.url }) + .from(posts) + .where(inArray(posts.url, urls)); + + return new Set(results.map(r => r.url)); + } + /** * Get all posts */