코드 저장소.

게시글 조회수에서 발생한 동시성 제어 본문

포폴/JPABlog

게시글 조회수에서 발생한 동시성 제어

slown 2024. 5. 31. 23:14

목차

1.문제상황
2.동시성 이슈?
3.비관적 락 VS 낙관적 락
4.프로젝트 적용

1.문제상황

 

프로젝트를 진행하면서 게시글 조회수 증가기능을 확인해보는 도중에 포스트맨으로 동시요청을 해보았는데 조회수의 결과가 원하는 만큼 증가하지 않는 문제가 발생을 해서 무슨 문제인지를 검색해 보았더니 동시성이슈에 관련된 문제임을 알았고 앞으로도 필요한 문제이기에 이 문제를 해결해 보기로 했다.

2.동시성 이슈?

동시성이라는것은 두개 이상의 세션이 공통된 자원에 대해서 읽고 쓰는 작업을 할 때 발생하는 문제동시성 이슈라고 합니다.

3.비관적 락 VS 낙관적 락

데이터베이스는 여러 사용자들이 같은 데이터를 동시에 접근 상황에서, 데이터의 무결성, 일관성을 지키기 위해 LOCK을 사용한다. Lock은 트랜잭션의 순차성을 보장하며, 락을 한 데이터는 다른 트랜잭션이 동시에 접근할 수 없게 된다.

 

비관적 락

비관적 락은 트랜잭션끼리 충돌이 발생한다고 가정을 하고 락을 거는 방법이다. DBMS의 락 기능을 사용하고, 데이터 수정시 트랜잭션의 충돌 여부를 확인할 수 있다.

위 사진을 설명하면 이러하다.

  • T1에서 게시글을 조회를 한다.
  • T2에서 게시글을 조회를 한다.
  • T1에서 게시글의 조회수를 증가를 하면서 락을 건다.
  • T1이 커밋을 한후 락을 제거한다.
  • T2에서도 똑같이 게시글의 조회수를 증가후 락을 건다.
  • T2에서도 커밋을 한후 락을 제거한다.

낙관적 락

 

긍정적 락트랜잭션이 충돌할 가능성이 매우 낮은 상황에 사용한다. DB가 제공하는 락 기능을 사용하지 않고 어플리케이션 레벨에서 자체적으로 락과 유사한 동작을 하도록 한다. Jpa에서는 @Version이라는 어노테이션을 엔티티에 적용을 해서 낙관적 락을 사용할 수 있다.

위 사진을 설명하자면 이러하다.

  • T1(트랜잭션A)에서 1번 게시글을 조회합니다.
  • T2(트랜잭션B)에서 1번 게시글을 조회합니다.
  • T1에서 게시글의 조회수를 증가하는 쿼리를 실행후 커밋을 하면 version을 +1한다.
  • T2에서도 똑같은 쿼리를 실행 후 커밋을 하면 version을 확인을 한다. 하지만 T2에서 조회한 version이 0인데 1이므로 예외를 발생을 한다.

이 둘을 정리해보면 이러하다.

구분 Optimisitic Lock Pessimistic Lock
정의 충돌이 없을 것으로 가정하여 락을 걸지 않음 충돌을 예상을 하고 미리 락을 검
사용법 JPA 사용시 @Version Mode 설정 및 쿼리에 직접 사용,DB단에 설정 가능
별칭 낙관적락/ 비선점인 락 비관적 락/ 선점적인 락
장점 데드락 가능성이 적으며 성능의 이점 충돌에 대한 오버헤드가 줄어들며 무결성을 지키기 용이
단점 충돌이 발생하면 오버헤드 발생 충돌이 없으면 오버헤드가 발생

 

그럼 둘중에 어느것을 할 것인가??

 

게시글 조회수증가는 대체적으로는 트랜잭션의 충돌이 일어날 경우가 적지만 충돌이 발생한 경우에는 별도의 트랜잭션 처리를 해야 되는 점과 그로 인한 추가적인 로직을 작성해야 되는점에서 봤을 때에는 낙관적락보다는 성능은 저하되지만 정확한 데이터의 일관성을 보장할 수 있는 비관적락을 적용하기로 했습니다.

4. 프로젝트 적용

우선은 비관적락을 적용하기 위해서 게시글 조회수 증가와 게시글 조회에 있는 서비스코드 와 리포지터리 코드를 수정을 해야 한다.

 

