Project & Issu

좋아요 기능 동시성 처리

gu9gu 2023. 5. 4. 20:05

요약 : 좋아요 service에 redis 분산 락 처리를 하여 동시성 테스트 완료

1차 커밋 : feat : 좋아요 기능 추가(redis를 사용한 동시성 처리, queryDSL 사용), board test code 추가 · jeoningu/Springboot-JPA-Blog@3726ea1 (github.com)

 

1. 좋아요 service 멀티쓰레드 동작시 갱신손실 문제

 동시성 처리를 해주지 않으면 좋아요 service를 테스트 코드에서 멀티쓰레드로 동작시켰을 때 동시성 테스트에 통과하지 못 합니다.

동시성 테스트를 해봤을 때 아래 로그에서 볼 수 있듯이 여러 쓰레드로 한 자원에 대해서 update를 하려고 할 때 이전 쓰레드에서 update를 하기 전 값을 select함으로써 이전 쓰레드에서 update한 값이 덮어쓰여지는 lost update (갱신 손실) 문제가 발생합니다.

갱신 손실(lost update)
하나의 트랜잭션이 갱신한 내용을 다른 트랜잭션이 덮어써서 갱신이 무효화 됨.
두 개 이상의 트랜잭션이 한 개의 데이터를 동시에 갱신할 때 발생함.

 

동시성 테스트 요구사항

  •  2개 게시글에 좋아요 동시 요청 테스트 ---> 각 게시글에 좋아요 요청한 만큼 count가 증가 돼야 함
  •  같은 사용자로 좋아요 동시 요청 테스트  --->  count +1 됐던게 count -1 돼서 count 수가 0이 돼야 함

(참고로, 아래 테스트는 완성된 좋아요 서비스 redis 분산 락 처리를 제거하고 쿼리로 +1 하는게 아니라 자바 코드로 +1 하는 경우의 테스트 결과입니다. thread가 어떻게 접근하는 지를 보여주기 위해 첨부했음)

1
2

 

2. 우선 로직을 단순화 해서 동시성 테스트시 문제가 발생하는 지 확인

아래와 같이 단순하게 select 후 count를 update를 하는 service단을 만들고동시성 테스트를 했을 때,

 java 코드로 +1한 값으로 update 쿼리를 날리는 경우는 갱신 손실이 발생합니다.

하지만  (update Board set likeCount=likeCount+? where board_id=? 형태의 query로 +1 update를 해주는) 쿼리 형태라기보단 index를 이용해서 조회하여 update 하는 쿼리로 날리면 MySQL에서 지원하는 원자적 연산에 의해 (update 쿼리가 순차적으로 실행돼서?) service를 호출한 만큼 좋아요 count가 올라갑니다. (갱신 손실 발생하지 않고 기본 멀티테스트 통과했음)

원자적 연산 참고

프로그래밍 초식 : DB 트랜잭션 조금 이해하기 02 격리 - YouTube

220425 TIL (동시성과 트랜잭션 격리) (velog.io)

07. 트랜잭션 (oopy.io)

 

1) 자바 코드로 +1 하는 경우  -> lost update(갱신 손실) 발생

    @Transactional
    public void like(Long boardId, User user) {
        // 게시글 검색
        Board findBoard = boardRepository.findById(boardId).orElseThrow(() -> {
            return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
        });
       findBoard.setLikeCount(findBoard.getLikeCount()+1);
	boardRepository.saveAndFlush(findBoard);
    }

발생 쿼리

더보기

update Board set modifiedDate=?, content=?, likeCount=?, title=?, user_id=?, viewCount=? where board_id=?

(참고로 boardRepository.saveAndFlush(findBoard); 를 제거하고 테스트 해봤는데, dirty checking으로 update쿼리를 만들어두고 @Transactional이 종료 되고 나서야 쿼리를 날리게 됨으로써 순서가 더 꼬여서 그런지 동시성 처리가 더욱더 의도한대로 되지 않고 갱신손실이 발생하였습니다)

 

2) 쿼리로 +1 하는 경우 (querydsl 사용) -> lost update(갱신 손실) 발생 안 함

    @Transactional
    public void like(Long boardId, User user) {
        // 게시글 검색
        Board findBoard = boardRepository.findById(boardId).orElseThrow(() -> {
            return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
        });
       boardRepository.addLikeCount(findBoard);

    }
    @Override
    public void addLikeCount(Board findBoard) {
        queryFactory.update(board)
                .set(board.likeCount, board.likeCount.add(1))
                .where(board.eq(findBoard))
                .execute();
        entityManager.clear();
    }

발생 쿼리

더보기

update Board set likeCount=likeCount+? where board_id=?

 

3.  좋아요 서비스에 로직 추가해서 테스트

 count를 올려주는 로직에서 추가적으로 로직을 넣어서 테스트 했을 때 원자적 연산 update 쿼리를 사용한다 하더라도 java 분기 로직에 의해 "같은 사용자로 좋아요 동시 요청 테스트"에 대해서는 테스트를 통과하지 못 합니다. 동시성 처리가 별도로 필요하게 됩니다.

 

적용된 로직은 다음과 같습니다.

1. 좋아요를 요청한 유저가 이미 좋아요를 눌렀는지?

1-1) 예 -> 좋아요 count -1, 좋아요 요청한 user정보 삭제

1-2) 아니요 -> 좋아요 count +1, 좋아요 요청한 user 정보 저장

    @Transactional
    public void doLike(Long boardId, User user) {
        // 게시글 검색
        Board findBoard = boardRepository.findWithUserById(boardId).orElseThrow(() -> {
            return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
        });
        /*log.debug("update 수행 전 게시글 {}의 좋아요 수 : {}", findBoard.getId(), findBoard.getLikeCount());*/

        // 이미 좋아요 되어있다면 카운트 감소, 좋아요 정보 삭제
        Optional<Like> likeByUserAndBoard = likeRepository.findByUserAndBoard(user, findBoard);
        if (likeByUserAndBoard.isPresent()){
            likeRepository.delete(likeByUserAndBoard.get());
            boardRepository.subtractLikeCount(findBoard);
        } else {

            // 좋아요 정보 저장
            Like like = Like.builder()
                    .user(user)
                    .board(findBoard)
                    .build();
            likeRepository.save(like);

            // 게시글에 좋아요 카운트 추가
            /*
            // 조회수만 1증가 시키는 간단한 예제이기 때문에 단순 쿼리로 해결할 수 있음
            findBoard.setLikeCount(findBoard.getLikeCount()+1);
            boardRepository.saveAndFlush(findBoard);
            */
            boardRepository.addLikeCount(findBoard);
        }
        /*Board findUpdatedBoard = boardRepository.findWithUserById(boardId).orElseThrow(() -> {
            return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
        });
        log.debug("update 수행 후 게시글 {}의 좋아요 수 : {}", findUpdatedBoard.getId(), findUpdatedBoard.getLikeCount());*/
    }

 

 

