상황
게시물에 좋아요 버튼을 누르면 좋아요 수가 1씩 증가하거나 감소해야하고, 댓글을 작성하거나 삭제하면 댓글 수가 1씩 증가하거나 감소해야 했다.
그런데, 그런 요청이 있을 때마다 좋아요 댓글 수 컬럼 값을 1씩 증가하거나 1씩 감소시키게 된다면, 충돌의 위험이 있었고, 데이터 무결성에 문제가 생길 수 있었다. 그래서 충돌을 방지하여 데이터 무결성을 높이는 락을 사용해야 했다. 하지만, 동시성 처리가 많은 상황에서는 락 경합이 빈번하게 발생하여 락 대기 시간이 길어질 수 있었고, 데이터베이스의 성능이 저하될 수 있었다.
해결방법
[ 1. 별도의 테이블을 사용하여 성능 향상 ]
좋아요와 댓글에 대한 테이블을 따로 만들고, 생성이 될 때마다 데이터를 하나씩 추가시키고 삭제할때마다 해당 데이터를 하니씩 삭제했다. 이처럼 별도의 테이블을 사용하면, 각 사용자가 독립적으로 별도의 행을 추가하거나 삭제하게 되어 트랜잭션 간의 충돌이 줄어들어 성능이 향상된다.
[ 2. 반정규화를 통한 조회 성능 최적화 ]
그리고나서 각각의 테이블에 대한 count query를 통해 게시물 column값을 갱신했다. 이렇게 중복 데이터(테이블과 컬럼)를 일부로 허용하는 반정규화를 통해 좋아요 수와 댓글 수를 미리 계산해두어 update query문 수는 많은 대신에 조회시 join연산을 매번 하지 않아도 되어 select query문 수를 줄일 수 있었다. (좋아요/댓글을 달거나 삭제하는 요청보다 조회가 훨씬 빈번하다고 판단했다.)
[ 3. 비관적 락을 통한 추가적인 동시성 제어 ]
다만, 여전히 여러 사용자가 동시에 좋아요를 누르거나 댓글을 작성하여 두 트랜잭션이 동시에 db와 컬럼값을 업데이트하려고 하면 마지막으로 반영된 값이 잘못될 수 있다. 결국, 정확한 좋아요 수와 댓글 수를 계산하고 갱신하기 위해서는 추가적인 동시성 제어가 필요하다. 비교적 동시성 문제가 발생할 가능성이 높은 경우, 비관적 락을 사용하여 자원을 보호하며 데이터 무결성을 확실히 보장할 수 있는데, 성능 저하를 초래할 수 있다는 단점이 있다. 반대로 동시성 문제가 드물게 발생하는 경우, 낙관적 락을 사용하여 충돌이 발생했을 때만 이를 처리하는 방식으로 성능을 최적화할 수 있다.
좋아요와 댓글에 대한 테이블을 따로 만들어서 데이터를 추가하고 삭제하는 방식은 동시성 문제를 줄이고 성능을 향상시킬 수 있다. 하지만, 여전히 여러 사용자가 동시에 좋아요를 누르거나 댓글을 작성할 때 동시성 문제가 발생할 수 있으며, 이를 해결하기 위해 추가적인 동시성 제어가 필요하다. 나는 비관적 락을 사용하겠다.
비관적 락 vs 낙관적 락
비관적 락(Pessimistic Lock)
- 트랜잭션이 시작될 때 데이터에 락을 걸어 다른 트랜잭션이 접근하지 못하게 한다.
- 동시성 문제가 많이 발생할 때 유용하지만 성능 저하를 초래할 수 있다.
- 데드락이 걸리지 않도록 로직을 잘 짜야 한다.
- 동시성 충돌이 빈번히 발생하고, 데이터 무결성이 매우 중요한 경우에 사용한다.
낙관적 락(Optimistic Lock)
- 트랜잭션이 데이터를 수정하기 전에 다른 트랜잭션이 데이터를 변경했는지 확인하는데, 이때 주로 버전 번호나 타임스탬프를 사용한다.
- 성능에 덜 영향을 미치지만 충돌이 발생했을 때는 재시도가 필요하다.
- 충돌 발생 시 재시도 로직은 개발자가 짜야 한다.
- 동시성 충돌이 드물고, 성능이 중요한 경우에 사용한다.
https://2jinishappy.tistory.com/344
비관적 락 적용 로직
우선, 알고넘어가야 할 개념인 transaction에 대해 설명해보려 한다.
트랜잭션(Transaction)은 데이터베이스에서 하나의 논리적인 작업 단위를 구성하는 일련의 작업들을 묶어서 하나의 작업처럼 처리하는 것을 의미한다. 트랜잭션의 중요한 특성은 원자성(Atomicity), 일관성(Consistency), 고립성(Isolation), 지속성(Durability)으로, 이를 ACID 속성이라고 부른다.
1. 원자성(Atomicity): 트랜잭션 내의 모든 작업이 전부 성공하거나, 전부 실패하게 한다. 만약 트랜잭션 중간에 예외가 발생하면 전체 트랜잭션이 롤백된다.
2. 일관성(Consistency): 트랜잭션이 성공적으로 완료된 후에는 데이터베이스의 상태가 일관성을 유지하도록 한다.
3. 고립성(Isolation): 트랜잭션이 다른 트랜잭션과 독립적으로 실행되도록 한다. 하지만, 기본적으로 설정된 고립성 수준에 따라 완전한 고립성을 보장하지 않을 수 있다.
4. 지속성(Durability): 트랜잭션이 성공적으로 커밋된 후에는 그 결과가 영구적으로 저장된다.
이런 트랜잭션은 Spring의 @Transactional을 통해 구현될 수 있다. 이때는 기본적으로 데이터베이스의 기본 격리 수준을 따른다. 필요에 따라 isolation 속성을 통해 트랜잭션의 격리 수준을 설정할 수 있다.
하지만, 트랜잭션이 다른 트랜잭션과 병렬로 처리될 수 있는 상황에서는 고립성을 충분히 보장하지 못하기에 이를 보완하기 위해 비관적 락 또는 낙관적 락을 사용한다.
1. Board entity에 비관적 락 걸기 - 좋아요
@Transactional 애노테이션이 적용된 메서드가 호출되어 트랜잭션이 시작되고, findByIdWithLock(boardId) 메서드가 호출되면서 해당 보드 엔티티에 비관적 락이 걸린다. 이 락은 다른 트랜잭션이 이 엔티티를 수정하지 못하게 한다. toggleLikeAndRetrieveCount() 혹은 cancelLikeAndRetrieveCount()메서드 내부의 로직이 실행되며 BordLike 엔티티와 Board 엔티티의 컬럼값이 갱신되어 정상적으로 반환된거나 에러가 발생하여 예외가 던져진다. 이렇게 메서드가 정상적으로 종료되면 트랜잭션이 커밋된 후 락이 해제되고, 예외가 발생하면 트랜잭션이 롤백되고 락이 해제된다.
이렇듯 특정 게시물에 대한 데이터를 가져오는 시점과 게시물 컬럼 값을 수정하는 시점 모두에 걸쳐 락을 걸어 동시성 문제를 방지할 수 있다.
@Transactional
public Board toggleLikeAndRetrieveCount(Long boardId, User user) {
Board board = boardRepository.findByIdWithLock(boardId)
.orElseThrow(() -> GeneralException.of(ErrorCode.BOARD_NOT_FOUND));
BoardLike existingLike = boardLikeRepository.findByUserAndBoard(user, board);
if (existingLike != null) {
// 이미 좋아요를 눌렀다면 에러 반환
throw new GeneralException(ErrorCode.ALREADY_LIKED_BOARD);
} else {
// 좋아요를 누르지 않았다면, 좋아요 누르기
boardLikeRepository.save(BoardLike.builder().user(user).board(board).build());
// 좋아요 수 업데이트
updateLikeCount(board);
}
return boardRepository.save(board);
}
@Transactional
public Board cancelLikeAndRetrieveCount(Long boardId, User user) {
Board board = boardRepository.findByIdWithLock(boardId)
.orElseThrow(() -> GeneralException.of(ErrorCode.BOARD_NOT_FOUND));
BoardLike existingLike = boardLikeRepository.findByUserAndBoard(user, board);
if (existingLike != null) {
// 이미 좋아요를 눌렀다면 좋아요 취소
boardLikeRepository.delete(existingLike);
// 좋아요 수 업데이트
updateLikeCount(board);
return boardRepository.save(board);
} else { // 취소할 좋아요가 없으면 에러 반환
throw new GeneralException(ErrorCode.LIKED_BOARD_NOT_FOUND);
}
}
@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT b FROM Board b WHERE b.id = :boardId")
Optional<Board> findByIdWithLock(@Param("boardId") Long boardId);
}
// 좋아요 수 업데이트
private void updateLikeCount(Board board) {
Long likeCount = boardLikeRepository.countAllByBoard(board);
board.updateLikeCount(likeCount);
}
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BoardLike extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
private Board board;
}
위처럼, 좋아요 수와 댓글 수와 같은 집계 값을 포함하는 Board 엔티티에 락을 걸어 업데이트할 때 다른 트랜잭션이 이 값을 변경하지 못하도록 할 수 있다.
그런데, 특정 사용자와 게시물 간의 좋아요 관계를 나타내는 BoardLike 엔티티에도 락을 거는 것이 좋다. 특정 사용자가 이미 좋아요를 눌렀는지 여부를 확인하고, 새로운 좋아요를 추가하거나 기존 좋아요를 삭제하는 작업을 안전하게 수행하도록 돕기 때문이다.
2. BoardLike entity에 비관적 락 걸기 - 좋아요
Board entity에 비관적 락을 거는 것과 로직은 비슷하다. 단순히 락이 걸리는 순간만 다를 뿐이다.
// 좋아요 토글 및 좋아요 수 조회
@Transactional
public Board toggleLikeAndRetrieveCount(Long boardId, User user) {
// 트랜잭션 시작
// 비관적 락을 사용하여 Board 엔티티를 가져옴
Board board = boardRepository.findByIdWithLock(boardId)
.orElseThrow(() -> GeneralException.of(ErrorCode.BOARD_NOT_FOUND));
// 비관적 락을 사용하여 BoardLike 엔티티를 가져옴
BoardLike existingLike = boardLikeRepository.findByUserAndBoardWithLock(user, board)
.orElse(null);;
if (existingLike != null) {
// 이미 좋아요를 눌렀다면 에러 반환 -> 트랜잭션 롤백 -> 락 해제
throw new GeneralException(ErrorCode.ALREADY_LIKED_BOARD);
} else {
// 좋아요를 누르지 않았다면, 좋아요 누르기
boardLikeRepository.save(BoardLike.builder().user(user).board(board).build());
// 좋아요 수 업데이트
updateLikeCount(board);
}
// 트랜잭션 커밋 -> 락 해제
return boardRepository.save(board);
}
// 좋아요 취소 및 좋아요 수 조회
@Transactional
public Board cancelLikeAndRetrieveCount(Long boardId, User user) {
// 트랜잭션 시작
// 비관적 락을 사용하여 Board 엔티티를 가져옴
Board board = boardRepository.findByIdWithLock(boardId)
.orElseThrow(() -> GeneralException.of(ErrorCode.BOARD_NOT_FOUND));
// 비관적 락을 사용하여 BoardLike 엔티티를 가져옴
BoardLike existingLike = boardLikeRepository.findByUserAndBoardWithLock(user, board)
.orElse(null);
if (existingLike != null) {
// 이미 좋아요를 눌렀다면 좋아요 취소
boardLikeRepository.delete(existingLike);
// 좋아요 수 업데이트
updateLikeCount(board);
// 트랜잭션 커밋 -> 락 해제
return boardRepository.save(board);
} else { // 취소할 좋아요가 없으면 에러 반환 -> 트랜잭션 롤백 -> 락 해제
throw new GeneralException(ErrorCode.LIKED_BOARD_NOT_FOUND);
}
}
@Repository
public interface BoardLikeRepository extends JpaRepository<BoardLike, Long> {
// 게시판 좋아요 개수 조회
Long countAllByBoard(Board board);
// 사용자와 게시판에 대한 좋아요 정보 조회
// BoardLike findByUserAndBoard(User user, Board board); - 이전 로직
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT bl FROM BoardLike bl WHERE bl.user = :user AND bl.board = :board")
Optional<BoardLike> findByUserAndBoardWithLock(@Param("user") User user, @Param("board") Board board);
}
3. BoardLike entity에 비관적 락 걸기 - 댓글
@Transactional
public Long createComment(Long boardId, CommentDto commentReqDto, User user) {
// 트랜잭션 시작
// 비관적 락을 사용하여 Board 엔티티를 가져옴
Board board = boardRepository.findByIdWithLock(boardId)
.orElseThrow(() -> GeneralException.of(ErrorCode.BOARD_NOT_FOUND));
Comment comment = CommentConverter.saveComment(commentReqDto, board, user);
commentRepository.save(comment);
updateCommentCount(board);
boardRepository.save(board);
// 트랜잭션 커밋 -> 락 해제
return comment.getId();
}
4. 비관적 락 로직에서 데드락 가능성 줄이기
위에서 말했듯이 비관적 락을 사용하면 데드락(교착상태) 발생의 가능성이 있다. 데드락이 발생하면 해결 방안이 또 머리가 아파오기 때문에 최대한 데드락을 예방해야 한다.
예방 방법
1. 락 순서 규칙 정하기: 모든 트랜잭션이 동일한 순서로 자원을 획득하도록 한다. 예를 들어, 트랜잭션들이 항상 먼저 Board를 락 걸고, 그 다음에 BoardLike를 락 걸도록 순서를 정한다.
2. 락 타임아웃 설정: 락을 얻기 위해 기다리는 시간을 제한한다. 특정 시간이 지나면 락을 얻지 못하고 예외를 발생시켜 트랜잭션을 롤백하게 한다. 이는 무한 대기 상태를 방지할 수 있다.
3. 짧은 트랜잭션: 트랜잭션을 가능한 한 짧게 유지하여 락을 오래 걸지 않도록 한다. 이렇게 하면 락 경합과 데드락 가능성이 줄어든다.
4. 명시적 락 관리: 필요하지 않은 경우 락을 걸지 않도록 하고, 트랜잭션 범위를 최소화한다. 예를 들어, 필요한 경우에만 PESSIMISTIC_WRITE 락을 사용한다.
5. 데드락 감지 및 회복: 일부 데이터베이스 시스템은 데드락 감지 및 회복 기능을 제공한다다. 이를 활용하여 데드락을 감지하고 트랜잭션을 롤백하여 해소할 수 있다.
이러한 방법들이 있는데, 위에 코드를 보면 알 수 있듯이 나는 락 순서 규칙을 정했고, PESSIMISTIC_WRITE 락을 사용하여 명시적으로 락을 관리했다. 추가적으로, 사용하고 있는 db인 MySQL에서 innodb_lock_wait_timeout 설정을 사용하여 락 타임아웃을 설정할 수 있을 것 같다.
결론(요약)
게시물에 좋아요 버튼을 누르거나 댓글을 작성/삭제할 때, 좋아요 수와 댓글 수 컬럼 값을 1씩 증가/감소시켜야 했다. 하지만, 이러한 작업이 동시에 발생하면 충돌이 발생할 수 있어 데이터 무결성이 위협받게 되었다.
이 문제를 해결하기 위해 여러 가지 방법을 사용했다. 첫 번째로, 세마포어 개념을 활용하여 좋아요와 댓글을 각각의 테이블에 독립적으로 저장하도록 했다. 사용자가 좋아요를 누르거나 댓글을 작성할 때마다 각각의 테이블에 데이터를 추가하고, 삭제할 때마다 데이터를 제거했다. 이렇게 별도의 테이블을 사용함으로써, 각 사용자가 독립적으로 별도의 행을 추가하거나 삭제하게 되어 트랜잭션 간의 충돌이 줄어들어 성능이 향상되었다.
두 번째로, 반정규화를 통해 조회 성능을 최적화했다. 각각의 테이블에 대한 count query를 통해 게시물의 컬럼 값을 갱신했다. 이렇게 중복 데이터(테이블과 컬럼)를 일부러 허용함으로써, 좋아요 수와 댓글 수를 미리 계산해두었다. 이로 인해 update query문 수는 많아졌지만, 조회 시 join 연산을 매번 하지 않아도 되므로 select query문 수를 줄일 수 있었다. 이는 좋아요나 댓글을 달거나 삭제하는 요청보다 조회가 훨씬 빈번하게 발생하는 상황에서 성능을 최적화하는 데 유리했다.
그럼에도 불구하고, 여러 사용자가 동시에 좋아요를 누르거나 댓글을 작성하는 경우 두 트랜잭션이 동시에 데이터베이스와 컬럼 값을 업데이트하려고 하면 마지막으로 반영된 값이 잘못될 수 있었다. 이를 해결하기 위해 비관적 락을 사용하여 동시성 문제를 방지했다. 트랜잭션이 시작될 때 특정 리소스에 락을 걸어 다른 트랜잭션이 접근하지 못하게 했다. 이로써, 트랜잭션 간의 충돌을 방지하고 데이터 무결성을 보장했다.
이러한 방법들을 통해, 데이터 무결성을 유지하면서도 성능을 최적화할 수 있었다.
'Backend > Spring' 카테고리의 다른 글
[Spring/Java] FCM을 통해 Push 알림 보내기 (0) | 2024.07.08 |
---|---|
[Spring/Java] CORS란? 해결법은? (0) | 2024.07.07 |
[Sping/Java] JPQL vs QueryDSL (0) | 2024.07.07 |
[Spring/Java] JPQL & Slice 객체(paging)이용하여 특정 조건의 게시물을 특정 방식으로 가져오기 (0) | 2024.07.07 |
[Spring/Java] Paging이란? Page 객체 vs Slice 객체 (+ 코드) (0) | 2024.07.07 |