스프링부트

스프링 부트 Redis 분산 락 활용 동시성 제어

컴공코딩러 2024. 7. 4. 16:32

 

 

 

 

 

 

 

나의 미간지 프로젝트

 

프로젝트를 또 개선하고 싶어서 프로젝트를 뜯어봤다.

 

문제점

멀티스레드 상황에 게시물을 조회했을때 동시성 문제로 인해 조회수가 정상적으로 증가 안하는 문제

 

  시간 순서 |           스레드 1            |      스레드 2
-------------------------------------------------------------
T1        | findById -> viewCount = 10   |
T2        |                              | findById -> viewCount = 10
T3        | viewCount++ -> 11            |
T4        |                              | viewCount++ -> 11
T5        | save(viewCount = 11)         |
T6        |                              | save(viewCount = 11)

 

 

위에가 문제가 되는 상황을 설명하고 있다.

 

원래는 10에서 2번 조회하면 12라는 조회수가 되어아 하지만

 

스레드가 게시물을 동시 Get 하여 둘다 10에서 11로 증가하였기 때문에 1밖에 증가가 안되었다.

 

만약 스레드가 2개가아니라 여러개일 경우 더욱 데이터에 문제가 생긴다.

 

재고관리 시스템에서 이런식으로 스레드에서 문제가 발생한다면 큰 문제가 생기는 코드이다.

 

결론

Redis 분산락을 사용하여 문제를 해결하였다.

 

 

 

개선 전

