동시성 문제와 분산 락

혁콩·2024년 6월 16일

모두의 음악

목록 보기
8/17
post-thumbnail

들어가기에 앞서

이전 두 포스트에선 synchronized 키워드를 통한 문제 해결 방법배타적 락을 이용한 문제 해결 방법을 알아보았다.

배타적 락을 이용한 문제 해결은 성능 저하데드락 문제를 발생시킬 수 있어 다른 해결 방법을 찾고 있었다.

이번 포스트에선 다른 해결 방법에 해당하는 user-name-lockRedis 의 분산 락을 다뤄보겠다.

구현

두 방법 모두 Spring AOP를 통해 구현하였다. 이유는 다음과 같다.

  1. 동시성 문제 해결 로직은 많은 곳에서 재사용되는 공통된 관심사이다.
  2. 따라서, @Transactional과 같이 어노테이션 기반으로 다양한 로직에서 쉽게 재사용할 수 있게 한다.

1. MySQL의 user-name-lock

프로젝트는 이곳에서 확인하실 수 있습니다. 내용은 Real MySQL을 기반으로 작성하였습니다.

먼저, user-name-lock(USER LOCK 혹은 네임드 락) 에 대해 알아보자.

Real MySQL에선 다음과 같이 나와있다.

GET_LOCK() 함수를 이용해 임의로 잠금을 설정할 수 있다.
이 잠금의 특징은 대상이 테이블이나 코드 또는 AUTO_INCREMENT와 같은 데이터베이스 객체가 아니라는 것이다.

유저 락은 단순히 사용자가 지정한 문자열(String)에 대해 획득하고 반납하는 잠금이다.
...
여러 클라이언트가 상호 동기화를 처리해야 할 때 데이터베이스의 유저 락을 이용하면 쉽게 해결할 수 있다.

먼저, 특정 키를 가진 락을 획득/반환하는 메소드를 만들었다.

@Repository
@RequiredArgsConstructor
public class UserNameLockDAO {

    private final JdbcTemplate jdbcTemplate;
    
    public void getLock(String key, int timeoutSeconds) {
        String sql = "SELECT GET_LOCK(?, ?)";
        jdbcTemplate.queryForList(sql, key, timeoutSeconds);
    }

    public void releaseLock(String key) {
        String sql = "SELECT RELEASE_LOCK(?)";
        jdbcTemplate.queryForList(sql, key);
    }
}

AOP를 통해 구현할 것이기에 어노테이션을 만들어주었다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UserNameLock {
	// 잠금에 설정할 문자열 키
    String key();
    
    // 락을 획득하기 위해 대기할 시간
    int leaseTime() default 3;
}

트랜잭션 처리를 위한 별도의 클래스를 만들어주었다. 이유는 아래에서..!

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(ProceedingJoinPoint pjp) throws Throwable {
        return pjp.proceed();
    }
}

이후, 락을 획득/반납하는 로직을 구현하였다.
락의 반납은 반드시 이루어져야 하기에, try-finally 구문을 사용했다.

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class UserNameLockAop {

    private final UserNameLockDAO lockDAO;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(어노테이션_경로.UserNameLock)")
    public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        UserNameLock lock = method.getAnnotation(UserNameLock.class);

        String key = lock.key();
        log.info("key : {}", key);

        try {
            lockDAO.getLock(key, lock.leaseTime());
            return aopForTransaction.proceed(joinPoint);
        } finally {
            lockDAO.releaseLock(key);
        }
    }
}

자. 이제 구현한 user-name-lock을 사용해보자.

    @UserNameLock(key = "'group:'.concat(#groupId)")
    public void joinGroup(GroupJoinRequestDto dto, Long groupId) {
		...
    }

2. Redis의 분산 락

RedisRemote Dictionary Server의 약자로 키(Key) - 값(Value) 쌍의 해시 맵과 같은 구조를 가진 비관계형(NoSQL) 데이터베이스 관리 시스템(DBMS)이다.

Redis는 in-memory 데이터 구조 저장소로 메모리에 데이터를 저장하기에, 디스크에 접근하는 DB보다 빠른 속도를 자랑하며, 이 때문에 주로 캐시 서버로 사용된다.

