11package akuma .whiplash .infrastructure .redis ;
22
3- import java .util .Objects ;
3+ import java .util .List ;
44import java .util .Optional ;
55import java .util .Set ;
66import lombok .RequiredArgsConstructor ;
7- import org .springframework .dao .DataAccessException ;
8- import org .springframework .data .redis .core .RedisOperations ;
97import org .springframework .data .redis .core .RedisTemplate ;
10- import org .springframework .data .redis .core .SessionCallback ;
8+ import org .springframework .data .redis .core .script . RedisScript ;
119import org .springframework .stereotype .Service ;
1210
1311@ Service
1412@ RequiredArgsConstructor
1513public class RedisService {
1614
15+ private static final RedisScript <Long > REMOVE_DEVICE_SCRIPT = RedisScript .of ("""
16+ local oldToken = redis.call('GET', KEYS[1])
17+ if not oldToken or oldToken == '' then
18+ redis.call('DEL', KEYS[1])
19+ return 0
20+ end
21+ redis.call('SREM', KEYS[2], oldToken)
22+ redis.call('DEL', 'fcm:token:' .. oldToken .. ':device')
23+ redis.call('DEL', KEYS[1])
24+ return 1
25+ """ , Long .class );
26+
27+ private static final RedisScript <Long > UPSERT_SCRIPT = RedisScript .of ("""
28+ local oldToken = redis.call('GET', KEYS[1])
29+ if oldToken and oldToken ~= '' and oldToken ~= ARGV[1] then
30+ redis.call('SREM', KEYS[2], oldToken)
31+ redis.call('DEL', 'fcm:token:' .. oldToken .. ':device')
32+ end
33+ redis.call('SET', KEYS[1], ARGV[1])
34+ redis.call('SET', 'fcm:token:' .. ARGV[1] .. ':device', ARGV[2])
35+ redis.call('SADD', KEYS[2], ARGV[1])
36+ return 1
37+ """ , Long .class );
38+
1739 private final RedisTemplate <String , String > redisTemplate ;
1840
1941 public Set <String > getFcmTokens (Long memberId ) {
@@ -30,43 +52,25 @@ public void removeInvalidToken(Long memberId, String token) {
3052
3153 /**
3254 * deviceId에 새 fcmToken을 등록(upsert).
33- * - 같은 토큰 재등록이면 idempotent하게 Set/매핑 보강만 함
3455 * - 다른 토큰으로 교체되면 이전 토큰을 member Set에서 제거하고 매핑 정리
56+ * - 같은 토큰 재등록이면 idempotent하게 처리됨
3557 *
36- * 참고: MULTI/EXEC 트랜잭션을 쓰므로 RedisTemplate에 transactionSupport=true 필요 .
58+ * Lua 스크립트로 GET-SREM-DEL-SET-SADD를 단일 원자적 명령으로 실행한다 .
3759 */
3860 public void upsertFcmToken (Long memberId , String deviceId , String newToken ) {
39- String deviceKey = keyDeviceToToken (deviceId );
40- String memberSetKey = keyMemberTokens (memberId );
41- String newTokenMapKey = keyTokenToDevice (newToken );
42-
43- String oldToken = redisTemplate .opsForValue ().get (deviceKey );
44-
45- // 1) 동일 토큰 재등록: idempotent 보강
46- if (Objects .equals (oldToken , newToken )) {
47- redisTemplate .opsForSet ().add (memberSetKey , newToken );
48- redisTemplate .opsForValue ().set (newTokenMapKey , deviceId );
49- return ;
61+ if (deviceId == null || deviceId .isBlank ()) {
62+ throw new IllegalArgumentException ("deviceId must not be null or blank" );
63+ }
64+ if (newToken == null || newToken .isBlank ()) {
65+ throw new IllegalArgumentException ("newToken must not be null or blank" );
5066 }
5167
52- // 2) 토큰 교체: 원자적 멀티 실행
53- redisTemplate .execute (new SessionCallback <Void >() {
54- @ Override
55- public Void execute (RedisOperations operations ) throws DataAccessException {
56- operations .multi ();
57- // 이전 토큰 정리
58- if (oldToken != null && !oldToken .isBlank ()) {
59- operations .opsForSet ().remove (memberSetKey , oldToken );
60- operations .delete (keyTokenToDevice (oldToken ));
61- }
62- // 신규 매핑/등록
63- operations .opsForValue ().set (deviceKey , newToken );
64- operations .opsForValue ().set (newTokenMapKey , deviceId );
65- operations .opsForSet ().add (memberSetKey , newToken );
66- operations .exec ();
67- return null ;
68- }
69- });
68+ redisTemplate .execute (
69+ UPSERT_SCRIPT ,
70+ List .of (keyDeviceToToken (deviceId ), keyMemberTokens (memberId )),
71+ newToken ,
72+ deviceId
73+ );
7074 }
7175
7276 // ===== 선택: 특정 디바이스 로그아웃 시 정리 =====
@@ -75,26 +79,10 @@ public Void execute(RedisOperations operations) throws DataAccessException {
7579 * 특정 deviceId에 매핑된 토큰을 제거하고, member Set에서도 제거.
7680 */
7781 public void removeFcmTokenForDevice (Long memberId , String deviceId ) {
78- String deviceKey = keyDeviceToToken (deviceId );
79- String memberSetKey = keyMemberTokens (memberId );
80-
81- String oldToken = redisTemplate .opsForValue ().get (deviceKey );
82- if (oldToken == null || oldToken .isBlank ()) {
83- redisTemplate .delete (deviceKey );
84- return ;
85- }
86-
87- redisTemplate .execute (new SessionCallback <Void >() {
88- @ Override
89- public Void execute (RedisOperations operations ) throws DataAccessException {
90- operations .multi ();
91- operations .opsForSet ().remove (memberSetKey , oldToken );
92- operations .delete (keyTokenToDevice (oldToken ));
93- operations .delete (deviceKey );
94- operations .exec ();
95- return null ;
96- }
97- });
82+ redisTemplate .execute (
83+ REMOVE_DEVICE_SCRIPT ,
84+ List .of (keyDeviceToToken (deviceId ), keyMemberTokens (memberId ))
85+ );
9886 }
9987
10088 // ===== 선택: 조회 유틸 =====
0 commit comments