[JPA] 동시성 이슈 해결해보기

YoungHo-Cha·2022년 8월 28일
4

Catch Bug Project

목록 보기
10/12
post-thumbnail

OverView

기능을 구현하면서 "조회 수", "고용(배치 및 매칭)"에 대해서 동시성을 고민하기 시작했습니다.

이 페이지는 동시성에 대한 고민과 해결하는 과정을 다룬 내용을 기록하겠습니다.

고민되는 상황 및 로직

프로젝트에서 동시성에 대한 고민이 든 기능은 2가지가 존재합니다.

  • 조회 수
  • 고용(배치 및 매칭)

두 상황을 차례대로 살펴보겠습니다.

조회 수

게시글을 조회하면 해당 게시글은 1씩 조회수가 증가합니다. 트래픽이 많아지고 동시에 요청하게될 경우 조회수에 문제가 발생할 것이라고 판단이 됩니다.

조회 수를 해결하기 위해서 락을 이용하려고 합니다.

낙관적 락 vs 비관적 락

두 가지의 락 중에서 비관적 락을 선택했습니다.
이유는 다음과 같습니다.

낙관적 락을 적용할 경우, 많은 사람들이 한번에 접근할 때 조회수를 Update 경우 Commit 지점까지 로직이 흐르게 됩니다. 가장 먼저 업데이트한 version의 경우는 정상적 수정이 적용되지만, 제외한 모든 version은 commit 시점까지 로직이 흘러 자원의 낭비라고 생각이 들었습니다.

비관적 락을 이용했을 경우 우려되는 상황은 다음과 같습니다.

  1. 조회수를 비관적 락으로 적용할 경우, 많은 사람들이 한번에 접근할 때 병목현상이 우려되었습니다.
  2. 조회수를 비관적 락으로 적용할 경우, 게시 글 자체에 접근을 할 수 없는 문제를 우려하였습니다.
  • 이 문제는 JPA 제공해주는 비관적 락의 종류인 "PESSIMISTIC_READ"를 이용하여 게시 글에는 접근을 할 수 있도록 조치하기로 하였습니다.

고용(배치 및 매칭)

게시글이 게시되었을 때, "고용(배치 및 매칭) 요청"을 하게 됩니다. 고용은 1개의 게시글에 1개의 고용만 가질 수 있습니다. 그래서 동시성 이슈가 발생하면 로직적으로 좋지 않다고 판단이 되어 락을 적용하기로 판단을 내렸습니다.

트랜잭션 vs 락

선착순으로 "Employ" 객체를 생성하기 때문에 락을 선택할 수 없었습니다.

고용(배치 및 매칭)은 요청이 왔을 경우 엔티티(레코드)가 새로 생성되는 로직입니다. 락은 테이블 상 레코드에 락을 걸기 때문에 존재하지 않는 레코드에 락을 걸 수 없다고 판단되었습니다. 그래서 락으로 구현할 수 없다고 판단이 되었습니다.

그리하여 트랜잭션을 선택하였습니다.

트랜잭션 격리 수준 정하기

트랜잭션에는 많은 격리 수준이 존재합니다.

각 격리 수준마다 선택하지 않은 이유 혹은 선택한 이유를 기록하겠습니다.

  1. Read Uncommitted
  • 해당 격리수준은 SELECT 구문이 실행되는 도중 Shared Lock이 걸리지 않는 격리 수준입니다.
  • 트랜잭션이 처리 중이거나 아직 commit 되지않은 데이터를 다른 트랜잭션이 읽을 수 있도록 허용하는 격리수준입니다.

다음의 코드를 살펴보겠습니다.

@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public DtoOfApplyEmploy apply(Long employeeId, Long boardId){
        Member employeeEntity = memberService.getMember(employeeId);
        Board boardEntity = boardService.getBoardEntity(boardId);

        employeeEntity.checkAbleToEmploy();
        boardEntity.checkAbleToApply();

        Member employerEntity = boardEntity.getHost();

        Employ createdEmployEntity = Employ.builder()
                .employee(employeeEntity)
                .employer(employerEntity)
                .board(boardEntity)
                .expiryTime(boardEntity.getCreatedTime().plusMinutes(10))
                .build();
        // 1
        employRepository.save(createdEmployEntity); // 2
        boardEntity.updateStatus(Status.MATCHED);

        return DtoOfApplyEmploy.builder()
                .employeeNickname(employeeEntity.getNickname())
                .employerNickname(employerEntity.getNickname())
                .boardId(boardEntity.getId())
                .build();

    }

위와같이 작성할 경우, "MySQLTransactionRollbackException" 이 발생하게 됩니다.

MySQLTransactionRollbackException 발생 이유 : 어떠한 테이블에 insert하는 시점에 다른 모듈에서 해당 테이블을 select하여 insert를 진행했을 경우에 발생합니다.

