개발/Spring

Part.1 Spring boot 갯수 제한 이벤트 구현(Redis 이용) Redisson, Transaction, Sync(lock) 유저가 많이 몰릴때.

JangHC 2022. 3. 19. 15:47

갯수 제한 이벤트시 순삭당하는 경우가 있습니다.

 

멀티 쓰레드 환경에서 sync하고 atomic하게 구현해야 정확하게 갯수에 맞춰서 사용자에게 서비스를 제공할 수 있습니다.

 

그러한 check를 db를 사용하기에는 너무 느리기에 redis를 사용하여 구현하였습니다.

 

일반 Spring boot에서 사용하는 Jedis 와 Lettuce는 lock 구현이 안되어 있어 redisson을 사용해야 합니다.

 

정확히는 스핀락을 사용하지 않는 pub/sub 기반으로 사용하려고 redisson을 사용합니다.

 

gradle

implementation 'org.redisson:redisson-spring-boot-starter:3.16.8'

 

application.properties

 

# Redis
spring.redis.host=localhost
spring.redis.port=6379

 

Config를 설정하여 Redisson Client를 bean에 등록합니다.

import java.io.IOException;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedissonConfig {
	
	private final RedisProperties redisProperties;
	
   @Profile({"local","prod","dev"}) // profile은 나누지 않았다면 제거해도 됨
    @Bean(name = "redissonClient")
    public RedissonClient redissonClientSingle() throws IOException {
        RedissonClient redisson = null;
        Config config = new Config();
        final Codec codec = new StringCodec(); // redis-cli에서 보기 위해
        config.setCodec(codec);
        config.useSingleServer().
        setAddress("redis://" + redisProperties.getHost()+":"+redisProperties.getPort()).
        setConnectionPoolSize(100) // pool size는 custom
        redisson = Redisson.create(config);   
        return redisson;
    }
   
    /* Cluster 구성 했을때.
    @Profile({"prod","dev"})
    @Bean(name = "redissonClient")
    public RedissonClient redissonClientCluster() throws IOException {
        String[] nodes = redisProperties.getUrl().split(",");   
        for (int i = 0; i < nodes.length; i++) {
            nodes[i] = "redis://" + nodes[i];
        }
        RedissonClient redisson = null;
        Config config = new Config();
        final Codec codec = new StringCodec(); // redis-cli에서 보기 위해
        config.setCodec(codec);
        config.useClusterServers()
                .setScanInterval(2000)     
                .addNodeAddress(nodes);
        redisson = Redisson.create(config);   
        return redisson;
    }
    */
}

 

Service로 모듈화를 시킴.

import java.util.concurrent.TimeUnit;

import org.redisson.api.RLock;
import org.redisson.api.RTransaction;
import org.springframework.transaction.TransactionStatus;

public interface MyRedis {
    public RLock lock(String lockKey);

    public RLock lock(String lockKey, long timeout);

    public RLock lock(String lockKey, TimeUnit unit, long timeout);

    public boolean tryLock(String lockKey, TimeUnit unit, long waitTime, long leaseTime);

    public void unlock(String lockKey);

    public void unlock(RLock lock);
    
    public Object getValue(String key);
    
    public void setValue(String key, Object value);

	public TransactionStatus startDBTransacton();

	public void commitDB(TransactionStatus status);

	public void rollbackDB(TransactionStatus status);

	public RTransaction startRedisTransacton();

	public void commitRedis(RTransaction transaction);

	public void rollbackRedis(RTransaction transaction);
    
    public boolean canUnlock(String lockKey);
}
import java.util.concurrent.TimeUnit;

import org.redisson.api.RLock;
import org.redisson.api.RTransaction;
import org.redisson.api.RedissonClient;
import org.redisson.api.TransactionOptions;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Component
@Getter
@RequiredArgsConstructor
public class MyRedisImpl implements MyRedis {

    private final RedissonClient redissonClient;        
    private final PlatformTransactionManager transactionManager;
    