Query DSL 부분 

    //게시글 조회(비관적 락 사용)
    @Override
    public Optional<BoardDto.BoardResponseDto> findByBoardDetail(Integer boardId) {
        BoardDto.BoardResponseDto result = jpaQueryFactory
                .select(Projections
                        .constructor(BoardDto.BoardResponseDto.class,qBoard))
                .from(qBoard)
                .where(qBoard.id.eq(boardId))
                .setLockMode(LockModeType.PESSIMISTIC_WRITE)
                .fetchOne();
        return Optional.ofNullable(result);
    }

    //게시글 조회수 증가 
    @Modifying
    @Transactional
    @Override
    public void updateReadCount(Integer boardId) {
        jpaQueryFactory.update(qBoard)
                .set(qBoard.readCount,qBoard.readCount.add(1))
                .where(qBoard.id.eq(boardId))
                .execute();
    }

 

위의 코드에서 주목을 해야 되는 점은 게시글 단일조회에서 setLockMode의 부분에 LockModeType입니다.

 

비관적락에 사용되는 모드는 3가지이고 3가지의 특징은 다음과 같습니다.


PESSIMISTIC_READ
PESSIMISTIC_WRITE
PESSIMISTIC_FORCE_INCREMENT

 

PESSIMISTIC_READ

 

- 다른 트랜잭션에게 읽기만을 허용하는 LockModeType입니다. Shared Lock( 여러 트랜잭션이 데이터를 읽을 수 있지만, 데이터를 수정할 수는 없음)을 이용해 락을 거는데 Shared Lock을 DB가 제공하지 않으면 PESSIMISTIC_WRITE와 동일하게 동작합니다.

 

PESSIMISTIC_WRITE

 

- DB에서 제공하는 행 배타잠금(Row Exclusive Lock)을 이용해 잠금을 획득한다. 다른 트랜잭션에서 쓰지도 읽지도 못합니다.

 

PESSIMISTIC_FORCE_INCREMENT

 

- DB에서 제공하는 행 배타잠금(Row Exclusive Lock)을 이용해 잠금을 걺과 동시에 버전을 증가시킵니다. 해당하는 엔티티에 변경은 없지만 하위 엔티티 갱신을 위해 잠금이 필요한 경우 사용할 수 있습니다.

 

위 3가지 모드에서 게시글 조회수 증가에 필요한 모드는 데이터를 한개씩 수정을 하므로 PESSIMISTIC_WRITE를 적용을 을 했습니다.

 

서비스단 코드 

	/*
      * 글 목록 단일 조회
      * @Param boardId
      * @Exception :게시글이 존재하지 않음.(NOT_BOARD_DETAIL)
      * @return : BoardResponseDto
    */
	@Transactional(readOnly = true)
	public BoardResponseDto getBoard(Integer boardId){
		//글 조회
		Optional<BoardResponseDto>boardDetail = Optional
				.ofNullable(repos.findByBoardDetail(boardId)
				.orElseThrow(() -> new CustomExceptionHandler(ErrorCode.NOT_BOARD_DETAIL)));

		if(boardDetail.isPresent()){
			updateReadCount(boardId);
			log.info("readCount:::"+boardDetail.get().getReadCount());
		}

		return boardDetail.get();
	}

	/*
	  * 게시글 조회수 증가.
	  * @Param boardId 게시글 번호
	 */
	public void updateReadCount(Integer boardId){
		repos.updateReadCount(boardId);
	}

 

작성을 했으니 실제로 조회수가 제대로 증가가 되는지를 테스트 코드와 Jmeter를 사용해서 검증을 해보겠습니다. 

 

우선은 테스트 코드입니다. 

테스트 코드에서 적용이 되는 ExecutorService , CountDownLatch를 사용해서 동시성을 테스트를 하는 코드입니다.

 

ExecutorService : 스레드풀을 만듭니다. 즉 동시접속자를 만듭니다. 동시접속자는 100명으로 했습니다.

CountDownLatch : 카운트 값을 설정을 하고 설정한 값이 0이 될 때까지 기다립니다. 

    @Test
    @DisplayName("비관적 락을 사용해서 게시글 조회수 동시성 제어 테스트")
    public void test()throws Exception{

        ExecutorService ex = Executors.newFixedThreadPool(100);
        CountDownLatch latch = new CountDownLatch(100);
        int result = 0;
        for (int i=0;i<100;i++){
            ex.execute(()->{
                boardService.getBoard(382);
                latch.countDown();
            });
        }
        latch.await();
        result = boardService.getBoard(382).getReadCount();

        assertThat(result).isEqualTo(100);
    }

 

