티스토리 뷰

공연 동행 구인 서비스의 1차 개발이 끝나고,

리팩토링 사항 중 한가지인

조회수 동시성 제어를 적용해보고자 합니다💪

 

동시성 문제란??

동일한 데이터에 여러 개의 작업이 동시에 접근할 때 발생할 수 있는 문제를 의미합니다.

 

예를 들어, 하나의 데이터를 수정하려는 두 개의 작업이 동시에 이루어질 때, 한 작업이 데이터를 수정하는 동안 다른 작업이 수정되기 전의 데이터를 바탕으로 수정을 하게 될 수 있습니다. 이로 인해 데이터의 일관성이 깨지고 예상치 못한 결과가 발생하게 됩니다.

 

아래 코드는 동행 구인글 조회 시, 조회수가 1 증가하도록 설정한 코드입니다.

@Service
@RequiredArgsConstructor
public class AccompanyServiceImpl implements AccompanyService {

    // ...
    @Transactional
    @Override
    public AccompanyPostResponse getAccompanyPost(Long currentMemberId, Long accompanyPostId) {
        AccompanyPost accompanyPost = accompanyPostRepository.findByIdAndIsActivatedIsTrue(
                        accompanyPostId)
                .orElseThrow(() -> new AccompanyPostNotFoundException(
                        AccompanyErrorCode.ACCOMPANY_POST_NOT_FOUND));
        accompanyPost.increaseViewCount();
        Long waitingCount = accompanyCommentRepository.countByAccompanyPostIdAndIsActivatedIsTrueAndIsAccompanyApplyCommentTrue(
                accompanyPostId);

        return AccompanyPostResponse.of(accompanyPost, waitingCount,
                MemberProfile.of(accompanyPost.getWriter(), currentMemberId));
    }
}

 

 

동시에 100개의 요청을 해보면,

@Test
@DisplayName("동시에 여러 조회가 이루어지는 경우, 모든 조회수가 정상적으로 반영되지 않는다.")
void fail_updateViewCount() {
    // given
    Member member = MemberDataFactory.createMember();
    memberRepository.save(member);
    Concert concert = concertRepository.save(ConcertDataFactory.createConcert());
    int size = 1, requestCnt = 100;
    AccompanyPost accompanyPost = accompanyPostRepository.saveAll(
            createAccompanyPosts(member, size, concert)).get(0);
    Long viewCountBeforeRequests = accompanyPost.getViewCount(), viewCountAfterRequests;

    // when
    List<CompletableFuture<Void>> getAccompanyPostRequestFutures = IntStream.range(0, 100)
            .mapToObj(i -> CompletableFuture.runAsync(() ->
                    accompanyService.getAccompanyPost(member.getId(), accompanyPost.getId())
            ))
            .toList();
    CompletableFuture.allOf(
            getAccompanyPostRequestFutures.toArray(
                    new CompletableFuture[getAccompanyPostRequestFutures.size()])
    ).join();
    viewCountAfterRequests = accompanyPostRepository.findById(accompanyPost.getId()).get()
            .getViewCount();

    // then
    System.out.println("viewCountBeforeRequests: " + viewCountBeforeRequests);
    System.out.println("viewCountAfterRequests: " + viewCountAfterRequests);
    Assertions.assertThat(viewCountAfterRequests - viewCountBeforeRequests)
            .isNotEqualTo(requestCnt);
}

 

조회가 100번 이루어졌는데, 조회 수는 19 증가한 것을 확인할 수 있습니다.

 

100번 조회했는데, 조회수는 19인 상황🍄

 


동시성 해결은 자바에서? 데이터베이스에서?

동시성은 자바에서도 해결이 가능하고, 데이터베이스에서도 해결이 가능합니다.

하지만 Java의 경우, 애플리케이션 운영의 입장에서 한가지 치명적인 문제가 있습니다.

여러 인스턴스와 하나의 데이터베이스 서버로 구성되어 있는 경우, 서로 다른 인스턴스 서버에서 실행되는 메소드의 데이터의 경우에는 자바에서 동시성을 해결하더라도, 데이터베이스의 입장에선 동시성 문제가 발생할 수 있습니다.🚨

 

이러한 이유로 데이터베이스 단에서 동시성 제어를 해주었습니다!

 

 


동시성 제어를 위해 Lock 사용하기

잠금(Locking)을 통해 트랜잭션의 실행 순서를 강제로 제어하여 순차적인 처리가 이루어지도록 보장합니다.

여러 작업이 동시에 접근하는 것을 막기 위해 데이터에 잠금을 걸어둠으로, 한 작업이 데이터를 사용하는 동안 다른 작업은 기다리게 됩니다. 즉, 데이터를 사용하는 작업이 완료되면 잠금을 해제하고 다음 작업이 데이터에 접근할 수 있게 되어 작업들이 충돌 없이 순차적으로 실행될 수 있도록 보장하는 것입니다🤓

 