다음의 명령어로 확인해보니

show engine innodb status;

위와 같이 데드락에 걸렸던 것을 볼 수 있었습니다.
(아직 해당 이슈에 대한 이해를 완벽히 하지 못하였습니다..)

선택하지 않은 이유 : 해당 격리수준은 데이터 정합성에 문제가 따를 수 있다고 판단했습니다.

조회수와 같이 순서가 중요하기 보단, 가장 먼저 수행한 로직이 중요하기 때문에 충돌방지만 하면 된다고 판단하였습니다.

  1. Read Commited
  • Select 하는 동안 다른 트랜잭션에서 조회를 하면 마지막으로 commit된 데이터가 조회됩니다.
  • 트랜잭션이 처리되는 동안 다른 요청에서 select 요청이 들어오게되면 처리되는 동안 변경된 데이터가 아닌 이전에 마지막으로 commit된 데이터를 조회하게됩니다.

선택하지 않은 이유 : 해당 격리수준은 Non-Repeatable-Read, Phantom-Read 문제가 발생한다고 판단되어 선택하지 않았습니다.

Non-Repeatable-Read : 한 트랙잭션 내에서 2번의 조회를 하였을 때, 조회된 내용이 다를 수 있는 문제
Phantom-Read : 한 트랜잭션에서 조회를 하였을 때, 결과 값의 개수가 다른 문제

  1. Repeatable Read
  • 트랜잭션이 처리 중이거나 아직 처리되지 않았을 때, Shared Lock이 걸려 다른 트랜잭션에서 조회할 수 없습니다.
  • Non-Repeatable-Read 문제가 생기지 않습니다.

선택하지 않은 이유 : Phantom-Read 문제가 발생한다고 판단되어 선택하지 않았습니다.

  1. Serializable Read
  • 트랜잭션이 처리 중일 경우에 조회, 삭제, 갱신 모두 할 수 없는 문제를 말합니다.
  • 읽기 모드에 일관성을 유지해줍니다.

모든 요청을 직렬화하기떄문에 병목현상이 발생할 수 있습니다.

선택한 이유 : 배치는 선착순 1명을 선정해야하기 때문에, 트랜잭션 중간에 읽기를 허용하면 안된다고 판단되었습니다.

병목현상에 대한 고찰 : 하나의 게시글에 엄청나게 많은 유저의 동시요청이 존재할 가능성이 현저히 낮다고 판단되어 해당 수준으로 선택하였습니다.

해결한 상황

이전 코드와 해결한 후의 코드를 살펴보겠습니다.

조회수

먼저 Board의 조회수 관련 메서드부터 살펴보겠습니다.
Board.java

public class Board extends BoardBaseEntity {
    // ... 중략 ...
   /**
   * 조회수
   */
    private int count;

    public void plusCount(){
        this.count = count + 1;
    }
}

위와 같이 구성되어있습니다.

조회 요청에 대한 Service 코드는 다음과 같습니다.

public DtoOfGetBoard getBoard(Long boardId){

        Board boardEntity = boardRepository.findById(boardId)
                .orElseThrow(() -> new NotFoundBoardException("해당 글을 찾을 수 없습니다."));
        System.out.println("version == " + boardEntity.getVersion());
        boardEntity.plusCount();
        System.out.println("count = " + boardEntity.getCount());

        // ... 중략 ...
    return //생략
}

동시 요청하기

curl을 이용하여 동시요청 테스트를 해보겠습니다.

curl http://localhost:8080/api/board/1 & 
curl http://localhost:8080/api/board/1 & 
curl http://localhost:8080/api/board/1 & 
curl http://localhost:8080/api/board/1 & 
curl http://localhost:8080/api/board/1

위와 같이 5번의 동시 요청을 했습니다.

결과를 살펴보면 다음과 같습니다.

version, count 모두 적용이 되지 않았습니다.

해결

먼저 boardRepository에서 메서드를 추가했습니다.

BoardRepository.java

/**
     * 조회수를 해결하기위한 비관적 락 조회 메서드
     * @param boardId : 조회하려는 게시글 id
     * @return : 조회된 Board Entity
     */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select b from Board b where b.id = :boardId")
    Optional<Board> findWithIdForUpdate(@Param("boardId") Long boardId);

그리고 Service의 메서드를 수정하였습니다.
BoardService.java

@Transactional
    public DtoOfGetBoard getBoard(Long boardId){

        Board boardEntity = boardRepository.findWithIdForUpdate(boardId)
                .orElseThrow(() -> new NotFoundBoardException("해당 글을 찾을 수 없습니다."));
        System.out.println("version == " + boardEntity.getVersion());
        boardEntity.plusCount();
        System.out.println("count = " + boardEntity.getCount());
        // ... 중략
    return //생략

}

결과는 다음과 같습니다.