4. redis 분산 락으로 동시성 처리(갱신손실 방지)

 좋아요 service같은 경우 좋아요 요청을 한 만큼 count가 올라가고 좋아요를 이미 누른 사람이라면 다시 count를 감소시키는 요구사항이 만족 되어야 합니다. 즉 멀티 쓰레드로 동작했을 때 좋아요 데이터의 일관성이 유지되도록 동시성 처리가 필요합니다. 동시성 처리란 공유자원을 동시에 갱신하는 트랙잭션이 여러개 발생하는 경우 공유 자원에 대해서 각 사용자의 수정사항이 무시 되지 않고 데이터의 일관성을 유지시키고 데드락을 방지하기 위해 안전하게 동작 시키는 것을 말합니다. 동시성 처리를 해주지 않았을 때 갱신 손실이 발생할 수 있는데, 갱신손실이란 여러 트랜잭션이 한 자원에 데이서 동시에 갱신을 하려 할 때 이전 트랜잭션의 작업 내용을 덮어써서 손실되는 현상을 말합니다. 이 갱신 손실을 방지하기 위한 방법으로는 낙관적 락, 비관적 락, 분산 락과 같은 방법들이 있다고 합니다.

 

 optimistic lock(낙관적 락)은 충돌이 적게 일어날 거라고 예상되는 경우 사용하는 방법입니다. 먼저 데이터를 조회한 후 데이터를 갱신하려고 할 때 내가 조회한 데이터가 맞는지 버전을 확인해서 버전이 다르면 충돌한 것으로 보고 예외를 발생시키고 추가적으로 재시도나 실패 처리를 하는 방법입니다. dead lock이 발생할 수 있는 방법이고 별도의 lock을 걸지 않기 때문에 성능이 좋을 수 있지만 충돌이 일어났을 때 재시도 및 실패 처리가 필요해서 구현이 어렵고 이로 인해 빈번한 충돌이 일어나는 경우 비관적 락보다 성능이 더 안 좋아질 수 있습니다. 또한 분산 디비를 사용하는 경우 낙관적 락만으로는 데이터의 일관성을 보장하기 어렵기 때문에 다른 방법을 고려해야 합니다.

 

 pessmistic lock(비관적 락)은 모든 트랜잭션에 충돌이 발생할거라 예상 될 때 사용하는 방법입니다. 낙관적 락과는 다르게 DB의 LOCK기능을 사용해서 모든 트랜잭션마다 lock을 거는 방법입니다. (주로 select.. for update 사용) dead lock이 발생할 수 있고  lock이 필요하지 않은 상황에도 lock을 사용하기 때문에 트래픽이 많은 경우에는 성능이 저하된다는 문제점이 있습니다. 그리고 낙관적 락과 마찬가지로 부산 디비를 사용하는 환경에서는 비관적 락만으로 데이터의 일광성을 보장하기 어렵기 때문에 다른 방법을 고려해야 합니다.

 

 다시 정리해보면 낙관적 락, 비관적 락 같은 경우 성능 저하 문제가 있고 dead lock이 발생할 수 있고 분산 디비를 사용하는 경우에는 데이터의 일관성을 유지시키기 위해 별도 처리가 필요하므로 분산 락을 처리할 필요가 있습니다. Distributed Lock (분산 락)은 공통된 저장소를 이용해서 자원이 사용중인지를 체크해서 동시성 처리를 하는 방법이기 때문에 분산된 디비를 사용하는 경우에도 동시성 처리를 할 수가 있습니다. 여러 분산 락 방식 중에서도 redis가 구현이 쉽고 성능이 좋은 기술입니다.

 

 직접 테스트를 해보니 좋아요 service의 동시성 처리에 낙관적 락을 적용했을 때 dead lock이 발생을 막을 수 없어서 적용할 수 없었습니다. 비관적 lock이나 redis lock으로는 동시성 처리가 가능했는데, 현재 프로젝트는 단일 디비를 사용하지만 추 후에 분산된 디비 환경을 적용해볼 계획이 있어서 redis lock을 적용하였습니다. 그리고 성능적인 측면에서 생각해봤을 때 "비관적 lock을 사용했을 때 필요로 하지 않는 상황에도 db에 lock을 사용하여 트래픽이 많이 몰리는 경우 성능이 저하된다."라고 했지만 단일 서버와 단일 디비를 사용하는 환경에서는 redis보다 비관적 락이 더 성능이 잘 나오는 것 같다.  이렇게 간단한 테스트 결과만으로 판단해도 되는지 모르겠지만 실제로 아래 테스트 결과를 보면 현재 프로젝트의 단일 서버+단일 디비 환경에서는 redis가 더 느리다. 하지만 멀티 서버를 사용하는 환경인 경우에는 또 어떻게 테스트 결과가 나올지 모르겠다.

redis 분산 락 적용 후 테스트
비관적 락 적용 후 테스트

 

참고

07. 트랜잭션 (oopy.io)

공유 자원에 대한 동시성 처리(ex) 재고 시스템 상품 구매, 주식 매매) (tistory.com)

동시성 문제 해결하기 V2 - 비관적 락(Pessimistic Lock) (velog.io)

동시성 문제 해결하기 V3 - 분산 DB 환경에서 분산 락(Distributed Lock) 활용 (velog.io)

리뷰 등록 동시성 처리 기법 선택 (oopy.io)

동시성(Concurrency) 이슈와 JPA Lock 메커니즘의 오해 — 이로운 개발하기 (tistory.com)

 

4-1 redis distributed lock을 사용하기 위한 java 라이브러리들

4-1-1. Lettuce

 - Lettuce는 Spin Lock(스핀 락)을 사용하여 Lock이 없는 프로세스는 Lock을 획득하기 위해 무한루프를 돌게 되면서 성능저하가 있습니다.

4-1-2. Redisson

 - Redisson은 Publish Subscribe 방식을 사용하여 redis에서 점유하고 있던 lock이 해제 되면 lock 걸려고 대기하고 있던 클라이언트에게 신호를 보내주는 방식을 사용하기 때문에 스핀락을 사용하는 Lettuce보다 성능적으로 이점이 있어서 Redisson을 주로 사용합니다.

 - 또한 lock을 걸기 위해 대기하고 있는 최대 시간, lock이 걸려있는 최대 시간을 설정할 수 있어서 lock이 해제되지 않는 문제로 무한루프에 빠질 위험을 없앨 수 있습니다.

 - 트랜잭션에서 따로 redis로 명령어를 요청해도 atomic이 보장되지 않는데, Redisson을 사용하면 Lua 스크립트를 활용하여 atomic을 보장하고 한번에 명령어롤 요청함으로써 요청 수를 줄일 수 있습니다.

 

4-2 Redisson으로 분산 락 적용해보기

- 의존성 추가

    // redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.redisson:redisson-spring-boot-starter:3.17.6'

 

- 설정

  • host와 port 설정을 위한 RedissonClient 빈 설정.
@Configuration
public class RedissonConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private String port;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + host + ":" + port);

        return Redisson.create(config);
    }
}
Make sure the port Redis uses to listen for connections (by default 6379 and additionally 16379 if you run Redis in cluster mode, plus 26379 for Sentinel) is firewalled, so that it is not possible to contact Redis from the outside world.

 

- reddison을 이용하여 분산 lock 구현