두 가지 방법의 Lock을 사용한 후 비교해보았습니다.

 

1. Update 쿼리로 조회수 직접 업데이트 하기 -> Update Lock

update 쿼리 실행 시 commit을 하기 전까지 해당 행 전체에 lock이 걸리므로 같은 데이터를 동시에 갱신 불가능 합니다.

public interface AccompanyPostRepository extends JpaRepository<AccompanyPost, Long>,
        AccompanyPostCustomRepository {

	// ...
    
    @Modifying
    @Query("UPDATE AccompanyPost ap SET ap.viewCount = ap.viewCount + 1 WHERE ap.id = :id")
    void updateViewCount(Long id);
}

 

@Service
@RequiredArgsConstructor
public class AccompanyServiceImpl implements AccompanyService {

    // ...
    @Transactional
    @Override
    public AccompanyPostResponse getAccompanyPostWithViewCountUpdateQuery(Long currentMemberId,
            Long accompanyPostId) {
        AccompanyPost accompanyPost = accompanyPostRepository.findByIdAndIsActivatedIsTrue(
                        accompanyPostId)
                .orElseThrow(() -> new AccompanyPostNotFoundException(
                        AccompanyErrorCode.ACCOMPANY_POST_NOT_FOUND));
        accompanyPostRepository.updateViewCount(accompanyPostId); // ** 여기!
        Long waitingCount = accompanyCommentRepository.countByAccompanyPostIdAndIsActivatedIsTrueAndIsAccompanyApplyCommentTrue(
                accompanyPostId);

        return AccompanyPostResponse.of(accompanyPost, waitingCount,
                MemberProfile.of(accompanyPost.getWriter(), currentMemberId));
    }
}

 

2. 비관적 락 적용하기 -> Exclusive Lock

비관적 락에는 공유락과 배타적 락 두가지가 존재하는데, 트랜잭션이 끝나는 시간 동안 Read/Write가 불가능한 배타적 락을 사용해주었습니다.(공유락은 데이터를 동시에 Read 가능, Write 불가능)

public interface AccompanyPostRepository extends JpaRepository<AccompanyPost, Long>,
        AccompanyPostCustomRepository {

	// ...

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select ap from AccompanyPost ap where ap.id = :id and ap.isActivated = true")
    Optional<AccompanyPost> findByIdAndIsActivatedIsTrueForUpdate(Long id);

}

 

@Service
@RequiredArgsConstructor
public class AccompanyServiceImpl implements AccompanyService {

    // ...
    @Transactional
    @Override
    public AccompanyPostResponse getAccompanyPostWithPessimisticLock(Long currentMemberId,
            Long accompanyPostId) {
        AccompanyPost accompanyPost = accompanyPostRepository.findByIdAndIsActivatedIsTrueForUpdate(
                        accompanyPostId)
                .orElseThrow(() -> new AccompanyPostNotFoundException(
                        AccompanyErrorCode.ACCOMPANY_POST_NOT_FOUND)); // ** 여기!
        accompanyPost.increaseViewCount();
        Long waitingCount = accompanyCommentRepository.countByAccompanyPostIdAndIsActivatedIsTrueAndIsAccompanyApplyCommentTrue(
                accompanyPostId);

        return AccompanyPostResponse.of(accompanyPost, waitingCount,
                MemberProfile.of(accompanyPost.getWriter(), currentMemberId));
    }
}

 

 


Update Lock vs Exclusive Lock

테스트 코드로, 동시에 100번 조회 시 조회수가 100만큼 오르는 것을 확인했습니다!

 

그렇다면 둘 중에 어떤 Lock을 사용할까 고민되어, 소요 시간을 측정해보았는데🤔

@Test
@DisplayName("동시에 여러 조회가 이루어지는 경우, 모든 조회수가 정상적으로 반영된다. - 조회수 증가 시 update 문 사용")
void success_updateViewCount_given_update_query() {
    // given
    Member member = MemberDataFactory.createMember();
    memberRepository.save(member);
    Concert concert = concertRepository.save(ConcertDataFactory.createConcert());
    int size = 1, requestCnt = 100;
    AccompanyPost accompanyPost = accompanyPostRepository.saveAll(
            createAccompanyPosts(member, size, concert)).get(0);
    Long viewCountBeforeRequests = accompanyPost.getViewCount(), viewCountAfterRequests;

    // when
    long beforeTime = System.currentTimeMillis();
    List<CompletableFuture<Void>> getAccompanyPostRequestFutures = IntStream.range(0, 100)
            .mapToObj(i -> CompletableFuture.runAsync(() ->
                    accompanyService.getAccompanyPostWithViewCountUpdateQuery(member.getId(),
                            accompanyPost.getId())
            ))
            .toList();
    CompletableFuture.allOf(
            getAccompanyPostRequestFutures.toArray(
                    new CompletableFuture[getAccompanyPostRequestFutures.size()])
    ).join();
    viewCountAfterRequests = accompanyPostRepository.findById(accompanyPost.getId()).get()
            .getViewCount();
    long afterTime = System.currentTimeMillis();

    // then
    System.out.println("소요시간(ms): " + (afterTime - beforeTime));
    Assertions.assertThat(viewCountAfterRequests - viewCountBeforeRequests)
            .isEqualTo(requestCnt);
}

