From 7f1e0bdda2d1c11b0113a87939bbda8ff20cdea4 Mon Sep 17 00:00:00 2001 From: Hyeonsuk Date: Sun, 18 May 2025 11:53:49 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20redisson=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80(MIKKI-248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/build.gradle b/backend/build.gradle index 8fcfd58d..16562316 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -97,6 +97,9 @@ dependencies { // 메일 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-mail' + + // Redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.27.0' } tasks.named('test') { From 98dd131bfdd7363cec4667834c9fe99832f5542b Mon Sep 17 00:00:00 2001 From: Hyeonsuk Date: Sun, 18 May 2025 11:56:24 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20Redis=20Config=EC=97=90=20redisson?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80(MIKKI-248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/config/RedisConfig.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/backend/src/main/java/com/backend/global/config/RedisConfig.java b/backend/src/main/java/com/backend/global/config/RedisConfig.java index d7ca7155..3ea6013a 100644 --- a/backend/src/main/java/com/backend/global/config/RedisConfig.java +++ b/backend/src/main/java/com/backend/global/config/RedisConfig.java @@ -2,6 +2,10 @@ import java.util.List; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; @@ -26,6 +30,17 @@ @EnableRedisRepositories public class RedisConfig { + @Value("${spring.data.redis.host}") + private String REDIS_HOST; + + @Value("${spring.data.redis.port}") + private String REDIS_PORT; + + @Value("${spring.data.redis.password}") + private String REDIS_PASSWORD; + + private static final String REDISSON_HOST_PREFIX = "redis://"; + /** * RedisTemplate 설정 */ @@ -71,4 +86,14 @@ public RedisTemplate> hotPostRedis return template; } + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress(REDISSON_HOST_PREFIX + REDIS_HOST + ":" + REDIS_PORT); + // .setPassword(REDIS_PASSWORD); + + return Redisson.create(config); + } + } \ No newline at end of file From 48debeedb5953568bbdd5eca2614202e753682f2 Mon Sep 17 00:00:00 2001 From: Hyeonsuk Date: Sun, 18 May 2025 11:57:37 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20Util=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94(MIKKI-248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/backend/global/util/RedisUtil.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/com/backend/global/util/RedisUtil.java b/backend/src/main/java/com/backend/global/util/RedisUtil.java index 965c442b..b73c20fe 100644 --- a/backend/src/main/java/com/backend/global/util/RedisUtil.java +++ b/backend/src/main/java/com/backend/global/util/RedisUtil.java @@ -26,20 +26,18 @@ public class RedisUtil { * 지정된 key 값을 1 증가시킨다. * * @param key Redis 저장된 key - * @return 증가된 결과 값 */ - public Long increment(final String key) { - return redisTemplate.opsForValue().increment(key); + public void increment(final String key) { + redisTemplate.opsForValue().increment(key); } /** * 지정된 key 값을 1 감소시킨다. * * @param key Redis 저장된 key - * @return 감소된 결과 값 */ - public Long decrement(final String key) { - return redisTemplate.opsForValue().decrement(key); + public void decrement(final String key) { + redisTemplate.opsForValue().decrement(key); } /** @@ -49,7 +47,7 @@ public Long decrement(final String key) { * @return 존재하면 true, 없으면 false */ public boolean hasKey(final String key) { - return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + return redisTemplate.hasKey(key); } /** @@ -78,7 +76,7 @@ public String getValue(final String key) { * @param key 삭제할 Redis key */ public void deleteKeyIfExists(final String key) { - if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { + if (redisTemplate.hasKey(key)) { redisTemplate.delete(key); } } @@ -102,7 +100,7 @@ public Map scanKeysAndValues(final String prefix) { ValueOperations ops = redisTemplate.opsForValue(); redisTemplate.execute((RedisCallback)connection -> { - ScanOptions options = ScanOptions.scanOptions().match(prefix + "*").count(100).build(); + ScanOptions options = ScanOptions.scanOptions().match(prefix + "*").count(1000).build(); try (var cursor = connection.scan(options)) { cursor.forEachRemaining(rawKey -> { String key = new String(rawKey, StandardCharsets.UTF_8); From 292e63543de308126cfebb8d5e4338b2ffafed34 Mon Sep 17 00:00:00 2001 From: Hyeonsuk Date: Sun, 18 May 2025 11:58:28 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20AOP=20=EA=B8=B0=EB=B0=98=20Redisson?= =?UTF-8?q?=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=97=90=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(MIKKI-248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/redisson/CustomSpringELParser.java | 19 +++++++ .../backend/global/redisson/RedissonLock.java | 19 +++++++ .../global/redisson/RedissonLockAspect.java | 53 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 backend/src/main/java/com/backend/global/redisson/CustomSpringELParser.java create mode 100644 backend/src/main/java/com/backend/global/redisson/RedissonLock.java create mode 100644 backend/src/main/java/com/backend/global/redisson/RedissonLockAspect.java diff --git a/backend/src/main/java/com/backend/global/redisson/CustomSpringELParser.java b/backend/src/main/java/com/backend/global/redisson/CustomSpringELParser.java new file mode 100644 index 00000000..62694925 --- /dev/null +++ b/backend/src/main/java/com/backend/global/redisson/CustomSpringELParser.java @@ -0,0 +1,19 @@ +package com.backend.global.redisson; + +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +public class CustomSpringELParser { + + public static Object getDynamicKey(String[] parameterNames, Object[] args, String key) { + SpelExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(key).getValue(context, Object.class); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/backend/global/redisson/RedissonLock.java b/backend/src/main/java/com/backend/global/redisson/RedissonLock.java new file mode 100644 index 00000000..3828ad2c --- /dev/null +++ b/backend/src/main/java/com/backend/global/redisson/RedissonLock.java @@ -0,0 +1,19 @@ +package com.backend.global.redisson; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RedissonLock { + + String key(); // Lock의 key + + long waitTime() default 5000L; // Lock 획득 시도 시간 + + long leaseTime() default 2000L; // Lock 점유하는 시간 +} diff --git a/backend/src/main/java/com/backend/global/redisson/RedissonLockAspect.java b/backend/src/main/java/com/backend/global/redisson/RedissonLockAspect.java new file mode 100644 index 00000000..171515af --- /dev/null +++ b/backend/src/main/java/com/backend/global/redisson/RedissonLockAspect.java @@ -0,0 +1,53 @@ +package com.backend.global.redisson; + +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class RedissonLockAspect { + + private final RedissonClient redissonClient; + + @Around("@annotation(com.backend.global.redisson.RedissonLock)") + public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature)joinPoint.getSignature(); + Method method = signature.getMethod(); + RedissonLock annotation = method.getAnnotation(RedissonLock.class); + + String key = String.valueOf( + CustomSpringELParser.getDynamicKey( + signature.getParameterNames(), + joinPoint.getArgs(), + annotation.key() + )); + + RLock lock = redissonClient.getLock(key); + + try { + boolean isLocked = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS); + if (!isLocked) { + log.warn("락 획득 실패: {}", key); + return null; // 혹은 예외 던져도 됨 + } + log.debug("락 획득 성공: {}", key); + return joinPoint.proceed(); + } finally { + lock.unlock(); + log.debug("락 해제 완료: {}", key); + } + } +} From 646202058b101e64f82b20f5f0a516bdde68efea Mon Sep 17 00:00:00 2001 From: Hyeonsuk Date: Sun, 18 May 2025 11:58:58 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=EC=97=90=20redisson=20=EB=B6=84=EC=82=B0?= =?UTF-8?q?=EB=9D=BD=20=EC=A0=81=EC=9A=A9(MIKKI-248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/like/service/LikeCacheService.java | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/com/backend/domain/like/service/LikeCacheService.java b/backend/src/main/java/com/backend/domain/like/service/LikeCacheService.java index 375bb707..1b4a93d2 100644 --- a/backend/src/main/java/com/backend/domain/like/service/LikeCacheService.java +++ b/backend/src/main/java/com/backend/domain/like/service/LikeCacheService.java @@ -1,7 +1,5 @@ package com.backend.domain.like.service; -import org.springframework.cache.annotation.CachePut; -import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import com.backend.domain.fishingtrippost.exception.FishingTripPostErrorCode; @@ -12,6 +10,7 @@ import com.backend.domain.shipfishingpost.exception.ShipFishingPostErrorCode; import com.backend.domain.shipfishingpost.exception.ShipFishingPostException; import com.backend.domain.shipfishingpost.repository.ShipFishingPostRepository; +import com.backend.global.redisson.RedissonLock; import com.backend.global.util.RedisUtil; import lombok.RequiredArgsConstructor; @@ -43,10 +42,26 @@ public class LikeCacheService { * @throws ShipFishingPostException 대상이 존재하지 않는 선상낚시 게시글일 경우 * @throws FishingTripPostException 대상이 존재하지 않는 동출모집 게시글일 경우 */ - @Cacheable(value = "like_count", key = "#type.name() + '::' + #targetId") public Long getLikeCount(final LikeTargetType type, final Long targetId) { validateLikeTarget(type, targetId); - return likeRepository.countByTargetTypeAndTargetId(type, targetId); + + String key = buildKey(type, targetId); + + if (redisUtil.hasKey(key)) { + String cached = redisUtil.getValue(key); + if (cached != null) { + try { + return Long.parseLong(cached); + } catch (NumberFormatException e) { + log.warn("[LikeCache] 캐시 파싱 실패: key={}, value={}", key, cached); + } + } + } + + Long count = likeRepository.countByTargetTypeAndTargetId(type, targetId); + redisUtil.setValue(key, count.toString()); + log.debug("[LikeCache] 캐시 미존재 → DB 조회 후 저장: {} = {}", key, count); + return count; } /** @@ -56,12 +71,16 @@ public Long getLikeCount(final LikeTargetType type, final Long targetId) { * @param type 좋아요 대상 타입 (SHIP_FISHING_POST, FISHING_TRIP_POST) * @param targetId 좋아요 대상 ID * @param isLike true → 좋아요, false → 좋아요 취소 - * @return Redis에 반영된 좋아요 수 */ - @CachePut(value = "like_count", key = "#type.name() + '::' + #targetId") - public Long updateLikeCountCache(final LikeTargetType type, final Long targetId, final Boolean isLike) { + @RedissonLock(key = "'lock:like_count:' + #type.name() + ':' + #targetId") + public void updateLikeCountCache(final LikeTargetType type, final Long targetId, final Boolean isLike) { String key = buildKey(type, targetId); // like_count::TYPE::ID - return isLike ? redisUtil.increment(key) : redisUtil.decrement(key); + + if (isLike) { + redisUtil.increment(key); + } else { + redisUtil.decrement(key); + } } /** @@ -70,6 +89,7 @@ public Long updateLikeCountCache(final LikeTargetType type, final Long targetId, * @param type 좋아요 대상 타입 (예: SHIP_FISHING_POST, FISHING_TRIP_POST) * @param targetId 좋아요 대상 ID */ + @RedissonLock(key = "'lock:like_cache_init:' + #type.name() + ':' + #targetId") public void initializeLikeCache(final LikeTargetType type, final Long targetId) { String key = buildKey(type, targetId); From 4206d90c5f39dad81c7a57ca799961b6b7eec4d5 Mon Sep 17 00:00:00 2001 From: Hyeonsuk Date: Sun, 18 May 2025 11:59:38 +0900 Subject: [PATCH 6/7] =?UTF-8?q?test:=20=EC=A0=81=EC=9A=A9=EB=90=9C=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=BD=94=EB=93=9C=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8C=85(MIKKI-248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../like/service/LikeCacheServiceTest.java | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 backend/src/test/java/com/backend/domain/like/service/LikeCacheServiceTest.java diff --git a/backend/src/test/java/com/backend/domain/like/service/LikeCacheServiceTest.java b/backend/src/test/java/com/backend/domain/like/service/LikeCacheServiceTest.java new file mode 100644 index 00000000..b07f3112 --- /dev/null +++ b/backend/src/test/java/com/backend/domain/like/service/LikeCacheServiceTest.java @@ -0,0 +1,106 @@ +package com.backend.domain.like.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.backend.domain.like.domain.LikeTargetType; +import com.backend.global.util.RedisUtil; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest +class LikeCacheServiceTest { + + @Autowired + private LikeCacheService likeCacheService; + + @Autowired + private RedisUtil redisUtil; + + private final LikeTargetType TYPE = LikeTargetType.FISHING_TRIP_POST; + private final Long TARGET_ID = 1L; + + private String redisKey; + + @BeforeEach + void setUp() { + redisKey = "like_count::" + TYPE.name() + "::" + TARGET_ID; + redisUtil.setValue(redisKey, "0"); + } + + @AfterEach + void tearDown() { + redisUtil.deleteKeyIfExists(redisKey); + } + + private void runConcurrentTest(Runnable incTask, Runnable decTask) throws InterruptedException { + ExecutorService executor = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(100); + + for (int i = 0; i < 50; i++) { + executor.submit(() -> { + try { + incTask.run(); + } finally { + latch.countDown(); + } + }); + } + + for (int i = 0; i < 50; i++) { + executor.submit(() -> { + try { + decTask.run(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + } + + @Test + @DisplayName("Redisson Lock 적용 [increase:50 decrease:50]") + void testWithRedissonLock() throws InterruptedException { + AtomicInteger incCounter = new AtomicInteger(); + AtomicInteger decCounter = new AtomicInteger(); + + runConcurrentTest( + () -> { + likeCacheService.updateLikeCountCache(TYPE, TARGET_ID, true); + incCounter.incrementAndGet(); + }, + () -> { + likeCacheService.updateLikeCountCache(TYPE, TARGET_ID, false); + decCounter.incrementAndGet(); + } + ); + + Long finalCount = Long.parseLong(redisUtil.getValue(redisKey)); + long expected = 0L; + + log.debug("\n" + + "====================== [LOCK 적용 결과] ======================\n" + + "기대값 : {}\n" + + "실제값 : {}\n" + + "증가 호출 수 : {}\n" + + "감소 호출 수 : {}\n" + + "===============================================================", + expected, finalCount, incCounter.get(), decCounter.get()); + + assertEquals(expected, finalCount); + } +} From 5d6effbe65054e3799489a073cd4cb8beee7be73 Mon Sep 17 00:00:00 2001 From: Hyeonsuk Date: Sun, 18 May 2025 12:18:06 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20local=ED=99=98=EA=B2=BD=20redis=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD(MIKKI?= =?UTF-8?q?-248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/backend/global/config/RedisConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/backend/global/config/RedisConfig.java b/backend/src/main/java/com/backend/global/config/RedisConfig.java index 3ea6013a..92d7d46d 100644 --- a/backend/src/main/java/com/backend/global/config/RedisConfig.java +++ b/backend/src/main/java/com/backend/global/config/RedisConfig.java @@ -36,8 +36,8 @@ public class RedisConfig { @Value("${spring.data.redis.port}") private String REDIS_PORT; - @Value("${spring.data.redis.password}") - private String REDIS_PASSWORD; + // @Value("${spring.data.redis.password}") + // private String REDIS_PASSWORD; private static final String REDISSON_HOST_PREFIX = "redis://";