1년 전 간단한 게시판 프로젝트를 진행하며 경험했던 댓글 개수 및 좋아요 개수 업데이트 동시성 문제를 경험하고 해결하며 공부한 내용을 나눠볼까 합니다.
문제 발생
여러 사용자가 동일한 게시글에 동시에 댓글을 작성하거나 좋아요를 변경(좋아요 또는 좋아요 취소)하는 경우, 트랜잭션 교착 상태(Deadlock)가 발생했습니다. 교착 상태가 발생하며 한 명의 사용자를 제외한 나머지 사용자는 댓글이 작성되지 않는 문제가 발생합니다.
Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
프로젝트는 Spring Boot 3.3, Java 17 환경이며 Spring Data JPA와 MySQL을 사용했습니다. 댓글과 좋아요 개수 업데이트 시 발생하는 문제는 동일하기 때문에 댓글 개수로 예시를 들어 설명하겠습니다.
예제 코드
우선 게시글과 댓글 객체는 다음과 같이 작성했습니다.
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
private int commentsCount;
...
public void increaseCommentsCount() {
commentsCount++;
}
}
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
private LocalDateTime writtenAt;
@JoinColumn(name = "post")
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
...
}
댓글이 게시글의 id를 외래키로 참조하는 단순하면서 흔하게 볼 수 있는 일대다 연관관계를 갖는 객체들입니다. 서비스 요구사항에 따라 게시글 목록 조회 시에 댓글 개수를 보여주어야 했기 때문에 댓글 개수 필드를 사용했습니다.
다음은 작성된 댓글을 저장하고 해당 게시글의 댓글 개수를 1만큼 증가시키는 코드입니다.
public void writeComment(Long postId, String commentContent) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException("Post not found"));
Comment comment = new Comment(commentContent, post);
post.increaseCommentsCount();
commentRepository.save(comment);
}
코드가 단순해서 동작을 쉽게 이해하실 수 있을 거라고 생각합니다.
테스트 코드
문제 발생 상황을 재현하기 위해 테스트 코드를 작성해 보겠습니다. java.util.concurrent
의 ExecutorService
와 CountDownLatch
를 사용하면 쉽게 병렬 실행 환경을 구현할 수 있습니다.
@SpringBootTest
public class CommentServiceParallelTest {
private static final int TOTAL_COUNT = 3;
private Post post;
private ExecutorService executorService;
private CountDownLatch latch;
@Autowired
CommentService commentService;
@Autowired
PostRepository postRepository;
@BeforeEach
void setUp() {
executorService = Executors.newFixedThreadPool(TOTAL_COUNT);
latch = new CountDownLatch(TOTAL_COUNT);
post = postRepository.save(new Post("제목", "내용"));
}
@Test
void 댓글_작성_동시성_테스트() throws Exception {
for (int i = 0; i < TOTAL_COUNT; i++) {
int commentNumber = i + 1;
executorService.submit(() -> {
try {
commentService.writeComment(post.getId(), "comment" + commentNumber);
} catch (Exception e) {
log.error("Exception", e);
} finally {
latch.countDown();
}
});
}
latch.await();
}
}
동시에 3명의 사용자가 동일한 게시글에 댓글을 작성하는 상황을 구현한 테스트 코드입니다. 해당 테스트를 실행하면 다음과 같이 오류가 발생합니다.
이미지에 나와있지는 않지만 두 개의 쓰레드에 대해 해당 에러 로그가 출력되어 있습니다. 3명의 사용자 중 한 명의 사용자만 댓글이 작성되고 나머지 두 사용자는 예외 발생으로 댓글이 작성되지 않은 것이죠.
데이터베이스를 확인해 보면 단 하나의 댓글만 작성되어 있습니다.
발생 원인
그럼 트랜잭션 교착 상태는 왜 발생했을까요?
처음에 예상한 원인은 JPA 특성상 게시글의 댓글 개수를 바로 증가시키는 것이 아닌 객체로 영속화(SELECT
쿼리 발생)한 후에 증가하여 INSERT
하기 때문에 발생하는 문제라고 생각했습니다. 트랜잭션 1과 트랜잭션 2가 댓글 개수가 동일한 상태로 게시글을 읽은 후에 각각 1씩 증가시킨 후 저장하는 과정에서 발생하는 충돌을 JPA가 감지하고 예외를 발생시켰다고 예상한 것입니다.
하지만 이런 상황을 교착 상태라고 보기에는 힘들었습니다.
데이터베이스 트랜잭션 교착 상태
MySQL의 InnoDB 엔진은 다음 명령어를 통해 트랜잭션 교착 상태 감지 내역을 확인할 수 있습니다.
SHOW ENGINE InnoDB STATUS;
해당 정보에는 트랜잭션이 공유 잠금(shared(s) lock)과 배타 잠금(exclusive(x) lock)을 획득하거나 대기한 정보가 포함되어 있습니다. 공유 잠금과 배타 잠금에 대해서는 아래 더보기란에 정리해 두겠습니다.
공유 잠금과 배타 잠금
MySQL InnoDB에서 사용하는 잠금인 공유 잠금과 배타 잠금에 대해 간단히 알아보겠습니다. InnoDB의 잠금을 제대로 이해하려면 트랜잭션 격리 수준을 이해해야 합니다. 기회가 된다면 다음에 해당 내용에 대해 정리해 볼까 합니다. 지금은 현재 다루고 있는 트랜잭션 교착 상태 문제를 이해할 수 있을 정도만 설명하겠습니다.
공유 잠금(shared lock, s lock)은 다른 트랜잭션이 데이터를 수정할 수 없도록 하기 위해 획득하는 잠금입니다. 즉, 아래에서 설명하는 배타 잠금을 다른 트랜잭션이 획득하지 못하도록 합니다. 여러 트랜잭션이 동시에 하나의 레코드에 대해 공유 잠금을 획득할 수 있습니다.
배타 잠금(exclusive lock, x lock)은 트랜잭션이 데이터를 수정하기 위해 획득하는 잠금입니다. 여러 트랜잭션이 동시에 하나의 레코드를 수정한다면 문제가 발생할 수 있습니다. 따라서 여러 트랜잭션이 동시에 하나의 레코드에 대해 배타 잠금을 획득할 수 없습니다.
다른 트랜잭션이 공유 잠금을 획득한 레코드에 대해 배타 잠금을 획득할 수 없으며, 다른 트랜잭션이 배타 잠금을 획득한 레코드에 대해 공유 잠금과 배타 잠금을 획득할 수 없습니다. 즉, 공유 잠금은 다른 트랜잭션이 레코드를 수정하지 못하게 하며 배타 잠금은 다른 트랜잭션이 '공유 잠금을 거는 읽기' 및 수정을 못하도록 합니다.
일단 이 정도 내용만 알고 있어도 이번에 다루는 내용은 충분히 이해할 수 있습니다.
출력된 내용을 요약해 보면 다음과 같은 흐름을 알 수 있습니다.
1. 트랜잭션 1이 post
테이블의 레코드에 대해 공유 잠금(s lock) 획득
2. 트랜잭션 2도 post
테이블의 레코드에 대해 공유 잠금(s lock) 획득
3. 트랜잭션 1이 post
테이블의 레코드에 대해 배타 잠금(x lock) 획득 권한 대기
4. 트랜잭션 2도 post
테이블의 레코드에 대해 배타 잠금(x lock) 획득 권한 대기
정리하면, Tx1과 Tx2가 동일한 레코드에 대해 s lock을 획득한 상태에서 Tx1이 x lock을 획득하려고 하지만 Tx2가 s lock을 획득한 상태이기 때문에 대기하고, Tx2도 x lock을 획득하려고 하면서 교착 상태가 발생된 것입니다. 둘 다 s lock을 획득한 상태로 x lock 획득 권한을 얻으려고 하며 무한정 대기해야 하는 상황을 미리 감지한 것입니다.
x lock은 게시글의 댓글 개수를 갱신하는 과정에서 필요하지만, SELECT ... FOR SHARE
문을 사용한 것도 아닌데 s lock은 왜 필요할까요? 바로 게시글(post
테이블)과 댓글(comment
테이블)간의 외래키 제약 조건 때문입니다. 새로운 댓글이 INSERT
, UPDATE
또는 DELETE
되는 경우, 외래키 제약 조건 확인을 위해 해당 게시글 레코드에 공유 잠금을 설정하는 것입니다.
재현하기
그럼 직접 댓글 작성 및 댓글 개수 업데이트 로직을 데이터베이스 수준에서 재현해 보겠습니다. 댓글을 작성하면 아래와 같이 3개의 쿼리가 실행됩니다. 잠금 획득 과정을 눈으로 확인하기 위해 autocommit을 사용하지 않기 위해 START TRANSACTION;
을 사용했습니다.
START TRANSACTION;
INSERT INTO comment (content, written_at, post)
VALUES ('content', CURRENT_TIMESTAMP, 1);
UPDATE post
SET comments_count = comments_count + 1
WHERE id = 1;
COMMIT;
새로운 댓글을 저장하고 게시글의 댓글 개수 값을 1만큼 증가시키는 쿼리입니다. (JPA에서 Post
객체를 영속화할 때 실행되는 SELECT
쿼리는 지금 크게 의미 없기 때문에 제외했습니다.)
트랜잭션의 잠금 획득 및 요청 정보는 performance_schema의 data_locks 테이블에서 확인할 수 있습니다.
SELECT ENGINE_TRANSACTION_ID AS Trx_Id,
OBJECT_NAME AS `Table`,
INDEX_NAME AS `Index`,
LOCK_DATA AS Data,
LOCK_MODE AS Mode,
LOCK_STATUS AS Status,
LOCK_TYPE AS Type
FROM performance_schema.data_locks;
MySQL 서버에 두 개의 커넥션을 생성하고 쿼리를 하나씩 실행해 보며 잠금을 확인해 보겠습니다. 아래는 Tx1이 새로운 댓글을 저장했을 때의 잠금 획득 정보입니다.
3번째 줄을 보면 post
테이블의 PK값이 1
인 레코드에 대해 s lock을 획득했습니다. 이 상태에서 새로운 트랜잭션으로 다시 댓글을 저장해 보겠습니다.
마찬가지로 새로운 트랜잭션도 s lock을 획득한 것을 볼 수 있습니다. 그럼 이 상태에서 Tx1(Trx_id 84888
트랜잭션)이 id가 1인 게시글의 댓글 개수를 1 증가시키면 어떻게 될까요? 게시글을 UPDATE
하기 위해 x lock을 요청하지만 Tx2(Trx_id 84889
트랜잭션)가 s lock을 획득하고 있기 때문에 대기하게 될 것입니다.
예상대로 x lock 획득 권한을 위해 대기하고 있습니다. 이제 Tx2도 게시글의 댓글 개수를 증가시키면 어떻게 될까요?
마지막 결과를 보면, 먼저 x lock을 요청한 트랜잭션이 최종적으로 x lock을 획득하며 게시글 UPDATE
를 실행합니다. 다른 트랜잭션은 모든 락을 반환하며 롤백됩니다. 게시글의 댓글 개수를 UPDATE
하는 작업도 실패할 뿐 아니라 INSERT
했던 댓글도 롤백으로 인해 테이블에 저장되지 않습니다.
해결 방안
지금까지 직접 트랜잭션 교착 상태가 발생하는 환경을 만들어 확인했습니다. 왜 교착 상태가 발생하고 댓글이 작성되지 않는지 확실하게 이해됐을 것입니다.
그럼 이 문제는 어떻게 해결할 수 있을까요? 다양한 방법을 생각해 볼 수 있습니다.
1. 트랜잭션을 사용하지 않기 - X
2. 댓글 개수를 먼저 증가한 후에 댓글 저장하기 - X
3. Java synchronized
키워드 사용하기 - X
4. 외래키 제약 조건 사용하지 않기 - X
5. 비관적 락 또는 낙관적 동시성 제어 사용하기 - O
트랜잭션 제거
Service Layer의 @Transactional
을 제거하여 트랜잭션을 사용하지 않는 방법입니다. 데이터베이스에서는 쿼리 한 개 단위로 트랜잭션이 적용됩니다. 이렇게 하면 교착 상태가 발생할 일이 없겠죠.
하지만 애플리케이션 수준에서 하나의 비즈니스 로직 수행 중에 예외가 발생하면 데이터베이스의 데이터 일관성이 유지되기 어렵습니다. 댓글은 작성되었지만 게시글의 댓글 개수는 증가하지 않을 수 있을 것입니다. 또한 여러 트랜잭션이 댓글 개수를 동일한 값을 읽어 갱신하여 댓글 개수가 정상적으로 증가하지 않는 문제가 발생합니다.
실패!
댓글 개수를 증가한 후 댓글 저장
쿼리 실행 순서만 변경하면 되는 간단한 방법인 것 같습니다. 비즈니스 측면에서 댓글 개수가 증가된 후에 댓글이 저장되는 흐름은 조금 어색한 것 같지만 해결 방안이 될 수는 있을 것이라고 생각했습니다.
하지만 JPA를 사용하면 쿼리 쓰기 지연으로 댓글의 INSERT
쿼리가 먼저 실행되게 됩니다. flush()
를 호출해 강제로 데이터베이스를 즉시 동기화할 수 있습니다.
public void writeComment(Long postId, String commentContent) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException(String.format("post not found for post id [%d]", postId)));
post.increaseCommentsCount();
entityManager.flush();
Comment comment = new Comment(commentContent, post);
commentRepository.save(comment);
}
하지만 레이스 컨디션으로 댓글 개수가 정상적으로 증가하지 않는 문제가 발생합니다.
실패!
synchronized 키워드
동기화를 위해 사용하는 Java의 synchronized
키워드를 사용하는 방법입니다.
public synchronized void writeComment(Long postId, String commentContent) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException(String.format("post not found for post id [%d]", postId)));
Comment comment = new Comment(commentContent, post);
post.increaseCommentsCount();
commentRepository.save(comment);
}
하지만 이 방법은 해결 방안이 될 수 없습니다. synchronized
키워드는 자바, 여기서는 writeComment()
메서드에만 적용됩니다. 해당 메서드 실행이 종료되고 트랜잭션이 커밋되기 전까지의 사이에 다른 트랜잭션이 writeComment()
를 실행할 수 있기 때문입니다.
실패!
외래키 제약 조건 제거
s lock을 획득하는 이유가 외래키 제약 조건 때문이므로 이를 아예 사용하지 않는 방법입니다. 하지만 여러 트랜잭션이 동시에 게시글을 영속화하고 값을 증가한 후에 업데이트 하면서 첫 번째 방법과 동일하게 댓글은 모두 잘 저장되지만 댓글 개수가 정상적으로 증가하지 않는 문제가 발생합니다.
실패!
낙관적 동시성 제어 / 비관적 락
낙관적 동시성 제어(Optimistic Concurrency Control)
낙관적 동시성 제어는 추가적인 버전 컬럼을 사용합니다. 낙관적 락이라고도 하지만 락을 사용하는 것이 아니기 때문에 동시성 제어라고 표현하는 게 더 맞는 것 같습니다.(비관적 락도 비관적 동시성 제어라고 표현하기도 합니다!) 애플리케이션 수준에서 UPDATE
, DELETE
시 WHERE
절에 이전에 조회했을 때의 버전과 일치하는지 여부를 확인하는 조건문이 추가되며, UPDATE
시에 버전을 1만큼 증가시킵니다.
UPDATE post
SET ... AND version = 2
WHERE id = 1
AND version = 1;
이 방식으로 어떻게 충돌을 감지하는지 간단한 예시를 하나 생각해 봅시다. 만약 Tx1이 데이터를 읽었을 때 version이 1이었고, Tx2가 동일한 데이터를 읽은 후(version 1) UPDATE
하며 version을 1에서 2로 증가시켰습니다. 이후에 Tx1도 해당 데이터를 UPDATE
하며 version을 1에서 2로 증가시키며 WHERE
절에 이전에 조회했을 때의 값인 1이 현재 version 값과 동일한지에 대한 조건문을 사용합니다. 하지만 이미 해당 데이터는 Tx2에 의해 버전이 2로 증가했기 때문에 WHERE
절을 만족하는 데이터가 없어 데이터베이스는 결과를 0을 반환할 것입니다.
Spring Data JPA에서 낙관적 동시성 제어를 적용하는 방법은 jakarta.persistence.Version
어노테이션을 사용하는 것입니다. 다음과 같이 @Version
어노테이션과 새로운 정수형 version
필드를 추가하겠습니다. 데이터베이스의 post
테이블에도 version
컬럼을 추가해야 합니다. (테스트 환경에서 hibernate.ddl-auto
옵션을 create
로 설정하여 테스트가 실행될 때마다 자동으로 테이블이 생성되도록 하였습니다.)
@Entity
public class Post {
...
@Version
private long version;
...
}
어노테이션과 필드를 추가함으로써 쉽게 낙관적 동시성 제어를 적용할 수 있습니다. 하지만 이 경우에도 트랜잭션 교착 상태가 발생하는 것은 동일합니다. 외래키 제약 조건을 제거해 주어 트랜잭션 교착 상태가 발생하지 않도록 해보겠습니다.
@Entity
public class Comment {
...
/* @JoinColumn(name = "post")
@ManyToOne(fetch = FetchType.LAZY)
private Post post; */
private Long postId;
...
}
연관관계를 제거하고 게시글의 id를 갖는 postId
필드를 추가했습니다. 이제 외래키 제약 조건을 사용하지 않기 때문에 트랜잭션 교착 상태는 발생하지 않을 것입니다. 또한 version 값을 통해 다른 트랜잭션이 데이터를 수정했는지 여부를 판단하기 때문에 외래키 제약 조건만 제거했을 때 발생하는 문제를 해결할 수 있습니다.
하지만 테스트를 실행해 보면 트랜잭션 교착 상태는 발생하지 않지만 다른 예외가 발생합니다.
다른 트랜잭션에 의해 데이터가 UPDATE
또는 DELETE
되었다는 메시지와 함께 ObjectOptimisticLockingFailureException
이 발생했습니다. UPDATE
쿼리의 결과로 0이 반환되어 애플리케이션에서 해당 예외가 발생한 것입니다. 낙관적 동시성 제어는 데이터베이스를 통해 잠금을 거는 것이 아닌 애플리케이션 수준에서 버전을 통해 충돌을 감지하는 방식이기 때문에 애플리케이션에서의 예외 처리 작업이 필요합니다. 예외 처리는 서비스에 맞게 처리하면 됩니다. 다양한 방법이 있지만 AOP를 사용한 작업 재시도를 고려해 볼 수 있을 것 같습니다.
낙관적 동시성 제어는 애플리케이션에서 추가적인 예외 처리 로직을 작성해야 하고, 외래키 제약 조건을 제거해야 하지만 트랜잭션 교착 상태를 해결할 수 있는 방안입니다.
성공!
✅ 비관적 락(Pessimistic Lock)
마지막 방법은 비관적 락을 사용하는 방법입니다. 비관적 락은 데이터 조회 시에 s lock 또는 x lock을 획득하는 읽기 잠금을 사용하는 것입니다. 현재 상황에서는 Post 객체를 id를 통해 조회해 영속화할 때가 해당됩니다.
MySQL에서는 SELECT ... FOR SHARE
와 SELECT ... FOR UPDATE
구문으로 읽기 잠금을 사용합니다. FOR SHARE
는 s lock을, FOR UPDATE
는 x lock을 획득합니다. 직접 쿼리를 실행해 확인해 보겠습니다.
START TRANSACTION;
SELECT *
FROM post
WHERE id = 1
FOR SHARE;
post
테이블의 PK가 1인 레코드에 s lock을 획득한 것을 볼 수 있습니다.
SELECT ... FOR UPDATE
도 확인해 보겠습니다.
START TRANSACTION;
SELECT *
FROM post
WHERE id = 1
FOR UPDATE;
이번에는 x lock을 획득한 것을 확인할 수 있습니다.
Spring Data JPA에서 비관적락을 사용하기 위해서는 Repository의 메서드에 @Lock
어노테이션을 사용하면 됩니다. value
값으로 LockModeType
의 PESSIMISTIC_READ
로 설정하면 FOR SHARE
구문을, PESSIMISTIC_WRITE
로 설정하면 FOR UPDATE
구문을 사용하게 됩니다.
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Post p WHERE p.id = :id")
Optional<Post> findByIdForUpdate(@Param("id") Long postId);
}
PESSIMISTIC_READ
는 게시글 조회(영속화) 시에 s lock을 획득합니다. s lock을 획득한다고 하여 트랜잭션 교착 상태가 발생하지 않는 것이 아닙니다. 게시글을 조회할 때 x lock을 미리 획득해야 다른 트랜잭션에서 게시글 조회 시에 x lock 요청과 함께 대기하게 됩니다. 따라서 PESSIMISTIC_WRITE
를 사용해야 합니다.
이제 댓글 작성 로직 메서드에서 읽기 잠금을 수행하는(비관적 락이 적용된) 메서드를 사용하도록 수정하겠습니다.
public void writeComment(Long postId, String commentContent) {
Post post = postRepository.findByIdForUpdate(postId) // findById -> findByIdForUpdate
.orElseThrow(() -> new IllegalArgumentException("Post not found"));
Comment comment = new Comment(commentContent, post);
post.increaseCommentsCount();
commentRepository.save(comment);
}
이제 Tx1이 Post 객체를 영속화할 때 해당 레코드에 대해 x lock을 획득할 것이고, Tx2도 Post
객체를 영속화할 때 해당 레코드에 대해 x lock을 획득하려고 하지만 Tx1이 이미 x lock을 획득한 상태이기 때문에 대기해야 합니다. Tx1의 모든 작업이 종료된 후 커밋하며 잠금을 반환하면 대기 중이던 Tx2가 x lock을 획득하며 작업을 시작하게 됩니다.
성공!
비관적 락을 사용해 동시에 여러 사용자가 동일한 게시글에 댓글을 작성했을 때 발생하는 트랜잭션 교착 상태를 해결할 수 있었습니다. 대신 비관적 락은 하나의 트랜잭션이 하나의 레코드에 대해 작업을 처리 중일 때 다른 모든 트랜잭션들은 대기해야 합니다. 이는 성능상 손해를 볼 수 있지만, 동시에 하나의 게시글에 댓글을 작성하게 되는 확률은 매우 희박할 것이기 때문에 사용해도 문제가 없다고 생각합니다.
이 외에도 댓글 개수를 저장하지 않고 조회 시에 댓글 개수를 count 하는 방법도 고려해 볼 수 있지만 게시판 특성상 모든 쿼리 중 조회 연산이 매우 큰 비중을 차지하기 때문에 해당 방법은 사용하지 않았습니다.
더 좋은 방법이 있거나 잘못된 정보가 있다면 댓글로 알려주세요!
References
https://www.baeldung.com/java-jpa-transaction-locks
https://www.baeldung.com/jpa-optimistic-locking
https://www.baeldung.com/jpa-pessimistic-locking
https://tecoble.techcourse.co.kr/post/2023-08-16-concurrency-managing/