내 프로젝트의 Payment를 개발하면서 가장 기본 중에 기본이 되는 문제를 직면했었다.
그것은 바로 동시성 문제!
스프링부트의 내장 서버는 기본적으로 톰캣, 언더토우 등등의 WAS로 돌아가는데 이 WAS는 멀티스레드 기반으로 동작한다.
A라는 상품 (재고 3개) 을 [가]군이 2개 구매하려 한다. 동시에 [나]군이 2개 구매하려 한다.
미세하게 나마 0.00001초의 차이가 있을 수 있다.
결국 각각의 스레드가 같은 상품의 재고를 조회한다. 원래대로라면 한 명은 못 사야 정상이다.
위 문제를 해결하기 위한 방법이 뭐가 있을까?
1. Synchronized
자바로 해결하는 방법이다.
Thread-Safe 하기 때문에 매우 좋아보이나, 서버가 증설될 경우 의미가 없어진다.
2. Database Lock
Database에 Lock을 걸 수 있는 방법이다.
테이블에 Lock을 걸거나 Mysql의 Innodb엔진은 레코드 Lock을 제공하므로 이를 통해 동시성 문제를 해결할 수 있다.
하지만 DB에 직접적인 Lock을 거는 것은 결코 좋은 방법은 아니다. 교착 상태(deadlock)에 빠질 수도 있고,
무엇보다 성능적인 부분에서 속도가 느려질 수 있다는 문제점이 있다.
3. Redisson을 이용한 분산 Lock
Redis를 이용해서 Lock을 걸 수 있는 방법이 있다.
서버가 여러 대일 경우에도 문제 없이 분산 Lock을 걸어 데이터의 정합성을 보장할 수 있다.
자바에서 제공하는 Redisson 클라이언트를 이용해 분산 Lock을 획득해 분산 환경에서도 동시성 문제를 해결할 수 있다.
4. 구현 과정
Build.gradle 의존성 추가
// Redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.17.6'
Redisson Configuration
- Redisson Client의 설정을 추가해 Bean으로 등록한다.
- 앞선 bean들은 기존에 사용하던 Lettuce 라이브러리가 있기 때문이고, 추가하고자 하는 Redisson client를 Bean으로 등록하자.
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.password}")
private String password;
@Value("${spring.redis.connectionTimeout}")
private Long connectionTimeout;
/**
* redis와 connection을 생성해 주는 객체
* @return LettuceConnectionFactory
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration =
new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
redisStandaloneConfiguration.setPassword(password);
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
/**
* redis 서버와 통신 및 유저에게 redis 모듈 기능 제공하기 위해 추상화를 통해
* 오퍼레이션 제공
* @return redisTemplate
*/
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate =
new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
@Bean
public StringRedisTemplate stringRedisTemplate() {
StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
stringRedisTemplate.setConnectionFactory(redisConnectionFactory());
stringRedisTemplate.setKeySerializer(new StringRedisSerializer());
stringRedisTemplate.setValueSerializer(new StringRedisSerializer());
return stringRedisTemplate;
}
@Bean
public RedissonClient redissonClient() {
String address = "redis://" + host + ":" + port;
Config config = new Config();
config.useSingleServer()
.setAddress(address)
.setPassword(password)
.setConnectTimeout(connectionTimeout.intValue());
return Redisson.create(config);
}
}
Lock을 적용하는 방법을 직접 코드에 적용 시키면 필요 이상의 비즈니스가 하나의 메서드에 들어갈 것 같다.
그래서 AOP를 이용해서 분산 Lock을 필요로 하는 곳에 적용 시키기 위해 애노테이션을 선언해 적용했다.
애노테이션 정의
- Lock 유지 시간과 대기 시간을 프로퍼티로 정의
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLocked {
/**
* redisson lock 유지 시간 <br>
* 단위 : milliseconds
*/
long leaseTime() default 1000;
/**
* redisson lock 획득 대기 시간 <br>
* 단위 : milliseconds
*/
long waitTime() default 3000;
}
AOP 구현
- Lock의 key는 필요에 따라 작명을 하면 될 것이다.
- tryLock을 통해 lock의 획득 여부 파악과 획득을 진행하고, @Around로 정의해 중간에 핵심관심사를 실행한다.
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RedisLockAspect {
private final RedissonClient redissonClient;
private static final String LOCK_SUFFIX = ":lock";
@Around("@annotation(com.daniel.mychickenbreastshop.global.aspect.annotation.RedisLocked)")
public Object executeWithLock(ProceedingJoinPoint joinPoint) {
String key = getLockableKey(joinPoint);
return execute(key, joinPoint);
}
private String getLockableKey(ProceedingJoinPoint joinPoint) {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
String methodName = method.getName();
String className = method.getDeclaringClass().toString();
return methodName + className + LOCK_SUFFIX;
}
private Object execute(String key, ProceedingJoinPoint joinPoint) {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
long leaseTime = method.getAnnotation(RedisLocked.class).leaseTime();
long waitTime = method.getAnnotation(RedisLocked.class).waitTime();
RLock lock = redissonClient.getLock(key);
Object result;
try {
boolean tryLock = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
if (!tryLock) {
log.error("Lock 획득에 실패했습니다.");
}
result = joinPoint.proceed();
} catch (Throwable e) {
throw new InternalErrorException(e);
} finally {
unlock(lock);
log.info("Redis unlocked!");
}
return result;
}
private void unlock(RLock lock) {
if (lock != null && lock.isLocked()) {
lock.unlock();
}
}
}
애노테이션 활용
- 애노테이션을 활용해 분산 락이 필요한 메서드에 달아주면 된다.
- 트랜잭션 처리가 필요한 곳을 AOP는 프록시 기반으로 동작하기 때문에 트랜잭션 전파를 따로 적용했다.
- 이렇게 하지 않을 경우 직접 커밋과 롤백을 해줘야 한다.
@Override
@RedisLocked(leaseTime = 3000)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public PaymentResult cancelPayment(PayCancelRequestDto payCancelRequestDto, String loginId) {
이렇게 간단하게 Redisson을 이용하여 분산 Lock을 구현해봤다.
좀 더 나은 성능과 나은 방법을 찾는 것은 참 쉽지 않은 것 같다. 또, 최선의 방법을 계속 찾느라 시도 조차 안 하는 것이 아니라 일단 단계에 맞는 시도를 밟아나가며 필요에 의해 최선의 방법을 적용 시키는 과정이 필요할 것 같다고 생각한다.
트랜잭션 관련 예외 문제 해결 처리 포스팅!
참고 자료
https://it-hhhj2.tistory.com/102
https://devroach.tistory.com/82
'📕 Spring Framework > Spring Project' 카테고리의 다른 글
[Refactor] 패키지 구조와 의존성 (2) | 2022.10.14 |
---|---|
[Redisson] 트랜잭션 문제 발생 및 해결 (0) | 2022.10.01 |
결제 API 리팩토링 - [2] (feat. WebClient) (7) | 2022.09.22 |
결제 API 리팩토링 - [1] (feat. 전략 패턴) (2) | 2022.09.20 |
동시성 조회 문제 해결 및 성능에 관한 고민 [Lock, Queue, Redis] (0) | 2022.09.14 |