아래 메서드들을 사용하여 기본적인 형태로 구현하였습니다.

  • getLock(): Lock을 조회한다.
  • tryLock() : 메서드를 통해 Lock을 획득한다.
    • WAIT_TIME : Lock을 획득하기 위해 대기 하는 시간
    • LEASE_TIME : Lock을 사용하는 시간, 해당 시간이 지나면 Lock을 반납한다.
    • TiemeUnit.SECONNDS : 시간 단위를 초로 사용
  • InterruptedException : 쓰레드가 인터럽트 될 경우 해당 예외를 발생시키므로 예외처리가 필요하다.
  • lock.unlock() : Lock을 반납한다
    /*
     좋아요 기능에 대해서 redisson을 이용하여 분산 락(Distributed lock) 처리
      - 여러 프로세스,쓰레드에서 공유자원에 접근할 때 동시 접근을 제어해서 자원의 정합성이 깨지지 않도록 하는 것
      - redisson 처리 메서드에는 @Transactional을 설정하면 lock 획득,해제가 정상동작하지 않기 때문에 @Transactional을 설정하면 안됨
      - 작업 메서드 doLike 에는 @Transactional 처리
     */
    public void likeUseRedisson(Long boardId, User user) {
        //key 로 Lock 객체 가져옴
        final String lockName = "boardId-" + boardId +" like";
        final RLock lock = redissonClient.getLock(lockName);
        try {
            //획득시도 시간, 락 점유 시간
            boolean available = lock.tryLock(100, 3, TimeUnit.SECONDS);
            if (!available) {
                log.warn("lock 획득 실패");
                return;
            }

            doLike(boardId, user);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }

dolike()

    @Transactional
    public void doLike(Long boardId, User user) {
        /*
        낙관적 락 처리시 try-catch 추가
        try {
        */
            // 게시글 검색
            /**/
            // 낙관적 락 처리 방법
            // Board findBoard = boardRepository.findByWithUserOptimisticLock(boardId).orElseThrow(() -> {
            // 비관적 락 처리 방법
            //Board findBoard = boardRepository.findByWithUserPessimisticWriteLock(boardId).orElseThrow(() -> {
            // 분산 락 처리 시에는 db에 lock 처리하지 않고 별도 저장소에 lock 처리
            Board findBoard = boardRepository.findWithUserById(boardId).orElseThrow(() -> {
                return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
            });
            /*log.debug("update 수행 전 게시글 {}의 좋아요 수 : {}", findBoard.getId(), findBoard.getLikeCount());*/

            // 이미 좋아요 되어있다면 카운트 감소, 좋아요 정보 삭제
            Optional<Like> likeByUserAndBoard = likeRepository.findByUserAndBoard(user, findBoard);
            if (likeByUserAndBoard.isPresent()) {
                likeRepository.delete(likeByUserAndBoard.get());
                boardRepository.subtractLikeCount(findBoard);
            } else {

                // 좋아요 정보 저장
                Like like = Like.builder()
                        .user(user)
                        .board(findBoard)
                        .build();
                likeRepository.save(like);

                // 게시글에 좋아요 카운트 추가
                /*
                // 조회수만 1증가 시키는 간단한 예제이기 때문에 단순 쿼리로 해결할 수 있음
                findBoard.setLikeCount(findBoard.getLikeCount()+1);
                boardRepository.saveAndFlush(findBoard);
                */
                boardRepository.addLikeCount(findBoard);
            }

            /*
            Board findUpdatedBoard = boardRepository.findWithUserById(boardId).orElseThrow(() -> {
                return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
            });
            log.debug("update 수행 후 게시글 {}의 좋아요 수 : {}", findUpdatedBoard.getId(), findUpdatedBoard.getLikeCount());
            */

        /*
        낙관적 락 처리시 try-catch 추가
        트랜잭션에서 board에 update할 때 optimistic lock으로 select했던 version과
        update하려는 시점의 board의 version이 다른 경우
        다른 트랜잭션에 의해 board가 update 됐다고 보고 ObjectOptimisticLockingFailureException가 발생 합니다.

        } catch (ObjectOptimisticLockingFailureException e) {
            // ObjectOptimisticLockingFailureException 발생한 좋아요 요청 트랜잭션에 대해서 처리가 필요함
        }
        */
    }

테스트 코드

package com.jig.blog.board;


import com.jig.blog.model.Board;
import com.jig.blog.model.Like;
import com.jig.blog.model.RoleType;
import com.jig.blog.model.User;
import com.jig.blog.repository.BoardRepository;
import com.jig.blog.repository.LikeRepository;
import com.jig.blog.repository.ReplyRepository;
import com.jig.blog.repository.UserRepository;
import com.jig.blog.service.BoardService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;

/**
 * 실제 db에 데이터 추가하면서 테스트를 수행
 *  - db에 데이터 없는 초기 상태라고 가정하고 테스트
 *  - 운영 중에도 테스트 수행할 수 있게 변경 필요할 듯
 *   : 테스트코드를 수정해서 실제 db에 테스트 했다가 테스트한 데이터는 지우는 방향으로 해도 되겠지만 테스트용 db를 사용하는 방법이 안전할 듯.
 */
@SpringBootTest
@Slf4j
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // @BeforeAll,@Afterall 을 사용하기 위함
public class BoardLikeServiceTest {

    @Autowired
    private BoardService boardService;
    @Autowired
    private BoardRepository boardRepository;
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private LikeRepository likeRepository;
    @Autowired
    private ReplyRepository replyRepository;
    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;
    private List<User> userList;
    private List<Board> boardList;
    private int DO_LIKE_SU = 100;

    /*
    beforeEach : 좋아요 요청할 게시글 추가
    afterEach : 매 테스트마다 좋아요 카운트를 하기 위해 추가했던 게시글, 좋아요 정보 삭제해서 초기 상태로 만든다.
    beforeAll : 동시성 테스트 요청시 사용될 사용자들 추가
    afterAll : 사용자 삭제
     */
    @BeforeEach
    public void beforeEach() {
        boardList = new ArrayList<Board>();
        for (int i=0; i<2; i++) {
            Board board = Board.builder()
                    .title("title")
                    .content("content")
                    .user(userList.get(0))
                    .build();

            this.boardList.add(board);
            boardRepository.saveAndFlush(board);
        }
    }

    @AfterEach
    public void afterEach() {
        likeRepository.deleteAll();
        boardRepository.deleteAll();
    }

    @BeforeAll // 테스트 클래스에서 한번만 실행
    public void beforeAll() {
        userList = new ArrayList<User>();
        for (int i=0; i<DO_LIKE_SU; i++) {
            User user = User.builder()
                    .username("test" + i)
                    .password(bCryptPasswordEncoder.encode("1234"))
                    .name("test1")
                    .email("test1@gmail.com")
                    .role(RoleType.USER)
                    .build();
            this.userList.add(user);
            userRepository.saveAndFlush(user);
        }
    }

    @AfterAll
    public void afterAll() {
        userRepository.deleteAll();
    }

    @Test
    @DisplayName("좋아요 요청 기본 테스트")
    void like() {
        // given
        // beforeEach()에서 사용자, 게시글 추가

        // when
        Long boardId = boardList.get(0).getId();
        User user = userList.get(0);
        boardService.likeUseRedisson(boardId, user);

        // then
        Board board = boardRepository.findWithLikesById(boardId).orElseGet(null);

        // 좋아요 수 확인
        assertThat(board.getLikeCount()).isEqualTo(1);

        // 좋아요 정보 확인
        Like like = board.getLikes().get(0);
        assertThat(like.getUser().getId()).isEqualTo(user.getId());
        assertThat(like.getBoard().getId()).isEqualTo(boardId);
    }

    @DisplayName("2개 게시글에 좋아요 동시 요청 테스트(race condition 일어나는 테스트)")
    @Test
    public void likeConcurrencyTest() throws InterruptedException {
        // given
        // beforeEach()에서 사용자, 게시글 추가

        // when
        int threadCount = DO_LIKE_SU;

        //멀티스레드 이용 ExecutorService : 비동기를 단순하게 처리할 수 있도록 해주는 java api
        ExecutorService executorService = Executors.newFixedThreadPool(32);

        //다른 스레드에서 수행이 완료될 때 까지 대기할 수 있도록 도와주는 API - 요청이 끝날때 까지 기다림
        CountDownLatch latch = new CountDownLatch(threadCount);

        log.info("좋아요 서비스 호출 반복문 시작");
        for (int i = 0; i < threadCount; i++) {
            int finalUserI = i;
            int finalBoardI = i%2;
            executorService.execute(() -> {
                try {
                    // 좋아요 서비스 호출
                    boardService.likeUseRedisson(boardList.get(finalBoardI).getId(), userList.get(finalUserI));
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        log.info("좋아요 서비스 호출 반복문 끝");

        // then
        Board resultBoardA = boardRepository.findById(boardList.get(0).getId()).orElseGet(null);
        Board resultBoardB = boardRepository.findById(boardList.get(1).getId()).orElseGet(null);
        // 2개 개시글에 각각 반씩 좋아요 했기 때문에 좋아요 카운트가 threadCount/2
        log.debug("테스트 수행 후 결과A :{}", resultBoardA.getLikeCount());
        log.debug("테스트 수행 후 결과B :{}", resultBoardB.getLikeCount());
        assertEquals(threadCount/2, resultBoardA.getLikeCount());
        assertEquals(threadCount/2, resultBoardB.getLikeCount());
    }

    @DisplayName("같은 사용자로 좋아요 동시 요청 테스트(race condition 일어나는 테스트)")
    @Test
    public void likeConcurrencyBySameUserTest() throws InterruptedException {
        // given
        // beforeEach()에서 사용자, 게시글 추가

        // when
        int threadCount = DO_LIKE_SU;

        //멀티스레드 이용 ExecutorService : 비동기를 단순하게 처리할 수 있도록 해주는 java api
        ExecutorService executorService = Executors.newFixedThreadPool(32);

        //다른 스레드에서 수행이 완료될 때 까지 대기할 수 있도록 도와주는 API - 요청이 끝날때 까지 기다림
        CountDownLatch latch = new CountDownLatch(threadCount);

        log.info("좋아요 서비스 호출 반복문 시작");
        Long BoardId = boardList.get(0).getId();
        for (int i = 0; i < threadCount; i++) {
            int finalI = i/2; // 같은 user로 2번씩 호출
            executorService.execute(() -> {
                try {
                    // 좋아요 서비스 호출
                    boardService.likeUseRedisson(BoardId, userList.get(finalI));
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        log.info("좋아요 서비스 호출 반복문 끝");

        // then
        Board resultBoard = boardRepository.findById(BoardId).orElseGet(null);
        assertEquals(0, resultBoard.getLikeCount()); // 같은 user로 2번씩 호출했기 때문에 likeCount가 0
    }

}

 

4-3. 참고

 추가적으로 redis 분산락은 비지니스 로직이 아니므로 aop를 이용해서 처리하도록 수정해보고 실제 분산 db에서 테스트를 진행해볼 계획입니다. 아래에서 낙관적 락, 비관적 락 처리에 대한 코드를 주석처리로 해놨는데, facade pattern을 적용해서 깔끔하게 수정해보려고 합니다.

 참고 키워드들 : redis aop, 분산 db, Redlock, Redis 동작 원리

 

 


5. 낙관적 락으로 동시성 제어해보기

낙관적 락은 충돌이 많이 일어나지 않을 것이라고 예상될 때 사용하고 db에 별도로 lock을 걸지 않고 version 컬럼값을 이용해서 동시성 처리를 하는 방법입니다. 낙관적 락으로는 dead lock 문제가 해결되지 않을 수 있습니다. 또한 db에 별도로 lock을 걸지 않기 때문에 비관적 lock에 비해 성능이 좋을 수 있지만 충돌이 일어난 경우 재시도 및 실패 처리로 인해 충돌이 빈번하게 일어나는 경우 성능이 안 좋을 수 있습니다. 또한 단일 서버나 멀티 서버 구조에서 모두 사용할 수 있고 비관적 락과 마찬가지로 단일 db 환경에서 사용하기 적합합니다. 

 

좋아요 service에 낙관적 락 적용시 dead lock 문제가 발생했는데 아래는 실제로 테스트 해본 내용입니다.

여기 와 현상이 같아서 많이 참고하였습니다.

5-1. 적용 해보기

5-1-1. 버전 칼럼 추가

@Entity
public class Board extends BaseTimeEntity {

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

    //버전 칼럼 추가
    @Version
    private Long version;
    .
    .
    .
  }

5-1-2. 낙관적 락 쿼리 설정

LockModeType.OPTIMISTIC 설정

public interface BoardRepository extends JpaRepository<Board, Long>, BoardCustomRepository {
    @Lock(value = LockModeType.OPTIMISTIC)
    @Query("select s from Board s join fetch s.user where s.id = :boardId")
    Optional<Board> findByWithUserOptimisticLock(@Param("boardId") final Long id);
}

5-1-3. 서비스 코드

    @Transactional
    public void doLike(Long boardId, User user) {
        // 게시글 검색
        /*// 낙관적 락 처리 방법*/
        Board findBoard = boardRepository.findByWithUserOptimisticLock(boardId).orElseThrow(() -> {
//        Board findBoard = boardRepository.findWithUserById(boardId).orElseThrow(() -> {
            return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
        });
        /*log.debug("update 수행 전 게시글 {}의 좋아요 수 : {}", findBoard.getId(), findBoard.getLikeCount());*/

        // 이미 좋아요 되어있다면 카운트 감소, 좋아요 정보 삭제
        Optional<Like> likeByUserAndBoard = likeRepository.findByUserAndBoard(user, findBoard);
        if (likeByUserAndBoard.isPresent()){
            likeRepository.delete(likeByUserAndBoard.get());
            boardRepository.subtractLikeCount(findBoard);
        } else {

            // 좋아요 정보 저장
            Like like = Like.builder()
                    .user(user)
                    .board(findBoard)
                    .build();
            likeRepository.save(like);

            // 게시글에 좋아요 카운트 추가
            /*
            // 조회수만 1증가 시키는 간단한 예제이기 때문에 단순 쿼리로 해결할 수 있음
            findBoard.setLikeCount(findBoard.getLikeCount()+1);
            boardRepository.saveAndFlush(findBoard);
            */
            boardRepository.addLikeCount(findBoard);
        }
        /*Board findUpdatedBoard = boardRepository.findWithUserById(boardId).orElseThrow(() -> {
            return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
        });
        log.debug("update 수행 후 게시글 {}의 좋아요 수 : {}", findUpdatedBoard.getId(), findUpdatedBoard.getLikeCount());*/
    }

 

5-1-4. 테스트  코드 및 결과

 2개 게시글에 좋아요 동시 요청하도록  멀티쓰레드 코드로 boardService.doLike(......) 로 호출하도록 하였습니다.

    @Transactional
    public void doLike(Long boardId, User user) {
        try {
            // 게시글 검색
            /**/
            // 낙관적 락 처리 방법
            Board findBoard = boardRepository.findByWithUserOptimisticLock(boardId).orElseThrow(() -> {
            //Board findBoard = boardRepository.findWithUserById(boardId).orElseThrow(() -> {
                return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
            });
            /*log.debug("update 수행 전 게시글 {}의 좋아요 수 : {}", findBoard.getId(), findBoard.getLikeCount());*/

            // 이미 좋아요 되어있다면 카운트 감소, 좋아요 정보 삭제
            Optional<Like> likeByUserAndBoard = likeRepository.findByUserAndBoard(user, findBoard);
            if (likeByUserAndBoard.isPresent()) {
                likeRepository.delete(likeByUserAndBoard.get());
                boardRepository.subtractLikeCount(findBoard);
            } else {

                // 좋아요 정보 저장
                Like like = Like.builder()
                        .user(user)
                        .board(findBoard)
                        .build();
                likeRepository.save(like);

                // 게시글에 좋아요 카운트 추가
                /*
                // 조회수만 1증가 시키는 간단한 예제이기 때문에 단순 쿼리로 해결할 수 있음
                findBoard.setLikeCount(findBoard.getLikeCount()+1);
                boardRepository.saveAndFlush(findBoard);
                */
                boardRepository.addLikeCount(findBoard);
            }
            
            /*
            Board findUpdatedBoard = boardRepository.findWithUserById(boardId).orElseThrow(() -> {
                return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
            });
            log.debug("update 수행 후 게시글 {}의 좋아요 수 : {}", findUpdatedBoard.getId(), findUpdatedBoard.getLikeCount());
            */
            
        /*
        트랜잭션에서 board에 update할 때 optimistic lock으로 select했던 version과 
        update하려는 시점의 board의 version이 다른 경우
        다른 트랜잭션에 의해 board가 update 됐다고 보고 ObjectOptimisticLockingFailureException가 발생 합니다.
         */
        } catch (ObjectOptimisticLockingFailureException e) {
            // ObjectOptimisticLockingFailureException 발생한 좋아요 요청 트랜잭션에 대해서 처리가 필요함
        }
    }

테스트 결과 : dead lock 발생

2023-05-29 23:47:06.290 ERROR 6576 --- [pool-2-thread-6] o.h.engine.jdbc.spi.SqlExceptionHelper   : Deadlock found when trying to get lock; try restarting transaction
.
.
.
Exception in thread "pool-2-thread-6" Exception in thread "pool-2-thread-2" org.springframework.dao.CannotAcquireLockException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.exception.LockAcquisitionException: could not execute statement
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:267)
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:233)
	at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:551)
	at org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:61)
	at org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:242)
	at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:152)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763)
	at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:388)
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763)
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:708)
	at com.jig.blog.repository.BoardRepositoryImpl$$EnhancerBySpringCGLIB$$11feb06d.addLikeCount(<generated>)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.springframework.data.repository.core.support.RepositoryMethodInvoker$RepositoryFragmentMethodInvoker.lambda$new$0(RepositoryMethodInvoker.java:289)
	at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:137)
	at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:121)
	at org.springframework.data.repository.core.support.RepositoryComposition$RepositoryFragments.invoke(RepositoryComposition.java:530)
	at org.springframework.data.repository.core.support.RepositoryComposition.invoke(RepositoryComposition.java:286)
	at org.springframework.data.repository.core.support.RepositoryFactorySupport$ImplementationMethodExecutionInterceptor.invoke(RepositoryFactorySupport.java:640)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:164)
	at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:139)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:81)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:388)
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:137)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:174)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215)
	at com.sun.proxy.$Proxy168.addLikeCount(Unknown Source)
	at com.jig.blog.service.BoardService.doLike(BoardService.java:209)
	at com.jig.blog.service.BoardService$$FastClassBySpringCGLIB$$31883f61.invoke(<generated>)
	at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:793)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763)
	at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:388)
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763)
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:708)
	at com.jig.blog.service.BoardService$$EnhancerBySpringCGLIB$$831c9dc1.doLike(<generated>)
	at com.jig.blog.board.BoardLikeServiceTest.lambda$likeConcurrencyTest$0(BoardLikeServiceTest.java:148)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)
