위와 같이 A라는 계좌를 thread1이 사용하고 있을때 동시에 thread2를 사용한다고 할 때 동기화 문제가 발생한다. 구체적으로 두 thread가 동시에 실행하게되면 처음 A계좌에있는 10,000원을 가져다 사용하기 때문에 마지막에 실행을 완료한 thread의 결과가 최종 결과가 된다.
해당 동기화 문제를 해결하기 위해 Redis의 SpinLock을 사용할 수 있다. SpinLock이란 뮤텍스처럼 한 시점의 하나의 스레드에만 접근할 수 있도록 하며 접근하지 못한 다른 스레드들은 접근 할 수 있을 때까지 루프를 돌며 재시도를 하는 것이다.
spring-redis
Dependency 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Docker 설정
1. Dockerfile
FROM redis:6
ENV TZ=Asia/Seoul
2. docker-compose
version: "3.8"
services:
account-service-redis:
container_name: account-service-redis
build:
dockerfile: Dockerfile
context: ./redis #Dockerfile 위치
image: {도커 ID}/redis
ports:
- "6379:6379"
application.yml 설정
spring:
redis:
host: localhost
port: 6379
Configuration
@Configuration
public class LocalRedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private Integer port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
Repository
@Component
@RequiredArgsConstructor
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(final String accountNumber) {
return redisTemplate
.opsForValue()
.setIfAbsent(getLockKey(accountNumber), "lock", 1, TimeUnit.SECONDS);
}
public Boolean unlock(final String accountNumber) {
return redisTemplate.delete(getLockKey(accountNumber));
}
public String getLockKey(String accountNumber) {
return "ACLK:" + accountNumber;
}
}
계좌번호를 하나의 스레드만 사용할 수 있도록 Key를 만들어 저장하도록 한다. 다른 스레드가 같은 계좌번호로 lock을 시도한다면 이미 같은 Key가 존재하기 때문에 실패하게될 것이다.
AOP 적용
1. annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface AccountLock {
}
2. 실행 로직
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class LockAopAspect {
private final RedisLockRepository redisLockRepository;
@Around("@annotation(com.example.account.aop.AccountLock) && args(request)")
public Object aroundMethod(
ProceedingJoinPoint pjp,
AccountLockIdInterface request
) throws Throwable {
//lock
while (!redisLockRepository.lock(request.getAccountNumber())) {
log.warn("해당 계좌는 사용 중입니다.");
Thread.sleep(100); //0.1초
}
try {
return pjp.proceed();
} finally {
//unlock
redisLockRepository.unlock(request.getAccountNumber());
}
}
}
위처럼 사용할 계좌번호가 사용 중이라면 while 루프를 돌며 unlock이 될 때까지 재시도를 하게된다. 이때 재시도를 하는 중간에 sleep을 수행하는데 이는 redis에 부담을 줄이기 위함이다.
적용
@PostMapping("/transaction/use")
@AccountLock
public UseBalance.Response useBalance(
@Valid @RequestBody UseBalance.Request request
) throws InterruptedException {
try {
Thread.sleep(5000L);//실제로 spinlock이 동작하는지 확인하기 위해 추가
return UseBalance.Response.from(
transactionService.useBalance(
request.getUserId(),
request.getAccountNumber(),
request.getAmount()
)
);
} catch (AccountException e) {
log.error("Failed to use balance.");
throw e;
}
}
embedded-redis
Dependency 추가
// redis client
implementation 'org.redisson:redisson:3.17.1'
// embedded redis
implementation('com.github.codemonstur:embedded-redis:1.2.0')
NOTE
가장 먼저 구현된 embedded-redis는 mac m1을 지원하지 않는 문제가 있다. 이외에도 업데이트를 잘 하지 않아 해당 레포를 fork하여 구현 및 개선한 것들이 여러개 존재한다. 그 중 codemonstur의 embedded-redis가 m1에서 정상적으로 동작하는 것을 확인했다.
application.yml 설정
spring:
redis:
host: 127.0.0.1
port: 6379
Configuration
1. RedisServer
@Configuration
public class LocalRedisConfig {
@Value("${spring.redis.port}")
private int redisPort;
private RedisServer redisServer;
@PostConstruct
public void startRedis() throws IOException {
redisServer = new RedisServer(redisPort);
redisServer.start();
}
@PreDestroy
public void stopRedis() throws IOException {
if (redisServer != null) {
redisServer.stop();
}
}
}
2. RedissonClient
@Configuration
public class RedisRepositoryConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort);
return Redisson.create(config);
}
}
Service
@Slf4j
@Service
@RequiredArgsConstructor
public class LockService {
private final RedissonClient redissonClient;
public void lock(String accountNumber) {
RLock lock = redissonClient.getLock(getLockKey(accountNumber));
try {
boolean isLock = lock.tryLock(1, 15, TimeUnit.SECONDS);
if(!isLock) {
log.error("Lock acquisition failed=");
throw new AccountException(ErrorCode.ACCOUNT_TRANSACTION_LOCK);
}
} catch (AccountException e) {
throw e;
} catch (Exception e) {
log.error("Redis lock failed", e);
}
}
public void unlock(String accountNumber) {
redissonClient.getLock(getLockKey(accountNumber)).unlock();
}
public String getLockKey(String accountNumber) {
return "ACLK:" + accountNumber;
}
}
AOP 적용
1. annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface AccountLock {
}
2. 실행 로직
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class LockAopAspect {
private final LockService lockService;
@Around("@annotation(com.example.account.aop.AccountLock) && args(request)")
public Object aroundMethod(
ProceedingJoinPoint pjp,
AccountLockIdInterface request
) throws Throwable {
//lock
lockService.lock(request.getAccountNumber());
try {
return pjp.proceed();
} finally {
//unlock
lockService.unlock(request.getAccountNumber());
}
}
}