상세 컨텐츠

본문 제목

Spring Retry를 활용한 메시지 좋아요 기능의 동시성 문제 해결 방안

공부/Spring

by seungpang 2024. 4. 11. 23:22

본문

반응형

Spring retry란?


Spring Retry는 Spring 프레임워크의 일부로, 재시도 패턴을 쉽게 구현할 수 있게 해주는 라이브러리다.

재시도 패턴은 일시적인 문제로 인해 실패한 작업을 자동으로 다시 시도함으로써, 애플리케이션의 안정성과 가용성을 향상시키는 데 유용하다.

메시지 좋아요 기능에 낙관적 락과 Spring retry 적용해보기

dependencies {
    ...
    implementation 'org.springframework.retry:spring-retry'
    implementation 'org.springframework:spring-aspects'
    ...
}

위와 같이 spring-retry 에 대한 dependency를 추가해준다. 그리고 Spring AOP를 추가해줘야 한다.

retry를 사용하는 방법은 크게 @RetryableRetryTemplate이 있다. 여기서는 간단하게 @Retryable로 구현해보기로 하자

@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(final String content, final Member author, final Long likes) {
        this.content = content;
        this.author = author;
        this.likes = likes;
    }

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

Message 클래스는 위와 같이 정의되어있다. 여기서 like() 메서드를 보면 해당 likes에 +1하는게 끝이다.

해당 로직에 문제가 없어 보이지만 동시에 좋아요를 실행하려고하면 문제가 발생한다.

    @Transactional
    public MessageLikeResponseDto likeMessageWithRetry(Long memberId, Long messageId) {
        final Message message = messageRepository.findByIdForUpdate(messageId)
                .orElseThrow(IllegalArgumentException::new);
        message.like();
        messageLikeRepository.save(new MessageLike(memberId, messageId));
        return new MessageLikeResponseDto(message.getLikes(), true);
    }
    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);
    }

위의 테스트 코드는 5개의 고정된 스레드풀을 생성하여 동시에 CountDownLatch로 동시에 실행될 작업의 수를 100으로 설정했다.

그래서 동시성 테스트를 시도했는데 기대했던거는 100개의 좋아요를 눌러서 결과값이 100일 기대했다.

하지만 기대와 다르게 32로 나왔다. 그 이유는 동시에 여러 스레드가 like() 메서드를 호출하여 likes 변수를 수정하려고 할 때 발생하는 동시성 문제때문이다.

각 스레드가 like() 메서드를 호출하고 likes 값을 읽을 때, 다른 스레드가 이미 likes 값을 변경했을 수 있으나, 이러한 변경 사항이 다른 스레드에 즉시 반영되지 않는다.

이로 인해 일부 '좋아요' 연산이 유실되어 최종적으로 기대했던 100이 아닌 32라는 결과값이 나오게 된다.

이 문제를 해결하기 위해 낙관적 락(Optimistic Lock)과 Spring의 @Retryable을 사용할 수 있다.

낙관적 락은 데이터에 대한 변경이 충돌할 가능성이 낮다고 낙관적으로 가정하고, 실제로 충돌이 발생했을 때만 대응하는 방식이다.

JPA에서는 @Version 어노테이션을 통해 낙관적 락을 구현할 수 있다.

@Version 어노테이션이 적용된 필드는 엔티티가 데이터베이스에 저장될 때마다 값이 자동으로 증가한다.

이 필드의 값이 변경되면 JPA는 데이터가 이미 변경되었다고 판단하고 OptimisticLockException을 발생시킨다.

public class Message {
    ...
    @Column(name = "likes", nullable = false)
    private Long likes;

    @Version
    private Long version;
    ...

Meesage에 version을 추가해준다.

public interface MessageRepository extends JpaRepository<Message, Long> {

    @Lock(LockModeType.OPTIMISTIC)
    @Query("select m from Message m where m.id = :id")
    Optional<Message> findByIdForUpdate(@Param("id") Long id);
}

그리고 해당 메시지를 가져오는 쿼리에 @Lock(LockModeType.OPTIMISTIC)을 명시해준다.

    @Retryable(retryFor = {OptimisticLockException.class, StaleObjectStateException.class,
            ObjectOptimisticLockingFailureException.class}, maxAttempts = 5, backoff = @Backoff(delay = 100))
    @Transactional
    public MessageLikeResponseDto likeMessageWithRetry(Long memberId, Long messageId) {
        final Message message = messageRepository.findByIdForUpdate(messageId)
                .orElseThrow(IllegalArgumentException::new);
        message.like();
        messageLikeRepository.save(new MessageLike(memberId, messageId));
        return new MessageLikeResponseDto(message.getLikes(), true);
    }

마지막으로 낙관적 락 Exception이 발생했을 때 재시도할 수 있게 @Retryable을 설정한다. 최대 재시도횟수는 5, 재시도 지연 속도는 100ms로 설정해줬다.

이러면 준비는 끝이 났다. 한번 테스트를 다시 시도하면

위와 같이 정상적으로 테스트를 성공한다. 그렇다면 retry에 내부 구현은 어떤 식으로 구현되는 걸까?

spring retry 실제 구현해보기


전체를 완전 똑같이 구현할 수는없지만 대략적인 느낌은 간단하게 구현해 볼 수 있다.

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

    int maxRetries() default 3;

    int delay() default 1000;
}

일단 RetryOnOptimisticLockingFailure 어노테이션을 정의했다.

