새소식

DB

[DB] 좋아요 성능 문제와 동시성 문제 해결하기 - 다중 유니크 키, 실행계획, JMeter

  • -

 

작년과 올해 프로젝트를 진행하면서 겪었던 문제들과, 이번 Real MySQL 북스터디를 하면서 습득했던 지식들로 문제를 해결해보았습니다.

 

시나리오

작년과 올해 진행했던 두개의 그룹 프로젝트.

 

많은 서비스들이 본인이 보았던 컨텐츠에 반응하거나 저장하기 위해 좋아요, 북마크 등의 기능을 가지고 있습니다.

 

이런 기능들은 다음과 같은 문제점들을 주의 해야 합니다.

 

  • 중복 요청 문제 (일명 따닥..)
  • 해당 로직과 관련된 쿼리의 성능 문제

 

준비

도메인은 다음과 같이 User, Post, Likes 를 준비했습니다.

@Entity
@AllArgsConstructor
@NoArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "USER_ID")
    private Long id;
}
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "LIKE_ID")
    private Long id;
}
@Entity
@NoArgsConstructor
public class Likes {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "LIKES_ID")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="USER_ID", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="POST_ID", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
    private Post post;

    public Likes(User user, Post post) {
        this.user = user;
        this.post = post;
    }
}

 

 

그러면 일단 어플리케이션 단에서 생각의 흐름대로 작성할 수 있는 코드를 살펴보겠습니다.

 

public void createLike(Long userId, Long postId) {
    User user = userRepository.getReferenceById(userId);
    Post post = postRepository.getReferenceById(postId);

    // 1
    if (likesRepository.existsByUserAndPost(user, post)) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    }
    // 2
    likesRepository.save(new Likes(user, post));
}
  1. 이전에 좋아요를 했는지 확인
  2. 이전에 좋아요를 누르지 않았다면 좋아요음! 완벽합니다. 이대로 한 번 돌려보겠습니다.
@Test
void concurrentTest() throws InterruptedException {
    likesService.createLike(1L, 1L);

    //한 번 더 좋아요를 실행하면, 예외가 발생합니다.
    Assertions.assertThatThrownBy(() -> likesService.createLike(1L, 1L))
            .isInstanceOf(ResponseStatusException.class);
}

순차적인 실행이기에, 의도대로 예외가 발생합니다.

 

따닥! 동시성 문제 해결하기.

그렇지만 실제  동시성이 높은 환경이면 어떻게 될까요?

유저가 실수로 버튼을 여러번 눌렀는데 그게 그대로 요청이 날아온 시나리오입니다.

 

유저가 5번의 요청을 동시에 날린 경우를 쓰레드를 통해 테스트 해보겠습니다.

@Test
void concurrentTest() throws InterruptedException {
    ExecutorService executorService = Executors.newFixedThreadPool(5);
    CountDownLatch latch = new CountDownLatch(5);

    for (long i = 0L; i < 5L; i++) {
        executorService.execute(() -> {
            likesService.createLike(1L, 1L);
            latch.countDown();
        });
    }

    latch.await();
}

엇. 

위에서 작성한 '기존에 좋아요를 눌렀는가'에 대한 예외가 터지지 않고 5개의 좋아요가 모두 테이블에 들어가게 되었습니다.

이렇게 되면 findByUserAndPost(user, post) 등의 쿼리에서 여러개의 값이 나오면서 이후 좋아요 관련 로직들이 정상적으로 동작하지 않게 됩니다.

 

실제 각 쿼리 요청들이 어떻게 일어났을지 예상해봅시다.

tx1 tx2
likesRepository.existsByUserAndPost(user, post)
이전에 좋아요가 눌러졌는지 조회 -> 정상
 
  likesRepository.existsByUserAndPost(user, post)
이전에 좋아요가 눌러졌는지 조회 -> 정상
좋아요 등록
likesRepository.save(new Likes(user, post))
 
COMMIT; 좋아요 등록
likesRepository.save(new Likes(user, post))
  COMMIT;

 

 

 

이 문제는 간단하게 'multiple unique key' 를 걸어주면 됩니다.

