Repository: gsainfoteam/ziggle-be
Pull request #170: Fix notification with FCM token
Author: login: GanghyeonSeo
Created: 2025-05-02T16:13:15Z | Merged: 2025-05-03T09:51:43Z

# Pull request

## 개요
<!-- 어떤 이슈를 해결하였나요? -->
FCM token을 이용하여 알림을 보내는 과정에서 버그를 유발할 수 있는 코드를 수정했습니다.

또한, 최초 알림 전송에 실패한 token을 대상으로 재전송을 시도하는 과정에서
각 token batch마다 몇 개의 token이 알림 전송에 실패했는지 더 자세한 디버그 로그를 출력하도록 변경했습니다.

알림 전송 실패가 토큰 만료 등의 이유인 경우에는 토큰을 삭제하도록 기능을 추가했습니다.

## PR Checklist

- [x] 환경에 따른 실행 여부를 확인하였나요?
- [x] 변경 내용에 관한 테스트를 진행하였나요?


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **신규 기능**
	- 잘못된 FCM 토큰을 자동으로 삭제하고, 알림 전송 시 실패한 토큰에 대해 1회 재시도하는 기능이 추가되었습니다.
- **버그 수정**
	- FCM 토큰 삭제 시 관련 로그 데이터도 함께 자동 삭제되도록 데이터베이스 연동이 개선되었습니다.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/apps/api/src/fcm/fcm.module.ts b/apps/api/src/fcm/fcm.module.ts
index 0ed05d7..7b3aa31 100644
--- a/apps/api/src/fcm/fcm.module.ts
+++ b/apps/api/src/fcm/fcm.module.ts
@@ -6,6 +6,7 @@ import { FcmConsumer } from './fcm.consumer';
 import { PrismaModule } from '@lib/prisma';
 import { LoggerModule } from '@lib/logger';
 import { CustomConfigModule } from '@lib/custom-config';
