좋아요 기능 동시성 문제 개선

1c2·2025년 4월 17일
0

back-end

목록 보기
4/4
  1. 기존 로직 (Soft Delete)
@Transactional
    public LikeResponseDTO toggleLikePost(LikeRequestDTO dto) {
        Post post = postRepository.findById(dto.postId())
                .orElseThrow(() -> new BadRequestException(POST_NOT_FOUND));
        Member member = memberRepository.findById(dto.userId())
                .orElseThrow(() -> new BadRequestException(MEMBER_NOT_FOUND));

        Optional<PostLike> existingLike = postLikeRepository.findByMemberAndPost(member, post);

        if (existingLike.isPresent()) {
            // 이미 좋아요 되어있다면 → 좋아요 취소
            postLikeRepository.delete(existingLike.get());
            post.decreaseLikeCount();
            return new LikeResponseDTO(post.getId(), post.getLikeCount());
        } else {
            // 좋아요 안 되어있다면 → 좋아요 추가
            postLikeRepository.save(PostLike.of(member, post));
            post.increaseLikeCount();
            // LogUtil.loggingInteraction(LIKE, post.getId());
            return new LikeResponseDTO(post.getId(), post.getLikeCount());
        }
    }

문제 상황

동시성 테스트

서비스 가입자 수는 60명. 이중 절반의 유저가 좋아요 / 좋아요 취소 요청을 반복해서 보내는 상황 가정했습니다.

Ramp-up으로 2분간 점진적 부하하고 이후 2분간 최대 부하를 지속했습니다.

30명의 유저가 10개의 포스트 중 하나로 좋아요 요청 / 취소 반복하는 상황을 설정했습니다.

테스트 결과

결과 분석

500 에러가 발생한 이유 : 요청이 여러 번 오는 조건 속에 동시성 문제 발생

해결 방법 List

  • DB Unique 제약 조건
  • Redis Cache
  • Synchronized
  • 분산 lock
  • 비동기 큐 사용
  • 쿼리 튜닝

해결 방법 1 : UNIQUE 제약 조건

ALTER TABLE likes
ADD CONSTRAINT uq_member_post UNIQUE (member_id, post_id);


Unique 키 제약조건을 추가하기 위해 Soft Delete 코드를 주석처리 했습니다.

기존 대비 전체적인 Internal Server Error가 줄었지만 완전히 문제가 해결되지는 않는 것을 확인할 수 있습니다.

로그 분석

SQLIntegrityConstraintViolationException & StaleObjectStateException

  • 2개 이상의 쓰레드에서 ‘좋아요’가 없다고 판단 → 삽입 시도 → 하나의 쓰레드에서는 에러 발생
  • 또는 삭제하려고 했는데 이미 삭제가 된 경우
  • 낙관적 동작을 하기 때문

Deadlock

( 내용 추가 예정 )

해결방법2 : Redis 사용 (Read through Write back)

기존 구조

변경 후 구조

장점 :
Redis는 단일 쓰레드로 동작하기에 동시성 문제를 어느 정도 해결해준다.
In memory DB로 빠르게 반영 가능하다.
DB 부하 분산해준다.

단점 :
동시성 문제를 완전히 해결하는 것은 아니다.
데이터 유실이 가능하다.
일관성 지연이 발생할 수 있다.

		@Transactional
    public LikeResponseDTO redisToggleLikePost(LikeRequestDTO dto) {
        String postKey = buildPostKey(dto.postId());
        String userKey = buildUserKey(dto.postId(), dto.userId());
        String userSetKey = buildUserSetKey(dto.postId());

        initLikeCountIfAbsent(dto.postId(), postKey); // 좋아요 수 캐싱

        boolean hasLiked = TRUE.equals(redisTemplate.hasKey(userKey));

        if (hasLiked) {
            processUnlike(postKey, userKey, userSetKey, dto.userId());
        } else {
            processLike(postKey, userKey, userSetKey, dto.userId());
        }

        redisTemplate.opsForSet().add(TRACKED_POST_SET_KEY, String.valueOf(dto.postId()));

        long likeCount = getLikeCount(postKey);
        return new LikeResponseDTO(dto.postId(), likeCount);
    }

실제로 동시성 문제를 회피하고 안정적으로 응답하는 것을 확인할 수 있습니다.

0개의 댓글