@Table(indexes = {@Index(name="idx02_unique_likes", columnList = "USER_ID, POST_ID", unique = true)})
public class Likes {
	...
}

 

이후에는 insert를 할 때 제약조건으로 인한 Exception이 발생합니다. 

그렇다면 사실 이전에 좋아요를 했는지에 대한 select count 쿼리 또한 날리지 않아도 됩니다.

 

실제로 서비스에는 이런식으로 적용했습니다.

public void createLike(Long userId, Long postId) {
    try {
        User user = userRepository.getReferenceById(userId);
        Post post = postRepository.findById(postId).orElseThrow(() -> new PhochakException(ResCode.NOT_FOUND_POST));
        likesRepository.save(likes);
    } catch (DataIntegrityViolationException e) {
        throw new PhochakException(ResCode.ALREADY_PHOCHAKED);
    }
}

이런 유니크 제약 조건을 걸면 유니크 인덱스가 생성됩니다. 이 유니크 인덱스는 성능 개선을 가져다 줄 수 있습니다. 생각해보면 좋아요를 주로 조회하는 경우는 다음 두가지 케이스가 있습니다.

  • 내가 좋아요한 게시글인지 확인 
  • 해당 포스트의 좋아요 개수 확인

여기에서 내가 좋아요한 게시글 같은 경우, (user_id, post_id) 쌍으로 검색이 일어납니다. 이 경우에 UNIQUE 인덱스가 걸려있다면 효율적인 탐색이 가능하게 됩니다. UNIQUE 인덱스는 옵티마이저에게 "조회할 값이 하나다" 라는 조회 성능상 이점이 되는 힌트를 주기도 합니다.

(2023.4.14 정정)

Real MySQL 을 읽으면서 해당 내용에 대해서 정정합니다. 유니크 인덱스 자체는 일반적인 보조 인덱스와 성능에 있어서 차이가 미미하다고 합니다. 책에 따르면, 인덱스에 대한 결과가 여러개라고 해도 원하는 해당 컬럼을 선별하는 작업은 CPU가 처리하며, 추가적인 I/O가 발생하는 것이 아니기 때문에 큰 차이는 없다고 합니다. (음.. 그런데 혹시 데이터의 개수가 페이지의 사이즈를 벗어날만큼 크다면 다른 얘기일 수 있지 않을까요? 물론 이 로직에서 해당하는 케이스는 아니지만, 개인적인 생각입니다!)

 

 

그렇다면 해당 포스트의 좋아요 개수를 카운트 하는 방식은 어떻게 개선해야 할까요?

 

 

반 정규화(De-Nomalization)로 성능 개선하기

기존 같은 방식은 좋아요가 늘어날수록 좋아요와 관련된 쿼리 성능이 급격하게 나빠진다는 문제가 있습니다.

 

특히 피드 조회에서 좋아요 개수를 카운트 해주는 기능이 있는데, 한 번에 많은 양을 요청하면서도 서비스에서 가장 많은 요청이 발생합니다.

 

포스트 하나의 좋아요 카운트 조회도 0.x초 정도가 걸리는데, 페이징 같은 경우 여러개의 포스트를 한 번에 조회해야 합니다.

SNS의 핵심 기능인 피드가 느리다면 유저들이 썩 달가워 하지 않을 것 같습니다.

 

또한 좋아요를 기반으로 정렬하거나 필터를 거는 기능은 상상도 못할 일입니다.

 

그렇다면 이런 문제를 해결하기 위해 반정규화를 고려합니다.

 

좋아요의 count를 관리하는 LikesCount 테이블을 따로 만들어 줍니다.

이 테이블은 게시글 테이블과 생명주기를 같이합니다.

@Entity
@NoArgsConstructor
@Table(indexes = {@Index(name="idx01_unique_post_id", columnList = "POST_ID", unique = true)})
public class LikesCount {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "LIKES_COUNT_ID")
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="POST_ID", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
    private Post post;

    @ColumnDefault("0")
    private int count;

    public LikesCount(Post post) {
        this.post = post;
    }
}

 

 