+import { UserModule } from '../user/user.module';
 
 @Module({
   imports: [
@@ -13,6 +14,7 @@ import { CustomConfigModule } from '@lib/custom-config';
     PrismaModule,
     BullModule.registerQueue({ name: 'fcm' }),
     LoggerModule,
+    UserModule,
   ],
   providers: [FcmService, FcmRepository, FcmConsumer],
   exports: [FcmService],
diff --git a/apps/api/src/fcm/fcm.service.ts b/apps/api/src/fcm/fcm.service.ts
index 56064d5..94db418 100644
--- a/apps/api/src/fcm/fcm.service.ts
+++ b/apps/api/src/fcm/fcm.service.ts
@@ -7,6 +7,7 @@ import { InjectQueue } from '@nestjs/bull';
 import { Queue } from 'bull';
 import { Loggable } from '@lib/logger/decorator/loggable';
 import { CustomConfigService } from '@lib/custom-config';
+import { UserService } from '../user/user.service';
 
 @Injectable()
 @Loggable()
@@ -17,6 +18,7 @@ export class FcmService {
     @InjectQueue('fcm') private readonly fcmQueue: Queue,
     private readonly customConfigService: CustomConfigService,
     private readonly fcmRepository: FcmRepository,
+    private readonly userService: UserService,
   ) {
     this.app = initializeApp({
       credential: cert({
@@ -76,73 +78,112 @@ export class FcmService {
       return [[...first, token], ...array];
     }, []);
 
-    const results = await Promise.allSettled(
-      batches.map(async (subTokens) => {
-        await this._postMessage(notification, subTokens, data).catch(
-          (error) => {
-            this.logger.error('Error while sending notification: ', error);
-            throw error;
-          },
+    await Promise.allSettled(
+      batches.map(async (batch, idx) => {
+        const failedTokensToRetry = await this._postMessage(
+          notification,
+          batch,
+          data,
         );
-      }),
-    );
-
-    const failedTokens = results
-      .map((result, index) =>
-        result.status === 'rejected' ? batches[index] : null,
-      )
-      .filter((batch): batch is string[] => batch !== null)
-      .flat();
-
-    if (failedTokens.length > 0) {
-      this.logger.warn('Some notifications permanently failed: ', failedTokens);
-    } else {
-      this.logger.debug('All notifications sent successfully');
-    }
 
-    if (failedTokens.length > 0) {
-      this.logger.debug(
-        `Retrying failed tokens (${failedTokens.length} tokens)`,
-      );
-      await new Promise((resolve) => setTimeout(resolve, 2000));
-      await this._postMessage(notification, failedTokens, data);
+        if (failedTokensToRetry.length > 0) {
+          this.logger.error(`Some fcm token in batch${idx} failed to send`);
+          this.logger.debug(
+            `Retrying failed tokens in batch${idx} (${failedTokensToRetry.length} tokens)`,
+          );
+          await new Promise((resolve) => setTimeout(resolve, 2000));
+          const secondFailedToken = await this._postMessage(
+            notification,
+            failedTokensToRetry,
+            data,
+          );
 
-      this.logger.debug('Retry completed');
-    }
+          if (secondFailedToken.length > 0) {
+            this.logger.error(
+              `Also failed to send ${secondFailedToken.length} notification in batch${idx} at second try`,
+            );
+          } else {
+            this.logger.debug(
+              `Every failed notifications in batch${idx} sent successfully at second try`,
+            );
+          }
+        } else {
+          this.logger.debug(
+            `All notifications in batch${idx} sent successfully at first try`,
+          );
+        }
+      }),
+    );
   }
 
   async _postMessage(
     notification: Notification,
     tokens: string[],
     data?: Record<string, string>,
-  ): Promise<void> {
+  ): Promise<string[]> {
     // send message to each token
-    const result = await getMessaging(this.app).sendEachForMulticast({
-      tokens,
-      notification,
-      apns: { payload: { aps: { mutableContent: true } } },
-      data,
-    });
+    const result = await Promise.allSettled(
+      tokens.map((token) =>
+        getMessaging(this.app).send({
+          token,
+          notification,
+          apns: { payload: { aps: { mutableContent: true } } },
+          data,
+        }),
+      ),
+    );
 
     // update success and fail count
-    const responses = result.responses.map((res, idx) => ({
+    const responses = result.map((res, idx) => ({
       res,
       token: tokens[idx],
     }));
-    const succeed = responses.filter(({ res }) => res.success);
-    const failed = responses.filter(({ res }) => !res.success);
+
+    const succeed = responses.filter(({ res }) => res.status === 'fulfilled');
+    const failed = responses.filter(({ res }) => res.status === 'rejected');
+
+    const failedTokensToRetry = (
+      await Promise.allSettled(
+        failed.map(async ({ res, token }) => {
+          if (
+            res.status === 'rejected' &&
+            [
+              'messaging/invalid-argument',
+              'messaging/unregistered',
+              'messaging/third-party-auth-error',
+            ].includes(res.reason.code)
+          ) {
+            try {
+              await this.userService.deleteFcmToken(token);
+            } catch (err) {
+              this.logger.warn(
+                'Failed to delete fcm token but just ignore it',
+                err,
+              );
+            }
+            return null;
+          }
+          return token;
+        }),
+      )
+    )
+      .filter(
+        (result): result is PromiseFulfilledResult<string | null> =>
+          result.status === 'fulfilled',
+      )
+      .map((result) => result.value)
+      .filter((token): token is string => token !== null);
 
     await this.fcmRepository.updateFcmTokensSuccess(
       succeed.map(({ token }) => token),
     );
-    await this.fcmRepository.updateFcmTokensFail(
-      failed.map(({ token }) => token),
-    );
+    await this.fcmRepository.updateFcmTokensFail(failedTokensToRetry);
 
-    // create logs
     await this.fcmRepository.createLogs(
       { notification, data },
       succeed.map(({ token }) => token),
     );
+
+    return failedTokensToRetry;
   }
 }
diff --git a/apps/api/src/user/user.repository.ts b/apps/api/src/user/user.repository.ts
index fd80a5c..8741f83 100644
--- a/apps/api/src/user/user.repository.ts
+++ b/apps/api/src/user/user.repository.ts
@@ -183,4 +183,22 @@ export class UserRepository {
       });
     return { message: 'success', fcmToken: fcmToken };
   }
+
+  async deleteFcmToken(fcmToken: string): Promise<void> {
+    await this.prismaService.fcmToken
+      .delete({
+        where: { fcmTokenId: fcmToken },
+      })
+      .catch((err) => {
+        if (err instanceof PrismaClientKnownRequestError) {
+          if (err.code === 'P2025') {
+            this.logger.debug('fcm token not found. Just ignore it');
+            return;
+          }
+          this.logger.error(err.message);
+        }
+        this.logger.error(err);
+        throw err;
+      });
+  }
 }
diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts
index 085e84a..36c763c 100644
--- a/apps/api/src/user/user.service.ts
+++ b/apps/api/src/user/user.service.ts
@@ -113,4 +113,8 @@ export class UserService {
   async setFcmToken(userUuid: string, fcmToken: setFcmTokenReq) {
     return this.userRepository.setFcmToken(userUuid, fcmToken);
   }
+
+  async deleteFcmToken(fcmToken: string) {
+    return this.userRepository.deleteFcmToken(fcmToken);
+  }
 }
diff --git a/prisma/migrations/20250502154948_add_cascade_fcm_token_log/migration.sql b/prisma/migrations/20250502154948_add_cascade_fcm_token_log/migration.sql
new file mode 100644
index 0000000..ec302fd
--- /dev/null
+++ b/prisma/migrations/20250502154948_add_cascade_fcm_token_log/migration.sql
@@ -0,0 +1,5 @@
+-- DropForeignKey
+ALTER TABLE "log" DROP CONSTRAINT "log_fcm_token_id_fkey";
+
+-- AddForeignKey
+ALTER TABLE "log" ADD CONSTRAINT "log_fcm_token_id_fkey" FOREIGN KEY ("fcm_token_id") REFERENCES "fcm_token"("fcm_token_id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 77a7410..9c06372 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -179,7 +179,7 @@ model Log {
   createdAt DateTime @default(now()) @map("created_at")
 
   fcmTokenId String   @map("fcm_token_id")
-  FcmToken   FcmToken @relation(fields: [fcmTokenId], references: [fcmTokenId])
+  FcmToken   FcmToken @relation(fields: [fcmTokenId], references: [fcmTokenId], onDelete: Cascade)
 
   @@map("log")
 }


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>apps/api/src/fcm/fcm.service.ts (1)</summary><blockquote>

`75-80`: **배치 분할 로직 가독성∙성능 개선 제안**

현재 reduce 로직은 인덱스 0부터 역순으로 unshift 하면서 배열을 구축해 가독성이 떨어집니다.  
500개 단위로 단순 슬라이싱하거나 유틸 함수를 쓰면 의도가 더욱 명확해집니다.

```diff
-const batches = tokens.reduce((acc, token, index) => {
-  if (index % 500 === 0) return [[token], ...acc];
-  const [first, ...array] = acc;
-  return [[...first, token], ...array];
-}, []);
+const BATCH_SIZE = 500;
+const batches: string[][] = [];
+for (let i = 0; i < tokens.length; i += BATCH_SIZE) {
+  batches.push(tokens.slice(i, i + BATCH_SIZE));
+}
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Biome (1.9.4)</summary>

[error] 78-78: Avoid the use of spread (`...`) syntax on accumulators.

Spread syntax should be avoided on accumulators (like those in `.reduce`) because it causes a time complexity of `O(n^2)`.
Consider methods such as .splice or .push instead.

(lint/performance/noAccumulatingSpread)

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between a78e7cb7fed5e986128f24a2987dd77e56056565 and 21203258a7a78690e2c98b470e3caa22217882f9.

</details>

<details>
<summary>📒 Files selected for processing (6)</summary>

* `apps/api/src/fcm/fcm.module.ts` (1 hunks)
* `apps/api/src/fcm/fcm.service.ts` (3 hunks)
* `apps/api/src/user/user.repository.ts` (1 hunks)
* `apps/api/src/user/user.service.ts` (1 hunks)
* `prisma/migrations/20250502154948_add_cascade_fcm_token_log/migration.sql` (1 hunks)
* `prisma/schema.prisma` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>apps/api/src/fcm/fcm.module.ts (2)</summary><blockquote>

<details>
<summary>apps/api/src/api.module.ts (1)</summary>

* `Module` (18-56)

</details>
<details>
<summary>apps/api/src/user/user.module.ts (1)</summary>

* `Module` (15-35)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (4)</summary><blockquote>

<details>
<summary>prisma/schema.prisma (1)</summary>

`181-182`: **관계 설정에 onDelete: Cascade 추가 변경 적절합니다.**

FCM 토큰이 삭제될 때 관련된 로그 레코드도 자동으로 삭제되도록 `onDelete: Cascade` 설정이 추가되었습니다. 이는 데이터 일관성 유지에 도움이 되며, 무효한 FCM 토큰 처리 로직과도 잘 맞습니다.

</details>
<details>
<summary>apps/api/src/fcm/fcm.module.ts (1)</summary>

`9-9`: **UserModule 가져오기 추가가 적절합니다.**

FCM 서비스에서 UserService를 사용하여 만료된 FCM 토큰을 삭제할 수 있도록 UserModule을 가져오는 변경이 추가되었습니다. 이는 PR의 목적에 맞게 알림 전송 실패 시 토큰 관리를 개선하기 위한 적절한 변경입니다.



Also applies to: 17-17

</details>
<details>
<summary>apps/api/src/user/user.service.ts (1)</summary>

`117-119`: **FCM 토큰 삭제 기능 추가가 적절합니다.**

FCM 토큰 삭제 기능을 추가하여 알림 전송 과정에서 만료되거나 유효하지 않은 토큰을 정리할 수 있도록 했습니다. 이는 FCM 서비스의 개선된 재시도 메커니즘과 함께 작동하여 알림 전송 신뢰성을 향상시킵니다.

</details>
<details>
<summary>prisma/migrations/20250502154948_add_cascade_fcm_token_log/migration.sql (1)</summary>

`1-5`: **마이그레이션 스크립트가 적절합니다.**

기존 외래 키 제약 조건을 삭제하고 ON DELETE CASCADE 및 ON UPDATE CASCADE 옵션이 포함된 새 제약 조건을 추가하는 마이그레이션 스크립트가 스키마 변경과 일치합니다. 이 변경으로 FCM 토큰이 삭제될 때 관련 로그 레코드도 자동으로 삭제되어 데이터베이스 일관성이 유지됩니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-05-02T16:16:18Z
user: login: siwonpada
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-02T16:29:21Z
user: login: GanghyeonSeo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T17:01:34Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-02T17:02:06Z
user: login: GanghyeonSeo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T17:04:34Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-02T17:06:12Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>apps/api/src/fcm/fcm.service.ts (2)</summary><blockquote>

`145-176`: **토큰 삭제 로직에서 중복된 예외 처리가 있습니다.**

토큰 삭제 시 try-catch 블록을 사용하고 있는데, 이미 제공된 학습 정보에 따르면 `UserRepository.deleteFcmToken` 메서드는 내부적으로 예외 처리를 수행하고 있습니다. 따라서 여기서의 예외 처리는 중복일 수 있습니다.


아래와 같이 try-catch 블록을 제거하여 코드를 간소화할 수 있습니다:

```diff
-            try {
              await this.userService.deleteFcmToken(token);
-            } catch (err) {
-              this.logger.warn(
-                'Failed to delete fcm token but just ignore it',
-                err,
-              );
-            }
```

---

`119-188`: **추가 개선 제안: 상수값 추출 및 메서드 분리**

코드의 가독성과 유지보수성을 더욱 향상시키기 위해 다음과 같은 추가 개선을 고려해보세요:

1. 오류 코드를 상수로 추출하여 재사용
2. 토큰 삭제 로직을 별도 메서드로 분리


```diff
+  // 삭제가 필요한 FCM 오류 코드
+  private readonly DELETE_TOKEN_ERROR_CODES = [
+    'messaging/invalid-argument',
+    'messaging/unregistered',
+    'messaging/third-party-auth-error',
+  ];
+
+  private async deleteInvalidToken(token: string): Promise<void> {
+    await this.userService.deleteFcmToken(token);
+  }
+
   async _postMessage(
    ...
-          if (
-            res.status === 'rejected' &&
-            [
-              'messaging/invalid-argument',
-              'messaging/unregistered',
-              'messaging/third-party-auth-error',
-            ].includes(res.reason.code)
-          ) {
+          if (
+            res.status === 'rejected' &&
+            this.DELETE_TOKEN_ERROR_CODES.includes(res.reason.code)
+          ) {
-            try {
-              await this.userService.deleteFcmToken(token);
-            } catch (err) {
-              this.logger.warn(
-                'Failed to delete fcm token but just ignore it',
-                err,
-              );
-            }
+            await this.deleteInvalidToken(token);
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 2b125a4cf373af2b80da2cde7dd76f33ca6941b1 and ba6e9e0822981067dc0306a668504699d6fddb9d.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `apps/api/src/fcm/fcm.service.ts` (3 hunks)
* `apps/api/src/user/user.repository.ts` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* apps/api/src/user/user.repository.ts

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧠 Learnings (1)</summary>

<details>
<summary>apps/api/src/fcm/fcm.service.ts (2)</summary>

```
Learnt from: GanghyeonSeo
PR: gsainfoteam/ziggle-be#170
File: apps/api/src/fcm/fcm.service.ts:145-163
Timestamp: 2025-05-02T17:06:09.647Z
Learning: UserRepository의 deleteFcmToken 메서드는 내부적으로 예외 처리를 하고 있으며, 토큰이 존재하지 않거나 다른 오류가 발생해도 예외를 던지지 않고 로깅만 수행합니다.
```

```
Learnt from: GanghyeonSeo
PR: gsainfoteam/ziggle-be#170
File: apps/api/src/fcm/fcm.service.ts:145-163
Timestamp: 2025-05-02T17:06:09.647Z
Learning: UserRepository의 deleteFcmToken 메서드는 내부적으로 예외 처리를 하고 있으며, 토큰이 존재하지 않거나 다른 오류가 발생해도 예외를 던지지 않고 로깅만 수행합니다.
```

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (4)</summary><blockquote>

<details>
<summary>apps/api/src/fcm/fcm.service.ts (4)</summary>

`10-10`: **의존성 주입이 적절히 추가되었습니다.**

UserService 의존성이 잘 추가되었습니다. 이를 통해 잘못된 FCM 토큰을 적절히 삭제할 수 있게 되었습니다.



Also applies to: 21-21

---

`81-116`: **배치 처리 및 재시도 로직이 효과적으로 구현되었습니다.**

토큰 배치 처리 및 재시도 로직이 명확하게 구현되었습니다. 특히 실패한 토큰에 대한 로깅과 2초 대기 후 재시도 메커니즘이 잘 구현되어 있습니다. 각 배치별로 실패한 토큰 수를 명확하게 로깅하여 디버깅이 용이해졌습니다.

---

`123-144`: **개별 토큰 처리 방식으로 변경된 것은 좋은 개선입니다.**

이전에 일괄 처리 방식에서 개별 토큰 처리 방식으로 변경되어 더 세분화된 오류 처리가 가능해졌습니다. 각 토큰별로 성공/실패 여부를 명확하게 구분하고 있어 좋습니다.

---

`177-187`: **성공 및 실패 토큰 처리가 명확합니다.**

성공한 토큰과 재시도가 필요한 토큰을 구분하여 적절히 처리하고 있습니다. 특히 영구적으로 실패한 토큰(삭제된 토큰)은 실패 카운트 업데이트에서 제외하는 것이 좋은 접근법입니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-03T09:46:48Z
user: login: coderabbitai[bot]
state: APPROVED
author_association: NONE
submitted_at: 2025-05-03T09:46:51Z
user: login: coderabbitai[bot]
state: APPROVED
author_association: NONE
submitted_at: 2025-05-03T09:46:52Z

Review comments:
diff_hunk: @@ -183,4 +183,23 @@ export class UserRepository {
       });
     return { message: 'success', fcmToken: fcmToken };
   }
+
+  async deleteFcmToken(fcmToken: string) {
+    return this.prismaService.fcmToken
+      .delete({
+        where: { fcmTokenId: fcmToken },
+      })
+      .catch((err) => {
+        if (err instanceof PrismaClientKnownRequestError) {
+          if (err.code === 'P2025') {
+            this.logger.debug('fcm token not found');
+            throw new NotFoundException();
+          }
+          this.logger.error(err.message);
+          throw new InternalServerErrorException();
+        }
+        this.logger.error(err);
+        throw new InternalServerErrorException();
+      });
+  }
path: apps/api/src/user/user.repository.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

_⚠️ Potential issue_

**NotFoundException 전파로 알림 처리 흐름이 중단될 수 있습니다**

`deleteFcmToken`에서 토큰이 이미 존재하지 않는 경우(`P2025`) `NotFoundException`을 그대로 throw 하고 있습니다.  
해당 메서드는 실패한 알림을 정리하는 보조 작업으로 호출되는데, 이 예외가 상위(`FcmService._postMessage`)로 전파되면 해당 배치가 `rejected` 상태로 처리돼 재시도·로그 기록 로직 전체가 스킵될 수 있습니다.  
토큰이 없다는 사실은 비즈니스적으로 “이미 정리 완료”에 해당하므로, 예외를 삼켜서 정상 흐름을 유지하거나 `void` 반환으로 처리하는 편이 안전합니다.

```diff
-  async deleteFcmToken(fcmToken: string) {
-    return this.prismaService.fcmToken
+  // 토큰 존재 여부와 무관하게 예외를 전파하지 않도록 변경
+  async deleteFcmToken(fcmToken: string): Promise<void> {
+    await this.prismaService.fcmToken
       .delete({
         where: { fcmTokenId: fcmToken },
       })
       .catch((err) => {
         if (err instanceof PrismaClientKnownRequestError) {
           if (err.code === 'P2025') {
-            this.logger.debug('fcm token not found');
-            throw new NotFoundException();
+            // 이미 삭제된 토큰 → 추가 로직 불필요
+            this.logger.debug('fcm token not found (ignored)');
+            return;
           }
```




<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  // 토큰 존재 여부와 무관하게 예외를 전파하지 않도록 변경
  async deleteFcmToken(fcmToken: string): Promise<void> {
    await this.prismaService.fcmToken
      .delete({
        where: { fcmTokenId: fcmToken },
      })
      .catch((err) => {
        if (err instanceof PrismaClientKnownRequestError) {
          if (err.code === 'P2025') {
            // 이미 삭제된 토큰 → 추가 로직 불필요
            this.logger.debug('fcm token not found (ignored)');
            return;
          }
          this.logger.error(err.message);
          throw new InternalServerErrorException();
        }
        this.logger.error(err);
        throw new InternalServerErrorException();
      });
  }
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-02T16:16:16Z
author_association: NONE
diff_hunk: @@ -76,73 +78,99 @@ export class FcmService {
       return [[...first, token], ...array];
     }, []);
 
-    const results = await Promise.allSettled(
-      batches.map(async (subTokens) => {
-        await this._postMessage(notification, subTokens, data).catch(
-          (error) => {
-            this.logger.error('Error while sending notification: ', error);
-            throw error;
-          },
+    await Promise.allSettled(
+      batches.map(async (batch, idx) => {
+        const failedTokensToRetry = await this._postMessage(
+          notification,
+          batch,
+          data,
         );
-      }),
-    );
-
-    const failedTokens = results
-      .map((result, index) =>
-        result.status === 'rejected' ? batches[index] : null,
-      )
-      .filter((batch): batch is string[] => batch !== null)
-      .flat();
-
-    if (failedTokens.length > 0) {
-      this.logger.warn('Some notifications permanently failed: ', failedTokens);
-    } else {
-      this.logger.debug('All notifications sent successfully');
-    }
 
-    if (failedTokens.length > 0) {
-      this.logger.debug(
-        `Retrying failed tokens (${failedTokens.length} tokens)`,
-      );
-      await new Promise((resolve) => setTimeout(resolve, 2000));
-      await this._postMessage(notification, failedTokens, data);
+        if (failedTokensToRetry.length > 0) {
+          this.logger.error(`Some fcm token in batch${idx} failed to send`);
+          this.logger.debug(
+            `Retrying failed tokens in batch${idx} (${failedTokensToRetry.length} tokens)`,
+          );
+          await new Promise((resolve) => setTimeout(resolve, 2000));
+          const secondFailedToken = await this._postMessage(
+            notification,
+            failedTokensToRetry,
+            data,
+          );
 
-      this.logger.debug('Retry completed');
-    }
+          if (secondFailedToken.length > 0) {
+            this.logger.error(
+              `Also failed to send ${secondFailedToken.length} notification in batch${idx} at second try`,
+            );
+          } else {
+            this.logger.debug(
+              `Every failed notifications in batch${idx} sent successfully at second try`,
+            );
+          }
+        } else {
+          this.logger.debug(
+            `All notifications in batch${idx} sent successfully at first try`,
+          );
+        }
+      }),
+    );
   }
 
   async _postMessage(
     notification: Notification,
     tokens: string[],
     data?: Record<string, string>,
-  ): Promise<void> {
+  ): Promise<string[]> {
     // send message to each token
-    const result = await getMessaging(this.app).sendEachForMulticast({
-      tokens,
-      notification,
-      apns: { payload: { aps: { mutableContent: true } } },
-      data,
-    });
+    const result = await Promise.allSettled(
+      tokens.map((token) =>
+        getMessaging(this.app).send({
+          token,
+          notification,
+          apns: { payload: { aps: { mutableContent: true } } },
+          data,
+        }),
+      ),
+    );
 
     // update success and fail count
-    const responses = result.responses.map((res, idx) => ({
+    const responses = result.map((res, idx) => ({
       res,
       token: tokens[idx],
     }));
-    const succeed = responses.filter(({ res }) => res.success);
-    const failed = responses.filter(({ res }) => !res.success);
+
+    const succeed = responses.filter(({ res }) => res.status === 'fulfilled');
+    const failed = responses.filter(({ res }) => res.status === 'rejected');
+
+    const failedTokensToRetry = (
+      await Promise.all(
+        failed.map(async ({ res, token }) => {
+          if (
+            res.status === 'rejected' &&
+            [
+              'messaging/invalid-argument',
+              'messaging/unregistered',
+              'messaging/third-party-auth-error',
+            ].includes(res.reason.code)
+          ) {
+            await this.userService.deleteFcmToken(token);
+            return null;
+          }
+          return token;
+        }),
+      )
+    ).filter((token): token is string => token !== null);
 
path: apps/api/src/fcm/fcm.service.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

_⚠️ Potential issue_

**deleteFcmToken 예외 처리 누락으로 _postMessage 전체가 실패할 수 있습니다**

`deleteFcmToken`이 `NotFoundException` 등을 throw 하면 `_postMessage`가 reject 되어,  
`postMessage`의 재시도·로그 작성 로직이 예상치 못하게 건너뛰어질 위험이 있습니다.  
토큰 삭제 실패는 알림 전송과 무관하므로, 개별 토큰 수준에서 try/catch로 삼켜 주세요.

```diff
-            await this.userService.deleteFcmToken(token);
-            return null;
+            try {
+              await this.userService.deleteFcmToken(token);
+            } catch (e) {
+              // 삭제 실패는 전송 로직에 영향 없도록 무시
+              this.logger.debug(`ignore delete error for token ${token}: ${e}`);
+            }
+            return null;
```




<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
    const failedTokensToRetry = (
      await Promise.all(
        failed.map(async ({ res, token }) => {
          if (
            res.status === 'rejected' &&
            [
              'messaging/invalid-argument',
              'messaging/unregistered',
              'messaging/third-party-auth-error',
            ].includes(res.reason.code)
          ) {
            try {
              await this.userService.deleteFcmToken(token);
            } catch (e) {
              // 삭제 실패는 전송 로직에 영향 없도록 무시
              this.logger.debug(`ignore delete error for token ${token}: ${e}`);
            }
            return null;
          }
          return token;
        }),
      )
    ).filter((token): token is string => token !== null);
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated reply by CodeRabbit -->

✅ Addressed in commit ba6e9e0

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-02T16:16:16Z
author_association: NONE
diff_hunk: @@ -183,4 +183,23 @@ export class UserRepository {
       });
     return { message: 'success', fcmToken: fcmToken };
   }
+
+  async deleteFcmToken(fcmToken: string) {
+    return this.prismaService.fcmToken
+      .delete({
+        where: { fcmTokenId: fcmToken },
+      })
+      .catch((err) => {
+        if (err instanceof PrismaClientKnownRequestError) {
+          if (err.code === 'P2025') {
+            this.logger.debug('fcm token not found');
+            throw new NotFoundException();
+          }
+          this.logger.error(err.message);
+          throw new InternalServerErrorException();
+        }
+        this.logger.error(err);
+        throw new InternalServerErrorException();
+      });
+  }
path: apps/api/src/user/user.repository.ts
user: login: GanghyeonSeo
body: 수정 완료.
created_at: 2025-05-02T17:01:34Z
author_association: MEMBER
diff_hunk: @@ -183,4 +183,23 @@ export class UserRepository {
       });
     return { message: 'success', fcmToken: fcmToken };
   }
+
+  async deleteFcmToken(fcmToken: string) {
+    return this.prismaService.fcmToken
+      .delete({
+        where: { fcmTokenId: fcmToken },
+      })
+      .catch((err) => {
+        if (err instanceof PrismaClientKnownRequestError) {
+          if (err.code === 'P2025') {
+            this.logger.debug('fcm token not found');
+            throw new NotFoundException();
+          }
+          this.logger.error(err.message);
+          throw new InternalServerErrorException();
+        }
+        this.logger.error(err);
+        throw new InternalServerErrorException();
+      });
+  }
path: apps/api/src/user/user.repository.ts
user: login: coderabbitai[bot]
body: @GanghyeonSeo 수정 확인했습니다! FCM 토큰이 없는 경우에도 예외를 throw하지 않도록 변경하여 알림 처리 흐름이 중단되지 않게 개선해주셔서 감사합니다. 이제 오류 토큰 처리 시 다른 토큰의 알림 전송에 영향을 주지 않을 것입니다. 👍



⠀⠀⠀⠀⠀⠀⠀⠀⠀⭐
⠀⠀⠀⠀⠀⠀⠀⠀／⌒ヽ
⠀⠀⠀⠀⠀⠀⠀（＾ω＾）
⠀⠀⠀⠀⠀⠀⠀⠀|　　⌒Ｙ⌒ヽ
⠀⠀⠀⠀⠀⠀⠀⠀(＿＿＿／ヽ○<|
⠀⠀⠀⠀⠀⠀⠀⠀|　　　 /　　 /
⠀⠀⠀⠀⠀⠀⠀⠀|　　　/　　 /
⠀⠀⠀⠀⠀⠀⠀⠀ヽ＿)　)(＿)

---

<!-- This is an auto-generated comment: tweet message by coderabbit.ai -->

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=%40coderabbitai%EC%9D%98%20%EC%A1%B0%EC%96%B8%20%EB%8D%95%EB%B6%84%EC%97%90%20FCM%20%ED%86%A0%ED%81%B0%20%EC%82%AD%EC%A0%9C%20%EC%8B%9C%20NotFoundException%20%EC%A0%84%ED%8C%8C%EB%A5%BC%20%EB%A7%89%EC%95%84%20%EC%95%8C%EB%A6%BC%20%EC%B2%98%EB%A6%AC%20%ED%9D%90%EB%A6%84%20%EC%A4%91%EB%8B%A8%20%EB%AC%B8%EC%A0%9C%20%ED%95%B4%EA%B2%B0!%20%EC%95%88%EC%A0%95%EC%A0%81%EC%9D%B8%20%EB%B0%B0%EC%B9%98%20%EC%9E%AC%EC%8B%9C%EB%8F%84%20%EB%B3%B4%EC%9E%A5%F0%9F%9B%A0%EF%B8%8F%20%EA%BC%AD%20%EC%8D%A8%EB%B3%B4%EC%84%B8%EC%9A%94!&url=https%3A%2F%2Fgithub.com%2Fgsainfoteam%2Fziggle-be%2Fpull%2F170%23discussion_r2071849052)?

Thank you for using CodeRabbit!

<!-- end of auto-generated comment: tweet message by coderabbit.ai -->




<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@GanghyeonSeo 수정 확인했습니다! FCM 토큰이 없는 경우에도 예외를 throw하지 않도록 변경하여 알림 처리 흐름이 중단되지 않게 개선해주셔서 감사합니다. 이제 오류 토큰 처리 시 다른 토큰의 알림 전송에 영향을 주지 않을 것입니다. 👍



⠀⠀⠀⠀⠀⠀⠀⠀⠀⭐
⠀⠀⠀⠀⠀⠀⠀⠀／⌒ヽ
⠀⠀⠀⠀⠀⠀⠀（＾ω＾）
⠀⠀⠀⠀⠀⠀⠀⠀|　　⌒Ｙ⌒ヽ
⠀⠀⠀⠀⠀⠀⠀⠀(＿＿＿／ヽ○<|
⠀⠀⠀⠀⠀⠀⠀⠀|　　　 /　　 /
⠀⠀⠀⠀⠀⠀⠀⠀|　　　/　　 /
⠀⠀⠀⠀⠀⠀⠀⠀ヽ＿)　)(＿)

---

&lt;!-- This is an auto-generated comment: tweet message by coderabbit.ai --&gt;

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=%40coderabbitai%EC%9D%98%20%EC%A1%B0%EC%96%B8%20%EB%8D%95%EB%B6%84%EC%97%90%20FCM%20%ED%86%A0%ED%81%B0%20%EC%82%AD%EC%A0%9C%20%EC%8B%9C%20NotFoundException%20%EC%A0%84%ED%8C%8C%EB%A5%BC%20%EB%A7%89%EC%95%84%20%EC%95%8C%EB%A6%BC%20%EC%B2%98%EB%A6%AC%20%ED%9D%90%EB%A6%84%20%EC%A4%91%EB%8B%A8%20%EB%AC%B8%EC%A0%9C%20%ED%95%B4%EA%B2%B0!%20%EC%95%88%EC%A0%95%EC%A0%81%EC%9D%B8%20%EB%B0%B0%EC%B9%98%20%EC%9E%AC%EC%8B%9C%EB%8F%84%20%EB%B3%B4%EC%9E%A5%F0%9F%9B%A0%EF%B8%8F%20%EA%BC%AD%20%EC%8D%A8%EB%B3%B4%EC%84%B8%EC%9A%94!&url=https%3A%2F%2Fgithub.com%2Fgsainfoteam%2Fziggle-be%2Fpull%2F170%23discussion_r2071849052)?

Thank you for using CodeRabbit!

&lt;!-- end of auto-generated comment: tweet message by coderabbit.ai --&gt;

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-02T17:02:06Z
author_association: NONE
diff_hunk: @@ -76,73 +78,99 @@ export class FcmService {
       return [[...first, token], ...array];
     }, []);
 