Caused by: org.hibernate.exception.LockAcquisitionException: could not execute statement
	at org.hibernate.dialect.MySQLDialect$3.convert(MySQLDialect.java:562)
	at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:37)
	at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:113)
	at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:99)
	at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:200)
	at org.hibernate.hql.internal.ast.exec.BasicExecutor.doExecute(BasicExecutor.java:80)
	at org.hibernate.hql.internal.ast.exec.BasicExecutor.execute(BasicExecutor.java:50)
	at org.hibernate.hql.internal.ast.QueryTranslatorImpl.executeUpdate(QueryTranslatorImpl.java:458)
	at org.hibernate.engine.query.spi.HQLQueryPlan.performExecuteUpdate(HQLQueryPlan.java:377)
	at org.hibernate.internal.SessionImpl.executeUpdate(SessionImpl.java:1483)
	at org.hibernate.query.internal.AbstractProducedQuery.doExecuteUpdate(AbstractProducedQuery.java:1714)
	at org.hibernate.query.internal.AbstractProducedQuery.executeUpdate(AbstractProducedQuery.java:1696)
	at com.querydsl.jpa.impl.JPAUpdateClause.execute(JPAUpdateClause.java:76)
	at com.jig.blog.repository.BoardRepositoryImpl.addLikeCount(BoardRepositoryImpl.java:34)
	at com.jig.blog.repository.BoardRepositoryImpl$$FastClassBySpringCGLIB$$64c0f0c5.invoke(<generated>)
	at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:793)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763)
	at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:137)
	... 54 more
Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
	at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:123)
	at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
	at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:916)
	at com.mysql.cj.jdbc.ClientPreparedStatement.executeUpdateInternal(ClientPreparedStatement.java:1061)
	at com.mysql.cj.jdbc.ClientPreparedStatement.executeUpdateInternal(ClientPreparedStatement.java:1009)
	at com.mysql.cj.jdbc.ClientPreparedStatement.executeLargeUpdate(ClientPreparedStatement.java:1320)
	at com.mysql.cj.jdbc.ClientPreparedStatement.executeUpdate(ClientPreparedStatement.java:994)
	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)
	at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeUpdate(HikariProxyPreparedStatement.java)
	at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:197)
	... 69 more

 

