이전에 구현했었던 커뮤니티 서비스에서 어느날부터 이상한 에러가 발생했다.
could not execute batch [(conn=55704)
Record has changed since last read in table 'board'] [update board set ~~]
로그로 경로를 확인해보니 트래픽이 점점 증가하면서 게시글 조회 시 간혈적으로 발생하는 오류였다.
테스트 환경에선 이상이 없었고 사용자가 늘어나면서 생긴 문제였기에 한 게시글에 동시 접근 시 발생하는 오류라고 판단하고 수정을 시작했다.
아래는 처음 코드 구조이다.
@Transactional
public BoardDetailDto getBoardDetail(UUID boardUuid, String userUuid) {
Board board = boardCrudService.findByUuid(boardUuid);
List<Comment> allComments = board.getComments();
board.addViewCount();
Boolean isMine = board.isMine(userUuid);
return new BoardDetailDto().from(board)
.withComments(allComments)
.isMine(isMine);
}
게시글을 먼저 찾고 조회수를 증가한 뒤, 나의 글 유무와 함께 리턴을 해주는 간단한 구조이다.
오류 내용으로 보아 조회수 업데이트 시 현재 영속성 컨텍스트에 있는 데이터와 db 데이터가 달라 발생한 문제로 보였다. 그래서 조회수 증가 로직을 따로 트랜잭션을 만들어 findByUuidWithDetails 로 영속성 컨텍스트에 저장된 데이터와 별개의 데이터를 사용하도록 만들었다.
@Transactional
public BoardDetailDto getBoardDetail(UUID boardUuid, String userUuid) {
Board board = boardCrudService.findByUuid(boardUuid);
List<Comment> allComments = board.getComments();
updateViewCountById(board.getId());
Boolean isMine = board.isMine(userUuid);
return new BoardDetailDto().from(board)
.withComments(allComments)
.isMine(isMine);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateViewCountById(Long id) {
boardRepository.incrementViewCount(id);
}
REQUIRES_NEW를 사용해 트랜잭션 전파에 영향을 받지 않는 별개의 트랜잭션을 만들어 충돌이 없도록 조회수 증가 로직을 만들었고 해당 오류는 사라졌다.
그런데 이렇게 되면 불필요한 트랜잭션이 생기게 되어서 좋지 못하다고 생각하고 다른 방식을 생각해봤다.
이번에는 격리 레벨 관점에서 확인을 해봤다.
회사에서 사용하는 db는 mariaDB이고 mariaDB의 격리 레벨은 Repeatable read이다. 즉 select을 할 때 for update 구문을 넣지 않으면 락이 걸리지 않아 언제든 수정, 삭제가 가능하다.
트랜잭션을 새로 생성하지 않고 select 시 for update를 추가하도록 수정해봤다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Board> findByUuid(UUID uuid);
findByUuid 쪽에 PESSIMISTIC_WRITE로 베타락을 걸어 다른 트랜잭션에서의 접근을 막아주도록 수정했다.
개념적으로만 알고있던 트랜잭션, 격리 수준, 락이 실서비스에서 어떤 영향을 미칠 수 있는지 온몸으로 느낄수 있었다.
결과적으로 베타락을 사용했는데, 주의할 점은 베타락은 안정적이지만 성능 저하가 발생할 수 있으므로 긴 시간 락을 유지하지 않도록 수행범위를 최소화해야한다.
끝