동시성 이슈 feat.좋아요 기능

김준석·2024년 2월 25일
0

향수 추천 서비스

목록 보기
21/21

좋아요 기능 개발중에 맞이한 동시성 이슈 문제와 해결 과정에 대해 작성하였습니다.

먼저 게시글 좋아요 기능을 단일로 각각 실행했을 때 정상적으로 작동하는 것을 확인하였습니다.

{
    "memberId": 1,
    "postId": 5,
    "likeStatus": "LIKE"
}

{
    "memberId": 2,
    "postId": 5,
    "likeStatus": "LIKE"
}


좋아요 테이블엔 요청대로 결과가 저장되었고,
Board 테이블에 있는 likeCount도 정상적으로 2로 update 되었습니다.

그렇다면 한번에 100개의 요청을 보냈을 때 어떤 일이 발생할 지 테스트해보았습니다.

    @Transactional
    @DisplayName("락을 사용하지 않았을 경우 값이 정확하게 반영되지 않는다.")
    @Test
    void test() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);

        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    reviewLikeService.pushLike(4L);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        LikeCount likeCount = reviewBoardRepository.findByBoardId(4L).get().getLikeCount();
        Assertions.assertEquals(likeCount.getLikeCount(), 100L);
    }

org.opentest4j.AssertionFailedError:
Expected :100
Actual :42

42개만 반영이 되는 문제가 발생하였습니다.

실제 운영중인 서버에서 네트워크 지연, 트래픽 병목 등으로 인해 동시성 문제가 발생할 수 있습니다. 따라서 반드시 해결해나가야 할 문제라고 생각하였습니다.

동시성이란?

A 유저와 B유저가 동시에 좋아요를 눌렀을 시에 최종 좋아요 개수는 2가 되어야 하지만, 2가 아닌 1이 증가되는 현상입니다.

발생하는 이유는 여러 프로세스나 스레드가 공유 자원에 동시에 접근하려고 할 때, 경합 조건이 발생하는데 이는 요청 요청 순서와 응답 순서가 반드시 일치함을 보장할 수 없는 상태를 의미합니다.

가령, A,B라는 두개의 프로세스가 동시에 실행될 때, 여러가지 요인(네트워크 등)에 의해 B가 먼저 수행될 수도 있고 A가 먼저 수행될 수도 있는 경우를 의미합니다.

이렇게 공유 자원에 대해 안정적인 접근과 제어를 하기 위해서는 한번의 하나의 요청만을 처리할 수 있도록 제한하는 Lock이 필요합니다.

이를 위한 여러가지 방법을 살펴보겠습니다.

먼저 Application Level에서 할 수 있는 Synchronized입니다.

Application Level

Synchronized

Synchronized는 현재 데이터를 사용하고 있는 스레드를 제외한 나머지 스레드들이 데이터에 접근할 수 없도록 하는 개념입니다.
이는 단일 JVM 인스턴스 내에서 실행되는 스레드들 사이에서 동기화를 하기 위해 설계된 방법입니다.
따라서, 하나의 서버 내에서 발생하는 동시성 문제를 해결할 수 있습니다.

    public synchronized void calculatePushButton(PostLike postLike) {
        if (postLike.getLikeStatus() == LikeStatus.LIKE) {
            increaseLikeCount();
        }
        if (postLike.getLikeStatus() == LikeStatus.UNLIKE) {
            increaseUnlikeCount();
        }
    }

이 때, 첫번째 스레드가 commit을 하기 전에 두번째 스레드가 commit이 이루어질 수 있기 때문에 @Transactional을 사용하면 안된다고 합니다.

사용한 결과


테스트가 통과되었습니다!

문제

단, 대부분의 서비스는 분산 시스템으로 구성되어있습니다. 즉 여러 대의 서버가 공유 자원에 동시에 접근할 가능성이 있습니다. 여러대의 서버에서 동시에 접근하게 되면 동시성 문제가 발생할 수 있습니다.
따라서 Application Level보다 더 상위에서 문제를 해결해야 한다고 생각했습니다.

