Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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);
}
}

/**
Expand All @@ -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);

Expand Down
25 changes: 25 additions & 0 deletions backend/src/main/java/com/backend/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 ์„ค์ •
*/
Expand Down Expand Up @@ -71,4 +86,14 @@ public RedisTemplate<String, List<FishingTripPostResponse.HotPost>> 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);
}

}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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 ์ ์œ ํ•˜๋Š” ์‹œ๊ฐ„
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
16 changes: 7 additions & 9 deletions backend/src/main/java/com/backend/global/util/RedisUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -102,7 +100,7 @@ public Map<String, Long> scanKeysAndValues(final String prefix) {
ValueOperations<String, String> ops = redisTemplate.opsForValue();

redisTemplate.execute((RedisCallback<Void>)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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading