Notice
Recent Posts
Recent Comments
Link
«   2024/06   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30
Archives
Today
Total
관리 메뉴

사부작사부작

분산락을 이용한 동시성 이슈 해결 본문

프로젝트

분산락을 이용한 동시성 이슈 해결

민철킴 2023. 5. 14. 14:00

 분산락이 필요한 상황 설명

유저들이 검색한 검색어를 Redis의 Sorted Set에 저장하고, 그걸 바탕으로 인기 검색어를 구현했다. 검색될 때마다 Redis에 저장시켰는데, 통신부하를 줄여보기 위해서 검색어를 서버에 리스트 안에 모아서 100개가 되면 Redis에 보내게 바꿨다.

지금 내 토이프로젝트 상황으로는 검색어 100개를 모으려면, 100일은 더 걸릴 것이다. (사용하는 유저가 없다..ㅠ) 그렇기에 100개가 안 되더라도 Redis에 저장시키기 위해서, 스케줄러로 하루에 2번씩 모아진 검색어를 저장시키게 했다.

하지만 문제가 발생했다! 왜냐하면 난 2대의 서버를 운영중이기 때문이다. 2대의 서버가 동시에 레디스로 데이터를 보내는데 여기서 동시성 이슈가 터졌다. 검색한 검색어 일부가 Redis에 저장이 안 된걸 확인했다.

분산락이란

분산 환경에서 동시성 이슈를 처리하기 위해 등장한 방법인 분산 락은 여러 서버 또는 프로세스에서 공유 리소스(여기선 Redis)에 대한 액세스를 조정하는 기술이다. 내 경우에서도 분산락을 적용하면 해결이 가능해보였다. Redis를 이용해 분산락을 적용했다.

서버들은 락을 획득하기 위해서, Redis에 특정 키 생성을 시도한다. 키가 존재하지 않는다면 지정된 시간동안 락을 획득하고 공유 리소스에 대한 접근 권한을 얻는다. 만약 키가 존재한다면 락을 획득하려는 시도는 실패한다. 공유 리소스에 대한 작업이 끝난다면 Redis에서 해당 키를 삭제해서 락을 해제한다. 그러면 대기하던 서버가 락을 획득하게 되는 것이다.

그렇다면 서버는 락이 해제되었는지 어떻게 알게 될까? 하나는 서버가 Redis에 주기적으로 확인하는 방식이고, 다른 하나는 pub/sub 방식으로 락이 해제되면 메세지를 받는 방식이다. 첫 번째 방식은 해제를 확인하기 위해서 많은 트래픽이 발생될 여지가 있기에 두 번째 방식으로 구현했다.

구현

두 번째 방식은 Redisson 라이브러리를 사용해 구현이 가능하다.기존에도 Redis를 사용중이기에 의존성만 추가해주면 사용이 가능하다. Redis를 사용하고 있지 않았다면, port나 호스트 주소 등을 설정해서 빈으로 등록시켜줘야 한다.

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

LockService

@Service
public class LockRankingService {
    private static final String LOCK_KEY ="RANKING_LOCK";
    private static final int WAIT_TIME = 10;
    private static final int LEASE_TIME = 5;

    private final RedissonClient redissonClient;
    private final RankingService rankingService;

    public LockRankingService(RedissonClient redissonClient, RankingService rankingService) {
        this.redissonClient = redissonClient;
        this.rankingService = rankingService;
    }

    public void record() {
        RLock lock = redissonClient.getLock(LOCK_KEY);

        try {
            boolean available = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS);

            if (available) {
                rankingService.searchKeywordToRedis();
            }

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            if (lock.isHeldByCurrentThread() && lock.isLocked()) {
                lock.unlock();
            }
        }
    }
}

Lock을 위해서 별도의 서비스를 만들고 RedissonClient를 주입받는다.

“Ranking_Lock”이라는 키로 락을 생성해서 락을 얻는다. 락을 얻는 것에 성공한다면 Redis에 검색어를 저장시키는 searchKeywordToRedis() 메서드가 실행된다. 마지막으로 unlock()을 통해 락을 해제한다.

여기서 waitTime은 락 획득을 하기위해 대기할 최대 시간을 나타내고, leaseTime은 락의 유효 시간을 나타나며 leaseTime이 지나면 락은 자동 해제된다.

@Service
@RequiredArgsConstructor
public class SchedulerRankingService {

    private final LockRankingService lockRankingService;

    @Scheduled(cron = "0 0 11,23 * * *")
    public void scheduleSearchKeywordToRedis() {
        lockRankingService.record();
    }
}

구현한 분산락을 적용해서 데이터가 누락되는 이슈를 해결할 수 있었다.

내 토이프로젝트에서는 책을 등록하는 메서드가 존재한다. 평점 등의 정보도 같이 입력해서 유저가 읽은 책을 등록할 수 있게 해준 기능이다. 만약 같은 책에 대해서 여러 요청이 동시에 들어온다면, 데이터의 정합성이 깨질 우려가 있다. 그러면 여기에도 분산락을 적용해보려 한다.

LockRankingService와 같은 로직을 매번 만들기는 번거롭다.락을 걸어주는 로직은 비지니스 로직과는 관련없는 코드다. 그렇기에 AOP를 적용해 락에 관한 로직을 분리시키려한다.

분산락을 적용할 메타 어노테이션

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface DistributedLock {
    String key();
    long waitTime() default 10L;
    long leaseTime() default 5L;
    TimeUnit timeUnit() default TimeUnit.SECONDS;
}

메타 어노테이션이 수행되는 AOP 클래스

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

    private final RedissonClient redissonClient;
    private final Transaction4Aop transaction4Aop;

    @Around("@annotation(com.project.book.common.config.aop.DistributedLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock annotation = method.getAnnotation(DistributedLock.class);

        String key = annotation.key();
        RLock lock = redissonClient.getLock(key);

        try {
            boolean available = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), annotation.timeUnit());

            if (!available) {
                return false;
            }

            return transaction4Aop.proceed(joinPoint);
        } catch (InterruptedException | IOException e) {
log.error("DistributedLock error : " + e.getMessage());
            throw new RuntimeException();
        } finally {
            if (lock.isHeldByCurrentThread() && lock.isLocked()) {
                lock.unlock();
            }
        }
    }
}

메타 어노테이션에 설정한 key를 바탕으로 락을 획득하는 로직을 구현했다. 위의 LockService 로직과 같다.

@Component
public class Transaction4Aop {

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

AOP를 적용할 메서드의 트랜잭션 처리를 위해서 별도의 클래스를 만들었다. 만약 1번 락의 트랙잭션이 커밋되지 않은채로 락이 해제되고, 다음 락이 작업에 들어간다면 데이터의 정합성을 보장할 수 없다.

이를 방지하기 위해서 획득한 락 안에서 새로 트랜잭션을 생성해준다. proceed() 메서드가 완료되고, 즉 트랜잭션이 커밋되고 finally 블록에서 락이 해제되기에 데이터 정합성을 지킬 수 있게 된다.

@Service
@RequiredArgsConstructor
public class SchedulerRankingService {

    private final RankingService rankingService;

    @Scheduled(cron = "0 0 11,23 * * *")
    @DistributedLock(key = "ranking")
    public void scheduleSearchKeywordToRedis() {
        rankingService.searchKeywordToRedis();
    }
}

AOP를 적용해 변경한 로직이다. watiTime,leaseTime은 원하는 값을 @DistributedLock에 파라미터로 넣어주면 된다. 여기선 설정해둔 디폴트 값으로 실행된다.