그렇다면 다음과 같은 두 가지 데이터를 저장 및 수정하게 됩니다.

  • Likes : 누가 어떤 포스트를 좋아요를 했는지 저장
  • LikesCount: 특정 포스트가 좋아요의 개수 + 1 수정

 

음,,, 그런데 LikesCount를 수정하면서 흔히들 말하는 동시성 문제가 발생하지 않을까요?

 

그렇다면 LikesCount 부분만 테스트를 진행해보겠습니다.

 

@Transactional
public void createLikeV2(Long postId) {
    try {
        LikesCount likesCount = likesCountRepository.findByPostId(postId);
        likesCount.increaseLikesCount(); // this.count = count + 1;
    } catch (DataIntegrityViolationException e) {
        log.error("서비스 에러 발생", e);
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    }
}

코드는 위와 같습니다.

실제 쿼리는 다음과 같이 나가는 것을 확인할 수 있습니다.

  1. SELECT * FROM like_count WHERE postId = ?
  2. UPDATE like_count SET count = ?

 

그렇다면 또 1000번의 동시 요청을 쓰레드를 통해 실행시켜 보겠습니다.

역시 동시성 문제가 발생했습니다.

 

 

이 경우 발생 가능한 상황을 시간순으로 나타내면 다음과 같습니다.

TX1이 먼저 N에 N+1을 했지만, TX2가 나중에 commit 되어 버리면서 TX1가 커밋한 내용이 덮어져버립니다.

 

이제 이 문제를 해결하기 위한 다양한 방법을 알아보겠습니다.

 

 

동시성 해결1: SELECT ...  FOR UPDATE

 

SELECT ... FOR UPDATE를 통해 읽는 시점에 락을 거는 방식으로 해결이 가능합니다.

JPA 에서 PESSIMISTIC_WRITE 를 사용하면 SELECT 에 FOR UPDATE가 붙어서 나가는 모습을 확인할 수 있습니다.

 

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="2000")})
LikesCount findByPostId(@Param("postId") Long postId);

이렇게 되면 해당 transaction이 끝나기 전까지 해당 컬럼에 대한 배타적 락이 걸리게 됩니다.

 

문제 없이 좋아요가 1000개 들어간 것을 확인할 수 있습니다.

 

 

동시성 해결2: 그냥 자동으로 생성되는 Exclusive Lock을 통해 간단하게 해결하기

 

사실 이 문제는 생각보다 간단하게 해결됩니다.

바로 UPDATE 쿼리를 작성했을때 자동으로 Exclusive Lock이 발생하게 되는데, 이것을 활용하면 쉽습니다

 

조회를 하지 않고 update를 하면 됩니다 !!!

 

@Modifying
@Query("UPDATE LikesCount l SET l.count = l.count + 1 where l.post.id = :postId")
void increaseViewCount(@Param("postId") Long postId);

 

update에서의 배타적 락은 where 절에서 참조된 컬럼에 대해 넥스트 키 락을 걸어서 해당 컬럼에 대해 정합성을 보장합니다.

 

이것을 기다리는 세션은 lock_wait_timeout만큼 기다린 후 타임아웃이 발생합니다, 그 전에 exclusive lock을 잡던 세션이 락을 해제하게 되면 이제 새로운 세션이 lock을 잡게 됩니다.

 

 

위의 increaseViewCount()를 반영한 service 로직입니다.

@Transactional
public void createLike(Long userId, Long postId) {
    User user = userRepository.getReferenceById(userId);
    Post post = postRepository.getReferenceById(postId);

    try {
        likesCountRepository.increaseViewCount(postId);
        likesRepository.save(new Likes(user, post));
    } catch (DataIntegrityViolationException e) {
        log.error("서비스 에러 발생", e);
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    }
}

 

왼쪽은 기존 Likes 테이블의 전체 컬럼을 count() 한 것이고, 오른쪽은 포스트에 대한 count 값만을 가지고 있습니다.

 

스레드 동시 요청 1000개를 실행하였고, 문제 없이 좋아요가 반영된 모습을 확인할 수 있습니다.

 

 

조금의 아이디어로 UPDATE 대상이 존재하는지 확인하고 정합성 문제 해결하기

