상세 컨텐츠

본문 제목

분산락을 이용한 메시지 좋아요 기능 동시성 문제 해결 방안

공부/Spring

by seungpang 2024. 5. 16. 19:15

본문

반응형

이전 글에서 spring retry로 간단한 동시성을 해결했지만 완벽한 해결방법이 아니다.

단일 서버가 아닌 다중 서버 환경에서는 동일한 데이터에 동시에 접근할 때 발생하는 동시성 문제는 낙관적 락과 spring retry로 해결하기에는 무리다.

그래서 이번에는 분산락을 통한 동시성 해결을 확인해보자

왜 분산락이 필요한가?

  • 낙관적 락 + 재시도(Spring retry)의 한계
    • 이전 글에서 낙관적 락과 Spring retry를 통해 DB 레벨에서 충돌 시 예외를 던지고 자동으로 재시도하는 방식을 시도했다.
    • 단일 서버에서는 일정 수준 동시성 충돌을 방어할 수 있지만 서버가 2대 이상으로 스케일 아웃되거나 트래픽이 폭주할 경우
      DB 예외가 계속 발생 -> 각 서버에서 재시도 반복 -> DB/애플리케이션 모두 과부하가 걸릴 위험이 있다.
  • 분산 환경에서 원자성 보장
    • 여러 서버가 동일한 자원에 동시에 접근한다면 서버 간에 한 번에 오직 하나만이 자원을 변경할 수 있도록 보장하는 메커니즘이 필요하다.
    • 이를 위해 공유 Lock을 관리하는 분산락이 등장한다.

분산락이란?

서로 다른 프로세스나 서버들이 동시에 동일한 자원에 접근할 때 오직 하나의 프로세스만이 해당 자원을 변경할 수 있게 Lock을 거는 메커니즘이다.

단일 서버 내부에서는 synchronizedReentrantLock을 사용해 스레드 동기화를 처리할 수 있지만 여러 서버가 동시에 접근하는 환경에서는 네트워크상의 어떤 공통된 시스템을 통해 누가 Lock을 가지고 있는지를 판단해야 한다. 이게 분산락의 핵심이다.

Redis 기반의 분산락

Redis로 분산락을 구현하는 방법중에 Redisson 라이브러리를 사용하는 방법이 있다.

  • Redisson은 Redis를 활용한 다양한 분산 기능(분산 락, 세마포어, 카운트 다운 래치 등)을 간편하게 사용할 수 있게 해주는 오픈소스 라이브러리이다.
  • 락 획득/해제 로직을 단순히 메서드 호출만으로 구현할 수 있고, 다양한 Lock 모드를 지원한다.

적용해보기


    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.redisson:redisson-spring-boot-starter:3.35.0'

위와 같이 redis와 redisson에 대한 의존성을 먼저 추가해준다.

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@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() {
        RedissonClient redisson = null;
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        redisson = Redisson.create(config);
        return redisson;
    }
}
  • Redis 호스트/포트를 지정하고, RedissonClient를 Bean으로 등록한다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Message {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Member author;

    @Column(name = "likes", nullable = false)
    private Long likes;

    public Message(String content, Member author, Long likes) {
        this.content = content;
        this.author = author;
        this.likes = likes;
    }

    public void like() {
        this.likes = likes + 1;
    }
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MessageLike {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "message_like_id")
    private Long id;

    @Column(name = "member_id")
    private Long memberId;

    @Column(name = "message_id")
    private Long messageId;

    public MessageLike(Long memberId, Long messageId) {
        this.memberId = memberId;
        this.messageId = messageId;
    }
}

도메인 로직은 이전 글과 동일하다.

@Service
@RequiredArgsConstructor
public class MessageLikeService {

  private final MessageRepository messageRepository;
  private final MessageLikeRepository messageLikeRepository;
  private final RedissonClient redissonClient;

  @DistributedLock(key = "#messageId")
  public MessageLikeResponseDto likeMessage(Long memberId, Long messageId) {
    String lockKey = "LOCK:" + messageId;
    RLock lock = redissonClient.getLock(lockKey);

    try {
      // 2) 락 획득 시도
      boolean available = lock.tryLock(5, 3, TimeUnit.SECONDS);
      if (!available) {
        throw new RuntimeException("Lock 획득 실패: 다른 프로세스가 사용 중입니다.");
      }

      // 3) 실제 DB 로직
      final Message message = messageRepository.findById(messageId)
              .orElseThrow(IllegalArgumentException::new);

      message.like();
      messageLikeRepository.save(new MessageLike(memberId, messageId));
      return new MessageLikeResponseDto(message.getLikes(), true);

    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      throw new RuntimeException("Lock 대기 중 인터럽트가 발생했습니다.", e);
    } finally {
      // 4) 락 해제
      if (lock.isHeldByCurrentThread()) {
        lock.unlock();
      }
    }
  }
}

