Skip to content

Commit 7ad781b

Browse files
committed
♻️ refactor: fcm 토큰 upsert할 때 Redis Lua Script 사용
1 parent 52c8990 commit 7ad781b

File tree

3 files changed

+83
-56
lines changed

3 files changed

+83
-56
lines changed

gradlew

100644100755
File mode changed.

src/main/java/akuma/whiplash/infrastructure/redis/RedisService.java

Lines changed: 43 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
11
package akuma.whiplash.infrastructure.redis;
22

3-
import java.util.Objects;
3+
import java.util.List;
44
import java.util.Optional;
55
import java.util.Set;
66
import lombok.RequiredArgsConstructor;
7-
import org.springframework.dao.DataAccessException;
8-
import org.springframework.data.redis.core.RedisOperations;
97
import org.springframework.data.redis.core.RedisTemplate;
10-
import org.springframework.data.redis.core.SessionCallback;
8+
import org.springframework.data.redis.core.script.RedisScript;
119
import org.springframework.stereotype.Service;
1210

1311
@Service
1412
@RequiredArgsConstructor
1513
public 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
// ===== 선택: 조회 유틸 =====

src/test/java/akuma/whiplash/infrastructure/redis/RedisServiceTest.java

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5-
import static org.junit.jupiter.api.Assertions.*;
65

76
import akuma.whiplash.common.config.RedisContainerInitializer;
7+
import java.util.concurrent.CountDownLatch;
8+
import java.util.concurrent.ExecutorService;
9+
import java.util.concurrent.Executors;
810
import org.junit.jupiter.api.AfterEach;
911
import org.junit.jupiter.api.DisplayName;
1012
import org.junit.jupiter.api.Nested;
@@ -82,5 +84,42 @@ void fail_tokenNull() {
8284
assertThatThrownBy(() -> redisService.upsertFcmToken(1L, "device", null))
8385
.isInstanceOf(IllegalArgumentException.class);
8486
}
87+
88+
@Test
89+
@DisplayName("성공: 동일 deviceId에 동시 요청이 와도 tokenCount가 1이다")
90+
void success_concurrentUpsertKeepsOneToken() throws InterruptedException {
91+
// given
92+
Long memberId = 1L;
93+
String deviceId = "shared-device";
94+
int threadCount = 10;
95+
96+
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
97+
CountDownLatch ready = new CountDownLatch(threadCount);
98+
CountDownLatch start = new CountDownLatch(1);
99+
CountDownLatch done = new CountDownLatch(threadCount);
100+
101+
for (int i = 0; i < threadCount; i++) {
102+
final String token = "token-" + i;
103+
executor.submit(() -> {
104+
ready.countDown();
105+
try {
106+
start.await();
107+
redisService.upsertFcmToken(memberId, deviceId, token);
108+
} catch (InterruptedException e) {
109+
Thread.currentThread().interrupt();
110+
} finally {
111+
done.countDown();
112+
}
113+
});
114+
}
115+
116+
ready.await();
117+
start.countDown();
118+
done.await();
119+
executor.shutdown();
120+
121+
// then
122+
assertThat(redisService.getFcmTokens(memberId)).hasSize(1);
123+
}
85124
}
86125
}

0 commit comments

Comments
 (0)