그런데 select 없이 위의 한줄짜리 update 쿼리는 문제가 있습니다.

UPDATE 쿼리는 해당 게시글이 존재하는지 존재하지 않는지도 모른채 나가기 때문입니다.

 

UPDATE 쿼리 자체로는 딱히 문제가 되지 않겠지만, 

likesRepository.save(new Likes(user, post));

에서 존재하지 않는 포스트에 대한 좋아요가 생기면서, 정합성 문제가 생기게 됩니다.

 

이런 문제들의 발현 확률을 줄이기 위해서 정규화를 진행하는게 아닐까요?

 

 

그렇다고 select 쿼리를 한 번 더 날리고 싶지는 않아서, 조금의 아이디어를 더해 다음과 같은 방식을 사용했습니다.

@Transactional
public void createLike(Long userId, Long postId) {
    User user = userRepository.getReferenceById(userId);
    Post post = postRepository.getReferenceById(postId);
    
    try {
        int countOfUpdatedRow = likesCountRepository.increaseViewCount(postId);
        if (countOfUpdatedRow < 1) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        likesRepository.save(new Likes(user, post));
    } catch (DataIntegrityViolationException e) {
        log.error("서비스 에러 발생", e);
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    }
}

RDBMS 에서 직접 update 쿼리를 날려보신 분들은 아시겠지만, 해당 update 쿼리가 반영된 컬럼의 수를 결과값으로 보여줍니다.

 

이것을 활용하여 실제로 update 가 적용된 테이블이 있는지 확인하면, 해당 테이블이 존재하는지에 대한 것을 체크하는 것과 같은 효과를 가지게 됩니다.

 

만약 여기에서 예외가 발생한다면 rollback이 되어서 정합성에 문제가 없겠죠?

 

또 생각해볼 부분은,

  • likesRepository.save(new Likes(user, post));
  • likesCountRepository.increaseViewCount(postId);

이 두개의 쿼리 중 어떤게 먼저 나가야하는가에 대한 고민을 하게 됩니다.

 

만약 대상 테이블이 존재하지 않는 이유로 rollback이 되는 상황이라면 increaseViewCount 이 먼저 오는게 쿼리 회수도 줄이고 롤백 비용을 아낄 수 있겠죠?

 

 

JMeter를 사용해서 성능 확인하기

 

반정규화 테이블에서 조회 vs count() 로 직접 세는 방식

실제로 얼마나 더 빠른지 JMeter로 동시 요청을 통해 살펴보겠습니다.

 

<테스트 조건>

  • 포스트 100개
  • 유저 1,000명
  • 좋아요 100,000개- 모든 포스트는 유저 1,000명의 좋아요를 받은 상태
  • 특정 포스트의 좋아요 개수 조회 1,000회 동시 요청
더보기

(더미 코드)

@PostConstruct
public void createDummy() {
    for (long k = 0L; k < 100L; k++) {
        Post post = new Post(k);
        postRepository.save(post);
        likesCountRepository.save(new LikesCount(post));
    }
    for (long k = 0L; k < 1000L; k++) {
        userRepository.save(new User(k));
    }

    for (long k = 0L; k < 1000L; k++) {
        for (long j = 0L; j < 100L; j++) {
            likesService.createLike(k, j);
        }
    }
}

 

JMeter 요청 설정값도 공유해드립니다.

 

<결과>

 

 COUNT() 쿼리로 조회

7초가 걸렸습니다.

또한 처리 요청에 대한 min 값과 max 값의 편차가 크고, 최대 6초까지 걸린 요청이 있었습니다.

 

별도의 테이블 조회

정확하게 1초만에 1000개의 요청이 한 번에 끝났습니다.

당연한 결과겠죠?

 

테스트로 진행한 쿼리는 단순히 하나의 게시글의 좋아요 수를 의미하지만,

실제 로직에서는 앞서 말씀드렸듯이 메인 피드에서 10개의 포스트를 한 번에 조회하는 복잡한 페이징 쿼리가 있었습니다.

 

10개의 테이블의 좋아요 1000개를 각각 한 번에 조회하는데, count로 하게 되면 서비스 메인에서 유저에게 보여지는 화면은 굉장히 느릴 것 입니다.

 

