깃허브 맞팔 탐지기 [03] - Redisson 분산락 도입

2025. 8. 24. 03:50·토이 프로젝트/깃허브 맞팔 탐지기

<서론> 

https://backend-newbie.tistory.com/45 , 이전 포스팅에서 문제 상황을 MySQL 네임드 락으로 해결하려다가 Redis 기반 분산락을 사용하는게 더 좋은 선택지가 될 것 같아서 변경하게 되었다.

 

 

 

 

<레디스로 분산락 구현>

SET lockKey lockValue NX PX expire

 

 

 Redis로 분산락을 구현하기 위해서는 위와 같은 명령어를 사용하면 된다. Redis의 SET 명령어에는 NX/XX 라는 옵션이 존재한다. NX의 경우 값이 존재하지 않으면 세팅해 달라는 의미이다. XX의 경우 키가 존재하는 경우에만 저장하라는 의미이다.

 

 EX/PX 라는 옵션도 존재하는데 EX의 경우 초단위로 TTL을 지정할수 있고, PX의 경우 밀리 초 단위로 TTL을 지정할 수 있다. 즉, 위의 명령어는 lock이 존재하지 않는다면 내가 원하는 TTL ms 동안 해당 락을 저장해 달라는 의미가 된다.  

 

 즉, NX 옵션을 통해서 락이 이미 존재하는지 아닌지 확인할 수 있다. 만약 NX 옵션으로 SET에 실패하면 락 획득에 실패 한 것이고 PX 옵션을 통해 혹시라도 락이 영구적으로 반납되지 않는 상황을 방지할 수 있다.

 

 

DEL lockkey

 

 락 반납은 저장된 Key를 삭제해 버리면 간단하게 진행할 수 있다. 문제는 위와 같이 단순하게 락을 해제할 수 있으면 해당 락을 가지고 있지 않아도 락을 반납할 수 있게 된다.

 

 이를 방지 하기 위해서 보통 lockValue를 이용한다. lockValue에 UUID 같은것들을 넣어두고 락 반납시 레디스에 존재하는 lockKey의 밸류와 클라이언트가 들고온 lockValue가 일치하면 락을 반납할 수 있게 한다. lockValue의 경우 lockKey : lockValue 저장에 성공한 클라이언트만 알 수 있다.       

 

 

if redis.call("get", KEYS[1]) == ARGV[1] then
   return redis.call("del", KEYS[1])
else
   return 0
end

 

 그래서 보통 Redis의 Lua 스크립트를 이용해 위와 같이 락을 반납한다. Lua는 Redis에서 채택하고 있는 내장 스크립트 언어이다. 이를 이용해서 Redis의 기본 명령어만으로 처리하기 힘든 복잡한 작업을 처리할 수 있다. 

 

 특정 문자열에 대한 락을 획득한다는 점에서 이전 포스팅에서 살펴본 MySQL 네임드락과 굉장히 유사해 보이는데 MySQL 네임드 락과 다르게 락 자동 반납조건이 세션종료가 아니라 TTL 이라는 점에서 차이가 있는 것 같다.

 

 

 

<Redis 클라이언트에 따른 분산락 구현 방식 비교>

 

<Spring Data Redis 공식문서>

 

 Redis를 스프링 애플리케이션에서 손쉽게 사용하기 위해 Spring Data Redis의 공식 문서를 참고했다. 스프링 서버에서 Redis와 커넥션을 맺고 명령을 보내려면 Redis 클라이언트가 필요하다. 여기에 Lettuce, Jedis, Redisson의 세 가지 클라이언트가 주로 사용된다.  

 

 여기서 Jedis의 경우 굉장히 가벼운 클라이언트로 분산락에 대한 언급이 많지 않은거 같아 선택지에서 제외했고 Lettuce와 Redisson에 분산락에 대한 레퍼런스가 많이 존재했다.

 

 Spring Data Redis 공식문서 에서는 Lettuce와 Jedis에 대한 가이드만 존재했다. Redission 라이브러리의 경우 직접 추가해서 사용해야 하는 것 같다.

 

 

 

 

 

 

@Component
@RequiredArgsConstructor
public class RedisLockManager {

    private final RedisTemplate<String, String> redisTemplate;

    public String tryLock(String key, Duration ttl) {
       String lockValue = UUID.randomUUID().toString();
       Boolean success = redisTemplate.opsForValue()
          .setIfAbsent(key, lockValue, ttl);

       return Boolean.TRUE.equals(success) ? lockValue : null;
    }