정상적으로 동시 요청이 적용된 것을 볼 수 있었습니다.

고용(배치 및 매칭)

EmployService.java에 어노테이션 추가

@Transactional(isolation = Isolation.SERIALIZABLE) // 추가
    public DtoOfApplyEmploy apply(Long employeeId, Long boardId){
        // 중략 필요한 로직 수행

        Employ createdEmployEntity = Employ.builder()
                .employee(employeeEntity)
                .employer(employerEntity)
                .board(boardEntity)
                .expiryTime(boardEntity.getCreatedTime().plusMinutes(10))
                .build();

        try { // try-catch문 추가
            employRepository.save(createdEmployEntity);
        }catch (Exception e){
            throw new TransactionException("이미 배치되었습니다.");
        }


        return //중략

    }
  • 어노테이션 추가
  • MySQLTransactionRollbackException에 대한 정확한 고찰을 하지 못하였기 때문에 try-catch문으로 예외처리를 하였습니다.

동시 요청하기

다음의 curl문으로 동시 요청을 진행하였습니다.

curl -X POST http://localhost:8080/api/employ/1 -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmlja25hbWUiOiLssKjsmIHtmLgiLCJnZW5kZXIiOiJtYWxlIiwiaWF0IjoxNjYxNzEwODA2LCJleHAiOjE2NjE3MTI2MDZ9.nwZB6ldHpIQ1Fu_gftWNTQGZPpXidobjQW1qqOx6hKM" &

curl -X POST http://localhost:8080/api/employ/1 -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmlja25hbWUiOiLssKjsmIHtmLgiLCJnZW5kZXIiOiJtYWxlIiwiaWF0IjoxNjYxNzEwODA2LCJleHAiOjE2NjE3MTI2MDZ9.nwZB6ldHpIQ1Fu_gftWNTQGZPpXidobjQW1qqOx6hKM" &

curl -X POST http://localhost:8080/api/employ/1 -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmlja25hbWUiOiLssKjsmIHtmLgiLCJnZW5kZXIiOiJtYWxlIiwiaWF0IjoxNjYxNzEwODA2LCJleHAiOjE2NjE3MTI2MDZ9.nwZB6ldHpIQ1Fu_gftWNTQGZPpXidobjQW1qqOx6hKM" &

curl -X POST http://localhost:8080/api/employ/1 -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmlja25hbWUiOiLssKjsmIHtmLgiLCJnZW5kZXIiOiJtYWxlIiwiaWF0IjoxNjYxNzEwODA2LCJleHAiOjE2NjE3MTI2MDZ9.nwZB6ldHpIQ1Fu_gftWNTQGZPpXidobjQW1qqOx6hKM" &

curl -X POST http://localhost:8080/api/employ/1 -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmlja25hbWUiOiLssKjsmIHtmLgiLCJnZW5kZXIiOiJtYWxlIiwiaWF0IjoxNjYxNzEwODA2LCJleHAiOjE2NjE3MTI2MDZ9.nwZB6ldHpIQ1Fu_gftWNTQGZPpXidobjQW1qqOx6hKM"

5개의 동시 요청을 진행했습니다.

  • 예상되는 결과
    • employ는 1개만 생성이 되어야 합니다.
    • 최초 employ요청을 제외한 나머지 요청들은 사용자 정의 예외클래스인 "TransactionException"이 발생되어야 합니다.

위 사진처럼 employ는 1개만 생성되었습니다.

위 사진처럼 "TransactionException"이 발생한 것을 볼 수 있었습니다.

profile
관심많은 영호입니다. 궁금한 거 있으시면 다음 익명 카톡으로 말씀해주시면 가능한 도와드리겠습니다! https://open.kakao.com/o/sE6T84kf

1개의 댓글

comment-user-thumbnail
2023년 11월 22일

안녕하세요, 작성해주신 글 잘 읽었습니다.
글을 읽으면서 의문이 생겨서 질문 남깁니다.

비관적 락(PESSIMISTIC_WRITE)을 이용했을 경우 많은 사람들이 한번에 접근할 때 병목현상이 우려되어 PESSIMISTIC_READ트랜잭션 격리 레벨을 Serializable Read로 설정하셨다고 언급 주셨는데요.

트랜잭션 격리 레벨을 Serializable Read로 설정하면 모든 요청을 직렬화하기 때문에 PESSIMISTIC_WRITE과 동일하게 병목현상이 발생할 수 있지 않을까요?

관련하여 하나의 게시글에 엄청나게 많은 유저의 동시요청이 존재할 가능성이 현저히 낮다고 판단하여 트랜잭션 격리 레벨을 Serializable Read 수준으로 선택하셨다고 했는데요.
이러한 이유라면 더욱 간단한 해결책인 PESSIMISTIC_WRITE을 왜 선택하지 않았는지 궁금하여 질문 남깁니다🙂

답글 달기