DB LEVEL

Lock

트랜잭션

트랜잭션 처리의 순차성을 보장하기 위한 방법입니다.
여기서 트랜잭션의 특징은

  • 원자성
    • 더이상 분해가 불가능한 최소 단위 -> 전부 처리되거나 하나도 처리되지 않아야 함
  • 일관성
    • 트랜잭선 실행의 결과로 데이터베이스 상태가 모순되지 않아야 함
  • 격리성
    • 실행 중인 트랜잭션에 다른 트랜잭션이 끼어들 수 없음
  • 영속성
    • 트랜잭션이 일단 성공적으로 완료되면 데이터베이스에 영속적으로 저장되어야 함

이 중에서 원자성 특징과 관련이 있는데, 예를 들어 두개의 세션 중 세션 1이 아직 커밋을 수행하지 않았는데, 세션 2가 동시에 같은 데이터를 수정하게 되면 문제가 발생합니다. 이를 원자성이 깨진다고 표현합니다.

또, 격리성과도 밀접한 연관이 있습니다.
트랜잭션의 격리 수준을 4단계로 나누어 본다면

  • READ UNCOMMITED(커밋되지 않은 읽기)
  • READ COMMITTED(커밋된 읽기)
  • REPEATABLE READ( 반복 가능한 읽기)
  • SERIALIZABLE( 직렬화 가능)

이 중 READ UNCOMMITED의 격리 수준이 가장 낮고 SERIALIZABLE의 격리 수준이 가장 높습니다.

(격리 수준이 낮을수록 동시성은 증가합니다. 다만 격리 수준에 따른 문제가 다양하게 발생합니다)

격리 수준에 따른 문제점을 더 상세히 살펴보자면,

READ UNCOMMITTED - 커밋하지 않은 데이터를 읽을 수 있습니다.
EX) 트랜잭션 1이 데이터를 수정하고 있는데 커밋하지 않아도 트랜잭션 2가 수정중인 데이터를 조회할 수 있습니다.(DIRTY READ)
이 상황에서 트랜잭션 2가 데이터 조회중에 트랜잭션 1이 롤백된다면 심각한 장애가 발생할 수 있습니다.

READ COMMITTED - 커밋한 데이터만 읽을 수 있기 때문에 DIRTY READ가 발생하지 않습니다. 하지만, 트랜잭션 1이 조회중인 상황에서 트랜잭션 2가 해당 내용을 수정하고 커밋하면 트랜잭션 1이 다시 조회할 때 수정된 데이터가 조회됩니다. 이렇게 반복해서 같은 데이터를 읽을 수 업슨ㄴ 상태를 NON-REPEATABLE READ라고 합니다.

REPEATBLE READ : READ COMMITTED와 다르게 한번 조회한 데이터를 반복해서 조회해도 동일한 데이터가 조회됩니다.
하지만, 반복 조회 시 결과가 달라지는 PHANTOM READ가 발생할 수 있습니다.
PHANTOM READ : 반복 조회 시 결과 집합이 달라지는 것

SERIALIZABLE : 가장 엄격한 트랜잭션 격리 수준입니다. 하지만 동시성 처리 성능이 급격히 떨어질 수 있습니다.

애플리케이션 대부분은 READ COMMITTED 수준으로 격리성을 설정한다고 합니다..!

LOCK

Lock은 여러 사용자들이 같은 데이터를 동시에 접근하는 상황에서 다른 세션의 데이터를 막아버리는 것입니다.

Lock은 위에서 살펴보았던 Synchronized에서도 비슷하게 사용되는 개념인데(Monitor Lock), 데이터에 접근하려는 세션이 Lock을 획득해야만 데이터의 변경을 할 수 있는 원리입니다.

즉 세션 1은 Lock을 획득했으므로 Update를 수행할 수 있는 반면, 세션 2는 세션 1이 갖고 있는 Lock을 받을 때까지 대기하는 것입니다.

비관적 락

비관적 락은 자원 요청 시 동시성 이슈가 자주 발생할 것이라고 '비관적'으로 예상하여 락을 걸어버리는 개념입니다.