    public boolean unlock(String key, String lockValue) {
       String luaScript =
          "if redis.call('get', KEYS[1]) == ARGV[1] then " +
             "   return redis.call('del', KEYS[1]) " +
             "else " +
             "   return 0 " +
             "end";

       DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
       redisScript.setScriptText(luaScript);
       redisScript.setResultType(Long.class);

       Long result = redisTemplate.execute(redisScript,
          Collections.singletonList(key),
          lockValue);

       return result != null && result > 0;
    }
}

 

 RedisConnection, RedisTemplate등은 뒤에서 다루겠지만 일단은 Lettuce를 사용하는 상황을 가정해 봤다. 이전에 봤던 SET NX PX는 setIfAbsent()를 이용해서 Redis에 전달할 수 있다.

 

 

 

String lockValue = redisLockManager.tryLock("follow:" + userId, Duration.ofMinutes(60));
log.info("락 경합 결과 획득한 lockValue : {}", lockValue);


if(lockValue != null) {
   // 데이터 수집 후 락 해제 (5초걸린다고 가정) 
}

 

 내 서비스에서는 위와 같이 락을 획득하고 락이 있다면 깃허브 API를 이용해 데이터를 수집한다. 중요한 건 락이 없다면 재시도를 하는게 아니라 바로 실패하고 202 응답을 클라이언트에게 전달한다.

 

 애초에 목적이 사용자가 중복된 깃허브 데이터 수집 요청하는 것을 막기위해서 였으니 락을 획득하기 위해서 재시도 할 필요가 없다. 

 

 

 

<실행 결과>

 

 실행 결과 락이 제대로 동작 하는 것을 볼 수 있었다. 이대로 Lettuce를 이용해서 분산락을 써도 되지만 우리 서비스에는 팔로우 쪽 말고도 다양한 곳에서 분산락이 필요하다. 

 

 팔로우 탐지기 쪽은 특별한 케이스로 락 획득 실패시 재시도를 할 필요가 없었지만 보통의 경우 락을 획득하기 위해서 재시도를 진행해야 한다. 다른 팀원들도 이를 사용할 수 있게 재시도를 적용한 로직을 만들었을 때 어떤 일이 생기는지 알아봤다.

 

 

 

 

 

String lockValue = null;

while (lockValue == null) {
    lockValue = redisLockManager.tryLock("follow:" + userId, Duration.ofMinutes(60));

    if (lockValue == null) {
       ThreadUtils.sleep(1000);
       log.info("락 획득 실패 1초 대기");
    }
}

// 데이터 수집 후 락 해제

 

 위와 같이 락이 해제 되었는지 계속 Redis 서버에 폴링 방식으로 확인을 진행했다.

 

 

 

<실행 결과>

 

 실행 결과 락을 잘 획득은 하지만 폴링 방식으로 Redis 서버에 계속 요청을 날려야 한다는 단점이 생겼다. 보통 Lettuce를 이용해서 분산락을 쓰지 않는 이유가 락 획득 재시도 방식 때문에 성능 저하가 우려되어서 그런것 같다.

 

 

 

 

 

 

 이번에는 Lettuce 대신 Redission을 이용해서 같은 과정을 진행해 보았다. Lettuce를 Redission으로 바꾸기만 한다고 마법처럼 while을 사용한 폴링 방식이 없어지는 것은 아니다. Redission 으로 전환한 다음 그냥 이전에 작성해둔 코드를 그대로 사용해도 똑같이 작동한다.

 

 

<Redisson RLock>

 

 Redisson은 Lettuce와 다르게 별도의 Lock 인터페이스를 제공한다. 

 

 

 

RLock lock = redissonClient.getLock("follow:" + userId);

boolean acquired = false;

try {
// 락 시도: 최대 60초 기다리고, 락 잡으면 60초간 점유
acquired = lock.tryLock(60, 60, TimeUnit.SECONDS);

if (!acquired) {
    log.info("락 획득 실패 (userId={})", userId);
    return;
}

    
// 락 획득 시 데이터수집 후 락 해제 
    
}

 

 해당 인터페이스와 RedissonClient를 이용해서 위와 같이 사용할 수 있다. 

 

 

