minaver / Daily-Issue

Archive daily issues
0 stars 0 forks source link

Spring에서의 동시성 이슈 #4

Closed minaver closed 1 year ago

minaver commented 1 year ago

이슈

Spring으로 사내 서버를 개선하다 동시성 이슈가 발생할 수 있는 취약 부분을 확인했다. 매장 안에 존재하는 트랙 안에서 개별적인 노드들의 취득 정보를 각 로봇들이 DB에서 가져와 사용해야하는데 특정 노드에 exist를 이미 취득하고 변경하는 상황(0 -> 1)에서 이외의 요청이 해당 노드의 exist를 취득하려 한다면 race condition 문제가 발생할 수 있다고 판단했다.

로봇의 경우 10개의 로봇이 한 매장에서 500ms(예시)에 한번씩 mqtt 통신을 보내는데, 목적지 선정 알고리즘이 마치기 전에 동시 요청이 들어올 가능성을 배제할 수 없다고 판단하였다.

공부

minaver commented 1 year ago

[NHN FORWARD 2021] Redis 야무지게 사용하기

Redis 캐시로 사용하기

데이터의 원래 소스보다 더 빠르고 효율적으로 액세스할 수 있는 임시 데이터 저장소로 같은 데이터가 반복적으로 액세스하는 경우, 잘 변하지 않는 데이터인 경우

캐싱 전략

읽기 전략

Look-Aside(Lazy Loading)

  1. 초기 발생할 수 있는 다수의 Cache Miss를 방지하기 위해 Cache Warming을 해줄 수 있음
  2. Redis에서 Cache Hit 먼저 확인
  3. Cache Miss의 경우 DB에 접근해서 원하는 데이터 찾음

쓰기 전략

Write-Around

  1. 모든 데이터를 DB에 저장
  2. Cache Miss가 발생한 경우 Cache의 데이터를 끌어옴

Write-Through

  1. DB 데이터를 저장시 Cache에도 함께 저장
  2. 재사용되지 않는 데이터를 관리하기 위해 몇분, 몇시간 동안만 데이터 저장하겠다는 의미인 expire-time을 설정함

Redis 데이터 타입

Strings, Bitmaps, Lists, Hashes, Sets, Sorted Sets, HyperLogLogs, Streams

MORE...

minaver commented 1 year ago

[Redisson Lock 적용 레퍼런스] Redisson 분산락을 이용한 동시성 제어

minaver commented 1 year ago

Redisson Lock In Code

RedissonLock.java tryLock, lockAsync, tryLockAsync, tryLockInnerAsync, tryAcquireAsync

minaver commented 1 year ago

Redisson 적용방법

minaver commented 1 year ago

Redisson을 사용한 동시성 제어 방법

1. 기초 설정

application.yml

서버 내부에 있는 redis를 사용할 것이므로 localhost ip를 사용한다.

redis:
    host: 127.0.0.1
    port: 6379

build.gradle

Redisson 의존성 추가

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

2. Redisson 기초 설정 RedissonConfig

RedissonClient를 사용하기 위해 bean으로 등록한다. 여기서 말하는 RedissonClient는 동기/비동기 인터페이스를 사용하여 모든 Redisson 개체에 액세스할 수 있는 기본 Redisson 인터페이스이다.(Main Redisson interface for access to all redisson objects with sync/async interface.)

@Configuration
public class RedissonConfig {
    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        RedissonClient redisson = Redisson.create(config);
        return redisson;
    }
}

3. 동시성 처리 로직 AOP

Redisson 개체에 액세스할 수 있는 기본 Redisson 인터페이스를 만들었으니 이제 사용하면 된다. 동시성 처리가 필요한 모든 부분에 lock 함수를 적용한다면 개발 효율성이 떨어진다. 또한 비즈니스 로직과 동시성 처리 로직의 관심사가 함께 있어 SRP에 위배된다.

위와 같은 이유로 AOP를 위한 Lock 어노테이션을 만들어 사용하려한다.

[ 장점 ]

  1. 개발 효율성 향상
  2. 비즈니스 로직과 동시성 처리 로직 분리(SRP 의거)
  3. 코드 재사용성 향상

@DistributeLock

먼저 어노테이션의 틀을 만든다

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributeLock {
    /** 락 이름 */
    String key();

    /** 시간 단위 (MILLISECONDS, SECONDS, MINUTE 가능) */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /** 락을 획득하기 위한 대기 시간 */
    long waitTime() default 6L;

    /**  락을 임대하는 시간 */
    long leaseTime() default 3L;
}

여기서 key()는 분산 락의 락을 설정할 이름이므로 어노테이션에서 필수값으로 입력받는다. 그외 파라미터는 선택적으로 설정할 수 있다.

