Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (1/3) - 템플릿 콜백 패턴

들어가며

우선 이 글은 1.템플릿 콜백 패턴으로 구현, 2. AOP 로 구현, 3. Spring Redission 의 한계와 극복 총 3편으로 이루어져 있습니다. 여기서 사용한 예제코드에는 특정 문제가 있습니다. 해당 문제에 대한 이야기는 3편에서 할 예정입니다.

그리고 Spring Redisson 이 뭔지, 왜 쓰는지, 분산락이 뭔지 등등 에 대해서는 글이 너무 길어져 설명하지 않았습니다. 궁금하신분은 인프런의 "재고시스템으로 알아보는 동시성 이슈 해결방법" 강의 를 보시거나 해당 강의의 잘 정리된 블로그글을 첨부하니 참고해주세요.

사건의 시작

사내 그룹웨어에서 칸반 기능을 개발 하던 도중 사용 하시던 동료 두 분이 버그를 제보해주셨다.

"저희 둘이 쓰는 칸반이 있는데 갑자기 순서가 이상해졌어요. 아마 둘이 동시에 같이 특정 Data 를 건드렸을때 부터 였던거 같은데 확인 좀 부탁드려요."

 

확인해보니 Data 들의 순서값들이 꼬여있었고 나는 해당 문제를 고치기 위해 파고들기 시작했다.

칸반시스템에서 대부분의 칸반 Data 들은 순서를 바꿀수 있는 기능이 있었다. Data 들의 순서에 관련된 기능은 대부분 로직이 비슷했기에 해당 순서를 바꾸는 부분을 다른 객체에게 위임해서 처리하게끔 코드를 짜놨었다. (처음엔 템플릿 메서드 패턴으로 AbstractMovableService 였었는데 추상클래스의 한계점 때문에 바꿨다. 해당 이야기는 기회가 된다면 다른 포스팅에서 해보겠다.)

당시 코드는 이런 느낌의 코드였다.

public interface Movable {
    Integer getPosition();
    void setPosition(final Integer position);
    Long getContextSeq();
    void setContext(final Long newContextSeq);

    default void moveContext(final Long newContextSeq, final Integer newPosition) {
        this.setContext(newContextSeq);
        this.setPosition(newPosition);
    }
}
public class MovableServiceImpl<ENTITY extends Movable> implements MovableService<ENTITY> {
    private final String entityClassName;
    private final MovableRepository<ENTITY> movableRepository;
    private final MovablePositionCache movablePositionCache;
    
    @Override
    public int getNextPosition(final ENTITY entity) {
    	...
    }
    ...
}
@Repository
public interface MovableRepository<T extends Movable> {

    @Query("SELECT coalesce(max(e.position), -1) + 1 FROM #{#entityName} e where e.contextSeq = :contextSeq")
    int findNextPosition(@Param("contextSeq") Long contextSeq);
    
    ...
}
@Service
@RequiredArgsConstructor
public class TaskCardServiceImpl implements TaskCardService {
    private final MovableService<TaskCard> movableService;
    private final TaskCardRepository taskCardRepository;
    ...
}

순서에 관련된 기능이 있는 Entity 클래스들은 Movable 이라는 Interface 를 구현하게끔 했고 Repository 들도 MovableRepository 를 상속받게끔 만들었다. 사용하는 Service 들은 MovableService 를 주입받아 사용했다.

 

회사 그룹웨어의 칸반에서 Context move.gif

문제가 됐던 부분은 DB 에서 순서에 관련된 값을 다룰때였다. 예를 들면 상단의 gif 처럼 이동이 일어날때 data 의 position 값과 부모 context 의 값이 변경되는데 그때 다른 사용자가 동시에 해당 부모 context에 있는 data 의 순서값을 변경을 한다면 데이터 정합성이 무너지게 되어 순서가 꼬이는 문제가 있었다.

 

이것을 방지하기 위해 해당 부모 context 에 대해 lock 을 걸고 처리하는 방법을 생각했었는데 RDB에 직접 Lock 걸기보단 어플리케이션 단에서 처리하고 싶었다. 낙관적 락 방식과 Redis 를 이용한 분산 락 방식 두가지가 생각 났었는데 RDB 에 컬럼을 추가하기 쉽지 않은 상황이었고 이미 Redis 는 구축이 되어있어서 Redis 를 이용한 Lock 인 Redisson Lock 을 채택했다. 

 

템플릿 콜백 패턴을 이용한 Redisson Lock 구현

RedisLockService 라는것을 만들어 Callback 함수를 넘기게끔 템플릿 콜백 패턴으로 구조를 만들어 사용했다.

public class MovableServiceImpl<ENTITY extends Movable> implements MovableService<ENTITY> {
	...
    @Override
    public int getNextPosition(final ENTITY entity) {
		...
        return redisLockService.callWithLock(
                    this.generateLockKeyBy(entity.getContextSeq()),
                    () -> ...
                )
    }
    ...
}
public interface RedisLockService {

    <T> T callWithLock(String lockKey, Supplier<T> supplier, RedisLockTime lockTime) throws DistributedLockException;

    default <T> T callWithLock(String lockKey, Supplier<T> supplier) throws DistributedLockException {
        return this.callWithLock(lockKey, supplier, RedisLockTime.createDefault());
    }
    ...
}
@Component
@Slf4j
public class RedisLockServiceImpl implements RedisLockService {

    private static final String REDIS_LOCK_PREFIX = "REDISSON_LOCK:";
    private final RedissonClient redissonClient;
    private final ApplicationEventPublisher applicationEventPublisher;

    @Override
    @Transactional
    public <T> T callWithLock(final String lockKey, final Supplier<T> supplier, final RedisLockTime lockTime) {
        String key = this.generateKey(lockKey);
        final RLock lock = redissonClient.getLock(key);
        return this.execute(supplier, lockTime, key, lock);
    }
    