<실행 결과>

 

 실행 결과 락도 정상적으로 작동하고 while 방식과 다르게 폴링 없이 작동하는 것을 볼 수 있었다. Redisson이 제공하는 락은 Redis의 Pub/Sub 방식을 이용해 구현 되어 있기 때문에 좀 더 효율적으로 동작한다.

 

 Pub/Sub 방식은 Publisher(발행자)와 Subscriber(구독자)가 존재하고, 발행자가 채널에 메시지를 발행하면 해당 채널을 구독하고 있는 구독자들은 그 메시지를 실시간으로 수신할수 있다.

 

 Redisson의 락은 이러한 Pub/Sub을 내부적으로 활용해 동작한다. 클라이언트가 락을 요청했을 때 이미 다른 클라이언트가 점유 중이라면 폴링 방식으로 락획득 재시도를 하지 않고 대기 상태로 들어간다. 이후 락이 해제 되면 락 해제 메시지를 발행해 락 획득을 대기 중인 다른 클라이언트 들에게 알려주는 방식이다.

 

 즉, Lettuce와 다르게 Redisson을 이용하면 락을 획득하지 못한 상황에서 락을 재시도 하는 방법이 달라지고 더 효율적인 분산락구현이 가능해진다. 이런 이유를 통해서 Redisson을 사용하기로 결정했다. 물론 내 쪽 follow는 락 획득 재시도가 필요하지는 않지만, 모든 팀원들이 각자의 파트에 범용적으로 사용할 수 있게 Redisson과 Redisson이 제공하는 락을 사용하기로 했다. 

 

 

 

 

<AOP로 구현>

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedissonLock {

    String key();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    long waitTime() default 10L;

    long leaseTime() default 10L;
}

 

 우선 해당 어노테이션이 붙어있는 메서드에 분산락을 적용하기 위해서 위와 같이 작성했다.  

 

 

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

...


@Around("@annotation(com.moyoy.infra.database.redis.support.RedissonLock)")
	public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
		MethodSignature signature = (MethodSignature) joinPoint.getSignature();
		Method method = signature.getMethod();
		RedissonLock redissonLock = method.getAnnotation(RedissonLock.class);

		String key = parseKey(signature.getParameterNames(), joinPoint.getArgs(), redissonLock.key());
		RLock rLock = redissonClient.getLock(key);

		boolean acquired = rLock.tryLock(
			redissonLock.waitTime(),
			redissonLock.leaseTime(),
			redissonLock.timeUnit()
		);

		if (!acquired) {
			log.info("Redisson Lock 획득 실패 | Key: {}", key);
			return null;
		}

		Object result = null;

		/// TODO : 트랜잭션이 필요한 곳에서는 트랜잭션과 관련된 제어가 추가적으로 필요함, ex) 트랜잭션 커밋 후 락을 해제해야 하는 등의 상황
		try {
			result = joinPoint.proceed();
			return handleResultWithLock(rLock, key, result);
		}
		finally {

			if (!isAsyncResult(result)) {
				unlock(rLock, key);
			}
		}
	}
    
    ...
    
    }

 

 그 후, 위와 같이 AOP를 만들어 줬는데 트랜잭션이 커밋되지 않았는데 락이 먼저 풀리는 경우를 방지하기 위해서 추가적인 로직을 작성해줘야 한다고 하는데, 일단 내 파트에서는 트랜잭션을 사용하지 않고 있기도 하고 하나의 좋은 주제가 될 것 같아서 이는 다른 포스팅에서 다뤄볼 생각이다.

 

 

 

<데이터 수집 시나리오>

 

 

 만일 맞팔리스트 조회 요청시 데이터가 없으면 데이터 수집 작업만 제출하고 202 응답을 클라이언트에 보내야 하기 때문에 위의 그림과 같은 시나리오로 동작한다.

 

 

<각각의 Task에서 락을 얻는 경우>

 

 문제는 락을 얻어야만 스레드 풀의 작업 큐에 들어갈 수 있게 하는 부분에서 발생했다. 만약 락을 얻는 과정이 Task 내부에 존재한다면 사용자가 계속 GET요청을 보낼 때 마다 Task를 제출하게 되고 Task 내부에서 락 획득을 시도후 데이터 수집 작업을 진행하는데 이렇게 될 경우, 원래 의도와는 다르게 Task의 수 만큼 데이터 수집 작업이 발생할 수 있게 된다.

 

 