이는 Lock의 종류 중 공유 락, 베타 락에 해당됩니다.

  • Shared Lock
    • 데이터를 조회할 경우 사용되고, 데이터를 읽어도 일관성에는 영향이 없기 때문에, 공유 락끼리는 동시 접근이 가능
  • Exclucive Lock
    • 데이터를 변경할 경우 사용되는 락, 다른 세션이 자원에 접근하는 것을 막고, 트랜잭션이 완료될 때까지 유지됨

이 때 자원을 Write하기 위해선 Exclucive Lock 을 얻어야 합니다. 얻기 위해선 Shared Lock이 걸려있는 다른 트랜잭션이 모두 수행된 후에 얻을 수 있습니다.

EX) 세션 1에서 A데이터를 읽을 때 (Shared Lock 갖고 있음), 세션 2에서 A데이터를 수정하려 하면, 세션 1이 갖고 있는 Shared Lock 때문에 Blocking 됩니다.
세션 1에서 commit이 완료 되어야 Lock이 넘어오기 때문에 commit 된 후 update를 수행할 수 있습니다.

JPA에서는
@Lock(LockModeType.PESSIMISTIC_WRITE)를 통해 비관적 락을 지정할 수 있습니다.

    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    Optional<PerfumeReviewBoard> findByBoardId(Long boardId);

비관적 락의 단점은?

  1. 데드락 가능성
    데드락은 두 개 이상의 프로세스나 스레드가 서로 자원을 얻지 못해서 서로 무한 대기에 빠지는 상황을 말합니다.
    특히, 여러 테이블이 조인되어 있을 경우, 트랜잭션이 다양한 순서로 여러 자원에 대한 Lock을 획득하려고 시도하기 때문에, 이러한 상호 의존성이 데드락을 초래할 수 있습니다.

EX)

트랜잭션 A와 B가 동시에 실행됩니다.

트랜잭션 A가 테이블 1의 락을 획득하고, 동시에 트랜잭션 B가 테이블 2의 락을 획득합니다.

트랜잭션 A가 작업을 계속하기 위해 테이블 2의 락을 요청합니다. 하지만 테이블 2는 이미 트랜잭션 B에 의해 잠겨 있어 대기해야 합니다.

트랜잭션 B도 작업을 계속하기 위해 테이블 1의 락을 요청합니다. 하지만 테이블 1은 이미 트랜잭션 A에 의해 잠겨 있어 대기해야 합니다.

  1. 비관적이기 때문에 성능 저하
    애초에 동시성 이슈가 자주 발생할 것을 염두하기 때문에, 실제 동시성 이슈가 많이 발생하지 않는다면 성능 저하가 발생할 수 있습니다.
    @DisplayName("비관적 락 사용했을 경우 값이 정상적으로 반영된다.")
    @Test
    void request() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);

        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() ->
            {
                try {
                    reviewLikeService.pushLike(19L);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        LikeCount likeCount = reviewBoardRepository.findByBoardId(19L).get().getLikeCount();
        Assertions.assertEquals(likeCount.getLikeCount(), 100L);
    }

사용 전

사용하지 않았을 경우 LikeCount가 15개만 올랐습니다.

사용 후

사용 후엔 정상적으로 100개가 올랐습니다.

낙관적 락

반대로 동시성 이슈가 자주 발생하지 않을 것으로 판단하고, 중간에 데이터 정합성 이슈가 발생하면 롤백을 수행해 정합성을 맞추는 원리입니다.

동시성 이슈가 발생했을 때 세션 1에서 값을 의미 업데이트 한 경우, 세션 2에선 해당 값을 조회할 수 없어 업데이트가 발생하지 않습니다. 업데이트가 발생하지 않는 것이 문제가 있는 것으로 판단하여 롤백처리를 합니다.

  • Version을 이용하여 데이터의 정합성을 맞춥니다.
  • Data를 조회한 후 update 실행 시에 현재 DB에 있는 데이터가 읽었던 version이 맞는지 확인한 후 업데이트 합니다.
  • 이 때, version이 다를 경우 다시 조회한 후 작업을 수행합니다.

Version

엔티티에 @Version을 추가하면 엔티티를 수정할 때마다 버전이 자동으로 1개씩 추가됩니다.
(type는 Long, Integer, Short, Timestamp가 있습니다)

ex)
version이 0인 A 엔티티가 존재할 때, 트랜잭션1이 A 엔티티를 수정하려고 조회했습니다. 이 때 트랜잭션2가 해당 데이터를 조회후 수정하고 커밋하면 Version이 1 늘어나게 됩니다.

