클라이언트 다수의 비동기 요청과 비관적 락
Spring Boot
, Spring Data JPA
, MySQL
을 사용한 프로젝트입니다. 해당 기술을 기반으로 글을 작성합니다.
클라이언트에서 다수의 서버 자원 상태 수정 비동기 요청 이후, 곧이어 해당 자원의 조회 요청이 올 때, 조회 요청이 먼저 처리되면 수정 요청의 결과를 반영하지 못하는 문제를 해결하기 위해 비관적 락을 적용했습니다. 그 과정 중 습득한 지식과 경험을 기록으로 남깁니다.
아래부터 클라이언트 코드 중 visibilitychange Event
핸들러 내부 visibilityState === 'hidden'
조건을 visibility hidden
이라고 칭하겠습니다.
배경
사용자가 한 페이지에서 서버 자원 수정 하면 visibility hidden
을 통해 서버로 비동기 요청을 보내는 코드가 있습니다. 수정한 자원의 수에 따라 해당 코드는 하나의 혹은 다수의 요청을 서버로 전송합니다.
이 떄 새로고침을 사용한 경우, 자원의 변경된 상태를 조회하는 요청이 이전 요청에 이어 서버로 전송됩니다.
Spring Boot 앱은 스레드 풀에 미리 생성한 10 개의 스레드를 보관하고 있으며, 각 요청 처리 작업을 각 스레드에 등록하고 실행합니다. 이 때 수정 작업보다 조회 작업이 먼저 처리되면, 수정 결과를 반영하지 못하는 상황이 발생합니다.
이 문제는 간헐적으로 발생하지만, 앱 사용자가 수정한 자원 상태를 화면에 충실하게 반영하기 위해 문제의 간헐적 발생을 막고자 했습니다.
원인
원인은 배경에서도 다루었듯 짧은 간격으로 수정, 조회 작업이 각 스레드에 할당되면서, 수정 작업 보다 조회 작업이 먼저 수행되기 때문입니다.
기존 코드
수정 대상은 댓글 좋아요 엔티티이며, 이 엔티티는 댓글을 참조(데이터베이스 외래키)합니다.
댓글 좋아요 서비스 등록 로직은 다음과 같습니다.
Comment(댓글)에서 좋아요 수를 관리하고 있기 때문에, 댓글이 존재하는지 조회합니다.
- 신규 등록의 경우 @PrePersist 메서드를 통해 좋아요 수를 올립니다.
- 취소된 좋아요의 재등록이라면 댓글의 좋아요 수를 직접 올립니다.
마찬가지로 삭제(soft delete) 로직에서도 댓글을 조회합니다.
댓글 삭제 전에 댓글이 존재하는지 조회합니다.
- 좋아요를 삭제하면 좋아요를 비활성화하고, 좋아요 수를 내립니다.
@Transactional
public CommentLike registerCommentLike(Long memberId, Long commentId) {
Member member = memberRepository.findById(memberId).orElseThrow(
() -> new NoSuchElementException("존재하지 않는 회원의 댓글 좋아요 요청.")
);
Comment comment = commentRepository.findById(commentId).orElseThrow(
() -> new NoSuchElementException("존재하지 않는 댓글에 좋아요 요청.")
);
AtomicReference<CommentLike> commentLike = new AtomicReference<>();
commentLikeRepository.findByMemberIdAndCommentId(memberId, comment.getId())
.ifPresentOrElse(
(stored) -> {
if (stored.getStatus() != EntityStatus.ACTIVE) {
stored.activate();
comment.addLikeCount();
}
commentLike.set(stored);
},
() -> commentLike.set(commentLikeRepository.save(CommentLike.builder()
.member(member)
.comment(comment)
.build()))
);
return commentLike.get();
}
@Transactional
public CommentLike deleteCommentLike(Long memberId, Long commentId) {
Comment comment = commentRepository.findById(commentId).orElseThrow(
() -> new NoSuchElementException("존재하지 않는 댓글에 좋아요 삭제 요청.")
);
CommentLike commentLike = commentLikeRepository.findByMemberIdAndCommentId(memberId, commentId).orElseThrow(
() -> new NoSuchElementException("존재하지 않는 '댓글 좋아요'에 삭제 요청.")
);
commentLike.softDelete();
comment.subtractLikeCount(); // Comment 엔티티에 좋아요 수 줄임.
return commentLike;
}
조회 대상이 되는 엔티티는 댓글입니다.
댓글 조회 서비스 로직은 다음과 같습니다. 오로지 조회만 합니다.
@Transactional(readOnly = true)
public Slice<CommentGetResponse> getSliceOfComments(Long boardId, int pageNumber) {
Pageable pageable = getNormalCommentsPageRequest(pageNumber);
Slice<Comment> found = commentRepository.findByBoardId(boardId, pageable);
return new SliceImpl<>(
found.get().map(CommentGetResponse::of).toList()
, found.getPageable()
, found.hasNext()
);
}
해결 방안
원인이 되는 것은 자원 수정 중에 조회를 하는 것입니다. 다른 작업을 하는 각각의 트랜잭션이 충돌할 때, 먼저 점유한 레코드에 락을 걸기로 했습니다.
- 수정 요청을 처리할 떄, 수정하기 전 해당 대상을 조회할 때 락을,
- 조회 요청을 처리할 때, 해당 레코드가 락이 걸려있다면 기다렸다 조회
하기로 합니다.
repository 메서드 중 조회 메서드에 다음과 같이 애너테이션을 추가합니다.
LockModeType.PESSIMISTIC_WRITE:
- 하나의 앤티티 인스턴스(레코드) 데이터를 수정하려고 시도하는 여러 트랜잭션을 순차적으로 실행(Serialization)하게 만듭니다.
- 이 락이 적용된 쿼리가 발생 한 후, 해당 레코드에 락을 걸려고 하는 요청을 대기하게 합니다.
- select … for update 쿼리
LockModeType.PESSIMISTIC_READ:
- 엔티티를 읽는 동안 수정을 차단하는 락. 조회는 가능.
- 하나의 락이기 때문에 앞선 트랜잭션이 엔티티 인스턴스(레코드)에 비관적 쓰기 락을 얻은 상태라면, 해당 트랜잭션이 끝나기까지 기다립니다.
- select … for update 쿼리
수정 대상을 조회하는 쿼리 findByIdForUpdate()
에는 LockModeType.PESSIMISTIC_WRITE
을
단순 조회 findByBoardId()
에는 LockModeType.PESSIMISTIC_READ
를 적용합니다.
조회 쿼리에
@Lock
이 없는 경우, 다른 트랜잭션이 비관적 쓰기로 락을 얻은 상태에서 작업 중인 레코드 조회가 가능했습니다.
@Repository
public interface CommentRepository extends JpaRepository<Comment, Long> {
@Lock(LockModeType.PESSIMISTIC_READ)
Slice<Comment> findByBoardId(Long boardId, Pageable pageable);
@Query(
"""
SELECT c
FROM Comment c
WHERE c.id = :commentId
""")
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Comment> findByIdForUpdate(@Param("commentId") Long commentId);
}
쓰기 작업에 락이 적용된 쿼리 메서드로 교체합니다.
@Transactional
public CommentLike registerCommentLike(Long memberId, Long commentId) {
// ...
// commentRepository method 교체
Comment comment = commentRepository.findByIdForUpdate(commentId).orElseThrow(
() -> new NoSuchElementException("존재하지 않는 댓글에 좋아요 요청.")
);
// ...
}
@Transactional
public CommentLike deleteCommentLike(Long memberId, Long commentId) {
// commentRepository method 교체
Comment comment = commentRepository.findByIdForUpdate(commentId).orElseThrow(
() -> new NoSuchElementException("존재하지 않는 댓글에 좋아요 삭제 요청.")
);
// ...
}
결과 및 테스트
이전 포스트에서 해당 문제 해결 여부 확인을 위한 테스트 코드를 작성했습니다. 테스트에서 동시성 문제 상황 재현 (@SpringBootTest, ScheduledExecutorService)
테스트 결과
// POST 요청 스레드 필터 계층에서 확인.
17:40:34.092 [...exec-1] INFO c.v.m.g.filter.RequestLogUtility: 25 - "POST /api/v1/comment-like/6 "
17:40:34.092 [...exec-6] INFO c.v.m.g.filter.RequestLogUtility: 25 - "POST /api/v1/comment-like/4 "
17:40:34.092 [...exec-3] INFO c.v.m.g.filter.RequestLogUtility: 25 - "POST /api/v1/comment-like/5 "
17:40:34.092 [...exec-4] INFO c.v.m.g.filter.RequestLogUtility: 25 - "POST /api/v1/comment-like/1 "
17:40:34.092 [...exec-2] INFO c.v.m.g.filter.RequestLogUtility: 25 - "POST /api/v1/comment-like/2 "
17:40:34.092 [...exec-5] INFO c.v.m.g.filter.RequestLogUtility: 25 - "POST /api/v1/comment-like/3 "
...
// POST 요청 스레드 트랜잭션 시작.
17:40:34.237 [...exec-6] DEBUG ...TransactionImpl: 53 - On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
17:40:34.237 [...exec-6] DEBUG ...TransactionImpl: 81 - begin
17:40:34.237 [...exec-2] DEBUG ...TransactionImpl: 53 - On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
17:40:34.237 [...exec-1] DEBUG ...TransactionImpl: 53 - On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
17:40:34.237 [...exec-2] DEBUG ...TransactionImpl: 81 - begin
17:40:34.237 [...exec-1] DEBUG ...TransactionImpl: 81 - begin
17:40:34.237 [...exec-5] DEBUG ...TransactionImpl: 53 - On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
17:40:34.237 [...exec-3] DEBUG ...TransactionImpl: 53 - On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
17:40:34.238 [...exec-5] DEBUG ...TransactionImpl: 81 - begin
17:40:34.238 [...exec-3] DEBUG ...TransactionImpl: 81 - begin
17:40:34.238 [...exec-4] DEBUG ...TransactionImpl: 53 - On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
17:40:34.238 [...exec-4] DEBUG ...TransactionImpl: 81 - begin
// GET 요청 스레드 필터 계층에서 확인 및 트랜잭션 시작.
17:40:34.242 [...exec-7] INFO ..equestLogUtility: 25 - "GET /api/v1/board/1/comments "
17:40:34.245 [...exec-7] DEBUG ...TransactionImpl: 53 - On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
17:40:34.246 [...exec-7] DEBUG ...TransactionImpl: 81 - begin
...
// POST 요청 스레드 6 select ... for update 쿼리
17:40:34.254 [...exec-6] DEBUG org.hibernate.SQL: 135 -
select
c1_0.id,
c1_0.board_id,
c1_0.content,
c1_0.like_count,
c1_0.member_id,
c1_0.register_date,
c1_0.update_date
from
comment c1_0
where
c1_0.id=? for update
// GET 요청 조회 쿼리 발송. ( 쓰기 작업 종료 전 )
17:40:34.254 [...exec-7] DEBUG org.hibernate.SQL: 135 -
select
c1_0.id,
c1_0.board_id,
c1_0.content,
c1_0.like_count,
c1_0.member_id,
c1_0.register_date,
c1_0.update_date
from
comment c1_0
left join
board b1_0
on b1_0.id=c1_0.board_id
where
b1_0.id=?
order by
c1_0.id
offset
? rows
fetch
first ? rows only for update
// POST 요청 스레드 3 트랜잭션은 조회보다 먼저 시작, 쿼리는 더 늦게 발송.
17:40:34.254 [...exec-3] DEBUG org.hibernate.SQL: 135 -
select
c1_0.id,
c1_0.board_id,
c1_0.content,
c1_0.like_count,
c1_0.member_id,
c1_0.register_date,
c1_0.update_date
from
comment c1_0
where
c1_0.id=? for update
...
// POST 요청 완료 및 트랜잭션 커밋
// 쿼리가 조회 스레드 보다 늦게 나갔던 스레드 3 커밋 포함.
17:40:34.284 [...exec-2] INFO ...mmentLikeService: 77 - POST commentLike done.
17:40:34.284 [...exec-2] DEBUG ....TransactionImpl: 98 - committing
17:40:34.284 [...exec-6] INFO ...mmentLikeService: 77 - POST commentLike done.
17:40:34.285 [...exec-6] DEBUG ....TransactionImpl: 98 - committing
17:40:34.285 [...exec-5] INFO ...mmentLikeService: 77 - POST commentLike done.
17:40:34.285 [...exec-1] INFO ...mmentLikeService: 77 - POST commentLike done.
17:40:34.285 [...exec-4] INFO ...mmentLikeService: 77 - POST commentLike done.
17:40:34.285 [...exec-5] DEBUG ....TransactionImpl: 98 - committing
17:40:34.285 [...exec-1] DEBUG ....TransactionImpl: 98 - committing
17:40:34.285 [...exec-4] DEBUG ....TransactionImpl: 98 - committing
17:40:34.285 [...exec-3] INFO ...mmentLikeService: 77 - POST commentLike done.
17:40:34.286 [...exec-3] DEBUG ....TransactionImpl: 98 - committing
// POST 요청 점유하고 있던 모든 레코드 업데이트 쿼리 발송
update
comment
set
board_id=?,
content=?,
like_count=?,
member_id=?,
register_date=?,
update_date=?
where
id=?
...
// GET 요청 스레드 서비스 로직 리턴 직전 로그.
17:40:34.295 [...exec-7] INFO ...e.CommentService: 146 - GET query done.
// GET 요청 스레드 커밋.
17:40:34.298 [...exec-7] DEBUG ....TransactionImpl: 98 - committing
// 좋아요 적용 된 결과 로그 (수정 중 추가)
19:43:34.129 [main] INFO ...CommentLikeConcurrencyTest: 168 - commentId: 1, likeCount: 1
19:43:34.160 [main] INFO ...CommentLikeConcurrencyTest: 168 - commentId: 2, likeCount: 1
19:43:34.160 [main] INFO ...CommentLikeConcurrencyTest: 168 - commentId: 3, likeCount: 1
19:43:34.160 [main] INFO ...CommentLikeConcurrencyTest: 168 - commentId: 4, likeCount: 1
19:43:34.160 [main] INFO ...CommentLikeConcurrencyTest: 168 - commentId: 5, likeCount: 1
19:43:34.160 [main] INFO ...CommentLikeConcurrencyTest: 168 - commentId: 6, likeCount: 1
위와 같은 테스트 결과를 얻으며, 테스트 주장은 성공했으며,
본래 목적인 트랜잭션의 순차적 수행이 이뤄지는 것을 확인할 수 있었습니다.
모든 수정 트랜잭션이 끝난 후, 조회 트랜잭션의 수행.
작업 후 돌아보며
현재는 앱 사용자가 많지 않아 특정 자원(댓글)에 대한 수정 요청이 많지 않습니다.
그로 인해 자원 수정 요청의 결과(댓글 좋아요 수)가 눈에 띕니다.
사용자가 많은 경우, 하나의 댓글에 대해 동시다발적으로 좋아요 등록•삭제 요청이 발생할 것입니다. 좋아요 수는 오르기도, 내리기도 할 것이고, 이 경우 사용자가 인식하는 내 좋아요의 작동 여부는 좋아요 수가 ‘하나 오르거나 내렸나’가 아니라, 좋아요 버튼의 시각적 변화일 것입니다.
이 문제는 간헐적으로 발생하며, 새로고침 후 좋아요 버튼은 ‘좋아요를 등록한 상태’이지만, 댓글 좋아요 수는 ‘0’인 특수한 상황에 부각됩니다. 서버에는 자원 수정 결과가 제대로 저장되었기 때문에 추가로 새로고침 하거나 페이지를 다시 방문하면 자원 상태는 올바르게 적용됩니다.
때문에 사소하다면 사소한 버그일 수 있지만, 문제로 삼았기에 동시성 문제와 데이터베이스 JPA 락에 대해 알 수 있는 좋은 경험의 발판이 됐습니다.
만약 사용자가 많고 댓글 좋아요 요청이 많은 상황이라면, 해결해야할 문제의 초점이 사용자의 좋아요 ‘요청이 빠짐없이 수행되었는 지’로 바귈 것이라 생각합니다. 그 때는 비관적 락외의 방안이 요구될 수도 있을 것입니다.
특정 댓글 좋아요 요청이 동시에 쏟아져 락 대기 시간이 길어져, 수정, 조회 요청 대기 시간이 길어지거나 데드락이 발생하는 경우,
- 버전 정보를 이용하는 낙관적 락으로, 수정 트랜잭션 충돌 시, 재시도 로직을 구현하는 방식을 택할 수 있겠습니다.