<동일한 유저에 대한 작업이 계속 밀려올때>

 

 동일한 유저에 대한 작업을 동시에 실행하는 경우는 위와 같이 한번만 실행 되도록 방어할 수 있지만 

 

 

<여러 회원들의 작업이 밀려옴>

 

 위와 같이 여러 회원의 작업이 함께 밀려오는 경우 1번 회원에 대한 데이터 갱신을 이미 처리했는데 2 3 4 5번 회원 갱신을 처리한 다음 다시 1번 회원의 작업을 실행할 차례가 되면 또 캐시 갱신을 위한 데이터 수집이 발생한다.

 

 

 

 

<처음 의도>

   

 처음 의도했던 건 어떤 회원의 데이터 수집작업을 제출하기 전에 follow:{userId}라는 이름의 락을 획득하고 락을 획득한 경우에만 작업을 제출하여 중복된 작업이 없게 만들고 캐시 갱신이 완료되면 follow:{userId}라는 이름의 락을 해제 하도록 만드는 것이었다.

 

 

 

<중복 데이터 수집 요청 방어>

 

 위와 같이 중복된 데이터 수집 요청을 방어하고 

 

 

 

 

<작업 완료 시>

 

 데이터 수집이 완료되면 캐시에 데이터를 저장하고 락을 해제하게 되고

 

 

 

 

<데이터 수집 완료 후 요청 처리>

 

 데이터 수집이 완료되면 위와 같이 캐시히트가 발생해 더이상 작업이 제출 되지 않는 구조를 원했다.

 

 

 

 

Object result = null;
try {
    result = joinPoint.proceed();
    return handleResultWithLock(rLock, key, result);
}
finally {

    if (!isAsyncResult(result)) {
       unlock(rLock, key);
    }
}



private Object handleResultWithLock(RLock rLock, String key, Object result) {

    if (result instanceof CompletableFuture<?>) {

        return ((CompletableFuture<?>) result).whenComplete((r, ex) -> {
            forceUnlock(rLock, key);
        });
    }

    return result;
}

  

 이를 위해서 일반작업의 경우 락을 획득했던 스레드가 자신의 동기 메서드 호출을 마친후 락을 해제하는 일반적인 시나리오와 HTTP 요청을 처리하던 스레드가 락을 획득 후, 먼저 종료하는 대신 데이터 수집 작업을 마친 다른 스레드풀의 스레드에서 작업을 마치고 해당 락을 반납할 수 있도록 처리했다.

 

 이를 어떻게 구현할까 고민이었는데 @Async 메서드의 반환 타입으로 CompletableFuture를 사용할 경우 제공되는 API를 활용하면 문제를 깔끔하게 해결할 수 있다는 점을 알게 되었다. CompletableFuture는 작업이 완료되는 시점에 실행할 콜백을 등록할 수 있는 메서드를 제공하기 때문에, 이를 통해 비동기 작업이 끝난 순간에 락을 해제하도록 구현할 수 있다.

 

 덕분에 락이 너무 일찍 풀려 중복 실행이 발생하거나, 반대로 락이 해제되지 않고 오래 유지되는 문제를 피할 수 있게 되었다. 대신 락을 획득한 스레드와 락을 해제하는 스레드가 다르기 때문에 RLock의 forceLock()을 사용해야 했다.

 

 

 

 

<실행 결과>

 

 이제 맞팔 탐지기 조회 메서드를 아무리 호출해도 단 1번의 캐시 갱신이 발생하고 캐시가 갱신되면 정상적으로 데이터 응답이 되도록 할 수 있었다.

 

 

 

 

<동시요청이 한번에 너무 많을 때 생길 수 있는 문제점과 해결방안>

<실행 결과>

 

 테스트 코드를 작성하기 전에 그냥 손으로 같은 해당 API를 광클해봤다. 그 결과, 가장 처음에 요청한 톰캣스레드1이 락을 잡고 데이터를 수집하는 것을 볼 수 있었다.

 

 

<실행 결과>

 

 그런데 11번째 같은 요청을 날리자 첫 번째 요청에서 락을 획득하고 작업을 제출한 톰캣 스레드 1이 다시 돌아와서 11 번째 요청을 처리했다. 이 과정에서 아직 해제되지 않은 락이 획득되어 버리는 문제가 발생했다.

 

 

 

 