    private <T> T execute(final Supplier<T> supplier, final RedisLockTime lockTime, final String key, final RLock lock) {
        try {
            log.debug("{} - lock 획득 시도", key);
            if (lock.tryLock(lockTime.getWaitTime(), lockTime.getLeaseTime(), lockTime.getTimeUnit())) {
                log.debug("{} - lock 획득 성공", key);
                return supplier.get();
            }
            throw new InterruptedException();
        } catch (InterruptedException e) {
            log.error("{} - lock 획득 실패", key);
            throw new DistributedLockException("요청이 너무 많아 처리에 실패했습니다. 재시도 해주세요.", e);
        } finally {
            applicationEventPublisher.publishEvent(new RedisLockEvent(key, lock));
        }
    }
    ...
    
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void subscribeUnlock(final RedisLockEvent lockEvent) {
        try {
            lockEvent.unlock();
            log.debug("{} - lock 해제 성공", lockEvent.key());
        } catch (IllegalMonitorStateException e) {
            log.warn("{} - 이미 해제된 lock 입니다.", lockEvent.key());
        }
    }

    private record RedisLockEvent(String key, RLock lock) {
        public void unlock() {
            this.lock.unlock();
        }
    }
}

Redisson Lock 은 jdk 1.5 버전에 나온 java.util.concurrent 패키지의 Lock interface 를 구현하고 있다. 해당 링크의 문서를 보면 lock 을 사용할때는 try-catch-finally 구문을 이용해서 finally 구문에서 unlock 을 하는것을 권장한다. 하지만 실제로 트랜잭션 안에서는 DB에 반영이 되기 전에 Lock 을 해제하면 데이터 정합성에 문제가 생길 수 있다. 칸반 시스템으로 예를 들어보자면 이렇다.

칸반 시스템 위치이동 동시요청 data flow

DB 에서 사용하고있는 격리수준에 따라 조금 다른 다이어그램이 나올수 있다. Repeatable Read 격리수준에서 문제가 되며 commit 이 더 늦게 일어난다면 그룹웨어에서 사용하고 있는 PostgreSQL 의 Default 격리수준인 Read commited 에서도 이런 현상이 발생 할 수 있다.

한번 값이 꼬인 뒤로는 Data 에 문제가 생겨 그 이후 이동은 원래 원하던 지점이 아닌 이상한 곳으로 이동하게 된다.

그래서 Transaction 이 끝난 후 Lock 을 해제 해주기 위해 Spring-tx에서 제공해주는 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)를 사용하고 메서드 레벨에 @Transactional 을 달아줘 부모 트랜잭션이 있으면 참가, 없으면 시작하도록 설정해줬다. 그리고 나서 사용해보니 문제없이 잘 작동하는것을 확인 할 수 있었다.

2023-07-22T23:11:51.260+09:00 DEBUG 21476 --- [ool-2-thread-1] com.min.redisson.RedisLockServiceImpl    : [REDISSON_LOCK:test:47] - lock 획득 시도
2023-07-22T23:11:51.261+09:00 DEBUG 21476 --- [ool-2-thread-2] com.min.redisson.RedisLockServiceImpl    : [REDISSON_LOCK:test:47] - lock 획득 시도
2023-07-22T23:11:51.265+09:00 DEBUG 21476 --- [ool-2-thread-1] com.min.redisson.RedisLockServiceImpl    : [REDISSON_LOCK:test:47] - lock 획득 성공
Hibernate: select 문..
Hibernate: update 문..
2023-07-22T23:11:52.651+09:00 DEBUG 21476 --- [ool-2-thread-1] com.min.redisson.RedisLockServiceImpl    : [REDISSON_LOCK:test:47] - lock 해제 성공
2023-07-22T23:11:52.652+09:00 DEBUG 21476 --- [ool-2-thread-2] com.min.redisson.RedisLockServiceImpl    : [REDISSON_LOCK:test:47] - lock 획득 성공
Hibernate: select 문..
Hibernate: update 문..
2023-07-22T23:11:52.667+09:00 DEBUG 21476 --- [ool-2-thread-2] com.min.redisson.RedisLockServiceImpl    : [REDISSON_LOCK:test:47] - lock 해제 성공
2023-07-22T23:11:52.667+09:00 DEBUG 21476 --- [ool-2-thread-3] com.min.redisson.RedisLockServiceImpl    : [REDISSON_LOCK:test:47] - lock 획득 시도
2023-07-22T23:11:52.668+09:00 DEBUG 21476 --- [ool-2-thread-3] com.min.redisson.RedisLockServiceImpl    : [REDISSON_LOCK:test:47] - lock 획득 성공
Hibernate: select 문..
Hibernate: update 문..
2023-07-22T23:11:52.681+09:00 DEBUG 21476 --- [ool-2-thread-3] com.min.redisson.RedisLockServiceImpl    : [REDISSON_LOCK:test:47] - lock 해제 성공

 

마무리

해당 부분을 구현하며 템플릿 콜백패턴 해당 부분을 AOP 로 cross-cutting concern 할 수 있겠다고 생각이 들었다. 그리고 실제로 그렇게 구현해볼 기회가 바로 찾아왔다. 해당 부분은 Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (2/3) - AOP 에서 다뤄보겠다.

 

Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (2/3) - AOP

들어가며 이 글은 1.템플릿 콜백 패턴으로 구현, 2. AOP 로 구현, 3. Spring Redission 의 한계와 극복 총 3편으로 이루어져 있습니다. 이번 글은 1.템플릿 콜백 패턴으로 구현 에서 참고하는 부분이 있으

jongmin4943.tistory.com