Skip to content

Commit c7bcd0e

Browse files
authored
[Feat]: Redis 분산락을 통한 입찰 동시성 문제 해결
RedissonConfig: Redisson 설정 DistributedLock: 분산락 어노테이션 DistributedLockAop: 분산락 AOP AopForTransaction: 트랜잭션 분리용 AOP BidService: 분산락 적용 BidConcurrencyTest: 100명 동시 입찰로 분산락 처리 검증 BidDistributedLockPerformanceTest: 동시성 및 성능 테스트
2 parents c3bd6fb + 500668c commit c7bcd0e

File tree

15 files changed

+1219
-257
lines changed

15 files changed

+1219
-257
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies {
3030
implementation("org.springframework.boot:spring-boot-starter-security")
3131
implementation("org.springframework.boot:spring-boot-starter-web")
3232
implementation("org.springframework.boot:spring-boot-starter-data-redis")
33+
implementation("org.redisson:redisson-spring-boot-starter:3.18.0")
3334
implementation("org.springframework.boot:spring-boot-starter-websocket")
3435
implementation("org.springframework.boot:spring-boot-starter-webflux")
3536
implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")

src/main/java/com/backend/domain/bid/service/BidService.java

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import com.backend.domain.product.event.helper.ProductChangeTracker;
1313
import com.backend.domain.product.repository.jpa.ProductRepository;
1414
import com.backend.global.exception.ServiceException;
15+
import com.backend.global.lock.DistributedLock;
1516
import com.backend.global.response.RsData;
1617
import com.backend.global.websocket.service.WebSocketService;
1718
import lombok.RequiredArgsConstructor;
@@ -26,12 +27,10 @@
2627
import java.util.List;
2728
import java.util.Map;
2829
import java.util.Set;
29-
import java.util.concurrent.ConcurrentHashMap;
3030
import java.util.concurrent.atomic.AtomicInteger;
3131
import java.util.stream.Collectors;
3232

3333
@Service
34-
@Transactional
3534
@RequiredArgsConstructor
3635
public class BidService {
3736
private final BidRepository bidRepository;
@@ -40,20 +39,15 @@ public class BidService {
4039
private final WebSocketService webSocketService;
4140
private final BidNotificationService bidNotificationService;
4241
private final ApplicationEventPublisher eventPublisher;
43-
private final Map<Long, Object> productLocks = new ConcurrentHashMap<>();
4442

4543
// ======================================= create methods ======================================= //
44+
@DistributedLock(key = "'product:' + #productId", waitTime = 10, leaseTime = 5)
4645
public RsData<BidResponseDto> createBid(Long productId, Long bidderId, BidRequestDto request) {
47-
// 상품별 락 객체 가져오기 (없으면 생성)
48-
Object lock = productLocks.computeIfAbsent(productId, k -> new Object());
49-
50-
// 동시성 제어: 같은 상품에 대한 입찰은 순차적으로 처리
51-
synchronized (lock) {
52-
return createBidInternal(productId, bidderId, request);
53-
}
46+
return createBidInternal(productId, bidderId, request);
5447
}
5548

56-
private RsData<BidResponseDto> createBidInternal(Long productId, Long bidderId, BidRequestDto request) {
49+
@Transactional
50+
public RsData<BidResponseDto> createBidInternal(Long productId, Long bidderId, BidRequestDto request) {
5751
// Product/Member 조회
5852
Product product = productRepository.findById(productId)
5953
.orElseThrow(() -> ServiceException.notFound("존재하지 않는 상품입니다."));
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.backend.global.config;
2+
3+
import org.redisson.Redisson;
4+
import org.redisson.api.RedissonClient;
5+
import org.redisson.config.Config;
6+
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.context.annotation.Bean;
8+
import org.springframework.context.annotation.Configuration;
9+
import org.springframework.context.annotation.Profile;
10+
11+
// 테스트 환경에서는 TestRedisConfiguration 사용
12+
@Configuration
13+
@Profile("!test & !bidtest")
14+
public class RedissonConfig {
15+
16+
@Value("${spring.data.redis.host}")
17+
private String redisHost;
18+
19+
@Value("${spring.data.redis.port}")
20+
private int redisPort;
21+
22+
@Value("${spring.data.redis.password:}")
23+
private String redisPassword;
24+
25+
private static final String REDISSON_HOST_PREFIX = "redis://";
26+
27+
@Bean
28+
public RedissonClient redissonClient() {
29+
Config config = new Config();
30+
config.useSingleServer()
31+
.setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort)
32+
.setPassword(redisPassword.isEmpty() ? null : redisPassword);
33+
34+
return Redisson.create(config);
35+
}
36+
}

src/main/java/com/backend/global/initdata/TestInitData.java

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,22 @@ public void work2() {
124124
Product product9 = productService.saveProduct(member4, requestDto9);
125125
productImageService.createProductImage(product9, "/image9_1.jpg");
126126

127+
// 입찰 생성은 별도 트랜잭션으로 분리
128+
self.createBids(product4.getId(), product9.getId(), member1.getId(), member2.getId());
129+
}
130+
131+
@Transactional
132+
public void createBids(Long product4Id, Long product9Id, Long member1Id, Long member2Id) {
127133
// 경매 진행
128-
bidService.createBid(product4.getId(), member1.getId(), new BidRequestDto(1200000L));
129-
bidService.createBid(product4.getId(), member2.getId(), new BidRequestDto(1300000L));
134+
bidService.createBid(product4Id, member1Id, new BidRequestDto(1200000L));
135+
bidService.createBid(product4Id, member2Id, new BidRequestDto(1300000L));
136+
137+
bidService.createBid(product9Id, member1Id, new BidRequestDto(900000L));
130138

131-
bidService.createBid(product9.getId(), member1.getId(), new BidRequestDto(900000L));
132-
product9.setStatus("낙찰");
133-
product9.setEndTime(LocalDateTime.now());
139+
// 낙찰 처리
140+
productService.findById(product9Id).ifPresent(product -> {
141+
product.setStatus("낙찰");
142+
product.setEndTime(LocalDateTime.now());
143+
});
134144
}
135145
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.backend.global.lock;
2+
3+
import org.aspectj.lang.ProceedingJoinPoint;
4+
import org.springframework.stereotype.Component;
5+
import org.springframework.transaction.annotation.Propagation;
6+
import org.springframework.transaction.annotation.Transactional;
7+
8+
/**
9+
* AOP에서 트랜잭션 분리를 위한 클래스
10+
*/
11+
@Component
12+
public class AopForTransaction {
13+
14+
@Transactional(propagation = Propagation.REQUIRES_NEW)
15+
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
16+
return joinPoint.proceed();
17+
}
18+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.backend.global.lock;
2+
3+
import org.springframework.expression.ExpressionParser;
4+
import org.springframework.expression.spel.standard.SpelExpressionParser;
5+
import org.springframework.expression.spel.support.StandardEvaluationContext;
6+
7+
/**
8+
* Spring Expression Language Parser
9+
*/
10+
public class CustomSpringELParser {
11+
12+
private CustomSpringELParser() {
13+
}
14+
15+
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
16+
ExpressionParser parser = new SpelExpressionParser();
17+
StandardEvaluationContext context = new StandardEvaluationContext();
18+
19+
for (int i = 0; i < parameterNames.length; i++) {
20+
context.setVariable(parameterNames[i], args[i]);
21+
}
22+
23+
return parser.parseExpression(key).getValue(context, Object.class);
24+
}
25+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.backend.global.lock;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
import java.util.concurrent.TimeUnit;
8+
9+
/**
10+
* Redisson Distributed Lock annotation
11+
*/
12+
@Target(ElementType.METHOD)
13+
@Retention(RetentionPolicy.RUNTIME)
14+
public @interface DistributedLock {
15+
16+
// 락의 이름
17+
String key();
18+
19+
// 락의 시간 단위
20+
TimeUnit timeUnit() default TimeUnit.SECONDS;
21+
22+
// 락을 기다리는 시간 (default - 5s) - 락 획득을 위해 waitTime 만큼 대기
23+
long waitTime() default 5L;
24+
25+
// 락 임대 시간 (default - 3s) - 락을 획득한 이후 leaseTime 이 지나면 락을 해제
26+
long leaseTime() default 3L;
27+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.backend.global.lock;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.aspectj.lang.ProceedingJoinPoint;
6+
import org.aspectj.lang.annotation.Around;
7+
import org.aspectj.lang.annotation.Aspect;
8+
import org.aspectj.lang.reflect.MethodSignature;
9+
import org.redisson.api.RLock;
10+
import org.redisson.api.RedissonClient;
11+
import org.springframework.stereotype.Component;
12+
13+
import java.lang.reflect.Method;
14+
15+
/**
16+
* @DistributedLock 선언 시 수행되는 Aop class
17+
*/
18+
@Aspect
19+
@Component
20+
@RequiredArgsConstructor
21+
@Slf4j
22+
public class DistributedLockAop {
23+
24+
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
25+
26+
private final RedissonClient redissonClient;
27+
private final AopForTransaction aopForTransaction;
28+
29+
@Around("@annotation(com.backend.global.lock.DistributedLock)")
30+
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
31+
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
32+
Method method = signature.getMethod();
33+
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
34+
35+
String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(
36+
signature.getParameterNames(),
37+
joinPoint.getArgs(),
38+
distributedLock.key()
39+
);
40+
41+
RLock rLock = redissonClient.getLock(key); // (1)
42+
43+
try {
44+
boolean available = rLock.tryLock(
45+
distributedLock.waitTime(),
46+
distributedLock.leaseTime(),
47+
distributedLock.timeUnit()
48+
); // (2)
49+
50+
if (!available) {
51+
log.warn("Redisson Lock 획득 실패 - method: {}, key: {}", method.getName(), key);
52+
throw new RuntimeException("현재 다른 요청을 처리 중입니다. 잠시 후 다시 시도해주세요.");
53+
}
54+
55+
log.debug("Redisson Lock 획득 성공 - method: {}, key: {}", method.getName(), key);
56+
return aopForTransaction.proceed(joinPoint); // (3)
57+
58+
} catch (InterruptedException e) {
59+
log.error("Redisson Lock 인터럽트 발생 - method: {}, key: {}", method.getName(), key);
60+
throw new InterruptedException();
61+
} finally {
62+
try {
63+
if (rLock.isHeldByCurrentThread()) {
64+
rLock.unlock(); // (4)
65+
log.debug("Redisson Lock 해제 - method: {}, key: {}", method.getName(), key);
66+
}
67+
} catch (IllegalMonitorStateException e) {
68+
log.info("Redisson Lock Already UnLock - serviceName: {}, key: {}", method.getName(), key);
69+
}
70+
}
71+
}
72+
}

src/main/java/com/backend/global/redis/RedisConfig.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
import org.springframework.context.annotation.Configuration;
66
import org.springframework.data.redis.connection.RedisPassword;
77
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
8+
import org.springframework.context.annotation.Profile;
89
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
910
import org.springframework.data.redis.core.RedisTemplate;
1011
import org.springframework.data.redis.serializer.StringRedisSerializer;
1112

1213
@Configuration
14+
@Profile("!test & !bidtest")
1315
public class RedisConfig {
1416

1517
@Value("${spring.data.redis.host}")

0 commit comments

Comments
 (0)