이후에 트랜잭션 1이 엔티티를 수정하고 커밋하면 늘어난 Version과 기존 버전이 달라서 예외가 발생하게 됩니다.

특징 ! -> @Version이 있는 필드는 JPA가 직접 관리하기 때문에 개발자가 임의로 수정하면 안됩니다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity(name = "perfume_review_board")
@EntityListeners(AuditingEntityListener.class)
public class PerfumeReviewBoard {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id", nullable = false)
    private Long boardId;

    @NotNull
    @CreatedDate
    private LocalDateTime createdDateTme;

    @NotNull
    @ManyToOne(fetch = FetchType.LAZY)
    private Member writer;

    @NotNull
    private String title;

    @NotNull
    private Content content;

    @Embedded
    private LikeCount likeCount;
    
    @Version
    private Long version;

엔티티에 @Version 필드를 추가해주었습니다.

@Repository
public interface ReviewBoardRepository extends JpaRepository<PerfumeReviewBoard, Long> {

    @Lock(value = LockModeType.OPTIMISTIC)
    Optional<PerfumeReviewBoard> findByBoardId(Long boardId);

Repository에도 명시해주었습니다. 다만 엔티티에 @Version이 명시되어 있으면 굳이 적어줄 필요는 없다고 합니다.

    public void pushLikeByOptimisticLock(Long boardId) throws InterruptedException {
        while (true) {
            try {
                pushLike(boardId);
                break;
            } catch (Exception e) {
                Thread.sleep(50);
            }
        }
    }

락 획득 성공시 break하고 미획득시 sleep합니다.

이 상태로 테스트를 돌렸는데 무한루프에 빠졌습니다.

    public void pushLikeByOptimisticLock(Long boardId) throws InterruptedException {
        int maxTry = 20;
        int minimumTry = 0;
        while (true) {
            try {
                pushLike(boardId);
                break;
            } catch (Exception e) {
                if (++minimumTry >= maxTry) {
                    throw new RuntimeException("최대 재시도 횟수 초과", e);
                }
                Thread.sleep(50);
            }
        }
    }

먼저 수행 횟수에 제한을 걸어서 테스트를 돌려본 결과 pushLike()가 성공하지 못하고 계속 catch문에 걸리고 있었습니다.

그리고 데이터베이스에도 반영이 되지 않았습니다. @Version은 데이터베이스가 영속 상태일때만 올바르게 동작하기 때문에 영속상태로 만든 후 update를 시도해보았습니다.

단점

  1. 동시성이 자주 발생하면 빈번한 롤백이 발생할 수 있다 -> 성능 저하

적용

Synchronized, 비관적 락, 낙관적 락, 레디스 등 다양한 방법들이 존재했습니다.
그중 낙관적 락을 사용하였는데, 근거는 다음과 같이 생각해보았습니다.

  • 좋아요 수는 충돌이 발생해도 서비스에 크리티컬한 영향을 미치지 않을 것이라 판단하였음, 그렇기에 성능상 이점을 챙길 수 있는 낙관적 락을 선택하기로 결정

  • Redis를 사용해 본 적이 없기에, 상대적으로 적용이 쉬운 Lock 방식을 선택함, 추후에 공부해볼 예정!

  • 서비스의 서버는 1대라 Synchronized를 사용하면 간단하게 해결되지만, 실제로 1대의 서버로 서비스를 운영하진 않기 때문에 X

더 작성 예정!!!!

profile
기록하면서 성장하기!

0개의 댓글