From 6e57b61c8f9ab308057578fe01bbbb7933370f5a Mon Sep 17 00:00:00 2001 From: CodeVac513 Date: Wed, 3 Dec 2025 16:07:14 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=93=A6=20chore:=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=EB=A5=BC=20=EC=9C=84=ED=95=9C=20wait=20queue=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose/definitions.json | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docker-compose/definitions.json b/docker-compose/definitions.json index d8a7c8d1..10c1ef28 100644 --- a/docker-compose/definitions.json +++ b/docker-compose/definitions.json @@ -29,6 +29,36 @@ "x-dead-letter-routing-key": "email.deadLetter" } }, + { + "name": "email.send.wait.5s", + "vhost": "/", + "durable": true, + "arguments": { + "x-message-ttl": 5000, + "x-dead-letter-exchange": "", + "x-dead-letter-routing-key": "email.send.queue" + } + }, + { + "name": "email.send.wait.10s", + "vhost": "/", + "durable": true, + "arguments": { + "x-message-ttl": 10000, + "x-dead-letter-exchange": "", + "x-dead-letter-routing-key": "email.send.queue" + } + }, + { + "name": "email.send.wait.30s", + "vhost": "/", + "durable": true, + "arguments": { + "x-message-ttl": 30000, + "x-dead-letter-exchange": "", + "x-dead-letter-routing-key": "email.send.queue" + } + }, { "name": "crawling.full.queue", "vhost": "/", From 4072331848aad69ca7acb9a3d8279e10a95e94b6 Mon Sep 17 00:00:00 2001 From: CodeVac513 Date: Thu, 4 Dec 2025 13:29:43 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=93=A6=20chore:=20wait=20queue=20TTL?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose/definitions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose/definitions.json b/docker-compose/definitions.json index 10c1ef28..cdafff68 100644 --- a/docker-compose/definitions.json +++ b/docker-compose/definitions.json @@ -50,11 +50,11 @@ } }, { - "name": "email.send.wait.30s", + "name": "email.send.wait.20s", "vhost": "/", "durable": true, "arguments": { - "x-message-ttl": 30000, + "x-message-ttl": 20000, "x-dead-letter-exchange": "", "x-dead-letter-routing-key": "email.send.queue" } From 6e40b50e536cc89e3ed621402a9bae41e57ef9b1 Mon Sep 17 00:00:00 2001 From: CodeVac513 Date: Tue, 9 Dec 2025 15:57:21 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20feat:=20waiting=20queue=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9E=AC=EC=8B=9C=EB=8F=84?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EA=B0=92=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/rabbitmq/rabbitmq.constant.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/email-worker/src/rabbitmq/rabbitmq.constant.ts b/email-worker/src/rabbitmq/rabbitmq.constant.ts index 237caf44..90def841 100644 --- a/email-worker/src/rabbitmq/rabbitmq.constant.ts +++ b/email-worker/src/rabbitmq/rabbitmq.constant.ts @@ -2,24 +2,36 @@ export const RMQ_EXCHANGES = { EMAIL: 'EmailExchange', CRAWLING: 'CrawlingExchange', DEAD_LETTER: 'DeadLetterExchange', -}; +} as const; export const RMQ_QUEUES = { EMAIL_SEND: 'email.send.queue', CRAWLING_FULL: 'crawling.full.queue', + EMAIL_SEND_WAIT_5S: 'email.send.wait.5s', + EMAIL_SEND_WAIT_10S: 'email.send.wait.10s', + EMAIL_SEND_WAIT_20S: 'email.send.wait.20s', EMAIL_DEAD_LETTER: 'email.deadLetter.queue', CRAWLING_FULL_DEAD_LETTER: 'crawling.full.deadLetter.queue', -}; +} as const; export const RMQ_ROUTING_KEYS = { EMAIL_SEND: 'email.send', CRAWLING_FULL: 'crawling.full', EMAIL_DEAD_LETTER: 'email.deadLetter', CRAWLING_FULL_DEAD_LETTER: 'crawling.full.deadLetter', -}; +} as const; export const RMQ_EXCHANGE_TYPE = { DIRECT: 'direct', TOPIC: 'topic', FANOUT: 'fanout', -}; +} as const; + +export const RETRY_CONFIG = { + MAX_RETRY: 3, + WAITING_QUEUE: [ + RMQ_QUEUES.EMAIL_SEND_WAIT_5S, + RMQ_QUEUES.EMAIL_SEND_WAIT_10S, + RMQ_QUEUES.EMAIL_SEND_WAIT_20S, + ], +} as const; From 2897d9b48a9903a902e0b82c8375f0b8fad21fb9 Mon Sep 17 00:00:00 2001 From: CodeVac513 Date: Tue, 9 Dec 2025 15:59:37 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=EB=A5=BC=20=EC=9C=84=ED=95=9C=20retryCount=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20waiting=20queue/DLQ=EB=A1=9C?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=B0=9C=ED=96=89=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- email-worker/src/rabbitmq/rabbitmq.service.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/email-worker/src/rabbitmq/rabbitmq.service.ts b/email-worker/src/rabbitmq/rabbitmq.service.ts index ab1d7f76..ca7dbabc 100644 --- a/email-worker/src/rabbitmq/rabbitmq.service.ts +++ b/email-worker/src/rabbitmq/rabbitmq.service.ts @@ -1,6 +1,6 @@ import { inject, injectable } from 'tsyringe'; import { RabbitMQManager } from './rabbitmq.manager'; -import { ConsumeMessage } from 'amqplib/properties'; +import { Options } from 'amqplib/properties'; import { DEPENDENCY_SYMBOLS } from '../types/dependency-symbols'; import logger from '../logger'; @@ -20,9 +20,18 @@ export class RabbitmqService { channel.publish(exchange, routingKey, Buffer.from(message)); } + async sendMessageToQueue( + queue: string, + message: string, + options?: Options.Publish, + ) { + const channel = await this.rabbitMQManager.getChannel(); + channel.sendToQueue(queue, Buffer.from(message), options); + } + async consumeMessage( queue: string, - onMessage: (payload: T) => void | Promise, + onMessage: (payload: T, retryCount: number) => void | Promise, ) { const channel = await this.rabbitMQManager.getChannel(); const { consumerTag } = await channel.consume(queue, async (message) => { @@ -30,7 +39,8 @@ export class RabbitmqService { if (!message) return; const parsedMessage = JSON.parse(message.content.toString()) as T; - await onMessage(parsedMessage); + const retryCount = message.properties.headers?.['x-retry-count'] || 0; + await onMessage(parsedMessage, retryCount); channel.ack(message); } catch (error) { From 854fe61128fdfeb1e721189f2f21b4c95829d218 Mon Sep 17 00:00:00 2001 From: CodeVac513 Date: Tue, 9 Dec 2025 16:01:44 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20error=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20error?= =?UTF-8?q?=EB=A5=BC=20email=20consumer=20=EA=B3=84=EC=B8=B5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=8D=98=EC=A7=80=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- email-worker/src/email/email.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/email-worker/src/email/email.service.ts b/email-worker/src/email/email.service.ts index 4f1f6929..1d0c46a8 100644 --- a/email-worker/src/email/email.service.ts +++ b/email-worker/src/email/email.service.ts @@ -48,7 +48,12 @@ export class EmailService { await this.transporter.sendMail(mailOptions); logger.info(`${mailOptions.to} 이메일 전송 성공`); } catch (error) { - logger.error(`${mailOptions.to} 이메일 전송 실패: ${error}`); + logger.error( + `${mailOptions.to} 이메일 전송 실패 + 오류 메시지: ${error.message} + 스택 트레이스: ${error.stack}`, + ); + throw error; } } From 905c8a3b347dc64f48e0dfbdcd60828b15774ae0 Mon Sep 17 00:00:00 2001 From: CodeVac513 Date: Tue, 9 Dec 2025 16:03:13 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=A8=20feat:=20error=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=ED=95=98=EA=B1=B0=EB=82=98=20DLQ=EB=A1=9C=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=EB=A5=BC=20=EB=B0=9C=ED=96=89=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- email-worker/src/email/email.consumer.ts | 121 ++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/email-worker/src/email/email.consumer.ts b/email-worker/src/email/email.consumer.ts index bae25f9f..97bd9d4b 100644 --- a/email-worker/src/email/email.consumer.ts +++ b/email-worker/src/email/email.consumer.ts @@ -3,8 +3,9 @@ import { inject, injectable } from 'tsyringe'; import { DEPENDENCY_SYMBOLS } from '../types/dependency-symbols'; import { EmailService } from './email.service'; import logger from '../logger'; -import { RMQ_QUEUES } from '../rabbitmq/rabbitmq.constant'; +import { RETRY_CONFIG, RMQ_QUEUES } from '../rabbitmq/rabbitmq.constant'; import { EmailPayload, EmailPayloadConstant } from '../types/types'; +import { Options } from 'amqplib/properties'; @injectable() export class EmailConsumer { @@ -25,7 +26,7 @@ export class EmailConsumer { this.consumerTag = await this.rabbitmqService.consumeMessage( RMQ_QUEUES.EMAIL_SEND, - async (payload: EmailPayload) => { + async (payload: EmailPayload, retryCount: number) => { if (this.shuttingDownFlag) { logger.warn('[EmailConsumer] Shutdown 중, 메시지 처리 건너뜀'); throw new Error('SHUTDOWN_IN_PROGRESS'); @@ -39,6 +40,8 @@ export class EmailConsumer { try { await this.handleEmailByType(payload); logger.info('[EmailConsumer] 이메일 전송 완료'); + } catch (error) { + await this.handleEmailByError(error, payload, retryCount); } finally { this.pendingTasks--; logger.info(`[EmailConsumer] 남은 작업: ${this.pendingTasks}`); @@ -120,4 +123,118 @@ export class EmailConsumer { }, 10000); }); } + + async handleEmailByError( + error: any, + payload: EmailPayload, + retryCount: number, + ) { + const stringifiedMessage = JSON.stringify(payload); + const retryOptions: Options.Publish = { + headers: { + 'x-retry-count': retryCount + 1, + }, + }; + + // Node.js 네트워크 레벨의 에러 + const isNetworkError = + error.code === 'ESOCKET' || + error.message?.includes('ECONNREFUSED') || + error.message?.includes('ETIMEDOUT') || + error.message?.includes('Unexpected socket close'); + if (isNetworkError) { + if (retryCount >= RETRY_CONFIG.MAX_RETRY) { + await this.rabbitmqService.sendMessageToQueue( + RMQ_QUEUES.EMAIL_DEAD_LETTER, + stringifiedMessage, + { + headers: { + 'x-retry-count': retryCount, + 'x-error-code': error.code || 'NONE', + 'x-error-message': error.message, + 'x-failed-at': new Date().toISOString(), + 'x-failure-type': 'MAX_RETRIES_EXCEEDED', + }, + }, + ); + return; + } + + await this.rabbitmqService.sendMessageToQueue( + RETRY_CONFIG.WAITING_QUEUE[retryCount], + stringifiedMessage, + retryOptions, + ); + return; + } + // SMTP 레벨의 에러 + if (error.responseCode) { + if (error.responseCode >= 500) { + // DLQ로 보내기 + await this.rabbitmqService.sendMessageToQueue( + RMQ_QUEUES.EMAIL_DEAD_LETTER, + stringifiedMessage, + { + headers: { + 'x-retry-count': retryCount, + 'x-error-code': error.code || 'NONE', + 'x-response-code': error.responseCode, + 'x-error-message': error.message, + 'x-failed-at': new Date().toISOString(), + 'x-failure-type': 'SMTP_PERMANENT_FAILURE', + }, + }, + ); + return; + } + + if (error.responseCode >= 400) { + if (retryCount >= RETRY_CONFIG.MAX_RETRY) { + // DLQ로 보내기 + await this.rabbitmqService.sendMessageToQueue( + RMQ_QUEUES.EMAIL_DEAD_LETTER, + stringifiedMessage, + { + headers: { + 'x-retry-count': retryCount, + 'x-error-code': error.code || 'NONE', + 'x-response-code': error.responseCode, + 'x-error-message': error.message, + 'x-failed-at': new Date().toISOString(), + 'x-failure-type': 'MAX_RETRIES_EXCEEDED', + }, + }, + ); + return; + } + await this.rabbitmqService.sendMessageToQueue( + RETRY_CONFIG.WAITING_QUEUE[retryCount], + stringifiedMessage, + retryOptions, + ); + return; + } + } + + logger.error( + `[EmailConsumer] 알 수 없는 에러로 DLQ 전송 + 오류 메시지: ${error.message} + 스택 트레이스: ${error.stack}`, + ); + // 즉시 DLQ로 + await this.rabbitmqService.sendMessageToQueue( + RMQ_QUEUES.EMAIL_DEAD_LETTER, + stringifiedMessage, + { + headers: { + 'x-retry-count': retryCount, + 'x-error-code': error.code || 'UNKNOWN', + 'x-error-message': error.message || 'Unknown error', + 'x-error-stack': error.stack, + 'x-failed-at': new Date().toISOString(), + 'x-failure-type': 'UNKNOWN_ERROR', + }, + }, + ); + } } From cc5076319c31ffd557a97791362f6e09c1c57271 Mon Sep 17 00:00:00 2001 From: CodeVac513 Date: Tue, 9 Dec 2025 16:06:37 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=A7=BC=20clean:=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20todo=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- email-worker/src/email/email.consumer.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/email-worker/src/email/email.consumer.ts b/email-worker/src/email/email.consumer.ts index 97bd9d4b..abc19422 100644 --- a/email-worker/src/email/email.consumer.ts +++ b/email-worker/src/email/email.consumer.ts @@ -170,7 +170,6 @@ export class EmailConsumer { // SMTP 레벨의 에러 if (error.responseCode) { if (error.responseCode >= 500) { - // DLQ로 보내기 await this.rabbitmqService.sendMessageToQueue( RMQ_QUEUES.EMAIL_DEAD_LETTER, stringifiedMessage, @@ -190,7 +189,6 @@ export class EmailConsumer { if (error.responseCode >= 400) { if (retryCount >= RETRY_CONFIG.MAX_RETRY) { - // DLQ로 보내기 await this.rabbitmqService.sendMessageToQueue( RMQ_QUEUES.EMAIL_DEAD_LETTER, stringifiedMessage, @@ -217,11 +215,12 @@ export class EmailConsumer { } logger.error( - `[EmailConsumer] 알 수 없는 에러로 DLQ 전송 + `[EmailConsumer] 알 수 없는 에러로 DLQ 메시지 발행 오류 메시지: ${error.message} 스택 트레이스: ${error.stack}`, ); - // 즉시 DLQ로 + // 즉시 DLQ로 메시지 발행 + // todo: Slack 이나 Discord 연동을 통한 새로운 에러에 대한 알림 구현 await this.rabbitmqService.sendMessageToQueue( RMQ_QUEUES.EMAIL_DEAD_LETTER, stringifiedMessage,