@Test
@DisplayName("동시에 여러 조회가 이루어지는 경우, 모든 조회수가 정상적으로 반영된다. - 비관적 락 사용")
void success_updateViewCount_given_pessimistic_lock() {
    // given
    Member member = MemberDataFactory.createMember();
    memberRepository.save(member);
    Concert concert = concertRepository.save(ConcertDataFactory.createConcert());
    int size = 1, requestCnt = 100;
    AccompanyPost accompanyPost = accompanyPostRepository.saveAll(
            createAccompanyPosts(member, size, concert)).get(0);
    Long viewCountBeforeRequests = accompanyPost.getViewCount(), viewCountAfterRequests;

    // when
    long beforeTime = System.currentTimeMillis();
    List<CompletableFuture<Void>> getAccompanyPostRequestFutures = IntStream.range(0, 100)
            .mapToObj(i -> CompletableFuture.runAsync(() ->
                    accompanyService.getAccompanyPostWithPessimisticLock(member.getId(),
                            accompanyPost.getId())
            ))
            .toList();
    CompletableFuture.allOf(
            getAccompanyPostRequestFutures.toArray(
                    new CompletableFuture[getAccompanyPostRequestFutures.size()])
    ).join();
    viewCountAfterRequests = accompanyPostRepository.findById(accompanyPost.getId()).get()
            .getViewCount();
    long afterTime = System.currentTimeMillis();

    // then
    System.out.println("소요시간(ms): " + (afterTime - beforeTime));
    Assertions.assertThat(viewCountAfterRequests - viewCountBeforeRequests)
            .isEqualTo(requestCnt);
}

 

2배 정도 차이가 나길래.. ?? 각각의 Lock에 대해 찾아보니

😯

 

Update Lock은 업데이트 작업에 대한 락이라, 해당 업데이트 작업이 완료되면 락이 해제되지만, Exclusive Lock은 트랜잭션 동안에 유지되어서 다른 트랜잭션이 해당 레코드에 접근하는 것을 막아 Update Lock이 소요시간이 더 적게 걸린다고 합니다.

 


🚨Update Lock은 Update 시에만 락이 걸린다.

업데이트 락의 경우 "Update"에만 락이 걸린다는 점에서 Select 쿼리를 통해 값을 가져온 후 수정하게 되면 아래와 같은 상황이 발생할 수 있습니다👀

현재 글의 조회수가 1인 경우, 사용자A와 사용자B가 동시에 조회를 하게 되면
사용자A와 B가 각각 조회수 1로 조회하게 되고
1에서 2로 업데이트하게 되어, 기대하는 결과는 조회수가 3이지만 결국 조회수는 2가 됩니다.

 

하지만 아래와 같이 쿼리문 자체에서 id를 읽어와서 수정하기 때문에 정상적으로 조회수 수정이 가능합니다!

@Query("UPDATE AccompanyPost ap SET ap.viewCount = ap.viewCount + 1 WHERE ap.id = :id")



 


Exclusive락의 느린 속도를 해결하는 또 다른 방법은 없을까?

 현재는 조회수만을 제어하기에 문제가 없지만, Select 쿼리 후 Update가 이루어지는 경우에는 Update 락을 사용하게 되면 문제가 발생할 수 있습니다. 이러한 상황에서 온전하게 막기 위해서는 Exclusive 락을 사용해야 하지만, 이 경우 속도가 느리다는 단점이 있습니다. 디스크 기반 Database인 MySQL은 메모리에 접근하는 것 보다 Disk I/O가 느리기 때문에, 인메모리 기반 Database Redis를 사용하면 빠르게 조회가 가능합니다. 특히 Redis는 기본 구조가 싱글 스레드이며 multiplexing이란 기술을 사용하여 단일 프로세스가 모든 클라이언트 요청을 처리하게 되어 동시성 제어를 위해 많이 사용됩니다.

 

➕multiplexting: 하나의 통신채널을 통해 다량의 데이터를 전송하는 데 사용되는 기술

 


결론👏

 조회수의 경우, Update에서만 충돌이 발생하기에 Update 락을 적용해주었습니다. "자바와 데이터베이스 둘 중 어느 쪽에서 동시성 제어를 하는 것이 좋을지" 그리고 "Update Lock과 Exclusive Lock 중 어떤 것을 선택해야 할지"에 대한 고민을 통해, 각각의 장단점을 분석하고 테스트로 소요 시간을 측정하여 적절한 방법을 선택해보았습니다. 다음 포스팅에서는 Redis를 통해 동시성 제어를 다루어보겠습니다~