maxRetries는 최대 재시도 횟수를 지정했고 delay는 재시도 사이의 대기 시간을 지정한다.

즉 해당 어노테이션을 설정한 메서드는 낙관적 락 예외가 발생할 경우 재시도 로직을 실행할 수 있게 설정하는거다.

@Slf4j
@Aspect
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@Component
public class RetryOnOptimisticLockingAspect {

    @Around("@annotation(RetryOnOptimisticLockingFailure)")
    public Object doConcurrentOperation(final ProceedingJoinPoint pjp) throws Throwable {
        int numAttempts = 0;
        final RetryOnOptimisticLockingFailure annotation = ((MethodSignature) pjp.getSignature()).getMethod().getAnnotation(RetryOnOptimisticLockingFailure.class);
        final int maxRetries = annotation.maxRetries();
        final int delay = annotation.delay();
        do {
            numAttempts++;
            try {
                return pjp.proceed();
            } catch (final OptimisticLockException | StaleObjectStateException |
                           ObjectOptimisticLockingFailureException oe) {
                log.error("RetryOnOptimisticLockingFailure:  {} 작업을 재시도 중, 재시도횟수 {}", pjp.getSignature().getName(), numAttempts);
                if (numAttempts > maxRetries) {
                    log.error("RetryOnOptimisticLockingFailure: 최대 재시도 횟수를 초과했습니다.");
                    throw oe;
                }
                Thread.sleep(delay); //재시도 전 지연
            }
        } while (numAttempts <= maxRetries);

        return null;
    }
}

위의 코드를 살펴보면 RetryOnOptimisticLockingAspect는 AOP를 정의한거다.

  • @Order(Ordered.LOWEST_PRECEDENCE - 1)
    AOP의 실행 우선순위를 정의한거다. 재시도 관련 로직이기때문에 재시도가 낮은 우선순위를 가져 나중에 실행하려고 하는거다.

  • @Around("@annotation(RetryOnOptimisticLockingFailure)")
    이 어드바이스가 RetryOnOptimisticLockingFailure 어노테이션이 붙은 메서드에 적용됨을 나타낸다.

  • ProceedingJoinPoint
    AOP 프록시를 통해 가로챈 메소드에 대한 정보와 그 메소드를 실행할 수 있는 기능을 제공하는 객체다.

  • OptimisticLockException(JPA) | StaleObjectStateException(Hibernate) | ObjectOptimisticLockingFailureException(Spring)
    낙관적 락은 commit 시점에 충돌을 알 수 있으며 충돌시 위와 같은 Exception이 발생한다.

메서드 실행시 3개의 Exception 중 하나가 발생하면, 지정된 대기 시가만큼 기다린 후 메서드를 재시도한다.

과정은 최대 재시도 횟수를 초과할 때까지 반복한다. 최대 재시도 횟수를 초과하면, 마지막으로 발생한 예외를 다시 던진다.

더 나아가


간단하게 retry를 살펴보았다. 간단하게 재시도 구현을 할 수 있는 장점이 있다.
하지만 retry 예제를 만들기위해 메시지 좋아요와 동시성문제를 가지고 왔는데 간단한 트래픽의 경우에는 이 해결방법이 괜찮겠지만 트래픽이 더 심해진다면 문제가 발생한다.

만약 여러 서버로 스케일 아웃이 되고 낙관적 락을 사용해여 재시도를 처리한다면 여러 서버에서 재시도 처리만하다 부하만 심해지고 동시성 문제가 그대로 발생할 것이다.

그렇기 때문에, 트래픽이 많아질수록 낙관적 락과 재시도 메커니즘만으로는 부족할 수 있다. 이런 경우, 다른 해결책을 고려해야 한다.

예를 들어, 분산 락이나 메시지 큐를 활용하는 방법이 있다.

분산 락은 여러 서버가 동일한 자원에 접근하려 할 때, 단 하나의 서버만이 접근을 허용받도록 하는 메커니즘이다.

이를 통해 동시성 문제를 해결할 수 있다. 분산락을 이용한 방법은 여기서 확인이 가능하다.

여러 서버가 동시에 같은 데이터를 변경하려고 할 때, 분산 락을 통해 오직 한 서버만이 변경을 할 수 있도록 제한함으로써 데이터 무결성을 보장할 수 있다.

또 다른 방법으로는 메시지 큐를 사용하는 것이다. 메시지 큐는 요청을 순서대로 처리할 수 있게 해주며, 시스템의 부하를 분산시킬 수 있다.

이는 특히 고부하 상황에서 유용하다.

메시지 큐를 사용하면 재시도 로직을 메시지 큐 시스템 내부에서 처리할 수 있으며, 이를 통해 서버 간의 부하를 균등하게 분배하고, 처리 속도를 개선할 수 있다.

결론적으로, 시스템의 규모가 커지고 트래픽이 증가함에 따라, 단순한 재시도 메커니즘을 넘어서서 더욱 강력하고 효율적인 해결책을 도입하는 것이 필요하다.
분산 락과 메시지 큐와 같은 기술은 이러한 문제를 해결하는 데 있어서 큰 도움이 될 수 있다.

이러한 접근 방식은 시스템의 안정성과 확장성을 보장하는 동시에, 동시성 문제를 효과적으로 해결할 수 있게 해준다.

해당 작성된 예제는 여기에서 확인 가능하다.

관련글 더보기