From 0445c5dbc2a4d17fe7dd3d4ad54c80f66118e971 Mon Sep 17 00:00:00 2001 From: Kimdonghwan Date: Sat, 13 Jun 2026 20:26:51 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B0=99=EC=9D=80=20=EA=B8=B0=EA=B8=B0?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=ED=95=A0=EB=8B=B9=C2=B7?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=A0=84=ED=99=98=20=EC=8B=9C=20FCM=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20unique=20=EC=B6=A9=EB=8F=8C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 물리 기기에서 FCM 토큰이 다른 row 로 재할당되거나 계정을 전환할 때, token 기준으로 찾은 row 의 (user_id, device_id) 를 갱신하는 순간 기존 device row 와 unique(user_id, device_id) 제약이 충돌해 등록 API 가 500 으로 실패하던 문제 수정. - syncExistingToken 에서 대상 row 갱신 전, 동일 (userId, deviceId) 의 다른 row 를 벌크 삭제(@Modifying)하여 Hibernate update-before-delete flush 순서로 인한 충돌 회피 - FcmTokenRepository.deleteByUserIdAndDeviceIdExcludingId 추가 - 실제 unique 제약이 적용되는 @DataJpaTest 통합 테스트로 재현·검증 (전체 410개 통과) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../notification/FcmTokenCommandService.java | 3 + .../required/FcmTokenRepository.java | 13 +++ ...FcmTokenCommandServicePersistenceTest.java | 80 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 src/test/java/com/souzip/application/notification/FcmTokenCommandServicePersistenceTest.java diff --git a/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java b/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java index 273a03d..6de27b8 100644 --- a/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java +++ b/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java @@ -26,6 +26,9 @@ public FcmToken registerOrUpdate(Long userId, FcmTokenRegisterRequest request) { } private FcmToken syncExistingToken(FcmToken existing, Long userId, FcmTokenRegisterRequest request) { + // 같은 기기(deviceId)로 이미 등록된 다른 row 가 있으면 unique(user_id, device_id) 충돌을 막기 위해 먼저 제거합니다. + // (같은 기기에서 계정을 전환하거나 FCM 토큰이 재할당되는 경우 발생) + fcmTokenRepository.deleteByUserIdAndDeviceIdExcludingId(userId, request.deviceId(), existing.getId()); existing.linkUser(userId); existing.syncDeviceIdentity(request.deviceType(), request.deviceId()); existing.updateDeviceInfo(request.deviceModel(), request.osVersion(), request.appVersion()); diff --git a/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java b/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java index 15c8e8b..e9ac595 100644 --- a/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java +++ b/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java @@ -4,7 +4,10 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; public interface FcmTokenRepository extends Repository { @@ -18,6 +21,16 @@ public interface FcmTokenRepository extends Repository { Optional findByUserIdAndDeviceId(Long userId, String deviceId); + // 같은 (user_id, device_id) 의 다른 row 를 즉시(벌크) 삭제합니다. + // 토큰 재할당 시 unique(user_id, device_id) 충돌을 막기 위해 update 전에 호출합니다. + @Modifying(flushAutomatically = true) + @Query("DELETE FROM FcmToken t WHERE t.userId = :userId AND t.deviceId = :deviceId AND t.id <> :excludeId") + void deleteByUserIdAndDeviceIdExcludingId( + @Param("userId") Long userId, + @Param("deviceId") String deviceId, + @Param("excludeId") Long excludeId + ); + List findByUserIdAndActiveTrue(Long userId); List findAllByActiveTrue(); diff --git a/src/test/java/com/souzip/application/notification/FcmTokenCommandServicePersistenceTest.java b/src/test/java/com/souzip/application/notification/FcmTokenCommandServicePersistenceTest.java new file mode 100644 index 0000000..b57bc5f --- /dev/null +++ b/src/test/java/com/souzip/application/notification/FcmTokenCommandServicePersistenceTest.java @@ -0,0 +1,80 @@ +package com.souzip.application.notification; + +import com.souzip.application.notification.required.FcmTokenRepository; +import com.souzip.domain.notification.DeviceType; +import com.souzip.domain.notification.FcmToken; +import com.souzip.domain.notification.FcmTokenRegisterRequest; +import com.souzip.shared.config.QuerydslConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +// 실제 unique(user_id, device_id) / unique(token) 제약이 적용되는 JPA 슬라이스에서 +// 토큰 재할당·계정 전환 시 충돌이 발생하지 않는지 검증합니다. +@DataJpaTest +@EnableJpaAuditing +@Import({FcmTokenCommandService.class, QuerydslConfig.class}) +class FcmTokenCommandServicePersistenceTest { + + @Autowired + private FcmTokenCommandService fcmTokenCommandService; + + @Autowired + private FcmTokenRepository fcmTokenRepository; + + @DisplayName("같은 기기의 토큰이 다른 row로 재할당돼도 unique 충돌 없이 갱신된다") + @Test + void registerOrUpdate_tokenReassignedToExistingDevice_noUniqueViolation() { + // given: user1 이 기기 D 에 기존 토큰으로 등록 + fcmTokenCommandService.registerOrUpdate(1L, request("token-old", "device-D")); + // 다른 기기에 token-new 가 이미 존재 + fcmTokenCommandService.registerOrUpdate(1L, request("token-new", "device-OTHER")); + + // when & then: 기기 D 가 token-new 로 재등록돼도 (user1, device-D) unique 충돌이 없어야 한다 + assertThatCode(() -> + fcmTokenCommandService.registerOrUpdate(1L, request("token-new", "device-D")) + ).doesNotThrowAnyException(); + + // (user1, device-D) row 는 하나만 남고 token-new 를 가리킨다 + FcmToken deviceRow = fcmTokenRepository.findByUserIdAndDeviceId(1L, "device-D").orElseThrow(); + assertThat(deviceRow.getToken()).isEqualTo("token-new"); + assertThat(deviceRow.isActive()).isTrue(); + + // token-new 를 들고 있던 기존 device-OTHER row 는 정리되어 활성 토큰은 1개만 남는다 + List active = fcmTokenRepository.findByUserIdAndActiveTrue(1L); + assertThat(active).hasSize(1); + assertThat(active.get(0).getDeviceId()).isEqualTo("device-D"); + } + + @DisplayName("다른 계정으로 전환해도 기기 토큰이 새 사용자로 충돌 없이 이전된다") + @Test + void registerOrUpdate_accountSwitchOnSameDevice_noUniqueViolation() { + // given: user1 이 기기 D 에 등록 후, 같은 토큰이 user2 row 로도 존재하는 상황을 만든다 + fcmTokenCommandService.registerOrUpdate(1L, request("token-shared", "device-D")); + + // when: user2 가 같은 기기(device-D)·같은 토큰으로 로그인(계정 전환) + assertThatCode(() -> + fcmTokenCommandService.registerOrUpdate(2L, request("token-shared", "device-D")) + ).doesNotThrowAnyException(); + + // then: 토큰은 user2 로 이전되고 (user2, device-D) 하나만 존재 + FcmToken row = fcmTokenRepository.findByToken("token-shared").orElseThrow(); + assertThat(row.getUserId()).isEqualTo(2L); + assertThat(row.getDeviceId()).isEqualTo("device-D"); + assertThat(fcmTokenRepository.findByUserIdAndActiveTrue(1L)).isEmpty(); + } + + private FcmTokenRegisterRequest request(String token, String deviceId) { + return FcmTokenRegisterRequest.of( + token, DeviceType.ANDROID, deviceId, "Galaxy S23", "Android 14", "1.0.0" + ); + } +}