Service 로직에서 분산락을 적용한 모습이다. 코드를 간단히 살펴보면 아래와 같다.

  • RedissonClient 직접 사용해서 tryLockunlock()을 해준다.
  • lock.tryLock(5, 3, TimeUnit.SECONDS)
    • 첫 번째 파라미터는 락을 획득하기 위해 대기하는 시간이다.
    • 두 번째 파라미터는 락을 획득 후 자동으로 락이 해제되기까지의 시간이다.
  • 락 해제
    • finally 구문에서 현재 스레드가 락을 가지고 있다면 unlock()을 호출하여 해제해야 한다.
    • 예외가 발생하든 정상 반환하든 반드시 해제해줘야 다음 요청이 락을 획득 할 수 있다.
    private static final Integer parallelism = 5;
    private final ExecutorService executorService = Executors.newFixedThreadPool(parallelism);

    @Test
    void 좋아요_정합성_테스트() throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(100);
        for (int i = 0; i < 100; i++) {
            long userId = i + 1;
            executorService.submit(() -> {
                try {
                    messageLikeService.likeMessage(userId, 1L);
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();

        assertThat(messageRepository.findById(1L).get().getLikes()).isEqualTo(100L);
    }

해당 테스트를 실행시키면 문제 없이 성공하는 걸 확인할 수 있다.

그렇다면 이러한 분산락을 필요한 곳에 일일히 저렇게 코드를 작성하는 건 비효율적이다.

Spring의 AOP를 활용하면 더 손쉽게 적용이 가능하다.

AOP로 적용해보기

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

    // SpEL을 통해 락 키를 생성하는 값
    String key();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    // 락을 획득하기 위해 대기하는 시간
    long waitTime() default 5L;

    // 락을 획득 후 자동으로 락이 해제되기까지의 시간
    long leaseTime() default 3L;
}

DistributedLock 어노테이션의 파라미터는 key는 필수, 나머지 값들은 커스텀 하게 설정할 수 있게 했다.

위의 lock.tryLock(5, 3, TimeUnit.SECONDS); 이 부분에 들어가는 값들이다.

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

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(DistributedLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

        // SpEL 파싱하여 실제 key 값 생성
        String key = REDISSON_LOCK_PREFIX + CustomELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
        RLock rLock = redissonClient.getLock(key);

        try {
            // waitTime, leaseTime 등을 어노테이션에서 가져옴
            boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
            if (!available) {
                log.info("락을 획들 할 수 없습니다. method: {}  key: {}", method.getName(), key);
                return false;
            }

            // // 핵심 로직 실행 -> 별도 트랜잭션 (REQUIRES_NEW)로 처리
            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            throw new InterruptedException();
        } finally {
            // 락 해제
            try {
                if (rLock.isHeldByCurrentThread()) {
                    rLock.unlock();
                }
            } catch (Exception e) {
                log.error("락이 해제하는 동안 오류가 발생했습니다. method: {}  key: {}", method.getName(), key, e);
            }
        }
    }
}

이 부분은 @DistributedLock 어노테이션 선언 시 수행되는 aop 클래스다.

기존 MessageLikeServicelikeMessage 부분의 락을 획득하고 해제하는 역할을 이 aop가 수행하는 것이다.

메서드에 @DistributedLock 어노테이션을 선언하기만 하면된다.

SpEL로 #messageId 등을 파싱해 LOCK: 형태의 키를 만들고 어노테이션에 지정한 waitTime, leaseTime 등을 활용해 tryLock()을 수행한다.

락 해제 시점도 AOP가 알아서 처리하므로, 비즈니스 로직(Service 클래스)에서는 단순히 @DistributedLock만 달면 된다.

여기서 새롭게 추가된 부분은 AopForTransactionCustomELParser 클래스다.

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}
  • @Transactional(propagation = Propagation.REQUIRES_NEW)를 통해 부모 트랜잭션과 분리된 새로운 트랜잭션에서 핵심 로직을 실행한다.
  • 분산 락 구간이 독립 트랜잭션으로 처리되기 때문에, 부모 트랜잭션의 커밋/롤백과 충돌하지 않고 확실히 원자성을 보장할 수 있다.
public class CustomELParser {

  private CustomELParser() {
  }

  public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        // SpEL Parser를 통해 #messageId 등 파라미터를 치환
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(key).getValue(context, Object.class);
    }
}
  • @DistributedLock(key="#messageId") 같은 식을 해석하여 런타임 파라미터를 얻기 위해 사용한다.

마치며

이전 글에서 작성한 낙관적 락 + Spring retry는 간단한 상황에서는 사용할만 하지만 서버가 여러대이고 트래픽이 높을 때는 충돌이 빈번해져 오히려 과부하가 발생한다.

Redis 기반의 분산락을 사용하면 다중 서버환경에서도 자원에 대한 원자적 접근을 안전하게 보장할 수 있다.

그리고 추가적으로 AOP를 적용해 어노테이션만 달아도 락을 획득/해제하도록 만들면 비즈니스 로직과 동시성 제어 로직을 깔끔하게 분리할 수 있었다.

다중 서버 동시성 해결에 Redis의 분산락이 하나의 해결책이 될 수 있다.

작성된 예제코드는 여기서 확인이 가능하다.

관련글 더보기