diff --git a/backend/build.gradle b/backend/build.gradle index 8fcfd58d2..165623165 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') { 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 375bb7072..1b4a93d2d 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); 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 d7ca71555..92d7d46da 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 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 000000000..626949257 --- /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 000000000..3828ad2c7 --- /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 000000000..171515afd --- /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); + } + } +} 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 965c442b2..b73c20fe0 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); 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 000000000..b07f3112b --- /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); + } +}