Java & Spring

Redis의 SpinLock을 사용한 동기화 문제 해결

ju_young 2023. 12. 28. 20:55
728x90

위와 같이 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());  
        }  
    }  
}

 

728x90