인프런 영문 브랜드 로고
인프런 영문 브랜드 로고

인프런 커뮤니티 질문&답변

김규혁님의 프로필 이미지

작성한 질문수

스프링부트로 직접 만들면서 배우는 대규모 시스템 설계 - 게시판

댓글 수 구현

댓글 수 구현에서 동시성 문제 해결 질문드립니다

해결된 질문

작성

·

65

0

  • 학습 관련 질문을 최대한 상세히 남겨주세요!

  • 고민 과정도 같이 나열해주셔도 좋습니다.

  • 먼저 유사한 질문이 있었는지 검색해보세요.

  • 인프런 서비스 운영 관련 문의는 1:1 문의하기를 이용해주세요.

안녕하세요 댓글 수 구현 강의를 해보다가 동시성 문제를 해결해보고 싶어서 비관적 락 for update를 사용하는 방법으로 한번 코드를 짜보고 테스트를 해보고 있습니다.

코드는 아래처럼 짜보았습니다

@Table(name = "article_comment_count")
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
public class ArticleCommentCount {
    @Id
    private Long articleId;
    private Long commentCount;

    public static ArticleCommentCount init(Long articleId, Long commentCount) {
        ArticleCommentCount articleCommentCount = new ArticleCommentCount();
        articleCommentCount.articleId = articleId;
        articleCommentCount.commentCount = commentCount;

        return articleCommentCount;
    }

    public void increase() {
        this.commentCount++;
    }

    public void decrease() {
        this.commentCount--;
    }
}
public interface ArticleCommentCountRepository extends JpaRepository<ArticleCommentCount, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<ArticleCommentCount> findLockedByArticleId(Long articleId);
}
@Service
@RequiredArgsConstructor
public class CommentService {
    private final CommentRepository commentRepository;
    private final Snowflake snowflake = new Snowflake();
    private final ArticleCommentCountRepository articleCommentCountRepository;

    @Transactional
    public CommentResponse create(CommentCreateRequest request) {
        Comment parent = findParent(request);

        Comment comment = commentRepository.save(
                Comment.create(
                        snowflake.nextId(),
                        request.getContent(),
                        parent == null ? null : parent.getCommentId(),
                        request.getArticleId(),
                        request.getWriterId()
                )
        );

        ArticleCommentCount articleCommentCount = articleCommentCountRepository.findLockedByArticleId(request.getArticleId())
                .orElseGet(() -> {
                    ArticleCommentCount newCount = ArticleCommentCount.init(request.getArticleId(), 0L);
                    articleCommentCountRepository.save(newCount);
                    return newCount;
                });

        articleCommentCount.increase();

        articleCommentCountRepository.save(articleCommentCount);

        return CommentResponse.from(comment);
    }

    private Comment findParent(CommentCreateRequest request) {
        Long parentCommentId = request.getParentCommentId();

        if (parentCommentId == null) {
            return null;
        }

        return commentRepository.findById(parentCommentId)
                .filter(not(Comment::getDeleted))
                .filter(Comment::isRoot)
                .orElseThrow();
    }

    public CommentResponse read(Long commentId) {
        return CommentResponse.from(commentRepository.findById(commentId).orElseThrow());
    }

    @Transactional
    public void delete(Long commentId) {
        commentRepository.findById(commentId)
                .filter(not(Comment::getDeleted))
                .ifPresent(comment -> {
                    if (hasChildren(comment)) {
                        comment.delete();
                    } else {
                        delete(comment);
                    }
                });
    }

    private boolean hasChildren(Comment comment) {
        return commentRepository.countBy(comment.getArticleId(), comment.getCommentId(), 2L) == 2;
    }

    private void delete(Comment comment) {
        commentRepository.delete(comment);

        articleCommentCountRepository.findLockedByArticleId(comment.getArticleId())
                .ifPresent(articleCommentCount -> {
                    articleCommentCount.decrease();
                    articleCommentCountRepository.save(articleCommentCount);
                });

        if(!comment.isRoot()) {
            commentRepository.findById(comment.getParentCommentId())
                    .filter(Comment::getDeleted)
                    .filter(not(this::hasChildren))
                    .ifPresent(this::delete);
        }
    }

    public CommentPageResponse readAll(Long articleId, Long page, Long pageSize) {
        return CommentPageResponse.of(
                commentRepository.findAll(articleId, (page - 1) * pageSize, pageSize).stream()
                        .map(CommentResponse::from)
                        .toList(),
                commentRepository.count(articleId, PageLimitCalculator.calculatePageLimit(page, pageSize, 10L))
        );
    }