DistributeLockAop

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributeLockAop {
    private static final String REDISSON_KEY_PREFIX = "RLOCK_";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    /**
     * DistributeLock 어노테이션 실행 전후 lock 기능 실행
     * */
    @Around("@annotation(com.example.helper_spring_boot.redisson.DistributeLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributeLock distributeLock = method.getAnnotation(DistributeLock.class); // @DistributeLock annotation을 가져옴

        String key = REDISSON_KEY_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributeLock.key());  // @DistributeLock에 전달한 key를 가져오기 위해 SpringEL 표현식을 파싱

        RLock rLock = redissonClient.getLock(key);  // Redisson에 해당 락의 RLock 인터페이스를 가져옴

        try {
            boolean available = rLock.tryLock(distributeLock.waitTime(), distributeLock.leaseTime(), distributeLock.timeUnit());    // tryLock method를 이용해 Lock 획득 시도 (획득 실패시 Lock이 해제 될 때까지 subscribe)
            if (!available) {
                return false;
            }

            log.info("get lock success {}" , key);
            return aopForTransaction.proceed(joinPoint);    // @DistributeLock이 선언된 메소드의 로직 수행(별도 트랜잭션으로 분리)
        } catch (Exception e) {
            Thread.currentThread().interrupt();
            throw new InterruptedException();
        } finally {
            rLock.unlock();
        }
    }
}

@DistributeLock 어노테이션에서 수행할 로직을 설정해보자. @Around로 지정된 패턴에 해당하는 메소드가 실행되기 전, 실행된 후 모두에서 동작하도록 설정한다. 또한 DistributeLock 설정시 입력한 key() 값을 직접 설정한 CustomSpringELParser.getDynamicValue() 로 파싱해 key String 값을 가져와 분산 락의 락으로 사용한다.

redissonClient에서 분산 락에 사용할 RLock 인터페이스를 가져오고 tryLock 메소드를 통해 락 획득을 시도한다.

이때 만약 획득하게 되면 aopForTransaction.proceed(joinPoint) 를 실행하여 @DistributeLock이 선언된 메소드의 로직을 수행하고 만약 획득하지 못하게 되면 락이 해제 될 때까지 subscribe 이때 설정한 waitTime()만큼 기다리게 된다.

4. AopForTransaction

@Component
public class AopForTransaction {

    /**
     * commit 까지 완료한 후에야 lock 을 해제해야한다.(lock 해제 후 commit 된다면 race-condition 문제 유지됨) <br>
     * Transactional 의 전파옵션은 propagation = Propagation.REQUIRES_NEW 로 선언하여 lock 별 신규 트랜젝션 생성 <br><br>
     *
     * [ 주의점 ] <br>
     * 이때 DistributeLockAop.lock 메소드에서 직접 별도의 트랜젝션을 생성한다면 가용할 수 있는 connection pool 을 모두 사용해 connection pool dead lock 이 발생할 여지가 있음 <br>
     * */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

먼저 @Transactional(propagation = Propagation.REQUIRES_NEW)을 사용하여 부모 트랜잭션과 독립적인 신규 트랜잭션을 생성해준다.

ProceedingJoinPoint에서 수행하는 일련의 비즈니스 로직은 동시성 제어를 해줘야하는 데이터 변경 작업일 것이다. 이때 AopForTransaction의 트랜잭션이 부모 트랜잭션과 동일하다면(부모 트랜잭션으로부터 전파) DB 변경 commit 시점이 락 해제 시점보다 이전이라는 보장을 할 수가 없게된다. (영속성 컨텍스트에 저장 후 DB에 commit하는 시기 vs 락 해제 시기) AopForTransaction의 트랜잭션 부모 트랜잭션과 별개로 처리되야하고 AopForTransaction의 트랜잭션이 모두 마무리 되면(commit이 끝나면) 부모 트랜잭션이 마무리되어 락이 해제되야한다. 즉 여기서@Transactional(propagation = Propagation.REQUIRES_NEW)를 사용한 이유는 부모 트랜잭션과 별개의 트랜잭션을 생성해주기 위함이다.

AopForTransaction 안에서는 ProceedingJoinPointproceed() 하여 기존 어노테이션이 선언된 부분의 로직을 수행하게 한다.

@Around Advice에서 사용할 공통 기능 매서드는 대부분 파라미터로 전달받은 ProceedingJoinPointproceed() 매서드만 호출하면 된다. 이때 여기서는 직접 DistributeLockAop 클래스에서 joinPoint.proceed() 하지 않고 따로 AopForTransaction을 만들어 joinPoint.proceed()를 호출했다.

minaver commented 1 year ago

Redisson 키 처리 방법

동시성 처리가 필요한 개별적인 엔티티별로 KEY를 설정해줘야 한다. 이때 KEY는 하나의 엔티티 객체(DB 튜플)에 대해 단일해야하므로 엔티티 별 포멧을 만들어 사용하도록 하였다.

public interface RedissonKey {