5-2.  dead lock 원인 분석

낙관적 락 처리를 했을 때 결과적으로 deadlock이 발생하기 때문에 적용이 낙관적 처리로는 동시성제어가 불가능했습니다.

5-2-1. dead lock 이란?

dead lock이란 두 개 이상의 프로세스나 스레드가  자원을 점유하고 있는 상태에서 다음 작업에서 서로 상대방이 가지고 있는 자원을 사용하려고 무한정 대기하고 있는 상황을 의미합니다.

  • 두 개 이상의 프로세스나 스레드 = 멀티쓰레드 코드에 의해 실행된 트랜잭션들
  • 자원 = lock

5-2-2. dead lock 발생한 이유

 - dead lock 확인

show engine innodb status; // sql 실행해서 dead lock 내용 확인

------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-05-29 23:47:07 0x12b8
*** (1) TRANSACTION:
TRANSACTION 292004, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 2686, OS thread handle 7584, query id 502102 localhost 127.0.0.1 jig123 updating
update Board set likeCount=likeCount+1 where board_id=5

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 4620 page no 4 n bits 72 index PRIMARY of table `blog`.`board` trx id 292004 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 11; compact format; info bits 0
 0: len 8; hex 8000000000000005; asc         ;;
 1: len 6; hex 0000000474a0; asc     t ;;
 2: len 7; hex 020000014316b5; asc     C  ;;
 3: len 8; hex 99b03b7bc6000000; asc   ;{    ;;
 4: len 8; hex 99b03b7bc6000000; asc   ;{    ;;
 5: len 7; hex 636f6e74656e74; asc content;;
 6: len 4; hex 80000013; asc     ;;
 7: len 5; hex 7469746c65; asc title;;
 8: len 8; hex 8000000000000000; asc         ;;
 9: len 4; hex 80000000; asc     ;;
 10: len 8; hex 8000000000000001; asc         ;;


*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 4620 page no 4 n bits 72 index PRIMARY of table `blog`.`board` trx id 292004 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 11; compact format; info bits 0
 0: len 8; hex 8000000000000005; asc         ;;
 1: len 6; hex 0000000474a0; asc     t ;;
 2: len 7; hex 020000014316b5; asc     C  ;;
 3: len 8; hex 99b03b7bc6000000; asc   ;{    ;;
 4: len 8; hex 99b03b7bc6000000; asc   ;{    ;;
 5: len 7; hex 636f6e74656e74; asc content;;
 6: len 4; hex 80000013; asc     ;;
 7: len 5; hex 7469746c65; asc title;;
 8: len 8; hex 8000000000000000; asc         ;;
 9: len 4; hex 80000000; asc     ;;
 10: len 8; hex 8000000000000001; asc         ;;


*** (2) TRANSACTION:
TRANSACTION 292003, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 2685, OS thread handle 15136, query id 502101 localhost 127.0.0.1 jig123 updating
update Board set likeCount=likeCount+1 where board_id=5

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 4620 page no 4 n bits 72 index PRIMARY of table `blog`.`board` trx id 292003 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 11; compact format; info bits 0
 0: len 8; hex 8000000000000005; asc         ;;
 1: len 6; hex 0000000474a0; asc     t ;;
 2: len 7; hex 020000014316b5; asc     C  ;;
 3: len 8; hex 99b03b7bc6000000; asc   ;{    ;;
 4: len 8; hex 99b03b7bc6000000; asc   ;{    ;;
 5: len 7; hex 636f6e74656e74; asc content;;
 6: len 4; hex 80000013; asc     ;;
 7: len 5; hex 7469746c65; asc title;;
 8: len 8; hex 8000000000000000; asc         ;;
 9: len 4; hex 80000000; asc     ;;
 10: len 8; hex 8000000000000001; asc         ;;


*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 4620 page no 4 n bits 72 index PRIMARY of table `blog`.`board` trx id 292003 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 11; compact format; info bits 0
 0: len 8; hex 8000000000000005; asc         ;;
 1: len 6; hex 0000000474a0; asc     t ;;
 2: len 7; hex 020000014316b5; asc     C  ;;
 3: len 8; hex 99b03b7bc6000000; asc   ;{    ;;
 4: len 8; hex 99b03b7bc6000000; asc   ;{    ;;
 5: len 7; hex 636f6e74656e74; asc content;;
 6: len 4; hex 80000013; asc     ;;
 7: len 5; hex 7469746c65; asc title;;
 8: len 8; hex 8000000000000000; asc         ;;
 9: len 4; hex 80000000; asc     ;;
 10: len 8; hex 8000000000000001; asc         ;;

*** (1) TRANSACTION: update Board set likeCount=likeCount+1 where board_id=5
  -> 한 트랜잭션이 update sql 실행하려고 함

*** (1) HOLDS THE LOCK(S)

  ->  트랜잭션이 board 테이블에 S lock 획득

*** (1) WAITING FOR THIS LOCK TO BE GRANTED : lock_mode X locks rec but not gap waiting

  -> X lock을 점유하기 위해 대기

*** (2) TRANSACTION: update Board set likeCount=likeCount+1 where board_id=5
  -> 다른 트랜잭션에서 update sql 실행하려고 함

*** (2) HOLDS THE LOCK(S)

  ->  한 트랜잭션이 board 테이블에 S lock

*** (2) WAITING FOR THIS LOCK TO BE GRANTED : lock_mode X locks rec but not gap waiting

  -> X lock을 점유하기 위해 대기

 

- S lock과 X lock

S lock (shared lock, 공유 락)
공유 락은 데이터를 읽을 때 사용합니다. 한 트랜잭션이 데이터에 공유 락을 걸면 다른 트랜잭션이 동일한 데이터에 공유 락을 걸 수는 있지만 베타적 락을 설정할 수는 없습니다. 즉 데이터를 동시에 읽는 건 가능하지만 데이터를 읽고 있을 때 다른 트랜잭션이 수정하는 건 불가능하다는 의미 입니다.

mysql에서는 fk가 있는 테이블에 fk를 포함한 데이터를 insert / update / delete 할 때  제약조건을 확인하기 위해 부모테이블에 공유 락을 설정합니다.

X lock (Exclusive  lock, 베타적 락)
베타적 락은 데이터를 변경할 때 사용합니다.
한 트랜잭션에서 한 데이터에  배타적 락이 설정되면 다른 트랜잭션에서는 공유락 베타적 락 둘 다 설정 하지 못 합니다. 즉 수정 중일 때 다는 트랜잭션이 수정 중인 데이터를 읽지도 수정하지도 못 합니다.

mysql에서는 update시에 사용되는 모든 레코드에 베타적 락을 설정합니다.

 

위와 같이 사용자가 S lock과 X lock을 직접 설정해주지 않아도 mysql 상에서 쿼리를 날릴 때 lock이 걸리는 것 입니다.

MySQL 5.6 Reference에서 관련된 내용을 찾을 수 있습니다. 

If a FOREIGN KEY constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. InnoDB also sets these locks in the case where the constraint fails.
.
.
UPDATE … WHERE … sets an exclusive next-key lock on every record the search encounters. However, only an index record lock is required for statements that lock rows using a unique index to search for a unique row.

 

- 위 내용대로 좋아요 로직을 살펴보면 한 트랜잭션에서 좋아요 유저 정보 추가할 때 board 테이블에 s lock 걸리고 좋아요 카운트 update하려 할 때 x lock을 점유하려고 하는데, 다른 트랜잭션에서 s lock이 걸려있으니 s lock이 해제되는 걸 대기 합니다. 다른 트랜잭션도 x lock을 점유하려고 할 때 이전 트랜잭션에서 s lock이 걸려있으니 s lock이 해제될 때 까지 대기하게 되면서 서로 다른 트랜잭션이 같은 자원에 대한 자원을 점유하려고 대기하게 되면서 dead lock에 걸리는 현상입니다.

 

likeRepository.save(like);

  --> likes 테이블에 insert할 때 부모테이블인 board 테이블에 s lock 걸림

boardRepository.addLikeCount(findBoard);

 --> board 테이블에 update 할 때 board테이블에 x lock 점유하려고 함

 

5-3. 낙관적 처리 정리

낙관적 락은 충돌이 많이 일어나지 않을 것이라고 예상될 때 사용하고 db에 별도로 lock을 걸지 않고 version 컬럼값을 이용해서 동시성 처리를 하는 방법입니다.

  • 멀티쓰레드로 동작을 할 때 deadlock이 발생을 할 수 있는데 (MySQL 같은 경우 동시성 처리와 무관하게 s lock x lock에 의해 dead lock이 발생할 수 있음) 낙관적 처리를 하는 경우에도 deadlock이 발생할 수 있습니다.
  • 그리고 별도의 lock을 걸지 않기 때문에 성능이 좋을 수 있지만 충돌이 일어난 경우 어플리케이션 단에서 재시도나 실패에 대한 처리 로직이 필요합니다.  이로 인해 빈번한 충돌이 일어나는 경우 성능이 비관적 락보다 성능이 더 안 좋을 수 있습니다.
  • 또한 낙관적 락은 version을 실제 디비에 저장해서version 값에 의존을 하기 때문에 싱글 디비일 때 사용할 수 있고 서버는 싱글 서버, 멀티 서버 모두 가능합니다.

 


6. 비관적 락으로 동시성 제어해보기

pessmistic lock(비관적 락)은 모든 트랜잭션에 충돌이 발생할거라 예상 될 때 사용하는 방법입니다. 낙관적 락과는 다르게 DB의 LOCK기능을 사용해서 모든 트랜잭션마다 lock을 거는 방법입니다. (주로 select.. for update 사용) dead lock이 발생할 수 있고  lock이 필요하지 않은 상황에도 lock을 사용하기 때문에 트래픽이 많은 경우에는 성능이 저하된다는 문제점이 있습니다. 그리고 낙관적 락과 마찬가지로 분산 디비를 사용하는 환경에서는 비관적 락만으로 데이터의 일광성을 보장하기 어렵기 때문에 다른 방법을 고려해야 합니다.

 

현재 프로젝트가 단일 디비를 사용하고 있고 개인 프로젝트라 트래픽이 많이 몰리는 것도 아니고 비관적 락을 적용했을 때 deadlock 도 발생하지 않기 때문에 좋아요 service에 비관적 락만으로 해결을 할 수가 있습니다.

아래는 적용한 내용입니다.

 

6-1. 비관적 락을 어떻게 처리할 것인가.

아무 동시성 처리를 안 했을 때 멀티쓰레드로 테스트 해보면 dead lock이 걸리는데, 한 트랜잭션에서 좋아요 유저 정보 추가할 때 board 테이블에 s lock 걸리고 좋아요 카운트 update하려 할 때 x lock을 점유하려고 하는데, 다른 트랜잭션에서 s lock이 걸려있으니 s lock이 해제되는 걸 대기 합니다. 다른 트랜잭션도 x lock을 점유하려고 할 때 이전 트랜잭션에서 s lock이 걸려있으니 s lock이 해제될 때 까지 대기하게 되면서 서로 다른 트랜잭션이 같은 자원에 대한 자원을 점유하려고 대기하게 되면서 dead lock에 걸리는 현상입니다. 이 현상을 해결하기 위해 처음 board 조회시에 x lock을 걸면 x lock은 다른 lock과 호환되지 않으므로 다른 트랜잭션이 board를 조회하려고 할 때 이미 걸려있는 x lock에 의해 대기하고 있다가 해제되면 작업을 수행하게 됩니다.

 

6-2. dead lock 발생하게 되는 상황

비관적을 lock을 사용할 때에도 두 테이블에 대해서 lock을 걸어서 처리하는 경우 dead lock에 발생할 수 있습니다. 

여기 블로그에서 설명해주신 내용입니다.

트랜잭션 A가 X테이블의 1번 데이터 row에 Lock을 건다.
트랜잭션 B가 Y테이블의 1번 데이터 row에 Lock을 건다.
트랜잭션 A가 Y테이블의 1번 데이터 row에 접근한다.
트랜잭션 B가 이미 Lock을 걸어놔서 대기한다.
트랜잭션 B가 X테이블의 1번 데이터 row에 접근한다.
트랜잭션 A가 이미 Lock을 걸어놔서 대기한다.

이렇게 되면 서로 다른 트랜잭션이 각자 자원을 점유하고, 상대방이 가진 자원을 얻기위해 무한히 대기하는 데드락이 발생한다.

 

 

6-3. 적용 해보기

 - 좋아요 service

    @Transactional
    public void doLike(Long boardId, User user) {
        /*
        낙관적 락 처리시 try-catch 추가
        try {
        */
            // 게시글 검색
            /**/
            // 낙관적 락 처리 방법
            // Board findBoard = boardRepository.findByWithUserOptimisticLock(boardId).orElseThrow(() -> {
            // 비관적 락 처리 방법
            Board findBoard = boardRepository.findByWithUserPessimisticWriteLock(boardId).orElseThrow(() -> {
             // 분산 락 처리 시에는 db에 lock 처리하지 않고 별도 저장소에 lock 처리   
            //Board findBoard = boardRepository.findWithUserById(boardId).orElseThrow(() -> {
                return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
            });
            /*log.debug("update 수행 전 게시글 {}의 좋아요 수 : {}", findBoard.getId(), findBoard.getLikeCount());*/

            // 이미 좋아요 되어있다면 카운트 감소, 좋아요 정보 삭제
            Optional<Like> likeByUserAndBoard = likeRepository.findByUserAndBoard(user, findBoard);
            if (likeByUserAndBoard.isPresent()) {
                likeRepository.delete(likeByUserAndBoard.get());
                boardRepository.subtractLikeCount(findBoard);
            } else {

                // 좋아요 정보 저장
                Like like = Like.builder()
                        .user(user)
                        .board(findBoard)
                        .build();
                likeRepository.save(like);

                // 게시글에 좋아요 카운트 추가
                /*
                // 조회수만 1증가 시키는 간단한 예제이기 때문에 단순 쿼리로 해결할 수 있음
                findBoard.setLikeCount(findBoard.getLikeCount()+1);
                boardRepository.saveAndFlush(findBoard);
                */
                boardRepository.addLikeCount(findBoard);
            }
            
            /*
            Board findUpdatedBoard = boardRepository.findWithUserById(boardId).orElseThrow(() -> {
                return new IllegalArgumentException("좋아요 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
            });
            log.debug("update 수행 후 게시글 {}의 좋아요 수 : {}", findUpdatedBoard.getId(), findUpdatedBoard.getLikeCount());
            */
            
        /*
        낙관적 락 처리시 try-catch 추가
        트랜잭션에서 board에 update할 때 optimistic lock으로 select했던 version과 
        update하려는 시점의 board의 version이 다른 경우
        다른 트랜잭션에 의해 board가 update 됐다고 보고 ObjectOptimisticLockingFailureException가 발생 합니다.
         
        } catch (ObjectOptimisticLockingFailureException e) {
            // ObjectOptimisticLockingFailureException 발생한 좋아요 요청 트랜잭션에 대해서 처리가 필요함
        }
        */
    }

 - repository에서 board 테이블 조회 쿼리에 PESSIMISTIC_WRITE 비관적 쓰기 락 처리

    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Board s join fetch s.user where s.id = :boardId")
    Optional<Board> findByWithUserPessimisticWriteLock(@Param("boardId") final Long id);

발생 쿼리 : select ..... for update 형태

더보기

Hibernate: select board0_.board_id as board_id1_0_0_, user1_.user_id as user_id1_3_1_, board0_.createdDate as createdd2_0_0_, board0_.modifiedDate as modified3_0_0_, board0_.content as content4_0_0_, board0_.likeCount as likecoun5_0_0_, board0_.title as title6_0_0_, board0_.user_id as user_id8_0_0_, board0_.viewCount as viewcoun7_0_0_, user1_.createdDate as createdd2_3_1_, user1_.modifiedDate as modified3_3_1_, user1_.email as email4_3_1_, user1_.name as name5_3_1_, user1_.password as password6_3_1_, user1_.provider as provider7_3_1_, user1_.providerId as provider8_3_1_, user1_.role as role9_3_1_, user1_.username as usernam10_3_1_ from Board board0_ inner join User user1_ on board0_.user_id=user1_.user_id where board0_.board_id=? for update

- 테스트 코드

    @DisplayName("2개 게시글에 좋아요 동시 요청 테스트(race condition 일어나는 테스트)")
    @Test
    public void likeConcurrencyTest() throws InterruptedException {
        // given
        // beforeEach()에서 사용자, 게시글 추가

        // when
        int threadCount = DO_LIKE_SU;

        //멀티스레드 이용 ExecutorService : 비동기를 단순하게 처리할 수 있도록 해주는 java api
        ExecutorService executorService = Executors.newFixedThreadPool(32);

        //다른 스레드에서 수행이 완료될 때 까지 대기할 수 있도록 도와주는 API - 요청이 끝날때 까지 기다림
        CountDownLatch latch = new CountDownLatch(threadCount);

        log.info("좋아요 서비스 호출 반복문 시작");
        for (int i = 0; i < threadCount; i++) {
            int finalUserI = i;
            int finalBoardI = i%2;
            executorService.execute(() -> {
                try {
                    // 좋아요 서비스 호출
                    boardService.doLike(boardList.get(finalBoardI).getId(), userList.get(finalUserI));
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        log.info("좋아요 서비스 호출 반복문 끝");

        // then
        Board resultBoardA = boardRepository.findById(boardList.get(0).getId()).orElseGet(null);
        Board resultBoardB = boardRepository.findById(boardList.get(1).getId()).orElseGet(null);
        // 2개 개시글에 각각 반씩 좋아요 했기 때문에 좋아요 카운트가 threadCount/2
        log.debug("테스트 수행 후 결과A :{}", resultBoardA.getLikeCount());
        log.debug("테스트 수행 후 결과B :{}", resultBoardB.getLikeCount());
        assertEquals(threadCount/2, resultBoardA.getLikeCount());
        assertEquals(threadCount/2, resultBoardB.getLikeCount());
    }

 

 

 

 

 

 

동시성 처리 참고

공유 자원에 대한 동시성 처리(ex) 재고 시스템 상품 구매, 주식 매매) (tistory.com)

[MySQL] MySQL 동시성 처리(1) - LOCK — CHAN-GPT (tistory.com)

동시성 문제 해결하기 V1 - 낙관적 락(Optimistic Lock) feat.데드락 첫 만남 (velog.io)

 

 

 

 

 


아래는 좋아요 기능을 추가하면서 경험했던 걸 참고용으로 적어놓았습니다.

 

 

1.  querydsl 사용하려다가 안 되서 삽질한 내용..

IntelliJ + Maven 환경에서 QueryDsl 사용하는 방법 (tistory.com)

 -> QClass가 생성되는데, QClass에 cannot find symbol 에러가 걸림.

 

3-1) QClass에 cannot find symbol  해결법 예시가 maven에서는 없고 gradle로만 올라와 있어서 gradle로 프로젝트 변환.

[Build]maven 프로젝트 gradle로 변경하기 (tistory.com)

[IntelliJ] Maven 프로젝트를 Gradle 프로젝트로 변경 :: 너나들이 개발 이야기 (tistory.com)

 

 3-1-1) gradle 윈도우에 설치(7.6.1의 complete 설치함.. 아무거나 해도 될 듯?) 하고 시스템 환경 변수에 gradle의 bin 폴더 설정

 3-1-2) 윈도우의 cmd에서 프로젝트 경로 접속해서 gradle로 초기화

 - gradle 파일들이 프로젝트 폴더에 설치 됨

 

 3-1-3) intellij에서 하단 알림에서 Load Gradle Project 클릭

 - intellij에서 gradle tool이  오른쪽에 활성화 됨

 

 3-1-4) pom.xml 파일 삭제 후 활성화 된 메이븐 새로고침 버튼 클릭

 - intellij에서 maven tool이 오른쪽에서 비 활성화 됨

 

 3-1-5) build.gradle 파일 확인해서 버전 명시되어 있는 것들 지우고 알맞게 수정 해서 build 테스트

 -> gradle tool에서 "build" 수행 : BUILD SUCCESSFUL in 2s

 

3-2) queryDsl 관련해서 build.gradle 파일 수정 시도

 queryDsl 관련해서 아무리 gradle 설정 바꿔봐도 cannot find symbol  오류 해결 안 됨.

 

3-3) intellij 설정에서 빌더의 java 버전과 build.gradle 파일에서 java 버전을 일치 시키고 "clean" 동작시키고 application run 하니까 cannot find symbol  에러 없어짐

1. intellij 설정
 intellij - settings - Build Tools - Gradle : Gradle JVM : 1.8

 

2.build.gradle 파일 설정

sourceCompatibility = '1.8'

 

2. 같은 트랜잭션 내에서 querydsl execute() 로 반영된 데이터를 조회해오려면 entiManager.clean();를 해서 영속성 컨텍스트를 비워줘야 db에서 반영된 값을 조회해올 수 있다.

flush() : 영속성 컨텍스트의 변경내용을 DB에 동기화 시키는 작업 수행
clean() : 영속성 컨텍스를 비우는 작업 수행
execute() : querydsl 수정 작업 수행

 - JPA는 데이터 갱신 쿼리를 수행할 때

 트랜잭션이 끝나기 전까지 영속성컨텍스트에 쿼리를 쌓아두었다가 트랜잭션이 끝나면 쿼리를 DB에 전달합니다.

하지만 execute()를 수행하면 영속성 컨텍스트에 값을 반영하지 않고 바로 DB에 쿼리를 전달합니다. 

 

- JPA는 같은 트랜잭션 내에서 데이터를 조회할 때

영속성 컨텍스트에 데이터가 있으면 db까지 조회하지 않고 영속성 컨텍스트 내의 데이터를 가져옵니다.영속성 컨텍스트에 데이터가 없다면 DB에서 조회해옵니다.

 

- 따라서 수정 연산을 querydsl execute()로 수행했을 때 같은 트랜잭션 내에서 반영된 데이터를 조회하려면  DB에서 값을 가져와야 하기 때문에 영속성 컨텍스트를 비워주면 됩니다.

 

 ===> execute() 연산 이후에 같은 트랜잭션에서 다시 조회를 할 경우 entiManager.clean(); 을 호출

 

@Override
public void addLikeCount(Board findBoard) {
    queryFactory.update(board)
            .set(board.likeCount, board.likeCount.add(1))
            .where(board.eq(findBoard))
            .execute();

    // querydsl 수정,삭제 작업시 execute() 호출
    // execute를 수행하면 수정사항을 db에만 반영, 영속성 컨텍스트에는 반영 안 함
    // 같은 트랜잭션 내에서 board 조회시 영속성컨텍스트에서 먼저 데이터를 조회 함
    // 반영되지 않은 데이터를 조회하게 됨.
    // 이 문제를 해결하기 위해 clear()를 수행해 연속성컨텍스트를 비워주면 DB에서 반영된 데이터를 조회해 옴
    //entityManager.flush(); // flush는 필요 없음
    entityManager.clear();
}

 

 테스트 코드 like() 에서 boardService.like()를 호출해서 boardRepository.addLikeCount()로 execute() 수행 후

테스트 코드 like()에서 boardRepository.findById()했을 때 반영된 값을 조회하려면 execute() 수행 후 entityManager.clear()를 해줘서 영속성 컨텍스트를 비움으로써 db에서 값을 조회할 수 있게 해야 한다.

  테스트 코드 like()에 @Transactional이 걸려있고

boardService.like()에도 @Transactional이 걸려있어서 같은 트랜잭션으로 인식됨

@Test
@Order(7)
@DisplayName("좋아요 테스트")
@Transactional
void like() {
    // given
    User user = User.builder()
            .username("user1")
            .password("1234")
            .name("user1")
            .email("user1@gmail.com")
            .role(RoleType.USER)
            .build();
    userService.joinUser(user);

    BoardReqDto boardReqDto = BoardReqDto.builder()
            .title("title")
            .content("content")
            .build();
    boardService.saveBoard(boardReqDto, user);
    Board savedBoard = boardRepository.findAllByOrderByCreatedDateDesc().get(0);

    // when
    boardService.like(savedBoard.getId(), user);

    // then
    Board board = boardRepository.findById(savedBoard.getId()).orElseGet(null);
    // 좋아요 수 확인
    assertThat(board.getLikeCount()).isEqualTo(1);
    // 좋아요 정보 확인
    Like like = board.getLikes().get(0);
    assertThat(like.getUser().getId()).isEqualTo(savedBoard.getId());
    assertThat(like.getBoard().getId()).isEqualTo(savedBoard.getId());
}

boardService.like()

@Transactional
public void like(Long boardId, User user) {

    // 게시글 검색
    Board findBoard = boardRepository.findById(boardId).orElseThrow(() -> {
        return new IllegalArgumentException("댓글 추가 실패 - 찾을 수 없는 board id 입니다. : " + boardId);
    });

    // 이미 좋아요 되어있다면 에러 반환
    if (likeRepository.findByUserAndBoard(user, findBoard).isPresent()){
        throw new DuplicateResourceException("already exist data by member id :" + user.getId() + " ,"
                + "board id : " + findBoard.getId());
    }

    // 좋아요 정보 저장
    Like like = Like.builder()
            .user(user)
            .board(findBoard)
            .build();

    // 게시글에 좋아요 카운트 추가
    likeRepository.save(like);
    boardRepository.addLikeCount(findBoard);
}

JPA에서 플러시(flush) 개념 및 호출 방법 3가지 (tistory.com)

[JPA] 영속성 컨텍스트와 플러시 이해하기 (tistory.com)

flush: 영속성 컨텍스트의 변경내용을 DB에 동기화 시키는 것

clean : 영속성 컨텍스트를 비우는 것

 

1차 캐시 데이터 vs DB 데이터 (velog.io)

Querydsl - 수정, 삭제 벌크 연산 :: IT 개발자들의 울타리 (tistory.com)

[JPA] Querydsl 수정, 삭제 벌크 연산 - 맛있는 개발자의 기록 일기 (jjunn93.com)

Querydsl - 중급 문법 | Backtony

QueryDSL 동적 쿼리 및 Bulk 연산 (tistory.com)

 

 

3.  실제로 db단에 적용하면서 테스트를 하는 방법도 있지만 일반적으로 어떻게 테스트코드를 작성하는지 학습

1) 테스트 기본 지식

[10분 테코톡] 🌊 바다의 JUnit5 사용법 - YouTube

[Spring] JUnit과 Mockito 기반의 Spring 단위 테스트 코드 작성법 (3/3) - MangKyu's Diary (tistory.com)

[TDD] 단위 테스트와 TDD(테스트 주도 개발) 프로그래밍 방법 소개 - (1/5) - MangKyu's Diary (tistory.com)

 

 

2) JPA 사용하는 service에서 동시성 테스트 할 때 주의사항

[JPA] @Transactional 과 동시성 (velog.io)

 

 

4. docker에 redis 설치해서 사용하기

1) 윈도우에 docker 설치