@Data
@Entity
@EqualsAndHashCode(callSuper = false)
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserPost extends BaseTimeEntity {
    @Id
    @Column(name="post_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ColumnDefault("0")
    private int viewCount;

    @Size(min = 2, max = 250, message = "내용은 2~500자 사이로 입력해주세요.")
    private String content;

    private String detailImageUrl;
    private String thumbnailImageUrl;
    private Double lat;
    private Double lng;
    private String address_name;
    private String tags;
    private Long tagsNum;
    private String profileImage;
    private String music_id;

    @OneToMany(mappedBy = "userPost", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
    @JsonManagedReference
    private List<UserComment> userComments = new ArrayList<>();

    @ManyToOne
    @JoinColumn(name = "user_id")
    @JsonBackReference
    private User user;

    // 댓글을 추가하는 메서드
    public void addComment(UserComment userComment) {
        userComments.add(userComment);
        userComment.setUserPost(this);
    }

    // 조회수를 증가시키는 메서드
    public void increase() {
        this.viewCount += 1;
    }
}

 

게시물에 대한 정보를 가지고있는 엔티티

 

@Operation(summary = "게시물 번호를 이용한 게시물 조회 API")
@GetMapping("/{postId}")
public DetailPageDto getPost(@PathVariable Long postId) {
    // 주어진 게시물 번호로 게시물을 조회하고 조회수를 증가
    UserPost userPost = userPostService.getUserPost(postId);
    userPostService.increasePostViewCount(postId);

    // 조회한 게시물 정보를 기반으로 DetailPageDto 객체를 생성하여 반환
    return DetailPageDto.builder()
            .id(userPost.getId())
            .viewCount(userPost.getViewCount())
            .content(userPost.getContent())
            .commentCount(userPost.getUserComments().size())
            .imageUrl(userPost.getDetailImageUrl())
            .profileImage(userPost.getProfileImage())
            .address_name(userPost.getAddress_name())
            .tags(userPost.getTags())
            .music_id(userPost.getMusic_id())
            .nickname(userPost.getUser().getNickname())
            .userComments(userPost.getUserComments())
            .createdDate(userPost.getCreatedDate())
            .modifiedDate(userPost.getModifiedDate())
            .build();
}

 

게시물을 조회하는 API 이다.

 

게시물을 가져온후 조회수를 1 증가 시킨 후 return 시킨다.

 

// 조회수를 증가시키는 함수
public void increasePostViewCount(Long id) {
    // 주어진 ID로 게시물을 조회
    UserPost userPost = boardRepository.findById(id).orElseThrow();

    // 조회수를 1 증가
    userPost.increase();

    // 변경된 게시물을 저장
    boardRepository.save(userPost);
}

 

게시물 증가시키는 함수이다.

 

개선후

AOP 적용 코드

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
    String key();
}

 

 

@Around("@annotation(preventDuplicateSubmit)")
public Object around(ProceedingJoinPoint joinPoint, PreventDuplicateSubmit preventDuplicateSubmit) throws Throwable {
    // 주어진 조인포인트와 애너테이션을 사용하여 키를 생성
    String key = generateKey(joinPoint, preventDuplicateSubmit);
    RLock lock = redissonClient.getLock(key);

    // 키 획득 시도 로그
    logger.info("키 획득 시도: " + key);

    // 지정된 시간 동안 락을 시도
    boolean isLocked = lock.tryLock(2100, 100, TimeUnit.MILLISECONDS);
    if (!isLocked) {
        // 락 획득 실패 시 로그를 기록하고 예외처리
        logger.warning("키 획득 실패: " + key);
        throw new RuntimeException("중복 요청이 감지되었습니다.");
    }
    // 락 획득 성공 시 로그
    logger.info("락 획득: " + key);

    try {
        // 원래의 메서드를 실행
        return joinPoint.proceed();
    } finally {
        // 메서드 실행 후 락을 해제
        lock.unlock();
        logger.info("언락: " + key);
    }
}

 

tryLock을 활용 1.5초간 락 획득 2초간 대기한다.

 

테스트해보니 이 시간이 가장 중요하다.

/**
 * 중복 요청을 방지하기 위해 Redisson 락을 사용하여 게시물의 조회수를 증가시키는 메서드
 * @param postId 조회수를 증가시킬 게시물의 ID
 */
@PreventDuplicateSubmit(key = "#postId")
public void redissonIncreasePostViewCount(Long postId)
{
    // 게시물 ID를 사용하여 해당 게시물을 조회
    UserPost userPost = boardRepository.findById(postId).orElseThrow();
    
    // 조회수를 증가
    userPost.increase();
    
    // 변경된 게시물을 저장
    boardRepository.save(userPost);
}

 

테스트 코드

 

@SpringBootTest
class BoardControllerTest {

    @Autowired
    UserPostService userPostService;

    @Autowired
    BoardRepository boardRepository;

    private final Integer Count = 100;

    @Test
    @DisplayName("동시에 100명의 게시물 조회 : 동시성 이슈")
    public void badViewTest() throws Exception {
        // 100명의 사용자가 동시에 게시물 조회를 시도하는 테스트를 실행
        request100Posts((_no) -> userPostService.increasePostViewCount(1L));
    }

    // 100명의 사용자가 동시에 게시물 조회를 시도하는 메서드
    private void request100Posts(Consumer<Void> action) throws InterruptedException {
        // 테스트 시작 전 조회수를 확인합니다.
        Optional<UserPost> byId = boardRepository.findById(1L);
        int beforeViewCount = byId.get().getViewCount();

        // 스레드 풀과 CountDownLatch를 사용하여 동시 요청을 처리
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(Count);

        for (int i = 0; i < Count; i++) {
            executorService.submit(() -> {
                try {
                    // 주어진 액션을 실행
                    action.accept(null);
                } finally {
                    // 작업이 끝날 때마다 latch 카운트 감소
                    latch.countDown();
                }
            });
        }

        // 모든 스레드가 작업을 마칠 때까지 대기
        latch.await();

        // 테스트 종료 후 조회수를 확인
        Optional<UserPost> byId2 = boardRepository.findById(1L);

        // 예상한 조회수 증가와 실제 증가가 일치하는지 검증
        assertThat(byId2.get().getViewCount()).isEqualTo(beforeViewCount + Count);
    }

    @Test
    @DisplayName("동시 100명 게시물 조회 : 분산락")
    public void redissonViewingTest() throws Exception {
        // 분산 락을 사용하여 100명의 사용자가 동시에 게시물 조회를 시도하는 테스트를 실행
        request100Posts((_no) -> userPostService.redissonIncreasePostViewCount(1L));
    }
}

 

   @Test
    @DisplayName("동시에 100명의 게시물 조회 : 동시성 이슈")
    public void badViewTest() throws Exception {
        request100Posts((_no) -> userPostService.increasePostViewCount(1L));
    }

 

먼저 동시성 문제가 발생하는 코드를 테스트해봤다.

 

 

당연히 정상적으로 조회수가 증가되지 않았다.

 

  시간 순서 |           스레드 1            |      스레드 2
-------------------------------------------------------------
T1        | findById -> viewCount = 10   |
T2        |                              | findById -> viewCount = 10
T3        | viewCount++ -> 11            |
T4        |                              | viewCount++ -> 11
T5        | save(viewCount = 11)         |
T6        |                              | save(viewCount = 11)

 

처음에 보여줬던 이런 상황이다. 참고로 스레드풀은 32개 까지 지정하였다.

 

 

   @Test
    @DisplayName("동시 100명 게시물 조회 : 분산락")
    public void redissonViewingTest() throws Exception {
        request100Posts((_no) -> userPostService.redissonIncreasePostViewCount(1L));
    }

그다음 redisson을 활용하여 분산 락을 통해 조회수를 증가 시켜봤다.

 

 

성공적으로 조회수를 증가시켰다.

 

결론

 

redisson 을 활용하여 분산락을 통해 동시성 이슈를 해결했다.

 

하지만 trylock 시간을 조정하면 모든 스레드가 락을 획득할수 없는 상황도 발생한다.\

 

그 내용은 밑에 적어보겠다.

 

redission은 무적인가

trylock 시간에 따라 성공할수도 있고 실패할수도있다.

tryLock의 1,2 번째 인자에 대한 설명이다.

 

  1. 락 획득 대기 시간 (tryLock의 첫 번째 인자): 다른 요청이 락을 해제할 때까지 현재 요청이 기다릴 최대 시간
    • 짧은 시간 (몇 백 밀리초): 즉각적인 응답이 중요한 경우. 예를 들어, 사용자에게 빠른 피드백이 필요한 웹 애플리케이션.
    • 긴 시간 (몇 초): 중요한 작업이 진행 중이어서 반드시 락을 획득해야 하는 경우. 예를 들어, 데이터 무결성이 중요한 백엔드 시스템.
  2. 락 유지 시간 (tryLock의 두 번째 인자): 락이 유지되는 시간입니다. 이 시간 동안 다른 요청은 락을 획득하지 못합니다.
    • 짧은 시간 (1초 미만): 빠르게 완료될 수 있는 작업.
    • 긴 시간 (몇 초에서 몇 분): 긴 시간이 걸리는 작업. 예를 들어, 파일 업로드나 데이터 처리

 

boolean isLocked = lock.tryLock(1000, 500, TimeUnit.MILLISECONDS);

 

1초간 기다리고 0.5초간 락 유지시간을 두었다.

테스트가 실패하였고 조회수가 7정도 올라가지 않았다.

 

여러번 실험 해보니 5~10% 정도가 안되는 것 같다.

 

boolean isLocked = lock.tryLock(500, 500, TimeUnit.MILLISECONDS);

 

0.5초간 기다리고 0.5초 락 유지시간을 두었다.

 

 

당연 대기시간을 줄였으니 더욱 락을 획득을 못하여 23이라는 조회수를 올리지 못했다.

 

요청의 20~25% 정도가 증가되지 않는다.

 

boolean isLocked = lock.tryLock(500, 100, TimeUnit.MILLISECONDS);

이번엔 0.1초의 유지시간을 두었다.

 

 

릴리즈 시간을 줄이면 더욱 많은 락을 획득 할것 같았지만 오히려 49 라는 조회수나 증가 시키지 못했다.

 

boolean isLocked = lock.tryLock(2000, 100, TimeUnit.MILLISECONDS);

 

2초 대기에 0.1초간 유지 하는 코드

 

패스 되었고 2.134 초가 걸렸다. 기본적으로 완료되는 시간이 2초이상걸리니 대기시간을 길게 잡아야하는것 같았다.

 

배운점

Redision 을 통해 분산락 으로 동시성 제어 하는 법을 깨달았다.

 

함수에 따라 락 시간을 잘 조절해야 된다는것을 깨달았다.

 

조회 증가 함수에 분산락을 적용했지만

 

댓글 증가 함수나 이런곳에 AOP 적용하면 다양하게 활용할 수 있을 것 같다. 굿굿