[FEAT] FCM 로직 수정 및 트라이브 챗 무음 토글 API 추가 #132
Conversation
📝 WalkthroughWalkthroughFCM 토큰 관리를 개선하여 동일 토큰의 다른 사용자 활성 토큰을 비활성화하도록 변경하고, 사용자 트라이브 음소거 기능을 추가하며, 채팅 및 이모지 알림에서 음소거된 사용자를 필터링합니다. Changes
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Tip Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord. 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/main/java/com/umc/nuvibe/domain/notification/service/FcmService.java (1)
26-42:⚠️ Potential issue | 🔴 Critical재로그인 시 비활성화된 토큰이 재활성화되지 않는 버그가 있습니다.
existsByUserAndToken(line 36)은isActive상태를 구분하지 않으므로, 로그아웃 후 동일 토큰으로 재로그인하면:
- 기존 레코드가 존재하므로 (비활성 상태) 새 레코드 저장이 스킵됨
- 기존 비활성 레코드를 재활성화하는 로직이 없음
- 결과적으로 해당 사용자에게 FCM 알림이 전송되지 않음
🐛 기존 토큰 재활성화 로직 추가 제안
`@Transactional` public void registerToken(Long userId, String token) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); // 1. 같은 토큰을 가진 다른 유저의 행 비활성화 (다계정 문제 방지) List<Fcm> otherUserTokens = fcmRepository.findByTokenAndIsActiveTrueAndUserNot(token, user); otherUserTokens.forEach(Fcm::deactivate); - // 2. 같은 유저 + 같은 토큰이 없을 때만 저장 - if (!fcmRepository.existsByUserAndToken(user, token)) { - fcmRepository.save(Fcm.builder() - .user(user) - .token(token) - .build()); - } + // 2. 같은 유저 + 같은 토큰이 이미 존재하면 재활성화, 없으면 새로 저장 + Optional<Fcm> existing = fcmRepository.findByUserAndToken(user, token); + if (existing.isPresent()) { + existing.get().activate(); + } else { + fcmRepository.save(Fcm.builder() + .user(user) + .token(token) + .build()); + } }이를 위해
FcmRepository에findByUserAndToken메서드와Fcm엔티티에activate()메서드가 필요합니다.src/main/java/com/umc/nuvibe/domain/user/service/AuthServiceImpl.java (1)
103-108:⚠️ Potential issue | 🟠 Major
withdraw메서드에서 FCM 토큰 비활성화 처리가 누락되었습니다.
logout메서드에서는deactivateAllTokens를 호출하여 사용자의 모든 FCM 토큰을 비활성화하지만,withdraw메서드에서는 사용자 삭제 전에 이 처리를 하지 않습니다. Fcm 엔티티의 외래 키가ON DELETE SET NULL로 설정되어 있기 때문에 제약 조건 위반은 발생하지 않지만,user_id가 NULL로 설정된 채로 남아있는 고아 레코드가 생깁니다. 이러한 토큰들은 여전히 활성 상태일 수 있어 데이터 무결성 문제가 될 수 있습니다.logout과 withdraw의 동작을 일관되게 하기 위해,
withdraw메서드에서도 사용자를 삭제하기 전에deactivateAllTokens를 호출하여 모든 FCM 토큰을 비활성화하는 것을 권장합니다.
🤖 Fix all issues with AI agents
In
`@src/main/java/com/umc/nuvibe/domain/notification/repository/FcmRepository.java`:
- Line 14: The existsByUserAndToken query in FcmRepository currently ignores
token active state causing re-login with same token to be treated as existing;
change the repository method signature from existsByUserAndToken to
existsByUserAndTokenAndIsActiveTrue so only active tokens are considered, or
alternatively in the service layer (where existsByUserAndToken is used together
with updateToken and deactivateAllTokens, e.g., in FcmTokenService or
AuthServiceImpl) detect a matching inactive token and call updateToken to
reactivate it; update usages of the repository method accordingly to ensure
re-login creates or reactivates an active FCM record.
In
`@src/main/java/com/umc/nuvibe/domain/notification/service/FcmTokenService.java`:
- Around line 19-22: deactivateToken currently calls fcmRepository.findByToken
which returns all records and then calls Fcm::deactivate on every result,
causing unnecessary dirty checks and UPDATEs for already inactive tokens; change
the repository call to use a new finder that only returns active tokens (e.g.,
add and use findByTokenAndIsActiveTrue in the repository) and then iterate over
that result in deactivateToken to call Fcm::deactivate only for active entries.
In
`@src/main/java/com/umc/nuvibe/domain/tribe/controller/UserTribeController.java`:
- Around line 91-99: The controller method muteUserTribe currently returns a
success response using Response.of(...); change it to use Response.ok(...)
instead to follow project convention: locate the method muteUserTribe in
UserTribeController and replace the
Response.of(UserTribeResultCode.USERTRIBE_MUTE_SUCCESS, res) return with
Response.ok(UserTribeResultCode.USERTRIBE_MUTE_SUCCESS, res) while keeping the
call to userTribeService.toggleMute(userId, userTribeId) and the
UserTribeMuteRes payload unchanged.
In
`@src/main/java/com/umc/nuvibe/domain/tribe/service/emoji/EmojiServiceImpl.java`:
- Around line 86-101: Extract the duplicated mute-check and notification send
logic into a private helper (e.g., sendEmojiNotificationIfNotMuted) and replace
the two identical blocks in the CREATED and UPDATED branches with a call to that
helper; the helper should accept (User imageOwner, Long reactorUserId, Long
tribeId, Chat chat, Long chatId), return early if imageOwner is null or
imageOwner.getId().equals(reactorUserId), check mute via
userTribeRepository.existsByUser_IdAndTribe_IdAndIsMutedTrue(imageOwner.getId(),
tribeId), fetch reactor nickname via
userRepository.findById(reactorUserId).orElse(null), and call
fcmService.sendNotification(imageOwner, NotificationType.NOTI_03,
chat.getTribe().getImageTag().name(), nickname, chatId, tribeId).
In `@src/main/java/com/umc/nuvibe/domain/user/service/AuthServiceImpl.java`:
- Around line 34-44: Constructor parameter indentation in AuthServiceImpl is
inconsistent: align the indentation of verificationService and fcmTokenService
to match the other parameters in the AuthServiceImpl(...) declaration. Edit the
AuthServiceImpl constructor signature so all parameters (UserRepository,
PasswordEncoder, JwtTokenProvider, EmailVerificationService verificationService,
FcmTokenService fcmTokenService) use the same column alignment/indent level to
maintain consistency.
| @@ -13,5 +13,7 @@ public interface FcmRepository extends JpaRepository<Fcm, Long> { | |||
|
|
|||
| boolean existsByUserAndToken(User user, String token); | |||
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check how existsByUserAndToken is used
echo "=== Checking existsByUserAndToken usage ==="
rg -n 'existsByUserAndToken' --type=java
echo ""
echo "=== Checking Fcm entity for activate/deactivate methods ==="
fd -t f -name 'Fcm.java' --exec rg -n 'activate|deactivate|isActive' {} +Repository: Team-Nuvibe/BE
Length of output: 691
🏁 Script executed:
#!/bin/bash
# Find FcmService and examine registerToken method
echo "=== Locating FcmService ==="
fd -t f -name 'FcmService.java'
echo ""
echo "=== Finding all Fcm-related files ==="
fd -t f -name '*Fcm*' -type fRepository: Team-Nuvibe/BE
Length of output: 603
🏁 Script executed:
#!/bin/bash
# Find Fcm entity and FcmService files
echo "=== Finding Fcm-related files ==="
find . -type f -name "*Fcm*.java"
echo ""
echo "=== Find Service files ==="
find . -type f -name "*FcmService*"Repository: Team-Nuvibe/BE
Length of output: 790
🏁 Script executed:
#!/bin/bash
# Read FcmService to see registerToken method
echo "=== Examining FcmService.registerToken ==="
cat src/main/java/com/umc/nuvibe/domain/notification/service/FcmService.javaRepository: Team-Nuvibe/BE
Length of output: 2227
🏁 Script executed:
#!/bin/bash
# Check Fcm entity structure
echo "=== Examining Fcm entity ==="
find . -type f -name "Fcm.java" -exec cat {} \;Repository: Team-Nuvibe/BE
Length of output: 1528
🏁 Script executed:
#!/bin/bash
# Check if there are other places where Fcm tokens are deactivated
echo "=== Searching for deactivate() calls ==="
rg -n 'deactivate\(' --type=java
echo ""
echo "=== Searching for updateToken() calls ==="
rg -n 'updateToken\(' --type=java
echo ""
echo "=== Check if there's any logic for token expiration or cleanup ==="
rg -n 'isActive\s*=\s*false' --type=javaRepository: Team-Nuvibe/BE
Length of output: 519
🏁 Script executed:
#!/bin/bash
# Look for any scheduled tasks or cleanup logic
echo "=== Checking for scheduled tasks or batch operations ==="
rg -n 'Scheduled|@Async|BatchService' --type=java -g '**/*.java' | head -20Repository: Team-Nuvibe/BE
Length of output: 2201
🏁 Script executed:
#!/bin/bash
# Check NotificationScheduler to see what it does
echo "=== Checking NotificationScheduler for token cleanup ==="
cat src/main/java/com/umc/nuvibe/domain/notification/scheduler/NotificationScheduler.java | head -100
echo ""
echo "=== Check if deactivate is called anywhere (with method reference) ==="
rg -n 'forEach.*deactivate' --type=javaRepository: Team-Nuvibe/BE
Length of output: 4086
🏁 Script executed:
#!/bin/bash
# Check if there's a logout or token cleanup mechanism
echo "=== Searching for logout-related methods ==="
rg -n 'logout|signout|disconnect' --type=java -i | head -20
echo ""
echo "=== Look for any code that modifies isActive field ==="
rg -n 'isActive.*=' --type=javaRepository: Team-Nuvibe/BE
Length of output: 1502
🏁 Script executed:
#!/bin/bash
# Check FcmTokenService to see what deactivate is doing
echo "=== Examining FcmTokenService ==="
cat src/main/java/com/umc/nuvibe/domain/notification/service/FcmTokenService.java
echo ""
echo "=== Examining AuthService logout method ==="
rg -n -A 20 'public void logout' src/main/java/com/umc/nuvibe/domain/user/service/AuthServiceImpl.javaRepository: Team-Nuvibe/BE
Length of output: 1746
🏁 Script executed:
#!/bin/bash
# Check FcmRepository for all query methods
echo "=== Examining FcmRepository for all methods ==="
cat src/main/java/com/umc/nuvibe/domain/notification/repository/FcmRepository.javaRepository: Team-Nuvibe/BE
Length of output: 677
existsByUserAndToken 메서드가 isActive 상태를 검증하지 않아 로그아웃 후 재로그인 시 FCM 토큰이 재등록되지 않는 버그가 있습니다.
로그아웃 시 AuthServiceImpl.logout()에서 fcmTokenService.deactivateAllTokens(user)를 호출하여 사용자의 모든 활성 토큰을 비활성화합니다. 그 후 동일한 토큰으로 재로그인할 때, existsByUserAndToken(user, token)은 비활성화 상태를 무시하고 true를 반환하므로, 새로운 활성 레코드가 생성되지 않습니다. 결과적으로 사용자는 계속해서 비활성 토큰 상태가 유지되어 알림을 수신할 수 없게 됩니다.
해결 방안:
existsByUserAndTokenAndIsActiveTrue로 변경하여 활성 토큰만 확인하거나- 비활성 토큰이 있는 경우
updateToken()메서드를 호출하여 재활성화하는 로직 추가
🤖 Prompt for AI Agents
In
`@src/main/java/com/umc/nuvibe/domain/notification/repository/FcmRepository.java`
at line 14, The existsByUserAndToken query in FcmRepository currently ignores
token active state causing re-login with same token to be treated as existing;
change the repository method signature from existsByUserAndToken to
existsByUserAndTokenAndIsActiveTrue so only active tokens are considered, or
alternatively in the service layer (where existsByUserAndToken is used together
with updateToken and deactivateAllTokens, e.g., in FcmTokenService or
AuthServiceImpl) detect a matching inactive token and call updateToken to
reactivate it; update usages of the repository method accordingly to ensure
re-login creates or reactivates an active FCM record.
| public void deactivateToken(String token) { | ||
| fcmRepository.findByToken(token).ifPresent(Fcm::deactivate); | ||
| List<Fcm> fcmList = fcmRepository.findByToken(token); | ||
| fcmList.forEach(Fcm::deactivate); | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
deactivateToken에서 이미 비활성화된 토큰도 불필요하게 처리됩니다.
findByToken은 isActive 상태와 무관하게 모든 레코드를 반환합니다. 이미 비활성화된 레코드에 대해서도 deactivate()를 호출하게 되어 불필요한 dirty checking 및 UPDATE 쿼리가 발생할 수 있습니다.
♻️ 활성 토큰만 조회하도록 개선
Repository에 findByTokenAndIsActiveTrue 메서드를 추가하고 사용:
- List<Fcm> fcmList = fcmRepository.findByToken(token);
+ List<Fcm> fcmList = fcmRepository.findByTokenAndIsActiveTrue(token);
fcmList.forEach(Fcm::deactivate);📝 Committable suggestion
‼️ 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.
| public void deactivateToken(String token) { | |
| fcmRepository.findByToken(token).ifPresent(Fcm::deactivate); | |
| List<Fcm> fcmList = fcmRepository.findByToken(token); | |
| fcmList.forEach(Fcm::deactivate); | |
| } | |
| List<Fcm> findByTokenAndIsActiveTrue(String token); |
| public void deactivateToken(String token) { | |
| fcmRepository.findByToken(token).ifPresent(Fcm::deactivate); | |
| List<Fcm> fcmList = fcmRepository.findByToken(token); | |
| fcmList.forEach(Fcm::deactivate); | |
| } | |
| public void deactivateToken(String token) { | |
| List<Fcm> fcmList = fcmRepository.findByTokenAndIsActiveTrue(token); | |
| fcmList.forEach(Fcm::deactivate); | |
| } |
🤖 Prompt for AI Agents
In
`@src/main/java/com/umc/nuvibe/domain/notification/service/FcmTokenService.java`
around lines 19 - 22, deactivateToken currently calls fcmRepository.findByToken
which returns all records and then calls Fcm::deactivate on every result,
causing unnecessary dirty checks and UPDATEs for already inactive tokens; change
the repository call to use a new finder that only returns active tokens (e.g.,
add and use findByTokenAndIsActiveTrue in the repository) and then iterate over
that result in deactivateToken to call Fcm::deactivate only for active entries.
| @PatchMapping("/{userTribeId}/mute") | ||
| @Operation(summary = "트라이브 챗 무음 설정", description = "특정 트라이브 챗의 푸시 알림 무음 토글") | ||
| public Response<UserTribeMuteRes> muteUserTribe( | ||
| @AuthUser Long userId, | ||
| @PathVariable Long userTribeId | ||
| ){ | ||
| UserTribeMuteRes res = userTribeService.toggleMute(userId, userTribeId); | ||
| return Response.of(UserTribeResultCode.USERTRIBE_MUTE_SUCCESS, res); | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
🧩 Analysis chain
🏁 Script executed:
fd -t f -e java Response.javaRepository: Team-Nuvibe/BE
Length of output: 1284
🏁 Script executed:
cat -n src/main/java/com/umc/nuvibe/global/apiPayLoad/response/Response.javaRepository: Team-Nuvibe/BE
Length of output: 2690
성공 응답에는 Response.ok()를 사용하세요.
프로젝트 컨벤션에 따르면, 성공 응답은 Response.of() 대신 Response.ok()를 사용하는 것이 권장됩니다. 두 메서드 모두 동일한 매개변수를 지원하며, ok()를 사용하면 의도가 더 명확합니다.
♻️ 제안된 수정
- return Response.of(UserTribeResultCode.USERTRIBE_MUTE_SUCCESS, res);
+ return Response.ok(UserTribeResultCode.USERTRIBE_MUTE_SUCCESS, res);🤖 Prompt for AI Agents
In
`@src/main/java/com/umc/nuvibe/domain/tribe/controller/UserTribeController.java`
around lines 91 - 99, The controller method muteUserTribe currently returns a
success response using Response.of(...); change it to use Response.ok(...)
instead to follow project convention: locate the method muteUserTribe in
UserTribeController and replace the
Response.of(UserTribeResultCode.USERTRIBE_MUTE_SUCCESS, res) return with
Response.ok(UserTribeResultCode.USERTRIBE_MUTE_SUCCESS, res) while keeping the
call to userTribeService.toggleMute(userId, userTribeId) and the
UserTribeMuteRes payload unchanged.
| // NOTI-03: 이미지 작성자에게 리액션 알림 (본인이 본인 이미지에 반응한 경우 제외) | ||
| if (imageOwner != null && !imageOwner.getId().equals(userId)) { | ||
| User reactor = userRepository.findById(userId).orElse(null); | ||
| String nickname = reactor != null ? reactor.getNickname() : ""; | ||
| fcmService.sendNotification( | ||
| imageOwner, | ||
| NotificationType.NOTI_03, | ||
| chat.getTribe().getImageTag().name(), | ||
| nickname, | ||
| chatId, | ||
| tribeId | ||
| ); | ||
| boolean isMuted = userTribeRepository.existsByUser_IdAndTribe_IdAndIsMutedTrue(imageOwner.getId(), tribeId); | ||
| if (!isMuted) { | ||
| User reactor = userRepository.findById(userId).orElse(null); | ||
| String nickname = reactor != null ? reactor.getNickname() : ""; | ||
| fcmService.sendNotification( | ||
| imageOwner, | ||
| NotificationType.NOTI_03, | ||
| chat.getTribe().getImageTag().name(), | ||
| nickname, | ||
| chatId, | ||
| tribeId | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
NOTI_03 알림 전송 로직이 두 곳에서 중복됩니다.
CREATED(Line 86-101)와 UPDATED(Line 121-136)의 무음 체크 및 알림 전송 로직이 거의 동일합니다. 별도 private 메서드로 추출하면 유지보수성이 향상됩니다.
♻️ 리팩토링 제안
클래스 하단에 헬퍼 메서드를 추가하고 두 곳에서 호출:
private void sendEmojiNotificationIfNotMuted(User imageOwner, Long reactorUserId, Long tribeId, Chat chat, Long chatId) {
if (imageOwner == null || imageOwner.getId().equals(reactorUserId)) return;
boolean isMuted = userTribeRepository.existsByUser_IdAndTribe_IdAndIsMutedTrue(imageOwner.getId(), tribeId);
if (isMuted) return;
User reactor = userRepository.findById(reactorUserId).orElse(null);
String nickname = reactor != null ? reactor.getNickname() : "";
fcmService.sendNotification(
imageOwner,
NotificationType.NOTI_03,
chat.getTribe().getImageTag().name(),
nickname,
chatId,
tribeId
);
}그리고 두 호출 지점을 다음과 같이 변경:
- if (imageOwner != null && !imageOwner.getId().equals(userId)) {
- boolean isMuted = userTribeRepository.existsByUser_IdAndTribe_IdAndIsMutedTrue(imageOwner.getId(), tribeId);
- if (!isMuted) {
- User reactor = userRepository.findById(userId).orElse(null);
- String nickname = reactor != null ? reactor.getNickname() : "";
- fcmService.sendNotification(
- imageOwner,
- NotificationType.NOTI_03,
- chat.getTribe().getImageTag().name(),
- nickname,
- chatId,
- tribeId
- );
- }
- }
+ sendEmojiNotificationIfNotMuted(imageOwner, userId, tribeId, chat, chatId);Also applies to: 121-136
🤖 Prompt for AI Agents
In
`@src/main/java/com/umc/nuvibe/domain/tribe/service/emoji/EmojiServiceImpl.java`
around lines 86 - 101, Extract the duplicated mute-check and notification send
logic into a private helper (e.g., sendEmojiNotificationIfNotMuted) and replace
the two identical blocks in the CREATED and UPDATED branches with a call to that
helper; the helper should accept (User imageOwner, Long reactorUserId, Long
tribeId, Chat chat, Long chatId), return early if imageOwner is null or
imageOwner.getId().equals(reactorUserId), check mute via
userTribeRepository.existsByUser_IdAndTribe_IdAndIsMutedTrue(imageOwner.getId(),
tribeId), fetch reactor nickname via
userRepository.findById(reactorUserId).orElse(null), and call
fcmService.sendNotification(imageOwner, NotificationType.NOTI_03,
chat.getTribe().getImageTag().name(), nickname, chatId, tribeId).
| public AuthServiceImpl(UserRepository userRepository, | ||
| PasswordEncoder passwordEncoder, | ||
| JwtTokenProvider jwtTokenProvider, | ||
| EmailVerificationService verificationService) { | ||
| EmailVerificationService verificationService, | ||
| FcmTokenService fcmTokenService) { | ||
| this.userRepository = userRepository; | ||
| this.passwordEncoder = passwordEncoder; | ||
| this.jwtTokenProvider = jwtTokenProvider; | ||
| this.verificationService = verificationService; | ||
| this.fcmTokenService = fcmTokenService; | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
생성자 파라미터 들여쓰기가 일관적이지 않습니다.
Line 37의 verificationService와 line 38의 fcmTokenService의 들여쓰기가 다릅니다.
🔧 들여쓰기 통일
public AuthServiceImpl(UserRepository userRepository,
PasswordEncoder passwordEncoder,
JwtTokenProvider jwtTokenProvider,
- EmailVerificationService verificationService,
- FcmTokenService fcmTokenService) {
+ EmailVerificationService verificationService,
+ FcmTokenService fcmTokenService) {📝 Committable suggestion
‼️ 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.
| public AuthServiceImpl(UserRepository userRepository, | |
| PasswordEncoder passwordEncoder, | |
| JwtTokenProvider jwtTokenProvider, | |
| EmailVerificationService verificationService) { | |
| EmailVerificationService verificationService, | |
| FcmTokenService fcmTokenService) { | |
| this.userRepository = userRepository; | |
| this.passwordEncoder = passwordEncoder; | |
| this.jwtTokenProvider = jwtTokenProvider; | |
| this.verificationService = verificationService; | |
| this.fcmTokenService = fcmTokenService; | |
| } | |
| public AuthServiceImpl(UserRepository userRepository, | |
| PasswordEncoder passwordEncoder, | |
| JwtTokenProvider jwtTokenProvider, | |
| EmailVerificationService verificationService, | |
| FcmTokenService fcmTokenService) { | |
| this.userRepository = userRepository; | |
| this.passwordEncoder = passwordEncoder; | |
| this.jwtTokenProvider = jwtTokenProvider; | |
| this.verificationService = verificationService; | |
| this.fcmTokenService = fcmTokenService; | |
| } |
🤖 Prompt for AI Agents
In `@src/main/java/com/umc/nuvibe/domain/user/service/AuthServiceImpl.java` around
lines 34 - 44, Constructor parameter indentation in AuthServiceImpl is
inconsistent: align the indentation of verificationService and fcmTokenService
to match the other parameters in the AuthServiceImpl(...) declaration. Edit the
AuthServiceImpl constructor signature so all parameters (UserRepository,
PasswordEncoder, JwtTokenProvider, EmailVerificationService verificationService,
FcmTokenService fcmTokenService) use the same column alignment/indent level to
maintain consistency.
🎋 이슈 및 작업중인 브랜치
🔑 주요 내용
Check List
Summary by CodeRabbit
변경 사항
새로운 기능
개선 사항