    String getKey();

    void allFieldNotNullValidation();

}

다음과 같이 RedissonKey 인터페이스에서 key를 받을 메소드와 Validation 메소드를 추상 메소드로 명시하고 이를 각 엔티티에서 구현하여 사용하는 방식으로 KEY를 생성한다.

아래는 Robot 엔티티를 다루는 KEY이다

public record RobotKey(Integer mapId, Integer robotId) implements RedissonKey {

    private static final String domain = "ROBOT_";

    @Override
    public String getKey() {
        allFieldNotNullValidation();
        StringBuffer key = new StringBuffer(domain);
        key.append(mapId);
        key.append("_");
        key.append(robotId);
        return key.toString();
    }

    @Override
    public void allFieldNotNullValidation() {
        if (this.mapId == null || this.robotId == null) throw new IllegalArgumentException();
    }
}

다음과 같이 특정 조건에 맞게 파라미터를 입력해야만 원하는 엔티티에 대한 KEY 값을 얻을 수 있다.

minaver commented 1 year ago

Redisson 테스트 방법

테스트 코드는 어느 환경에서 실행하던지 동일하게 실행돼야 한다. 즉 로컬 PC에 직접 Redis를 띄워서 테스트를 진행하는 것은 좋은 방법이 아니다. 또한 기존 Redis 환경에 있는 데이터가 테스트를 오염시킬 수도 있으므로 직접 Redis를 띄워 사용하지 않고 TestContainer는 테스트 환경에서 도커 컨테이너를 실행하여 테스트 하는 방법을 사용한다.

TestContainer

build.gradle

...
// test-containers
testImplementation group: 'org.testcontainers', name: 'testcontainers', version: '1.17.2'
...

RedisTestContainers

@DisplayName("Redis Test Containers")
@Configuration
public class RedisTestContainers {

    private static final String REDIS_DOCKER_IMAGE = "redis:5.0.3-alpine";

    static {    // (1) redis:5.0.3-alpine 라는 이미지에 새 컨테이너를 생성
        GenericContainer<?> REDIS_CONTAINER =
            new GenericContainer<>(DockerImageName.parse(REDIS_DOCKER_IMAGE))
                .withExposedPorts(6379)
                .withReuse(true);

        REDIS_CONTAINER.start();    // (2) Redis Container 실행 (6379 포트)

        // (3) RedisContainer 와 연결하기 위해 host, port를 매핑
        System.setProperty("spring.redis.host", REDIS_CONTAINER.getHost());
        System.setProperty("spring.redis.port", REDIS_CONTAINER.getMappedPort(6379).toString());
    }
}

해당 코드는 테스트 디렉토리 안에 위치시킨다. 테스트를 실행하면 다음과 같이 도커 환경에서 testcontainers 와 redis 가 사용되는 것을 확인할 수 있다.

스크린샷 2023-05-15 오전 10 41 05

테스트 코드

먼저 useRobotBattery@DistributeLock이 다음과 같이 적용되어 있다.

@DistributeLock(key = "#key")
public void useRobotBattery(RobotsPK robotsPK, String key) { ... }

아래는 로봇 배터리를 비동기 스레드 100개가 1씩 차감시키는 테스트이다. 초기 테스트 배터리는 100이다. Race-Condition이 발생하지 않고 100개의 스레드가 정상적으로 1씩 차감시켰다면 결과는 0이 되어야 한다.


@DisplayName("Redis CRUD Test")
@SpringBootTest
public class RedisCrudTest {

    ...

    @RepeatedTest(10)
    @DisplayName("Redisson_정상작동_로봇_배터리_동시성_테스트")
    void 로봇_배터리_동시성_테스트() throws InterruptedException {
        // given
        int numberOfThreads = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        RobotKey robotKey = new RobotKey(mapsId, robotsId);

        // when
        for (int i = 0; i < numberOfThreads; i++) {
            executorService.submit(() -> {
                try {
                    robotsService.useRobotBattery(robotsPK,robotKey.getKey());
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        Robots persistRobots = robotsRepository.findById(robotsPK)
                .orElseThrow(IllegalArgumentException::new);

        // then
        assertThat(persistRobots.getBattery()).isZero();
    }
}

다음과 같이 10번 테스트시 10번 모두 테스트 성공하는 것을 확인할 수 있다.

스크린샷 2023-05-15 오전 10 44 40