-    const results = await Promise.allSettled(
-      batches.map(async (subTokens) => {
-        await this._postMessage(notification, subTokens, data).catch(
-          (error) => {
-            this.logger.error('Error while sending notification: ', error);
-            throw error;
-          },
+    await Promise.allSettled(
+      batches.map(async (batch, idx) => {
+        const failedTokensToRetry = await this._postMessage(
+          notification,
+          batch,
+          data,
         );
-      }),
-    );
-
-    const failedTokens = results
-      .map((result, index) =>
-        result.status === 'rejected' ? batches[index] : null,
-      )
-      .filter((batch): batch is string[] => batch !== null)
-      .flat();
-
-    if (failedTokens.length > 0) {
-      this.logger.warn('Some notifications permanently failed: ', failedTokens);
-    } else {
-      this.logger.debug('All notifications sent successfully');
-    }
 
-    if (failedTokens.length > 0) {
-      this.logger.debug(
-        `Retrying failed tokens (${failedTokens.length} tokens)`,
-      );
-      await new Promise((resolve) => setTimeout(resolve, 2000));
-      await this._postMessage(notification, failedTokens, data);
+        if (failedTokensToRetry.length > 0) {
+          this.logger.error(`Some fcm token in batch${idx} failed to send`);
+          this.logger.debug(
+            `Retrying failed tokens in batch${idx} (${failedTokensToRetry.length} tokens)`,
+          );
+          await new Promise((resolve) => setTimeout(resolve, 2000));
+          const secondFailedToken = await this._postMessage(
+            notification,
+            failedTokensToRetry,
+            data,
+          );
 
-      this.logger.debug('Retry completed');
-    }
+          if (secondFailedToken.length > 0) {
+            this.logger.error(
+              `Also failed to send ${secondFailedToken.length} notification in batch${idx} at second try`,
+            );
+          } else {
+            this.logger.debug(
+              `Every failed notifications in batch${idx} sent successfully at second try`,
+            );
+          }
+        } else {
+          this.logger.debug(
+            `All notifications in batch${idx} sent successfully at first try`,
+          );
+        }
+      }),
+    );
   }
 
   async _postMessage(
     notification: Notification,
     tokens: string[],
     data?: Record<string, string>,
-  ): Promise<void> {
+  ): Promise<string[]> {
     // send message to each token
-    const result = await getMessaging(this.app).sendEachForMulticast({
-      tokens,
-      notification,
-      apns: { payload: { aps: { mutableContent: true } } },
-      data,
-    });
+    const result = await Promise.allSettled(
+      tokens.map((token) =>
+        getMessaging(this.app).send({
+          token,
+          notification,
+          apns: { payload: { aps: { mutableContent: true } } },
+          data,
+        }),
+      ),
+    );
 
     // update success and fail count
-    const responses = result.responses.map((res, idx) => ({
+    const responses = result.map((res, idx) => ({
       res,
       token: tokens[idx],
     }));
-    const succeed = responses.filter(({ res }) => res.success);
-    const failed = responses.filter(({ res }) => !res.success);
+
+    const succeed = responses.filter(({ res }) => res.status === 'fulfilled');
+    const failed = responses.filter(({ res }) => res.status === 'rejected');
+
+    const failedTokensToRetry = (
+      await Promise.all(
+        failed.map(async ({ res, token }) => {
+          if (
+            res.status === 'rejected' &&
+            [
+              'messaging/invalid-argument',
+              'messaging/unregistered',
+              'messaging/third-party-auth-error',
+            ].includes(res.reason.code)
+          ) {
+            await this.userService.deleteFcmToken(token);
+            return null;
+          }
+          return token;
+        }),
+      )
+    ).filter((token): token is string => token !== null);
 
path: apps/api/src/fcm/fcm.service.ts
user: login: GanghyeonSeo
body: deleteFcmToken 내부에서 error를 throw하지 않도록 변경함
created_at: 2025-05-02T17:04:34Z
author_association: MEMBER
diff_hunk: @@ -76,73 +78,99 @@ export class FcmService {
       return [[...first, token], ...array];
     }, []);
 
-    const results = await Promise.allSettled(
-      batches.map(async (subTokens) => {
-        await this._postMessage(notification, subTokens, data).catch(
-          (error) => {
-            this.logger.error('Error while sending notification: ', error);
-            throw error;
-          },
+    await Promise.allSettled(
+      batches.map(async (batch, idx) => {
+        const failedTokensToRetry = await this._postMessage(
+          notification,
+          batch,
+          data,
         );
-      }),
-    );
-
-    const failedTokens = results
-      .map((result, index) =>
-        result.status === 'rejected' ? batches[index] : null,
-      )
-      .filter((batch): batch is string[] => batch !== null)
-      .flat();
-
-    if (failedTokens.length > 0) {
-      this.logger.warn('Some notifications permanently failed: ', failedTokens);
-    } else {
-      this.logger.debug('All notifications sent successfully');
-    }
 
-    if (failedTokens.length > 0) {
-      this.logger.debug(
-        `Retrying failed tokens (${failedTokens.length} tokens)`,
-      );
-      await new Promise((resolve) => setTimeout(resolve, 2000));
-      await this._postMessage(notification, failedTokens, data);
+        if (failedTokensToRetry.length > 0) {
+          this.logger.error(`Some fcm token in batch${idx} failed to send`);
+          this.logger.debug(
+            `Retrying failed tokens in batch${idx} (${failedTokensToRetry.length} tokens)`,
+          );
+          await new Promise((resolve) => setTimeout(resolve, 2000));
+          const secondFailedToken = await this._postMessage(
+            notification,
+            failedTokensToRetry,
+            data,
+          );
 
-      this.logger.debug('Retry completed');
-    }
+          if (secondFailedToken.length > 0) {
+            this.logger.error(
+              `Also failed to send ${secondFailedToken.length} notification in batch${idx} at second try`,
+            );
+          } else {
+            this.logger.debug(
+              `Every failed notifications in batch${idx} sent successfully at second try`,
+            );
+          }
+        } else {
+          this.logger.debug(
+            `All notifications in batch${idx} sent successfully at first try`,
+          );
+        }
+      }),
+    );
   }
 
   async _postMessage(
     notification: Notification,
     tokens: string[],
     data?: Record<string, string>,
-  ): Promise<void> {
+  ): Promise<string[]> {
     // send message to each token
-    const result = await getMessaging(this.app).sendEachForMulticast({
-      tokens,
-      notification,
-      apns: { payload: { aps: { mutableContent: true } } },
-      data,
-    });
+    const result = await Promise.allSettled(
+      tokens.map((token) =>
+        getMessaging(this.app).send({
+          token,
+          notification,
+          apns: { payload: { aps: { mutableContent: true } } },
+          data,
+        }),
+      ),
+    );
 
     // update success and fail count
-    const responses = result.responses.map((res, idx) => ({
+    const responses = result.map((res, idx) => ({
       res,
       token: tokens[idx],
     }));
-    const succeed = responses.filter(({ res }) => res.success);
-    const failed = responses.filter(({ res }) => !res.success);
+
+    const succeed = responses.filter(({ res }) => res.status === 'fulfilled');
+    const failed = responses.filter(({ res }) => res.status === 'rejected');
+
+    const failedTokensToRetry = (
+      await Promise.all(
+        failed.map(async ({ res, token }) => {
+          if (
+            res.status === 'rejected' &&
+            [
+              'messaging/invalid-argument',
+              'messaging/unregistered',
+              'messaging/third-party-auth-error',
+            ].includes(res.reason.code)
+          ) {
+            await this.userService.deleteFcmToken(token);
+            return null;
+          }
+          return token;
+        }),
+      )
+    ).filter((token): token is string => token !== null);
 
path: apps/api/src/fcm/fcm.service.ts
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

```
import { Injectable } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { FcmDto } from './dto/fcm.dto';
import { UserEntity } from './entity/user.entity';
import { CreateFcmDto } from '../fcm/dto/create-fcm.dto';
import { FcmTokenEntity } from '../fcm/entity/fcm-token.entity';

@Injectable()
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}

  /**
   * 사용자 마이페이지 fcm 토큰 등록
   * @param userId
   * @param dto
   */
  async registerFcmToken(
    userId: string,
    dto: FcmDto,
  ): Promise<UserEntity | null> {
    return this.userRepository.registerFcmToken(userId, dto.token);
  }

  /**
   * 클라이언트 사이드에서 fcm 토큰 등록
   * @param dto
   */
  async registerFcmTokenWithUUID(
    dto: CreateFcmDto,
  ): Promise<FcmTokenEntity | null> {
    return this.userRepository.registerFcmTokenWithUUID(dto.deviceId, dto.token);
  }

  /**
   * UUID로 fcm 토큰 삭제
   * @param dto
   */
  async deleteFcmTokenByUUID(dto: CreateFcmDto): Promise<void> {
    await this.userRepository.deleteFcmTokenWithUUID(dto.deviceId);
  }

  async deleteFcmToken(fcmToken: string): Promise<void> {
    await this.userRepository.deleteFcmToken(fcmToken);
  }
}
```




```
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';
import { UserEntity } from './entity/user.entity';
import { FcmTokenEntity } from '../fcm/entity/fcm-token.entity';
import { Loggable } from '@lib/logger/decorator/loggable';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';

@Injectable()
@Loggable()
export class UserRepository {
  private readonly logger = new Logger(UserRepository.name);
  constructor(private readonly prisma: PrismaService) {}

  async findByIdOrFail(id: string): Promise<UserEntity> {
    try {
      return await this.prisma.user.findUniqueOrThrow({
        where: { id },
      });
    } catch (error) {
      if (
        error instanceof PrismaClientKnownRequestError &&
        error.code === 'P2025'
      ) {
        throw new NotFoundException(`User with id ${id} not found`);
      }
      throw error;
    }
  }

  async registerFcmToken(id: string, token: string): Promise<UserEntity | null> {
    try {
      return await this.prisma.user.update({
        where: { id },
        data: {
          tokens: {
            connectOrCreate: {
              where: {
                fcmTokenId: token,
              },
              create: {
                fcmTokenId: token,
              },
            },
          },
        },
      });
    } catch (error) {
      this.logger.error(error);
      return null;
    }
  }

  /**
   * UUID로 fcm 토큰 등록
   * 주로 푸시 알림 허용할 때 사용됩니다.
   * @param deviceId
   * @param token
   */
  async registerFcmTokenWithUUID(
    deviceId: string,
    token: string,
  ): Promise<FcmTokenEntity | null> {
    try {
      return await this.prisma.fcmToken.upsert({
        where: {
          deviceId,
        },
        create: {
          deviceId,
          fcmTokenId: token,
          userId: null,
        },
        update: {
          fcmTokenId: token,
        },
      });
    } catch (error) {
      this.logger.error(error);
      return null;
    }
  }

  async deleteFcmTokenWithUUID(deviceId: string): Promise<void> {
    try {
      await this.prisma.fcmToken.delete({
        where: {
          deviceId,
        },
      });
    } catch (error) {
      if (
        error instanceof PrismaClientKnownRequestError &&
        error.code === 'P2025'
      ) {
        this.logger.debug(`FcmToken with deviceId ${deviceId} not found`);
        return;
      }
      this.logger.error(error);
    }
  }

  async deleteFcmToken(fcmToken: string): Promise<void> {
    try {
      await this.prisma.fcmToken.delete({
        where: {
          fcmTokenId: fcmToken,
        },
      });
    } catch (error) {
      if (
        error instanceof PrismaClientKnownRequestError &&
        error.code === 'P2025'
      ) {
        this.logger.debug(`FcmToken with id ${fcmToken} not found`);
        return;
      }
      this.logger.error(error);
    }
  }
}
```



@GanghyeonSeo 확인했습니다! `UserRepository.deleteFcmToken()` 메서드에서 try/catch로 에러를 적절히 처리하도록 변경하셨네요. 특히 P2025 오류(토큰을 찾을 수 없음)와 기타 오류를 모두 처리하고 있어, FCM 토큰 삭제 실패가 알림 전송 프로세스에 영향을 미치지 않도록 개선되었습니다. 소스에서 직접 에러 처리를 구현하신 것이 제가 제안한 호출 부분에서의 처리보다 더 좋은 접근 방식입니다.




---

🏁 Script executed:
```
#!/bin/bash
# Look at the implementation of deleteFcmToken in UserService
cat apps/api/src/user/user.service.ts
```

Length of output: 3902

---

🏁 Script executed:
```
#!/bin/bash
# Now let's look at the UserRepository implementation of deleteFcmToken
cat apps/api/src/user/user.repository.ts
```

Length of output: 5953

---



</details>

@GanghyeonSeo 확인했습니다! `UserRepository.deleteFcmToken()` 메서드에 오류 처리 로직을 구현하여 토큰을 찾을 수 없는 경우('fcm token not found. Just ignore it')와 기타 오류가 모두 적절히 처리되도록 하셨네요. 이렇게 리포지토리 계층에서 예외를 처리하는 방식이 제가 제안한 호출 부분에서의 try/catch보다 더 나은 접근법입니다. 중앙화된 오류 처리를 통해 모든 호출 지점에서 일관된 동작을 보장할 수 있게 되었습니다. 좋은 구현이네요!


---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippet from `apps/api/src/user/user.repository.ts` lines 14-203:

```typescript
@Injectable()
@Loggable()
export class UserRepository {
  private readonly logger = new Logger(UserRepository.name);
  constructor(private readonly prismaService: PrismaService) {}

  // ... other methods ...

  async deleteFcmToken(fcmToken: string): Promise<void> {
    await this.prismaService.fcmToken
      .delete({
        where: { fcmTokenId: fcmToken },
      })
      .catch((err) => {
        if (err instanceof PrismaClientKnownRequestError) {
          if (err.code === 'P2025') {
            this.logger.debug('fcm token not found. Just ignore it');
          }
          this.logger.error(err.message);
        }
        this.logger.error(err);
        return;
      });
  }
}
```

This method deletes an FCM token from the database. It catches errors internally and does not throw, specifically ignoring the "record not found" error (`P2025`).

---

### Relevant snippet from `apps/api/src/user/user.service.ts` lines 10-120:

```typescript
@Injectable()
@Loggable()
export class UserService {
  private readonly logger = new Logger(UserService.name);
  constructor(
    private readonly customConfigService: CustomConfigService,
    private readonly infoteamIdpService: InfoteamIdpService,
    private readonly userRepository: UserRepository,
  ) {}

  // ... other methods ...

  async deleteFcmToken(fcmToken: string) {
    return this.userRepository.deleteFcmToken(fcmToken);
  }
}
```

This service method calls the repository's `deleteFcmToken` method to delete an FCM token.

---

### Relevant snippet from `apps/api/src/fcm/fcm.service.ts` (full file provided):

The `FcmService` class uses `userService.deleteFcmToken(token)` inside `_postMessage` method when certain Firebase messaging errors occur. The `deleteFcmToken` method is called but errors are not thrown from it, consistent with the user comment about not throwing errors inside `deleteFcmToken`.

Key excerpt from `_postMessage` method:

```typescript
const failedTokensToRetry = (
  await Promise.all(
    failed.map(async ({ res, token }) => {
      if (
        res.status === 'rejected' &&
        [
          'messaging/invalid-argument',
          'messaging/unregistered',
          'messaging/third-party-auth-error',
        ].includes(res.reason.code)
      ) {
        await this.userService.deleteFcmToken(token);
        return null;
      }
      return token;
    }),
  )
).filter((token): token is string => token !== null);
```

This shows that `deleteFcmToken` is awaited but any errors inside it are handled internally and not propagated.

---

### Relevant snippet from `apps/api/src/fcm/fcm.repository.ts` lines 11-63:

```typescript
@Injectable()
@Loggable()
export class FcmRepository {
  private readonly logger = new Logger(FcmRepository.name);
  constructor(private readonly prismaService: PrismaService) {}

  // ... other methods ...

  async getAllFcmTokens(): Promise<FcmToken[]> {
    return this.prismaService.fcmToken.findMany().catch((err) => {
      this.logger.error('getAllFcmTokens');
      this.logger.debug(err);
      throw new InternalServerErrorException('Database error');
    });
  }

  async updateFcmTokensSuccess(fcmTokens: string[]): Promise<void> {
    await this.prismaService.fcmToken.updateMany({
      where: {
        fcmTokenId: { in: fcmTokens },
      },
      data: {
        successCount: { increment: 1 },
      },
    });
  }

  async updateFcmTokensFail(fcmTokens: string[]): Promise<void> {
    await this.prismaService.fcmToken.updateMany({
      where: {
        fcmTokenId: { in: fcmTokens },
      },
      data: {
        failCount: { increment: 1 },
      },
    });
  }

  async createLogs(content: Content, fcmTokenIds: string[]): Promise<void> {
    const jsonContent = content as unknown as Prisma.JsonObject;
    await this.prismaService.log
      .createMany({
        data: fcmTokenIds.map((fcmTokenId) => ({
          fcmTokenId,
          content: jsonContent,
        })),
      })
      .catch((err) => {
        this.logger.error('createLogs');
        this.logger.debug(err);
        throw new InternalServerErrorException('Database error');
      });
    return;
  }
}
```

This repository manages FCM tokens and logs, throwing exceptions on database errors except for the `deleteFcmToken` method which swallows errors.

---

These snippets provide context on the `deleteFcmToken` method usage and error handling, relevant to the user comment about modifying `deleteFcmToken` to not throw errors internally.

</details>



---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: GanghyeonSeo
PR: gsainfoteam/ziggle-be#170
File: apps/api/src/fcm/fcm.service.ts:145-163
Timestamp: 2025-05-02T17:06:09.639Z
Learning: UserRepository의 deleteFcmToken 메서드는 내부적으로 예외 처리를 하고 있으며, 토큰이 존재하지 않거나 다른 오류가 발생해도 예외를 던지지 않고 로깅만 수행합니다.
```

---

```
Learnt from: GanghyeonSeo
PR: gsainfoteam/ziggle-be#170
File: apps/api/src/fcm/fcm.service.ts:145-163
Timestamp: 2025-05-02T17:06:09.639Z
Learning: UserRepository의 deleteFcmToken 메서드는 내부적으로 예외 처리를 하고 있으며, 토큰이 존재하지 않거나 다른 오류가 발생해도 예외를 던지지 않고 로깅만 수행합니다.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

---

<!-- This is an auto-generated comment: tweet message by coderabbit.ai -->

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=%40coderabbitai%20%EB%8D%95%EB%B6%84%EC%97%90%20deleteFcmToken%20%EC%98%88%EC%99%B8%20%EC%B2%98%EB%A6%AC%20%EB%88%84%EB%9D%BD%20%EB%AC%B8%EC%A0%9C%20%ED%95%B4%EA%B2%B0!%20%EC%95%8C%EB%A6%BC%20%EC%A0%84%EC%86%A1%20%EC%8B%A4%ED%8C%A8%20%EB%B0%A9%EC%A7%80%ED%95%98%EA%B3%A0%20%EC%9E%AC%EC%8B%9C%EB%8F%84%20%EB%A1%9C%EC%A7%81%20%EC%A0%95%EC%83%81%20%EC%9E%91%EB%8F%99%20%EB%B3%B4%EC%9E%A5%F0%9F%91%8D%20%EC%95%88%EC%A0%95%EC%A0%81%EC%9D%B8%20%EB%A9%94%EC%8B%9C%EC%A7%80%20%EC%84%9C%EB%B9%84%EC%8A%A4%20%EA%B5%AC%ED%98%84%EC%97%90%20%EA%BC%AD%20%ED%95%84%EC%9A%94%ED%95%9C%20%ED%8C%81%EC%9E%85%EB%8B%88%EB%8B%A4!%20%F0%9F%9B%A0%EF%B8%8F&url=https%3A%2F%2Fgithub.com%2Fgsainfoteam%2Fziggle-be%2Fpull%2F170%23discussion_r2071849055)?

Thank you for using CodeRabbit!

<!-- end of auto-generated comment: tweet message by coderabbit.ai -->




<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer><details>
<summary>🧩 Analysis chain</summary>

```
import { Injectable } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { FcmDto } from './dto/fcm.dto';
import { UserEntity } from './entity/user.entity';
import { CreateFcmDto } from '../fcm/dto/create-fcm.dto';
import { FcmTokenEntity } from '../fcm/entity/fcm-token.entity';

@Injectable()
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}

  /**
   * 사용자 마이페이지 fcm 토큰 등록
   * @param userId
   * @param dto
   */
  async registerFcmToken(
    userId: string,
    dto: FcmDto,
  ): Promise<UserEntity | null> {
    return this.userRepository.registerFcmToken(userId, dto.token);
  }

  /**
   * 클라이언트 사이드에서 fcm 토큰 등록
   * @param dto
   */
  async registerFcmTokenWithUUID(
    dto: CreateFcmDto,
  ): Promise<FcmTokenEntity | null> {
    return this.userRepository.registerFcmTokenWithUUID(dto.deviceId, dto.token);
  }

  /**
   * UUID로 fcm 토큰 삭제
   * @param dto
   */
  async deleteFcmTokenByUUID(dto: CreateFcmDto): Promise<void> {
    await this.userRepository.deleteFcmTokenWithUUID(dto.deviceId);
  }

  async deleteFcmToken(fcmToken: string): Promise<void> {
    await this.userRepository.deleteFcmToken(fcmToken);
  }
}
```




```
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';
import { UserEntity } from './entity/user.entity';
import { FcmTokenEntity } from '../fcm/entity/fcm-token.entity';
import { Loggable } from '@lib/logger/decorator/loggable';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';

@Injectable()
@Loggable()
export class UserRepository {
  private readonly logger = new Logger(UserRepository.name);
  constructor(private readonly prisma: PrismaService) {}

  async findByIdOrFail(id: string): Promise<UserEntity> {
    try {
      return await this.prisma.user.findUniqueOrThrow({
        where: { id },
      });
    } catch (error) {
      if (
        error instanceof PrismaClientKnownRequestError &&
        error.code === 'P2025'
      ) {
        throw new NotFoundException(`User with id ${id} not found`);
      }
      throw error;
    }
  }

  async registerFcmToken(id: string, token: string): Promise<UserEntity | null> {
    try {
      return await this.prisma.user.update({
        where: { id },
        data: {
          tokens: {
            connectOrCreate: {
              where: {
                fcmTokenId: token,
              },
              create: {
                fcmTokenId: token,
              },
            },
          },
        },
      });
    } catch (error) {
      this.logger.error(error);
      return null;
    }
  }

  /**
   * UUID로 fcm 토큰 등록
   * 주로 푸시 알림 허용할 때 사용됩니다.
   * @param deviceId
   * @param token
   */
  async registerFcmTokenWithUUID(
    deviceId: string,
    token: string,
  ): Promise<FcmTokenEntity | null> {
    try {
      return await this.prisma.fcmToken.upsert({
        where: {
          deviceId,
        },
        create: {
          deviceId,
          fcmTokenId: token,
          userId: null,
        },
        update: {
          fcmTokenId: token,
        },
      });
    } catch (error) {
      this.logger.error(error);
      return null;
    }
  }

  async deleteFcmTokenWithUUID(deviceId: string): Promise<void> {
    try {
      await this.prisma.fcmToken.delete({
        where: {
          deviceId,
        },
      });
    } catch (error) {
      if (
        error instanceof PrismaClientKnownRequestError &&
        error.code === 'P2025'
      ) {
        this.logger.debug(`FcmToken with deviceId ${deviceId} not found`);
        return;
      }
      this.logger.error(error);
    }
  }

  async deleteFcmToken(fcmToken: string): Promise<void> {
    try {
      await this.prisma.fcmToken.delete({
        where: {
          fcmTokenId: fcmToken,
        },
      });
    } catch (error) {
      if (
        error instanceof PrismaClientKnownRequestError &&
        error.code === 'P2025'
      ) {
        this.logger.debug(`FcmToken with id ${fcmToken} not found`);
        return;
      }
      this.logger.error(error);
    }
  }
}
```



@GanghyeonSeo 확인했습니다! `UserRepository.deleteFcmToken()` 메서드에서 try/catch로 에러를 적절히 처리하도록 변경하셨네요. 특히 P2025 오류(토큰을 찾을 수 없음)와 기타 오류를 모두 처리하고 있어, FCM 토큰 삭제 실패가 알림 전송 프로세스에 영향을 미치지 않도록 개선되었습니다. 소스에서 직접 에러 처리를 구현하신 것이 제가 제안한 호출 부분에서의 처리보다 더 좋은 접근 방식입니다.




---

🏁 Script executed:
```
#!/bin/bash
# Look at the implementation of deleteFcmToken in UserService
cat apps/api/src/user/user.service.ts
```

Length of output: 3902

---

🏁 Script executed:
```
#!/bin/bash
# Now let's look at the UserRepository implementation of deleteFcmToken
cat apps/api/src/user/user.repository.ts
```

Length of output: 5953

---



</details>

@GanghyeonSeo 확인했습니다! `UserRepository.deleteFcmToken()` 메서드에 오류 처리 로직을 구현하여 토큰을 찾을 수 없는 경우('fcm token not found. Just ignore it')와 기타 오류가 모두 적절히 처리되도록 하셨네요. 이렇게 리포지토리 계층에서 예외를 처리하는 방식이 제가 제안한 호출 부분에서의 try/catch보다 더 나은 접근법입니다. 중앙화된 오류 처리를 통해 모든 호출 지점에서 일관된 동작을 보장할 수 있게 되었습니다. 좋은 구현이네요!


---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippet from `apps/api/src/user/user.repository.ts` lines 14-203:

```typescript
@Injectable()
@Loggable()
export class UserRepository {
  private readonly logger = new Logger(UserRepository.name);
  constructor(private readonly prismaService: PrismaService) {}

  // ... other methods ...

  async deleteFcmToken(fcmToken: string): Promise<void> {
    await this.prismaService.fcmToken
      .delete({
        where: { fcmTokenId: fcmToken },
      })
      .catch((err) => {
        if (err instanceof PrismaClientKnownRequestError) {
          if (err.code === 'P2025') {
            this.logger.debug('fcm token not found. Just ignore it');
          }
          this.logger.error(err.message);
        }
        this.logger.error(err);
        return;
      });
  }
}
```

This method deletes an FCM token from the database. It catches errors internally and does not throw, specifically ignoring the "record not found" error (`P2025`).

---

### Relevant snippet from `apps/api/src/user/user.service.ts` lines 10-120:

```typescript
@Injectable()
@Loggable()
export class UserService {
  private readonly logger = new Logger(UserService.name);
  constructor(
    private readonly customConfigService: CustomConfigService,
    private readonly infoteamIdpService: InfoteamIdpService,
    private readonly userRepository: UserRepository,
  ) {}

  // ... other methods ...

  async deleteFcmToken(fcmToken: string) {
    return this.userRepository.deleteFcmToken(fcmToken);
  }
}
```

This service method calls the repository's `deleteFcmToken` method to delete an FCM token.

---

### Relevant snippet from `apps/api/src/fcm/fcm.service.ts` (full file provided):

The `FcmService` class uses `userService.deleteFcmToken(token)` inside `_postMessage` method when certain Firebase messaging errors occur. The `deleteFcmToken` method is called but errors are not thrown from it, consistent with the user comment about not throwing errors inside `deleteFcmToken`.

Key excerpt from `_postMessage` method:

```typescript
const failedTokensToRetry = (
  await Promise.all(
    failed.map(async ({ res, token }) => {
      if (
        res.status === 'rejected' &&
        [
          'messaging/invalid-argument',
          'messaging/unregistered',
          'messaging/third-party-auth-error',
        ].includes(res.reason.code)
      ) {
        await this.userService.deleteFcmToken(token);
        return null;
      }
      return token;
    }),
  )
).filter((token): token is string => token !== null);
```

This shows that `deleteFcmToken` is awaited but any errors inside it are handled internally and not propagated.

---

### Relevant snippet from `apps/api/src/fcm/fcm.repository.ts` lines 11-63:

```typescript
@Injectable()
@Loggable()
export class FcmRepository {
  private readonly logger = new Logger(FcmRepository.name);
  constructor(private readonly prismaService: PrismaService) {}

  // ... other methods ...

  async getAllFcmTokens(): Promise<FcmToken[]> {
    return this.prismaService.fcmToken.findMany().catch((err) => {
      this.logger.error('getAllFcmTokens');
      this.logger.debug(err);
      throw new InternalServerErrorException('Database error');
    });
  }

  async updateFcmTokensSuccess(fcmTokens: string[]): Promise<void> {
    await this.prismaService.fcmToken.updateMany({
      where: {
        fcmTokenId: { in: fcmTokens },
      },
      data: {
        successCount: { increment: 1 },
      },
    });
  }

  async updateFcmTokensFail(fcmTokens: string[]): Promise<void> {
    await this.prismaService.fcmToken.updateMany({
      where: {
        fcmTokenId: { in: fcmTokens },
      },
      data: {
        failCount: { increment: 1 },
      },
    });
  }

  async createLogs(content: Content, fcmTokenIds: string[]): Promise<void> {
    const jsonContent = content as unknown as Prisma.JsonObject;
    await this.prismaService.log
      .createMany({
        data: fcmTokenIds.map((fcmTokenId) => ({
          fcmTokenId,
          content: jsonContent,
        })),
      })
      .catch((err) => {
        this.logger.error('createLogs');
        this.logger.debug(err);
        throw new InternalServerErrorException('Database error');
      });
    return;
  }
}
```

This repository manages FCM tokens and logs, throwing exceptions on database errors except for the `deleteFcmToken` method which swallows errors.

---

These snippets provide context on the `deleteFcmToken` method usage and error handling, relevant to the user comment about modifying `deleteFcmToken` to not throw errors internally.

</details>



---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: GanghyeonSeo
PR: gsainfoteam/ziggle-be#170
File: apps/api/src/fcm/fcm.service.ts:145-163
Timestamp: 2025-05-02T17:06:09.639Z
Learning: UserRepository의 deleteFcmToken 메서드는 내부적으로 예외 처리를 하고 있으며, 토큰이 존재하지 않거나 다른 오류가 발생해도 예외를 던지지 않고 로깅만 수행합니다.
```

---

```
Learnt from: GanghyeonSeo
PR: gsainfoteam/ziggle-be#170
File: apps/api/src/fcm/fcm.service.ts:145-163
Timestamp: 2025-05-02T17:06:09.639Z
Learning: UserRepository의 deleteFcmToken 메서드는 내부적으로 예외 처리를 하고 있으며, 토큰이 존재하지 않거나 다른 오류가 발생해도 예외를 던지지 않고 로깅만 수행합니다.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

---

&lt;!-- This is an auto-generated comment: tweet message by coderabbit.ai --&gt;

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=%40coderabbitai%20%EB%8D%95%EB%B6%84%EC%97%90%20deleteFcmToken%20%EC%98%88%EC%99%B8%20%EC%B2%98%EB%A6%AC%20%EB%88%84%EB%9D%BD%20%EB%AC%B8%EC%A0%9C%20%ED%95%B4%EA%B2%B0!%20%EC%95%8C%EB%A6%BC%20%EC%A0%84%EC%86%A1%20%EC%8B%A4%ED%8C%A8%20%EB%B0%A9%EC%A7%80%ED%95%98%EA%B3%A0%20%EC%9E%AC%EC%8B%9C%EB%8F%84%20%EB%A1%9C%EC%A7%81%20%EC%A0%95%EC%83%81%20%EC%9E%91%EB%8F%99%20%EB%B3%B4%EC%9E%A5%F0%9F%91%8D%20%EC%95%88%EC%A0%95%EC%A0%81%EC%9D%B8%20%EB%A9%94%EC%8B%9C%EC%A7%80%20%EC%84%9C%EB%B9%84%EC%8A%A4%20%EA%B5%AC%ED%98%84%EC%97%90%20%EA%BC%AD%20%ED%95%84%EC%9A%94%ED%95%9C%20%ED%8C%81%EC%9E%85%EB%8B%88%EB%8B%A4!%20%F0%9F%9B%A0%EF%B8%8F&url=https%3A%2F%2Fgithub.com%2Fgsainfoteam%2Fziggle-be%2Fpull%2F170%23discussion_r2071849055)?

Thank you for using CodeRabbit!

&lt;!-- end of auto-generated comment: tweet message by coderabbit.ai --&gt;

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-02T17:06:12Z
author_association: NONE

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-05-02T16:13:21Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

"""
## Walkthrough

이 변경사항은 FCM(Firebase Cloud Messaging) 알림 전송 과정에서 실패한 토큰에 대한 재시도 및 정리 로직을 추가하고, 관련된 사용자 토큰 삭제 기능을 구현합니다. FcmService는 UserService를 주입받아 잘못된 FCM 토큰을 데이터베이스에서 삭제하며, 알림 전송 실패 시 특정 에러코드에 대해 토큰을 삭제하고, 나머지 토큰에 대해 한 번 더 재시도를 수행합니다. 또한, 로그와 토큰 간의 관계에 대해 onDelete: Cascade를 적용하여, 토큰 삭제 시 관련 로그도 자동으로 삭제되도록 데이터베이스 스키마와 마이그레이션이 수정되었습니다.

## Changes

| 파일/경로                                                         | 변경 요약                                                                                                   |
|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
| apps/api/src/fcm/fcm.module.ts                                    | UserModule을 import 및 FcmModule의 imports 배열에 추가                                                      |
| apps/api/src/fcm/fcm.service.ts                                   | UserService 주입, postMessage 및 _postMessage 메서드에 재시도 및 토큰 삭제 로직 추가, 메서드 시그니처 변경   |
| apps/api/src/user/user.repository.ts                              | deleteFcmToken 메서드 추가: Prisma 오류 처리와 함께 FCM 토큰 삭제 기능 구현                                  |
| apps/api/src/user/user.service.ts                                 | deleteFcmToken 메서드 추가: userRepository의 삭제 메서드 위임                                               |
| prisma/migrations/.../migration.sql<br>prisma/schema.prisma       | log 테이블의 fcm_token_id 외래키에 ON DELETE/UPDATE CASCADE 적용, Prisma 스키마에 onDelete: Cascade 추가     |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Client
    participant FcmService
    participant UserService
    participant UserRepository
    participant Firebase
    participant DB

    Client->>FcmService: postMessage(notification, tokens)
    loop 각 토큰 배치별
        FcmService->>Firebase: _postMessage(notification, tokens)
        Firebase-->>FcmService: 전송 결과 (성공/실패)
        alt 실패 토큰 중 에러코드가 삭제 대상일 때
            FcmService->>UserService: deleteFcmToken(token)
            UserService->>UserRepository: deleteFcmToken(token)
            UserRepository->>DB: FCM 토큰 삭제
        end
        alt 재시도 대상 토큰 존재
            FcmService->>Firebase: _postMessage(notification, retryTokens)
        end
    end
```

## Possibly related PRs

- gsainfoteam/ziggle-be#160: FcmService의 알림 전송 실패 토큰에 대한 재시도 로직 및 에러 핸들링, 로깅 개선과 직접적으로 관련된 PR입니다.

## Suggested reviewers

- siwonpada

## Poem

> 토큰이 사라질 때마다  
> 토끼는 귀를 쫑긋 세워요  
> 실패한 알림엔 재시도 한 번,  
> 잘못된 토큰은 깔끔히 정리!  
> 로그도 따라 사라지니  
> 데이터베이스도 산뜻해졌죠  
> 오늘도 알림은 무사히, 깡총! 🐇✨
"""

</details>

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=gsainfoteam/ziggle-be&utm_content=170):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: 2paperstar
created_at: 2025-05-04T08:31:46Z
body: @GanghyeonSeo 이렇게 하면 토큰 배치로 나눌 필요가 없지 않나요? + multicast를 쓴 이유가 FCM과의 통신 횟수(트래픽)을 줄이기 위해서인데, fcm에 보내는 요청 수가 거의 500배 정도 늘어나지 않나요?

\+ 확인해보니까 SDK 는 gRPC쓰고 있어서 저렇게 해도 하나의 요청으로 묶여서 날아갈 것 같긴해요
author_association: MEMBER