Multiple Unique Key 다시 확인하기

@Table(indexes = {@Index(name="idx02_unique_likes", columnList = "USER_ID, POST_ID", unique = true)})
public class Likes {
...
}

음! multiple index를 걸게 되면, 인덱스 키의 순서는 매우 중요합니다.

 

가장 먼저 작성한키를 우선 기준으로 실제 인덱스 테이블에서 정렬이 되어 들어가기 때문입니다.

어떤 순서로 정렬이 되어 들어가는지 생각해볼까요?

 

 

오른쪽이 (USER_ID, POST_ID)에 대한 인덱스 테이블이며, 왼쪽이 (POST_ID, USER_ID)에 대한 인덱스 테이블입니다.

즉 먼저 오는 인덱스 키 값을 우선적으로 정렬되어 있기 때문에 비즈니스 로직에 따라 탐색 비용이  달라질 수 있습니다.

 

그렇기 때문에 서비스에서 어떤 방식으로의 접근이 더 많이 일어나는지 고려하고 인덱스를 정의해야 합니다.

 

지금 현재 상태에서는 어떨까요?

이제 게시글을 기준으로 좋아요를 카운트 하는 경우를 별도의 테이블로 빼냈기 때문에 게시글 ID만을 기준으로 탐색하는 경우는 거의 없습니다. 대신 현재 서비스에서 좋아요를 북마크 기능과 동일하게 사용하고 있어서, 마이페이지에서 내가 좋아요한 게시글을 페이징으로 조회하는 기능이 존재합니다.

 

그래서 USER_ID 으로 탐색하는 것을 우선적으로 생각하여, (USER_ID, POST_ID) 순서로 Multiple Unique Index 를 설정하였습니다. 그러면 인덱스를 타면서 더 빠른 탐색이 일어나겠죠? 특히 범위 조건이라면 더더욱 확연한 성능 차이를 보여줄 것 입니다.

 

 

USER_ID와 POST_ID를 통해 조회를 해보고, 실제 실행 계획을 살펴보겠습니다.

 

 

 

다중 인덱스의 뒤에 값으로 조회 하는 경우

explain select * from likes where post_id=10

Extra에 skip scan을 하였다고 나와 있는데, 이 skip scan은 MySQL 8.0 버전부터 지원하는 기능입니다.

원래대로라면 이 경우에 인덱스를 활용할 수 없었지만, 스킵 스캔을 통해 앞의 의존 인덱스를 건너뛰고 뒤에 나오는 인덱스를 활용할 수 있게 지원하는 기능입니다.

이 기능 덕분인지 아니면 unique 제약조건이 있어서인지, 사실 성능 테스트에서는 실행 시간이 크게 다르지 않았습니다.

 

 

다중 인덱스의 처음 값으로 조회 하는 경우 

explain select * from likes where user_id=10

type이 ref라면, user_id=10 이라는 조건을 보고 unique index를 참조 했음을 의미합니다.

의도대로 인덱스를 탔다고 볼 수 있겠네요!

 

공식 문서에는 다음과 같이 적혀있습니다. 설명은 조금 애매하지만, 예시로 적혀있는 query를 보니 맞다고 생각됩니다.

All rows with matching index values are read from this table for each combination of rows from the previous tables. 
ref is used if the join uses only a leftmost prefix of the key or if the key is not a  PRIMARY KEY or UNIQUE index (in other words, if the join cannot select a single row based on the key value). If the key that is used matches only a few rows, this is a good join type.
SELECT * FROM ref_table WHERE key_column = expr ;
...

 

 

결론

많은 서비스들에서 제공하는 좋아요 로직에 대해서 어떻게 데이터 정합성을 유지할지, 어떻게 최적화할지에 대해서 살펴봤습니다. 확실히 MySQL 공부 이후에 프로젝트를 리팩토링 해보니, 부족한 부분들과 생각치도 못한 인사이트를 많이 얻었습니다.

 

이러한 기본적인 동작들을 공부해서 알고 있어야 다양한 문제들을 미리 예방하고 개선할 수 있을 것 같습니다.

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.