Redis싱글 스레드로 동작한다는 특징 또한 있는데, 이 때문에 동시성 문제를 해결하는데에 자주 사용된다.

프로젝트는 이곳에서 확인하실 수 있습니다. 구현은 Kurly Tech blog를 참고해 구현하였습니다.

먼저 의존성을 추가한다. 버전은 이곳에서 볼 수 있다.

dependencies {
    implementation 'org.redisson:redisson-spring-boot-starter:3.31.0'
}

마찬가지로 어노테이션을 생성한다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {
	
    // 잠금 설정할 문자열 키
    String key();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

	// 락을 얻기 위해 대기할 최대 시간
    long waitTime() default 5L;

	// 락을 보유할 수 있는 최대 시간
    long leaseTime() default 3L;
}

Redisson 구현체를 사용하기 위한 설정을 한다. 필요한 정보를 적고, RedissonClient를 빈으로 등록한다.

# .yml 설정 파일
spring:
  data:
    redis:
      host:
      port: 
@Slf4j
@Configuration
public class RedissonConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

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

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

    @Bean
    public RedissonClient redissonClient() {
        log.info("Connecting to Redis at {}:{} ", redisHost, redisPort);
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        return Redisson.create(config);
    }
}

이제 AOP를 통해 락 처리에 대한 로직을 작성해보자.
위와 마찬가지로 트랜잭션을 위한 클래스를 만든다.

@Component
public class AopForTransaction {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(ProceedingJoinPoint pjp) throws Throwable {
        return pjp.proceed();
    }
}

이후 락을 획득/반납하는 로직을 구현한다.

@Slf4j
@RequiredArgsConstructor
@Aspect
@Component
public class RedisLockAop {
    private static final String REDIS_LOCK_PREFIX = "LOCK:";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(어노테이션_경로.RedisLock)")
    public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RedisLock redisLock = method.getAnnotation(RedisLock.class);

        String key = REDIS_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(
                signature.getParameterNames(), joinPoint.getArgs(), redisLock.key());

        RLock rLock = redissonClient.getLock(key);

        try {
            boolean available = rLock.tryLock(redisLock.waitTime(), redisLock.leaseTime(), redisLock.timeUnit());
            if (!available) {
                return false;
            }
            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            throw new InterruptedException();
        } finally {
            try {
                rLock.unlock();
            } catch (IllegalMonitorStateException e) {
                log.info("이미 반납된 락 : {}", key);
            }
        }
    }
}

Redisson 구현체는 pub/sub 방식의 재시도 로직을 지원하기에 별도로 구현할 필요가 없다. 또한, 이는 디폴트 구현체인 Lettuce스핀 락 방식보다 Redis에 더 적은 부하를 가한다.

자. 이제 구현한 어노테이션을 사용해 락을 적용해보자.

    @RedisLock(key = "'group:'.concat(#groupId)")
    public void joinGroup(GroupJoinRequestDto dto, Long groupId) {
		...
    }

왜?

1. 별도의 트랜잭션?

두 방법 모두 트랜잭션을 시작하는 별도의 클래스를 두었다.

@Component
public class AopForTransaction {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(ProceedingJoinPoint pjp) throws Throwable {
        return pjp.proceed();
    }
}

동시성 문제와 synchronized스프링의 선언적 트랜잭션 관리 에서 이미 언급한 문제인데, 락을 획득하기 전에 트랜잭션이 이미 시작되는 문제가 존재한다.

메소드 자체에 @Transactional을 통해 구현한다면 다음과 같은 흐름을 갖는다.
락을 획득하기 전에 트랜잭션이 시작되기에, REPEATABLE READ 격리 수준에선 동시성 문제를 아예 해결할 수 없다.

또한, 트랜잭션보다 락이 먼저 해제되기 때문에 첫번째 트랜잭션이 커밋되기 전 두번째 트랜잭션이 락을 획득할 가능성 또한 존재한다.