<톰캣 스레드풀 초기화 과정>

 

 나는 별도로 application.yml 파일에 톰캣 스레드에 대한 커스터마이징을 하지 않은 상태였고 default 설정인 스레드 풀에 10개의 스레드가 유지되고 요청이 너무 많으면 최대 스레드를 200개까지 늘리고, 늘어난 스레드의 수명은 60000ms 인 설정이 걸려 있었다.

 

 

<문제 상황>

 

 즉, 1번 톰캣 스레드가 userId=1 인 Redisson Lock을 잡고 작업을 제출했고 해당 작업을 수행하는 스레드가 작업을 완료후 해제를 해줘야 하는 상황이라서 2~10번 스레드는 절대로 userId=1인 락을 획득할 수가 없다.

 

 

 

 

<문제 상황>

 

 그런데 우연히 아직 userId=1 인 락이 반납되지 않았는데 같은 1번 스레드에서 userId=1 인 락을 획득하는 상황이 발생할 경우, Redisson이 제공하는 락은 같은 스레드에서 재진입을 허용한다. 그래서 이런 경우를 차단해 줘야 했다.

 

 

 

RLock rLock = redissonClient.getLock(key);

boolean acquired = false;

if (rLock.isHeldByCurrentThread()) {
    log.warn("Redisson Lock 재진입 차단 | Key: {}", key);
} else {
    acquired = rLock.tryLock(
       redissonLock.waitTime(),
       redissonLock.leaseTime(),
       redissonLock.timeUnit()
    );
}

 

 다행이도 Redisson의 RLock 인터페이스에는 이런 요구사항을 해결할수 있는 api가 존재했다.

 

 

 

<실제 락 value>

 

 실제로 Redisson 락을 잡게 되면 위와 같이 해시형태의 value가 존재하는데 이 값중에 락 재진입 카운트가 기록되어 있다.

 

 

 

if (rLock.isHeldByCurrentThread()) {
    log.warn("Redisson Lock 재진입 차단 | Key: {}", key);
}

 

 해당 부분에서 락 밸류를 이용해서 재진입을 허용하지 않게 만들었다.

 

 

 

 

<실행 결과>

 

 드디어 원했던 결과를 얻을 수 있었다.

 

 

 

      

 

 

 

 

 

https://docs.spring.io/spring-data/redis/reference/

 

Spring Data Redis :: Spring Data Redis

Costin Leau, Jennifer Hickey, Christoph Strobl, Thomas Darimont, Mark Paluch, Jay Bryant Copies of this document may be made for your own use and for distribution to others, provided that you do not charge any fee for such copies and further provided that

docs.spring.io

https://helloworld.kurly.com/blog/distributed-redisson-lock/

 

풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson

어노테이션 기반으로 분산락을 사용하는 방법에 대해 소개합니다.

helloworld.kurly.com

 

'토이 프로젝트 > 깃허브 맞팔 탐지기' 카테고리의 다른 글

깃허브 맞팔 탐지기 [05] - Redis 글로벌 캐시 도입  (0) 2025.08.28
깃허브 맞팔 탐지기 [04] - 저장할 데이터 분석 후 캐시 저장소 선정  (0) 2025.08.27
깃허브 맞팔 탐지기 [02] - MySQL 네임드 락 도입 고민  (0) 2025.08.23
깃허브 맞팔 탐지기 [01] - 문제상황 분석 & API 설계  (0) 2025.08.22
'토이 프로젝트/깃허브 맞팔 탐지기' 카테고리의 다른 글
  • 깃허브 맞팔 탐지기 [05] - Redis 글로벌 캐시 도입
  • 깃허브 맞팔 탐지기 [04] - 저장할 데이터 분석 후 캐시 저장소 선정
  • 깃허브 맞팔 탐지기 [02] - MySQL 네임드 락 도입 고민
  • 깃허브 맞팔 탐지기 [01] - 문제상황 분석 & API 설계
moyoy
moyoy
  • moyoy
    백엔드 공부 내용 정리
    moyoy
  • 전체
    오늘
    어제
    • 분류 전체보기 (9)
      • 궁금한거 (0)
      • 회고 (0)
      • 토이 프로젝트 (9)
        • 깃허브 연동 로그인 (3)
        • 깃허브 맞팔 탐지기 (5)
        • 깃허브 기반 랭킹 시스템 (0)
        • 공통 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
moyoy
깃허브 맞팔 탐지기 [03] - Redisson 분산락 도입
상단으로

티스토리툴바