테스트 코드의 내용은 게시글 조회수를 비관적 락을 사용해서 동시에 100명이 들어왔을 경우를 테스트 코드로 작성을 한 것이고 결과는 아래와 같이 성공을 했다. 

 

그 다음은 Jmeter를 사용해서 게시글의 조회수가 정상적으로 작동이 되는지를 확인해 보겠습니다.

 

 

 

스레드의 수 (동시접속자)의 수는 200으로 설정을 했고 Loop Count 는 3번으로 해서 총 600번의 요청을 하도록 설정을 했습니다. 

 

 

Jwt 로그인을 사용하기 때문에 Header에 Authorization 으로 토큰값을 넣었습니다. 

헤더에 추가를 하는 방법은 처음에 나온 테스트에 우클릭 -> add -> Config Elements -> Http Header Manager로 들어가시면 됩니다.

 

다음은 Api에 관한 설정입니다. 

 

Port Number : 프로젝트에서 사용되는 포트번호

Server Name or IP :  현재는 배포가 안되어 있는 상태로 localhost를 작성했습니다.

Path : api의 url을 작성을 하면 됩니다. 

 

테스트를 해보면 다음과 같은 결과가 나옵니다. 

 

 

 

테스트 결과를 나타낸 결과 입니다. 설정을 한대로 스레드수를 200명으로 3번을 반복한 결과 입니다. 위의 그래프를 보시면 처리율(TPS)가 71.8sec로 나와있고 평균적으로 2281ms로 안정적이지만 문제라면 편차율이 높은데 이 부분의 경우에는 비관적락의 특성상 대기기간이 길어져서 생긴 결과로 보입니다.  결과적으로는 동시접속을 했을경우에 조회수를 정상적으로 올릴수 있게 되었습니다. 

 

그럼 두번째 문제로 편차율이 높은 것을 줄여보는 방안을 생각을 해보면 비관적인 락을 사용하기 때문에 제가 생각한 방안은 두가지 입니다.

  • 조회하는 락의 범위를 줄인다. 
    • 조회를 하는 서비스 단에서는 현재 메서드 전체에 락을 걸었기 때문에 데이터를 조회하는 부분만 락을 걸고 그 후의 로직인 조회수 증가 쿼리는 락을 해제한 이후에 사용을 하도록 하는 방법
  • 트랜잭션을 분리한다.
    • 위에서 언급을 했다시피 한 메서드에서 락을  사용을 하기도 하고 트랜잭션도 조회와 조회수가 같은 트랜잭션을 사용하고 있습니다. 
    • 한개의 트랜잭션으로 사용하는 것이 아닌 각각의 조회와 조회수 증가의 트랜잭션을 분리를 한다면 성능이 개선이 되지 않을까합니다.

그래서 반영이 된 코드는 다음과 같습니다. 

//게시글 조회(비관적 락 사용) o.k
    @Override
    public Optional<BoardDto.BoardResponseDto> findByBoardDetail(Integer boardId) {
        BoardDto.BoardResponseDto result = jpaQueryFactory
                .select(Projections
                        .constructor(BoardDto.BoardResponseDto.class,qBoard))
                .from(qBoard)
                .where(qBoard.id.eq(boardId))
                .setLockMode(LockModeType.PESSIMISTIC_WRITE)
                .fetchOne();
        return Optional.ofNullable(result);
    }

    //게시글 조회수 증가 o.k
    @Modifying
    @Transactional(propagation = Propagation.REQUIRED)
    @Override
    public void updateReadCount(Integer boardId) {
        jpaQueryFactory.update(qBoard)
                .set(qBoard.readCount,qBoard.readCount.add(1))
                .where(qBoard.id.eq(boardId))
                .execute();
    }

 

추가가 된 점은 조회수 증가 쿼리에서 트랜잭션에 REQUIRED를 넣었습니다. 해당 전파옵션은 트랜잭션이 존재하는 경우 해당 트랜잭션에 참여를 합니다. 다시 말해서 조회기능이 정상적으로 작동이 되면 메서드에서 비관적 락이 작동이 되고 그 다음에 조회수 증가가 작동이 되는 방식입니다. 

 

이후의 상황을 Jmeter로 다시 확인을 해보겠습니다. 

 

처음에 봤던 그래프와 비교를 해본다면  

 

편차치가 3202에서 1791로 많이 내려간것을 알수가 있고 처리율의 경우에도 전반적으로 상승을 했음을 알 수가 있습니다.