[Windows 10] Docker 설치 완벽 가이드(Home 포함) - LZ (lainyzine.com)

 

2) docker에 redis 설치

[Redis] local에서 Redis 사용하기 / Docker 사용하여 Redis server 접속 (velog.io)

Docker 이용하여 Redis 설치 — nathan 개발블로그 (tistory.com)

 

3) redis 시작

[Spring Redis-Session] Spring Redis Session With Docker (tistory.com)

 

CMD에서 DOCKER를 이용해서 서버 구동

docker pull redis
docker network create redis-net

#dockerRedis라는 이름의 컨테이너를 redis-net 네트워크에 붙여 실행한다.
docker run --name dockerRedis -p 6379:6379 --network redis-net -d redis redis-server --appendonly yes

#redis-cli로 dockerRedis에 접속한다.
docker run -it --network redis-net --rm redis redis-cli -h dockerRedis

도커를 재시작했을 때, redis-cli에 다시 접속

docker exec -it dockerRedis redis-cli

 

 

5. 원자적 연산에 대해서 알게 되면 기록 해보자

프로그래밍 초식 : DB 트랜잭션 조금 이해하기 02 격리 - YouTube

17:40
원자적 연산에 대해서 질문드립니다.

[질문 정리]
db마다 원자적 연산 지원 여부, 지원하는 쿼리 형태를 어디에서 확인하면 되나요?
mysql 같은 경우 테스트해봤을 때 지원하는 거 같은데, mysql 사이트에서 해당 내용이 안보입니다.


[내용]
update article set readcnt = readcnt+1 where id =1; 와 같은 특정 형식의 쿼리만 지원을 하는 건가요? 아니면 지원하는 쿼리 형태가 여러개 있는건가요?

그리고 db에서 실제로 원자적 연산을 지원하는 지 확인해야 된다고 해주셨는데요. db 사이트에서 확인해보면 되는 걸까요?

mysql atomic operation
mysql cursor stability
로 검색해보기도 하고
아래에서 찾아봤는데 일치하는 내용이 안 보입니다.
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-transaction-model.html

java, spring boot, jpa, mysql 환경에서 테스트해봤을 때 update article set readcnt = readcnt+1 where id =1; 형태로 쿼리 날려보니까 멀티쓰레드 코드에서 변경 유실 발생 안 하고 동시성 처리가 됩니다.
(참고로 java단 코드에서 if문에 의해서 값이 달라져야 하는 경우나 +1연산을 java단에서 해서 update 쿼리를 날리는 경우 변경 유실이 발생했어요.)