    @Override
    public RLock lock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock();
        return lock;
    }

    @Override
    public RLock lock(String lockKey, long leaseTime) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(leaseTime, TimeUnit.SECONDS);
        return lock;
    }

    @Override
    public RLock lock(String lockKey, TimeUnit unit, long timeout) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(timeout, unit);
        return lock;
    }

    @Override
    public boolean tryLock(String lockKey, TimeUnit unit, long waitTime, long leaseTime) {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            return lock.tryLock(waitTime, leaseTime, unit);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    @Override
    public void unlock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.unlock();
    }
    
    @Override
	public void unlock(RLock lock) {
    	lock.unlock();
	}
    
    @Override
    public TransactionStatus startDBTransacton() {
    	DefaultTransactionDefinition def = new DefaultTransactionDefinition();
		def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
    	return transactionManager.getTransaction(def);
    }
    
    @Override
    public void commitDB(TransactionStatus status) {
    	transactionManager.commit(status);
    }
    
    @Override
    public void rollbackDB(TransactionStatus status) {
    	transactionManager.rollback(status);
    }
    
    @Override
    public RTransaction startRedisTransacton() {
    	return redissonClient.createTransaction(TransactionOptions.defaults());
    }
    
    @Override
    public void commitRedis(RTransaction transaction) {
    	transaction.commit();
    }
    
    @Override
    public void rollbackRedis(RTransaction transaction) {
    	transaction.rollback();
    }
    
    @Override
    public Object getValue(String key)
    {
    	return redissonClient.getBucket(key).get();
    }
    
    @Override
    public void setValue(String key, Object value)
    {
    	redissonClient.getBucket(key).set(value);
    }
    
    
	@Override
	public boolean canUnlock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        if(lock != null && lock.isLocked() && lock.isHeldByCurrentThread())
        {
            return true;
        }
        return false;
    }
}

 

 

사용하는 부분

 

import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;

import org.redisson.api.RBucket;
import org.redisson.api.RTransaction;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;


@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService
{
	private final MyRedis myRedis;

	@Override
	public boolean temp() {
		String lock = "lock_key"; // 해당 키에만 lock을 걸고 싶으면 value를 key로
		boolean ret = false;
		RTransaction transaction = myRedis.startRedisTransacton();
		TransactionStatus status = myRedis.startDBTransacton();
		try {
            if (myRedis.tryLock(lock, TimeUnit.SECONDS, 5L, 10L)) {
            	// Redis 처리
                RBucket<int[]> imageBucket = transaction.getBucket(/*key*/);
            	int imageList[] = imageBucket.get();
                
                /* redis test
                if(true)
                {
                	throw new Exception()
                }
                */
                
        		imageList[3]++;
                imageBucket.set(imageList);
                
                /* redis test2
                if(true)
                {
                	throw new Exception()
                }
                */
                
    			RBucket<Integer> memberBucket = transaction.getBucket(/*key*/);
        		int savedBitmap = memberBucket.get();
                memberBucket.set(++savedBitmap);
                
                /*
                DB 처리
                */
                
                /* db,redis rollback test
                if(true)
                {
                	throw new Exception()
                }
                */
                
            	myRedis.commitRedis(transaction);
            	myRedis.commitDB(status);
            }
        } catch (Exception e) {
        	myRedis.rollbackRedis(transaction);
        	myRedis.rollbackDB(status);
            e.getStackTrace();
            ret = false;
        } finally {
        	if (myRedis.canUnlock(lock)) {
        	   myRedis.unlock(lock);
    	   }
        }
		return ret;
	}
}

 

temp method에 @Transactional을 사용하면 위험합니다.

내부적으로 AOP를 사용하기 때문에 unlock이 된 후 commit이 되므로 동기화가 제대로 되지 않을 수도 있습니다.

 

그래서 redis에 lock을 잡고 db와 redis의 transaction 처리를 따로 해줘야 합니다.

 

redis transaction은 transaction처리가 가능하도록 transaction에서 제공하는 api로 set,get을 해야 합니다.

 

자세한 내용은 RTransaction을 보면 list,map등 여러가지 구현 할 수 있습니다.

 

감사합니다.