    // 무한 스크롤
    public List<CommentResponse> readAll(Long articleId, Long lastParentCommentId, Long lastCommentId, Long limit) {
        List<Comment> comments = lastParentCommentId == null || lastCommentId == null ?
                commentRepository.findAllInfiniteScroll(articleId, limit) :
                commentRepository.findAllInfiniteScroll(articleId, lastParentCommentId, lastCommentId, limit);
        return comments.stream()
                .map(CommentResponse::from)
                .toList();
    }

    public Long count(Long boardId) {
        return articleCommentCountRepository.findById(boardId)
                .map(ArticleCommentCount::getCommentCount)
                .orElse(0L);
    }
}

@Test
void concurrencyCountTest() throws InterruptedException {
    Long articleId = 24L;
    int threadCount = 10;

    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for(int i = 0; i < threadCount; i++) {
        final Long writerId = (long) (1000 + i);

        executorService.execute(() -> {
            try {
                createComment(new CommentCreateRequest(articleId, "concurrency test", null, writerId));
            } catch (Exception e) {
                System.err.println("Exception in thread: " + Thread.currentThread().getName() + " -> " + e.getMessage());
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    Long commentCount = restClient.get()
        .uri("/v1/comments/articles/{articleId}/count", articleId)
        .retrieve()
        .body(Long.class);

    System.out.println("최종 commentCount = " + commentCount);
    assertThat(commentCount).isEqualTo(threadCount);
}

그런데 이렇게 했을때

맨 처음 실행을 하면 1개의 데이터만 삽입되고 나머지 9개는 소실이됩니다

그리고 한번더 실행하면 11개의 데이터가 저장되는데 맨처음 저장된 1개의 데이터 + 10개의 스레드가 저장한 10개의 데이터가 되어 11개가 됩니다.

여기서 문제가 article_comment_count 테이블에 데이터가 아예 없을때 10개의 스레드가 동시에 insert문을 날리려고해서 Duplicate entry '24' for key 'article_comment_count.PRIMARY' 이런 문제가 나오지 않나 생각이 듭니다만.. create 메서드에 @Transactional(isolation = Isolation.READ_COMMITTED)로 격리 수준을 높여봤지만 여전히 문제가 해결되지 않습니다.

혹시 제가 잘못 이해한 부분이 있을까요? 그리고 동시성 문제를 해결하려면 어떻게 해야할까요? gpt에 물어보거나 구글링해서 찾아봐도 해결이 되지 않아서 질문드립니다!

답변 2

0

쿠케님의 프로필 이미지
쿠케
지식공유자

규혁님, 안녕하세요!

 

아래 코드를 살펴보겠습니다.

       ArticleCommentCount articleCommentCount = articleCommentCountRepository.findLockedByArticleId(request.getArticleId())
                .orElseGet(() -> {
                    ArticleCommentCount newCount = ArticleCommentCount.init(request.getArticleId(), 0L);
                    articleCommentCountRepository.save(newCount);
                    return newCount;
                });

        articleCommentCount.increase();

        articleCommentCountRepository.save(articleCommentCount);

        return CommentResponse.from(comment);

최초 요청 시점에는 ArticleCommentCount가 아예 만들어져 있지 않습니다.

따라서 findLockedByArticleId를 호출하더라도 락이 잡히는건 없고, 모든 최초 동시 요청은 orElseGet 블록으로 들어가게 됩니다.

모든 요청이 ArticleCommentCount를 반복호출 하고, 동일한 데이터를 중복 저장하려고 합니다.

하지만 유니크 제약으로 인해 1건의 요청만 성공하고, 나머지는 모두 실패하게 됩니다.

즉, 카운트는 1만 정상적으로 처리됩니다.

ArticleCommentCount가 정상적으로 만들어지고 난 이후 요청들은, 락이 잡히기 때문에 카운트 증가가 유실없이 처리됩니다.

 

이를 해결하기 위해서는 게시글 생성 시점에 ArticleCommentCount를 0으로 초기화해둘 수도 있고,

최초 동시 요청 시에는, 첫번째 요청이 처리될 때까지 나머지 요청들은 대기상태로 만들어둘 수도 있습니다.

DB의 레코드 락을 걸 수 있는 데이터가 아직 없으므로, 다른 방법으로 부가적인 처리가 필요한 것입니다.

그런데 꼭 이러한 처리가 필요할지, 실제 운영 관점에서 생각해볼 필요는 있을 것 같습니다.

게시글이 생성되자마자 저 아주 찰나의 순간에, 최초 동시 요청이 우르르 몰려오는 상황이 과연 존재할까요?!

테스트에서는 코드로 동시 요청을 자동화하기 때문에 충분히 상황을 만들어낼 수 있지만, 실 사용자 입장에서는 이러한 상황은 없거나 극히 드물 것 같습니다.

물론, 시스템 안정성과 신뢰성은 중요하지만, 이러한 극단적인 예외 케이스 때문에 시스템을 더욱 복잡하게 구축할 필요가 있을지에 대해 고민해보면 좋을 것 같습니다!

 

혹시 더 궁금한 점 있으시면 편히 문의 주세요!

김규혁님의 프로필 이미지
김규혁
질문자

와 감사합니다 ㅠㅠ

0

인프런 AI 인턴님의 프로필 이미지

안녕하세요, 인프런 AI 인턴입니다. 김규혁님.

비관적 락을 이용하여 댓글 수의 동시성 문제를 해결하려는 시도는 훌륭해 보입니다. 하지만, 질문에서 설명된 실행 결과는 여러 스레드가 동시에 동일한 자원에 접근하면서 발생할 수 있는 데이터 충돌 및 중복 문제 에 대처하지 못했기 때문에 발생할 가능성이 높습니다.

문제 분석

  1. 중복된 Entry 생성: 여러 스레드가 동시에 ArticleCommentCount 테이블에 동일한 articleId 를 가진 새로운 엔트리를 삽입하려다 보니 중복 문제(Duplicate entry '24' for key ...)가 발생하고 있습니다. 이 문제는 트랜잭션이 동시에 실행되는 경우 발생할 수 있으며, 데이터베이스의 트랜잭션 격리 수준에 의해서도 영향을 받을 수 있습니다.

  2. 비관적 락 사용의 한계: 비관적 락을 사용하여 특정 시점에 한 스레드만 자원에 접근하도록 하였으나, 문제가 되는 것은 첫 삽입 시에 동시에 여러 스레드가 articleCommentCountRepository.save(newCount);를 실행하려는 점입니다. 여기서는 find이전 단계에서 병목현상이 생길 수 있습니다.

해결 방법

  1. 초기 데이터 설정:

    • ArticleCommentCount 엔트리를 처음부터 0으로 삽입해 두는 것을 추천합니다. 이는 여러 스레드가 동시에 엔트리를 삽입하려고 할 때 발생할 수 있는 충돌을 사전에 예방할 수 있습니다.
  2. 락과 병목 조정:

    • @Transactional 의 격리 수준을 조절하거나, 비관적 락을 find 이외에도 적용해서 모든 상황에서 쓰기 작업이 완료되기 전까지 다른 쓰기 작업이 대기하도록 설정해 볼 수 있습니다.
  3. entity 존재 확인과 조건부 삽입:

    • 이미 존재하는 경우에 대비하여 하단의 하위 조건부 코드를 보강하고, 이 과정에서의 예외 처리 방식을 재정비하는 것이 한 가지 방법이 될 수 있습니다:
   ...
   ArticleCommentCount articleCommentCount = articleCommentCountRepository
            .findLockedByArticleId(request.getArticleId())
            .orElseGet(() -> {
                // 엔트리 존재 여부 체크 후 중복 방지
                long initialCount = articleCommentCountRepository
                    .findById(request.getArticleId())
                    .map(ArticleCommentCount::getCommentCount)
                    .orElse(0L);

                if (initialCount == 0) {
                    ArticleCommentCount newCount = ArticleCommentCount.init(request.getArticleId(), 0L);
                    articleCommentCountRepository.save(newCount);
                }
                return articleCommentCount;
            });
   ...

관련 사례나 유사한 문제 해결을 위한 더 많은 팁을 제공하는 아래의 링크를 참고하시기 바랍니다:

저는 질문자님의 이해를 돕고자 지식공유자님의 답변을 참고하여 유사한 문제 해결 방안 링크를 제공하고 있습니다. 현재 베타 기능으로 답변이 충분치 않을 수 있는 점 양해 부탁드리며, 추가적으로 궁금한 점이 있으시면 자유롭게 질문해 주세요. 곧 지식공유자께서 추가적인 답변을 제공해 드릴 것입니다.