AopForTransactional 클래스를 도입한 후의 흐름을 보자. 락을 획득한 후, 트랜잭션을 시작하며 트랜잭션이 종료되어 변경이 모두 반영된 후에 락을 반납한다.

이를 통해 기존의 문제였던 REPEATABLE READ와 @Transactional 문제를 해결한다.

2. 트랜잭션 전파

AopForTransactional 클래스를 보면 전파 수준이 REQUIRES_NEW로 지정한 것을 볼 수 있다.

이를 알아보기 위해 한가지 상황을 생각해보자.

어떠한 로직 내부에서 joinGroup() 메소드를 호출해야 한다.

@Transactional
public void 어떠한_메소드() {
	...
    joinGroup(...);
}

만약 전파 수준이 디폴트인 REQUIRED로 되어있다면, joinGroup() 메소드의 트랜잭션은 어떠한_메소드 의 트랜잭션에 참여하게 될 것이다.


AopForTransaction이라는 별도의 클래스를 뒀음에도 동일한 문제가 발생한다. 기존 트랜잭션에 참여하기에 트랜잭션 시작 시점락 해제 시점 모두 뒤틀리게 된다.

REQUIRES_NEW 전파 수준을 사용함으로써 joinGroup() 메소드를 독립적인 트랜잭션으로 동작함을 지정함으로써 다시 발생한 동시성 문제를 해결한다.

무엇을 선택해야 할까

1. MySQL의 USER-LEVEL-LOCK

USER-LEVEL-LOCK을 사용했을 때 느꼈던 가장 큰 장점은

별도의 무언가가 추가 될 필요가 없다.

Redis를 사용하기 위해서 했던 작업들은 다음과 같다.

  1. Redis 설치
  2. Redis client 구현체 확인
  3. 스프링 적용 방법 확인, 구현

하지만, USER-LEVEL-LOCK을 사용할 땐 락 획득을 위한 쿼리만 작성해주면 되기에 훨씬 비교적 간단했다.

다만, DB가 락을 관리한다는 것은 그만큼 많은 작업을 DB에 위임한다는 것이며, 그만큼의 부하가 추가된다는 단점이 존재한다.

2. Redis

Redis는 많은 곳에서 사용하고 있는 만큼 자료가 많았다. 락의 관리를 별도의 Redis 서버에서 관리하기에 DB 부하가 그만큼 감소한다는 장점 또한 있다.

다만, Redis 또한 단점이 존재하는데, 락을 위해 Redis를 도입함으로써 Redis단일 장애지점이 될 수 있다는 것이다. 이에 Redis측은 클러스터링과 Red Lock 알고리즘을 사용하여 문제를 해결한다.
채널톡 개발 볼로그에 의하면 성능 저하가 극심하기에 가용성이 중요한 상황이라면, Redis Cluster보다는 Zookeeper Cluster를 활용하는 것이 더 올바른 방향이고, Zookeeper는 많이 무겁다고 한다.

또한, Redis라는 관리 대상이 하나 추가되기에 인프라 관리에 보다 많은 관심을 요하게 된다.

3. trade-off

모두 구현해보며 현재 상황에서 가장 좋다고 느낀 것은 USER-LEVEL-LOCK이다.

단순히 Locking을 위해 Redis를 도입하는 건 굉장한 낭비라고 생각되었다. 도입하기 위해 투자할 시간, 도입한 후 관리에 투자할 시간을 모두 합한다면 가성비가 좋지 않다고 느껴졌다.

다만, USER-LEVEL-LOCK은 결국 DB가 관리하기에 데이터베이스에 부하가 추가되기에 Locking 작업이 많아질 경우 성능 저하에 대해서도 고려해야 할 것이다.

Redis를 이미 사용중이거나 다른 문제를 해결하기 위해 도입을 생각하는 중이라면 충분히 도입할 만 하다고 생각한다.

참고 자료

Kurly Tech blog - 풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson
레디스란 무엇인가? - 특징, 장단점, 사용 사례
redis docs - distributed lock
채널톡 개발 블로그 - Distributed Lock 구현 과정

profile
아는 척 하기 좋아하는 콩

0개의 댓글