경기요 - 경기대학교 주변 맛집 모음
[ 배포 링크 ] [ 프로젝트 깃허브 ]
이용자들이 직접 추천하고 싶은 맛집 정보 등록을 요청하고 등록된 맛집에 대한 평가가 이루어질 수 있는 맛집 지도 플랫폼입니다.
개요
이번 프로젝트 개발을 진행하면서 리뷰 작성 기능에 대한 동시성 테스트를 진행하였습니다. 리뷰 작성 기능의 경우 많은 트래픽이 순간적으로 몰리는 경우가 드물지만, 리뷰 작성 이벤트 등 특수한 요구사항이 등장한다면 동시 요청이 많아질 수 있기 때문에 이를 대비하고자 테스트를 진행하게 되었습니다.
성능 개선을 위한 테스트를 진행하면서 Deadlock 문제도 마주하게 되었는데, 이를 발견하고 해결해 나가는 과정에 대해서 다뤄보도록 하겠습니다!
사전 지식
우선 Deadlock이란 무엇인지 이해하기 위해 MySQL의 공식 문서를 살펴보도록 하겠습니다.
A deadlock is a situation where different transactions are unable to proceed because each holds a lock that the other needs. Because both transactions are waiting for a resource to become available, neither ever release the locks it holds. When deadlock detection is enabled (the default) and a deadlock does occur, InnoDB detects the condition and rolls back one of the transactions (the victim).
[출처 : https://dev.mysql.com/doc/refman/8.3/en/innodb-deadlocks.html]
'교착 상태'는 서로 다른 트랜잭션이 서로 필요한 잠금을 보유하고 있기 때문에 트랜잭션을 진행할 수 없는 상황입니다. 두 트랜잭션 모두 리소스가 사용 가능해질 때까지 대기하며, 보유하고 있는 잠금을 해제하지도 않습니다.
교착 상태가 감지가 활성화 되어있고(기본값) 교착 상태가 발생한다면 InnoDB는 트랜잭션 중 하나를 롤백합니다.

쉽게 이야기하면 위 그림과 같이 자원에 대한 요구가 뒤엉킨 상태를 '교착 상태'라고 할 수 있습니다. MySQL 에서는 Lock이란 것이 존재하기 때문에 이러한 교착 상태에 빠질 수 있게 되는데요. 여기서 Lock이란, 한 세션에서 트랜잭션을 시작하고 데이터를 수정하는 동안 커밋이나 롤백되기 전에는 다른 트랜잭션이 해당 데이터를 수정할 수 없게 하여 데이터의 무결성을 보장하는 역할을 합니다.
보통 공유락(S-Lock) 과 배타락(X-Lock)을 사용하게 되는데, 공유락은 데이터를 읽을 때 무결성을 보장하기 위한 잠금이고, 배타락은 데이터를 변경할 때 사용하는 잠금입니다.

위 그림은 MySQL 에서 교착 상태가 발생하는 예시 중 하나로 교착 상태가 발생하기 까지의 과정을 살펴보면 아래와 같습니다.
(1). TX A에서 game_master 2번 데이터를 수정하며 해당 레코드에 배타락을 설정한다.
(2). TX B에서 game_detail 2번 데이터를 수정하며 해당 레코드에 배타락을 설정한다.
(3). TX A에서 game_detail 2번 데이터를 수정하려 하지만 (2)에서 TX B가 해당 레코드에 배타락을 걸었기 때문에 대기한다.
(4). TX B에서 game_master 2번 데이터를 수정하려 하지만, TX A가 (1)에서 설정한 배타락에 의해 대기한다.
(3), (4) 과정이 진행되기 위해서는 (1), (2) 작업에서 설정한 배타락이 해제되어야 하고, 락이 해제되기 위해서는 COMMIT이 이루어져야 하지는데, 어느 한쪽에서도 다음 작업을 진행할 수 없기 때문에 영원히 멈춘 교착 상태에 빠지게 됩니다.
위와 같이 DeadLock에 빠지지 않기 위해서는 데이터 접근 순서에 유의해야 합니다. 위 예제라면, game_master를 업데이트 한 이후 game_detail을 업데이트 하겠다는 규칙을 정해 접근 순서를 고정함으로써 해결할 수 있겠습니다.
얼추 지식을 쌓았으니 본론으로 들어가보도록 하겠습니다.
문제 해결 과정
우선 동시성 테스트를 위해 nGrinder를 사용하였으며 해당 API의 목표 TPS는 20 ~ 60
으로 설정하였습니다. 현재 서비스의 MAU가 50,000,000 이라고 가정할 때, 50,000,000 / 30(일) / 24(시간) / 60(분) / 60(초)
계산을 통해 20
이라는 값을 얻을 수 있어 이를 하한선으로 설정하였고, 해당 글을 참고하여 리뷰 이벤트와 같은 특수 상황에서는 평소보다 3~5배의 TPS를 처리해야 한다는 설정을 통해 20 * 3 = 60
을 상한선으로 설정하였습니다.
@Override
@Transactional
public void createReview(UserInfo userInfo,
Long restaurantId,
ReviewCreateRequest request,
List<MultipartFile> multipartFiles) {
User user = loadUserPort.getById(userInfo.userId());
Restaurant restaurant = loadRestaurantPort.getById(restaurantId);
Review review = Review.builder()
.rating(request.rating())
.content(request.content())
.restaurant(restaurant)
.reviewerId(user.getId())
.reviewerNickname(user.getNickname())
.build();
restaurant.updateAverageRating();
Review savedReview = saveReviewPort.save(review);
if (Objects.isNull(multipartFiles) || multipartFiles.isEmpty()) return;
imageService.createImage(multipartFiles, ImageType.REVIEW, savedReview.getId());
}
리뷰 생성 메서드는 위와 같으며 주요 특징을 정리하면 다음과 같습니다.
1. Restaurant (1) : Review (N) 양방향 관계를 맺고 있다.
2. 리뷰에 작성된 평가를 식당에 반영하기 위해 restaurant.updateAverageRating()
메서드를 통해 식당 정보를 업데이트 한다.
3. 리뷰에 이미지 정보가 존재한다면 imageService.createImage
를 통해 S3 버킷에 이미지를 저장한다.
테스트를 진행하기 전 가장 문제가 될 수 있다고 생각한 부분은 imageService.createImage
입니다. 리뷰 생성부터 S3 버킷에 이미지를 업로드하는 과정까지가 모두 하나의 트랜잭션이이고 모든 과정이 동기적으로 수행됩니다. 이는 이미지 크기에 따라 요청 처리 속도와 커넥션 점유 시간에 많은 영향을 미치기 때문에 다양한 문제가 발생할 수 있는 지점이라고 생각하였습니다.
이제 테스트를 진행해보도록 하겠습니다.
첫 번째 테스트


테스트 실행 결과 예상했던 것과 생각지 못한 Deadlock이 발생하는 것을 확인할 수 있었습니다. 로그를 좀 더 살펴보면 Deadlock found when trying to get lock; try restarting transaction] [update restaurants set address=?, ...
라는 부분을 확인할 수 있었는데, 해당 쿼리는 restaurant.updateAverageRating()
에 의해 변경된 사항이 JPA의 더티 체킹을 통해 작성되는 내용입니다. 위에서 살펴보았듯이 UPDATE 쿼리는 배타락을 획득하게 되는데, 이 배타락이 다른 락과 교착 상태에 빠진 상황인 것입니다. 즉, 해당 API에는 또 다른 락이 존재하는 것이죠.
만일 UPDATE 쿼리로 인한 잠금만 존재했다면 Deadlock이 발생하지 않았을 것입니다. 선행 트랜잭션에서 배타락을 가지고 있는 동안 나머지 요청들이 대기하게 (Blocking) 되긴 하겠지만, 선행 트랜잭션이 COMMIT 되면 해소되기 때문입니다.
그렇다면 또 다른 락이 생성는 부분은 어디일까요? 바로 saveReviewPort.save(review)
이 부분입니다. 앞서 Restaurant과 Review는 일대다 관계라고 설명드렸었는데, Review에서 Restaurant에 대한 참조키를 들고 있기 때문에 Review를 Insert 하는 과정에서 외래키 제약조건에 의한 공유락이 걸리게 됩니다. Review가 생성되는 도중에 참조하고 있는 Restaurant이 삭제되는 경우를 생각해본다면 왜 이러한 동작이 존재하는지 이해할 수 있습니다.
S Lock이 사용된 이유 - MySQL 공식 문서
If a FOREIGN KEY constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. InnoDB also sets these locks in the case where the constraint fails.

실제로 두 개의 세션을 생성하고 위 API와 마찬가지로 공유락과 배타락이 순차적으로 실행되도록 쿼리를 싱행하면 데드락이 발생하는 것을 확인할 수 있습니다. SHOW ENGINE InnoDB STATUS
명령어를 통해 데드락이 발생 과정에 대한 로그를 확인할 수 있는데 이를 요약하면 다음과 같습니다.
TX 1 | TX 2 | LOCK 상황 |
INSERT INTO reviews VALUES(...); | (1) restaurants S Lock 설정 | |
INSERT INTO reviews VALUES ...); | (2) restaurants S Lock 설정 | |
UPDATE restaurants SET average_rating = 3 WHERE id = 1; | (3) (2)에 의해 X Lock 대기 | |
UPDATE restaurants SET average_rating = 5 WHERE id = 1; | (4) (1)에 의해 X Lock 대기 (4) 해소를 위해서는 (1) 해소가 필요 -> (1) 해소를 위해서는 (3) 해소 필요 -> (3) 해소를 위해서는 (2) 해소 필요 -> (2) 해소를 위해서는 (4) 해소 필요 -> 데드락 발생 |
// 더 간단한게 요약하면 다음과 같음
S lock from transaction 1 set,
S lock from transaction 2 set,
X lock from transaction 1 requested, blocked by S lock from transaction 2,
X lock from transaction 2 requested, blocked by S lock from transaction 1
!! Deadlock !!
그런데, 저와 거의 동일한 문제를 겪은 사례를 MySQL 버그 리포트에서 확인할 수 있었습니다. 왜 버그 리포트에 등록된 것일까요?
버그 리포트 - https://bugs.mysql.com/bug.php?id=48652
[9 Nov 2009 21:13] Brandon Petty
This is a very troubling deadlock scenario. Table A has a Fkey constraint referencing the Pkey of Table B. Two transactions insert data into Table A that contain the same FKey constraint value. Each transaction then attempts to update the same row in Table B which was referenced by the constraint. The first transaction gets blocked and the second transaction triggers a deadlock. This is a very common operation for adding items, for example, into a queue via the inserts and then incrementing the queue count via an update. I think this is more of an architectural bug. We have been using MSSQL Server, Oracle, and DB2 with no problems. MSQL Server, for example, lets the first UPDATE go through so it can be committed... thus never causing a deadlock.
매우 골치 아픈 시나리오입니다.
1. 테이블 A에는 테이블 B의 PK를 참조하는 FK 제약 조건이 있습니다.
2. 두 트랜잭션에서 동일한 FK 제약 조건을 지닌 데이터를 테이블 A에 삽입합니다.
3. 다음으로 각 트랜잭션은 제약 조건에 의해 참조된 테이블 B의 동일한 행을 업데이트 하려고 시도합니다.
4. 이때 첫 번째 트랜잭션이 차단되고, 두 번째 트랜잭션이 교착 상태를 유발합니다.
이는 데이터를 삽입한 후에 업데이트를 통해 수를 증가시키는 매우 일반적인 작업입니다. 따라서, 저는 이 것이 구조적 버그에 가깝다고 생각합니다. 지금껏 MSSQL Server, Oracle, DB2를 문제 없이 사용해 왔는데, MSSQL의 경우 첫 번째 UPDATE가 커밋될 수 있도록 함으로써 교착 상태가 발생하지 않습니다.
작성자에 따르면 위와 같은 흐름은 자주 일어날 수 있는 흐름이며 다른 DB의 경우 먼저 진입한 UPDATE를 처리하는 식으로 데드락을 방지하기 때문에 MySQL 에서의 교착 상태는 구조적 버그라고 주장합니다. 저는 위 상황이 버그다!라는 생각은 못했기 때문에 해당 내용이 흥미로워 좀 더 살펴보았습니다.
[12 Nov 2009 5:26] Valeriy Kravchuk
Doesn't this quote from the manual explain why deadlock happens?
See http://dev.mysql.com/doc/refman/5.1/en/innodb-locks-set.html.
매뉴얼에서 설명하지 않았나요? (외래키에 대한 공유락 내용..(생략))
다음 링크를 참고해보세요.
[13 Nov 2009 4:43] Brandon Petty
No, you are correct... the manual does say that. But just because the manual acknowledges its own design limitations does not make it right, however. This is a serious architectural problem... not so much because of the deadlock, but because it is a huge concurrency issue. The update should never block in the first place. Agreed? I guess this is really a feature request ticket requesting the addition of "update locks". Without them not only will MySQL deadlock needlessly, you wont be able to scale with high concurrency based applications that actually have a relational database schema!
맞습니다. 매뉴얼에는 그렇게 나와있습니다. 그러나 매뉴얼이 자체 설계 한계를 인정한다고 해서 정답이 되는 것은 아닙니다. 이건 심각한 구조적 문제에요. 교착 상태 때문이 아니라 심각한 동시성 문제이기 때문이죠. 업데이트는 처음부터 차단되어서는 안 됩니다. 제가 생각하기에 이번 티켓은 실제로 "업데이트 잠금" 기능의 추가를 요청하는 티켓이 될 것 같네요. 이 기능이 없으면 MySQL은 교착 상태가 불필요하게 발생하며 높은 동시성 기반 애플리케이션으로 확장할 수 없습니다.
오호.. 그렇군요.. 작성자는 MySQL이 매뉴얼대로 동작하긴 하지만, 심각한 동시성 문제를 유발하기 때문에 꼭 해결되어야 한다고 생각하는 것 같습니다. 하지만, 아쉽게도 2009년에 작성된 해당 티켓에 대해 현재까지도 MySQL 측에서 답변을 주지 않았습니다. 작성자가 바라는 대로 변경될 여지는 없는 것 같네요.. 어쨌든, 버그 리포트에 등록은 되어 있으나 버그는 아닌 것으로 확인되었으니 해당 문제를 해결할 일만 남았습니다.
두 번째 테스트
위에서 정리한 내용을 토대로 교착 상태를 가장 간단하게 해결할 수 있는 방법인 외래키 제약조건을 삭제한 후 테스트를 진행해보았습니다.

테스트 결과 Deadlock이 발생하지 않았습니다! 그런데 이렇게 쉽게 참조 무결성을 포기해도 되는 걸까요? 실무에서는 여러가지 이유들로 외래키를 사용하지 않는 경우가 많다고 들었지만, 저에게는 Deadlock 이슈 이외에는 참조 무결성을 포기할 이유가 없다고 생각하였습니다. 따라서 외래키 제약조건을 삭제하지 않으면서도 Deadlock 문제를 해결할 수 있는 방법을 모색하기로 하였습니다.
보통 동시성 문제를 제어하기 위해서는 낙관적 락, 비관적 락, 분산락을 사용합니다. 프로젝트 환경에 따라 적절한 락을 선택해야 하고 선택에 대한 근거가 있어야 하기 때문에 하나하나씩 생각해 보았습니다.
낙관적 락

낙관적 락은 위와 같이 version 정보를 통해 동시성을 제어합니다. 하지만, 저의 경우에는 적용할 수 없는 방법입니다. 왜냐 하면 결국 version을 조작하는 UPDATE 쿼리의 배타락과 외래키에 의한 공유락이 충돌하게 되는 것은 변함이 없기 때문입니다. 고로 낙관적 락은 PASS!!
비관적 락

비관적 락은 SELECT FOR UPDATE
를 이용하여 데이터 조회 시점부터 배타락을 획득하여 처리하는 방법입니다. 배타락은 다른 잠금과 호환되지 않기 때문에 트랜잭션 시작부터 종료까지 자신 이외의 다른 잠금의 접근을 막음으로써 동시성 제어가 가능하게 됩니다. 조회 시점에 배타락을 획득함으로써 외래키에 의한 공유락이 설정되는 것을 막을 수 있기에 Deadlock도 해결이 가능합니다.
다만, 조회 시점부터 배타락을 걸기 때문에 다른 요청들은 Blocking 상태가 되므로 성능적으로 아쉬운 부분이 있을 수 있습니다. 그럼에도 목표하는 TPS가 나온다면 좋은 해결방안이 될 수 있기 때문에 1순위로 비관적 락 적용을 고민하였습니다.
💡 비관적 락에서도 Deadlock이 발생할 수 있습니다!
앞서 사전 지식에서 설명드린 예시가 바로 그 사례입니다. 저의 경우에는 하나의 자원을 바라보기 때문에 비관적 락을 사용하더라도 Deadlock이 발생하지 않습니다.
분산락

분산락은 서버 또는 DB가 분산된 환경에서 자주 사용되는 락입니다. MySQL의 네임드 락, Redis의 스핀락 or pub/sub 등으로 구현이 가능합니다. 세부적인 동작 방식에 차이는 있지만, 전체적으로는 위 그림과 같은 방식으로 동작합니다.
1. Lock을 제어하는 관리자(MySQL, Redis)로부터 락을 획득한다.
2. 나머지 요청들은 락 획득을 대기한다.
3. 락을 다 사용하면 관리자에게 반납한다.
4. 대기 중이던 요청이 락을 획득하여 작업을 재개한다.
(반복)

직접 사용해보진 않았지만 제가 생각하는 분산락의 장점은 위 코드와 같이 락을 획득해야만 서비스 로직이 실행되게 함으로써 DB의 커넥션을 효율적으로 사용할 수 있다는 점입니다. 동시에 여러 요청이 커넥션을 점유한 상태로 대기하는 것보다 락을 획득한 요청만 DB로부터 커넥션을 얻을 수 있게 함으로써 DB 부하를 줄일 수 있기 때문입니다.
다만, 저의 경우 분산환경 프로젝트가 아니고 서비스의 성숙도와 규모를 보았을때 당장 분산락이 필요하다고는 생각되지 않았습니다. 따라서 우선은 비관적 락을 이용하여 해결을 해보기로 결정하였습니다.
세 번째 테스트
비관적 락을 적용하기에 앞서, 위에서 다뤘던 MySQL 버그 리포트에 다음과 같은 내용이 있었습니다.
[17 Nov 2009 16:22] Heikki Tuuri
There is a workaround for this in the database application code. UPDATE FIRST the parent row
이에 대한 해결 방법이 데이터베이스 애플리케이션 코드에 있습니다. 부모 행을 먼저 업데이트 하세요.
업데이트를 먼저 하라는 내용이었는데, 생각해보니 UPDATE 동작이 먼저 이뤄지면 배타락이 먼저 걸리게 되고, 비관적 락과 거의 동일한 방식으로 Deadlock을 해결할 수 있게 됩니다. 이 방식이 조회 시점부터 배타락을 거는 비관적 락보다 락 점유 시간을 보다 짧게 가져갈 수 있기 때문에 성능적으로 우위에 있다고 생각하여 해당 방식을 적용하여 테스트를 진행하기로 하였습니다.
기존의 UPDATE 쿼리는 코드 라인 상으로는 INSERT 쿼리를 발생시키는 지점보다 앞서있지만, JPA의 Dirty Checking에 의해 발생하는 쿼리이기 때문에 트랜잭션 커밋 시점에 쿼리가 발생하여 INSERT 쿼리보다 늦게 동작하게 됩니다. 따라서 UPDATE 쿼리가 지연 없이 바로 발생할 수 있도록 위와 같이 업데이트 이후 entityManger.flush()
를 통해 즉각적으로 쿼리를 발생시키도록 하였습니다.
@Override
@Transactional
public void createReview(UserInfo userInfo,
Long restaurantId,
ReviewCreateRequest request,
List<MultipartFile> multipartFiles) {
User user = loadUserPort.getById(userInfo.userId());
Restaurant restaurant = loadRestaurantPort.getById(restaurantId);
Review review = Review.builder()
.rating(request.rating())
.content(request.content())
.restaurant(restaurant)
.reviewerId(user.getId())
.reviewerNickname(user.getNickname())
.build();
updateRestaurantWithoutDirtyChecking(restaurant);
Review savedReview = saveReviewPort.save(review);
if (Objects.isNull(multipartFiles) || multipartFiles.isEmpty()) return;
imageService.createImage(multipartFiles, ImageType.REVIEW, savedReview.getId());
}
private void updateRestaurnatWithoutDirtyChecking(Restaurant restaurnat) {
restaurant.updateAverageRating();
entityManager.flush();
}
또한, 코드상에 뜬금없이 EntityManger 동작이 추가되었기 때문에 다른 개발자들이 봤을 때도 이해할 수 있도록 위와 같이 private 메서드를 별도로 만들어 동작 의도를 나타내주었습니다.

테스트 결과 외래키 제약 조건을 삭제했던 두 번째 테스트와 거의 비슷한 TPS를 보여주고 있으며, Deadlock 문제도 발생하지 않는 것을 확인할 수 있었기 때문에 최종적으로는 위 방식을 적용하여 문제를 해결하기로 결정하였습니다.
마무리
Deadlock 문제는 해결하였으나 아직 한 가지 문제가 남아있습니다. 바로 목표 TPS입니다. 위에서 목표 TPS를 20 ~ 60으로 설정하였는데 현재 TPS는 11.3으로 아직 많이 부족한 상황입니다. 현재 TPS가 저조한 이유는 S3 버킷에 이미지를 업로드하는 과정까지 하나의 Transaction으로 묶여있기 때문입니다.
현재 '리뷰'라는 도메인에 '이미지'가 포함되어 있기 때문에 하나의 트랜잭션에서 다루는 것이 맞을 수도 있습니다. 하지만, ReviewImage로 생각하지 않고 '리뷰'와 '이미지'로 분리해서 생각하면 이미지 역시 별도의 도메인으로 볼 수 있습니다. 따라서 트랜잭션 분리와 함께 리뷰와 이미지의 강한 결합을 분리하는 것이 좋아보입니다.
이에 대한 내용은 다음 포스팅에서 자세히 다뤄보도록 하겠습니다!!
Reference
https://bugs.mysql.com/bug.php?id=48652
MySQL Bugs: #48652: Deadlock due to Foreign Key constraint
bugs.mysql.com
https://dev.mysql.com/doc/refman/8.3/en/innodb-deadlocks.html
MySQL :: MySQL 8.3 Reference Manual :: 17.7.5 Deadlocks in InnoDB
17.7.5 Deadlocks in InnoDB A deadlock is a situation where different transactions are unable to proceed because each holds a lock that the other needs. Because both transactions are waiting for a resource to become available, neither ever release the lock
dev.mysql.com
https://coding-business.tistory.com/32
DB Lock에 대한 이해와 MySQL Lock의 특징
개요 목적 이번 시간에는 DB Lock에 대해서 알아본다. 먼저 DB 락의 필요성과 역할을 알아본 후에 Lock종류와 Lock에서 발생할 수 있는 문제도 알아본다. 그리고 Lock 문제 중 데드락을 해결하는 방법
coding-business.tistory.com
낙관적 락과 비관적 락은 갱신 손실 문제를 어떻게 해결하는가
동작 원리와 선택 기준에 대하여
velog.io
'DB' 카테고리의 다른 글
조회 성능 최적화(4) - Cursor & Navigation (1) | 2024.03.16 |
---|---|
조회 성능 최적화(2) - 슬로우 쿼리 튜닝하기 (1) | 2024.02.25 |
경기요 - 경기대학교 주변 맛집 모음
[ 배포 링크 ] [ 프로젝트 깃허브 ]
이용자들이 직접 추천하고 싶은 맛집 정보 등록을 요청하고 등록된 맛집에 대한 평가가 이루어질 수 있는 맛집 지도 플랫폼입니다.
개요
이번 프로젝트 개발을 진행하면서 리뷰 작성 기능에 대한 동시성 테스트를 진행하였습니다. 리뷰 작성 기능의 경우 많은 트래픽이 순간적으로 몰리는 경우가 드물지만, 리뷰 작성 이벤트 등 특수한 요구사항이 등장한다면 동시 요청이 많아질 수 있기 때문에 이를 대비하고자 테스트를 진행하게 되었습니다.
성능 개선을 위한 테스트를 진행하면서 Deadlock 문제도 마주하게 되었는데, 이를 발견하고 해결해 나가는 과정에 대해서 다뤄보도록 하겠습니다!
사전 지식
우선 Deadlock이란 무엇인지 이해하기 위해 MySQL의 공식 문서를 살펴보도록 하겠습니다.
A deadlock is a situation where different transactions are unable to proceed because each holds a lock that the other needs. Because both transactions are waiting for a resource to become available, neither ever release the locks it holds. When deadlock detection is enabled (the default) and a deadlock does occur, InnoDB detects the condition and rolls back one of the transactions (the victim).
[출처 : https://dev.mysql.com/doc/refman/8.3/en/innodb-deadlocks.html]
'교착 상태'는 서로 다른 트랜잭션이 서로 필요한 잠금을 보유하고 있기 때문에 트랜잭션을 진행할 수 없는 상황입니다. 두 트랜잭션 모두 리소스가 사용 가능해질 때까지 대기하며, 보유하고 있는 잠금을 해제하지도 않습니다.
교착 상태가 감지가 활성화 되어있고(기본값) 교착 상태가 발생한다면 InnoDB는 트랜잭션 중 하나를 롤백합니다.

쉽게 이야기하면 위 그림과 같이 자원에 대한 요구가 뒤엉킨 상태를 '교착 상태'라고 할 수 있습니다. MySQL 에서는 Lock이란 것이 존재하기 때문에 이러한 교착 상태에 빠질 수 있게 되는데요. 여기서 Lock이란, 한 세션에서 트랜잭션을 시작하고 데이터를 수정하는 동안 커밋이나 롤백되기 전에는 다른 트랜잭션이 해당 데이터를 수정할 수 없게 하여 데이터의 무결성을 보장하는 역할을 합니다.
보통 공유락(S-Lock) 과 배타락(X-Lock)을 사용하게 되는데, 공유락은 데이터를 읽을 때 무결성을 보장하기 위한 잠금이고, 배타락은 데이터를 변경할 때 사용하는 잠금입니다.

위 그림은 MySQL 에서 교착 상태가 발생하는 예시 중 하나로 교착 상태가 발생하기 까지의 과정을 살펴보면 아래와 같습니다.
(1). TX A에서 game_master 2번 데이터를 수정하며 해당 레코드에 배타락을 설정한다.
(2). TX B에서 game_detail 2번 데이터를 수정하며 해당 레코드에 배타락을 설정한다.
(3). TX A에서 game_detail 2번 데이터를 수정하려 하지만 (2)에서 TX B가 해당 레코드에 배타락을 걸었기 때문에 대기한다.
(4). TX B에서 game_master 2번 데이터를 수정하려 하지만, TX A가 (1)에서 설정한 배타락에 의해 대기한다.
(3), (4) 과정이 진행되기 위해서는 (1), (2) 작업에서 설정한 배타락이 해제되어야 하고, 락이 해제되기 위해서는 COMMIT이 이루어져야 하지는데, 어느 한쪽에서도 다음 작업을 진행할 수 없기 때문에 영원히 멈춘 교착 상태에 빠지게 됩니다.
위와 같이 DeadLock에 빠지지 않기 위해서는 데이터 접근 순서에 유의해야 합니다. 위 예제라면, game_master를 업데이트 한 이후 game_detail을 업데이트 하겠다는 규칙을 정해 접근 순서를 고정함으로써 해결할 수 있겠습니다.
얼추 지식을 쌓았으니 본론으로 들어가보도록 하겠습니다.
문제 해결 과정
우선 동시성 테스트를 위해 nGrinder를 사용하였으며 해당 API의 목표 TPS는 20 ~ 60
으로 설정하였습니다. 현재 서비스의 MAU가 50,000,000 이라고 가정할 때, 50,000,000 / 30(일) / 24(시간) / 60(분) / 60(초)
계산을 통해 20
이라는 값을 얻을 수 있어 이를 하한선으로 설정하였고, 해당 글을 참고하여 리뷰 이벤트와 같은 특수 상황에서는 평소보다 3~5배의 TPS를 처리해야 한다는 설정을 통해 20 * 3 = 60
을 상한선으로 설정하였습니다.
@Override
@Transactional
public void createReview(UserInfo userInfo,
Long restaurantId,
ReviewCreateRequest request,
List<MultipartFile> multipartFiles) {
User user = loadUserPort.getById(userInfo.userId());
Restaurant restaurant = loadRestaurantPort.getById(restaurantId);
Review review = Review.builder()
.rating(request.rating())
.content(request.content())
.restaurant(restaurant)
.reviewerId(user.getId())
.reviewerNickname(user.getNickname())
.build();
restaurant.updateAverageRating();
Review savedReview = saveReviewPort.save(review);
if (Objects.isNull(multipartFiles) || multipartFiles.isEmpty()) return;
imageService.createImage(multipartFiles, ImageType.REVIEW, savedReview.getId());
}
리뷰 생성 메서드는 위와 같으며 주요 특징을 정리하면 다음과 같습니다.
1. Restaurant (1) : Review (N) 양방향 관계를 맺고 있다.
2. 리뷰에 작성된 평가를 식당에 반영하기 위해 restaurant.updateAverageRating()
메서드를 통해 식당 정보를 업데이트 한다.
3. 리뷰에 이미지 정보가 존재한다면 imageService.createImage
를 통해 S3 버킷에 이미지를 저장한다.
테스트를 진행하기 전 가장 문제가 될 수 있다고 생각한 부분은 imageService.createImage
입니다. 리뷰 생성부터 S3 버킷에 이미지를 업로드하는 과정까지가 모두 하나의 트랜잭션이이고 모든 과정이 동기적으로 수행됩니다. 이는 이미지 크기에 따라 요청 처리 속도와 커넥션 점유 시간에 많은 영향을 미치기 때문에 다양한 문제가 발생할 수 있는 지점이라고 생각하였습니다.
이제 테스트를 진행해보도록 하겠습니다.
첫 번째 테스트


테스트 실행 결과 예상했던 것과 생각지 못한 Deadlock이 발생하는 것을 확인할 수 있었습니다. 로그를 좀 더 살펴보면 Deadlock found when trying to get lock; try restarting transaction] [update restaurants set address=?, ...
라는 부분을 확인할 수 있었는데, 해당 쿼리는 restaurant.updateAverageRating()
에 의해 변경된 사항이 JPA의 더티 체킹을 통해 작성되는 내용입니다. 위에서 살펴보았듯이 UPDATE 쿼리는 배타락을 획득하게 되는데, 이 배타락이 다른 락과 교착 상태에 빠진 상황인 것입니다. 즉, 해당 API에는 또 다른 락이 존재하는 것이죠.
만일 UPDATE 쿼리로 인한 잠금만 존재했다면 Deadlock이 발생하지 않았을 것입니다. 선행 트랜잭션에서 배타락을 가지고 있는 동안 나머지 요청들이 대기하게 (Blocking) 되긴 하겠지만, 선행 트랜잭션이 COMMIT 되면 해소되기 때문입니다.
그렇다면 또 다른 락이 생성는 부분은 어디일까요? 바로 saveReviewPort.save(review)
이 부분입니다. 앞서 Restaurant과 Review는 일대다 관계라고 설명드렸었는데, Review에서 Restaurant에 대한 참조키를 들고 있기 때문에 Review를 Insert 하는 과정에서 외래키 제약조건에 의한 공유락이 걸리게 됩니다. Review가 생성되는 도중에 참조하고 있는 Restaurant이 삭제되는 경우를 생각해본다면 왜 이러한 동작이 존재하는지 이해할 수 있습니다.
S Lock이 사용된 이유 - MySQL 공식 문서
If a FOREIGN KEY constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. InnoDB also sets these locks in the case where the constraint fails.

실제로 두 개의 세션을 생성하고 위 API와 마찬가지로 공유락과 배타락이 순차적으로 실행되도록 쿼리를 싱행하면 데드락이 발생하는 것을 확인할 수 있습니다. SHOW ENGINE InnoDB STATUS
명령어를 통해 데드락이 발생 과정에 대한 로그를 확인할 수 있는데 이를 요약하면 다음과 같습니다.
TX 1 | TX 2 | LOCK 상황 |
INSERT INTO reviews VALUES(...); | (1) restaurants S Lock 설정 | |
INSERT INTO reviews VALUES ...); | (2) restaurants S Lock 설정 | |
UPDATE restaurants SET average_rating = 3 WHERE id = 1; | (3) (2)에 의해 X Lock 대기 | |
UPDATE restaurants SET average_rating = 5 WHERE id = 1; | (4) (1)에 의해 X Lock 대기 (4) 해소를 위해서는 (1) 해소가 필요 -> (1) 해소를 위해서는 (3) 해소 필요 -> (3) 해소를 위해서는 (2) 해소 필요 -> (2) 해소를 위해서는 (4) 해소 필요 -> 데드락 발생 |
// 더 간단한게 요약하면 다음과 같음
S lock from transaction 1 set,
S lock from transaction 2 set,
X lock from transaction 1 requested, blocked by S lock from transaction 2,
X lock from transaction 2 requested, blocked by S lock from transaction 1
!! Deadlock !!
그런데, 저와 거의 동일한 문제를 겪은 사례를 MySQL 버그 리포트에서 확인할 수 있었습니다. 왜 버그 리포트에 등록된 것일까요?
버그 리포트 - https://bugs.mysql.com/bug.php?id=48652
[9 Nov 2009 21:13] Brandon Petty
This is a very troubling deadlock scenario. Table A has a Fkey constraint referencing the Pkey of Table B. Two transactions insert data into Table A that contain the same FKey constraint value. Each transaction then attempts to update the same row in Table B which was referenced by the constraint. The first transaction gets blocked and the second transaction triggers a deadlock. This is a very common operation for adding items, for example, into a queue via the inserts and then incrementing the queue count via an update. I think this is more of an architectural bug. We have been using MSSQL Server, Oracle, and DB2 with no problems. MSQL Server, for example, lets the first UPDATE go through so it can be committed... thus never causing a deadlock.
매우 골치 아픈 시나리오입니다.
1. 테이블 A에는 테이블 B의 PK를 참조하는 FK 제약 조건이 있습니다.
2. 두 트랜잭션에서 동일한 FK 제약 조건을 지닌 데이터를 테이블 A에 삽입합니다.
3. 다음으로 각 트랜잭션은 제약 조건에 의해 참조된 테이블 B의 동일한 행을 업데이트 하려고 시도합니다.
4. 이때 첫 번째 트랜잭션이 차단되고, 두 번째 트랜잭션이 교착 상태를 유발합니다.
이는 데이터를 삽입한 후에 업데이트를 통해 수를 증가시키는 매우 일반적인 작업입니다. 따라서, 저는 이 것이 구조적 버그에 가깝다고 생각합니다. 지금껏 MSSQL Server, Oracle, DB2를 문제 없이 사용해 왔는데, MSSQL의 경우 첫 번째 UPDATE가 커밋될 수 있도록 함으로써 교착 상태가 발생하지 않습니다.
작성자에 따르면 위와 같은 흐름은 자주 일어날 수 있는 흐름이며 다른 DB의 경우 먼저 진입한 UPDATE를 처리하는 식으로 데드락을 방지하기 때문에 MySQL 에서의 교착 상태는 구조적 버그라고 주장합니다. 저는 위 상황이 버그다!라는 생각은 못했기 때문에 해당 내용이 흥미로워 좀 더 살펴보았습니다.
[12 Nov 2009 5:26] Valeriy Kravchuk
Doesn't this quote from the manual explain why deadlock happens?
See http://dev.mysql.com/doc/refman/5.1/en/innodb-locks-set.html.
매뉴얼에서 설명하지 않았나요? (외래키에 대한 공유락 내용..(생략))
다음 링크를 참고해보세요.
[13 Nov 2009 4:43] Brandon Petty
No, you are correct... the manual does say that. But just because the manual acknowledges its own design limitations does not make it right, however. This is a serious architectural problem... not so much because of the deadlock, but because it is a huge concurrency issue. The update should never block in the first place. Agreed? I guess this is really a feature request ticket requesting the addition of "update locks". Without them not only will MySQL deadlock needlessly, you wont be able to scale with high concurrency based applications that actually have a relational database schema!
맞습니다. 매뉴얼에는 그렇게 나와있습니다. 그러나 매뉴얼이 자체 설계 한계를 인정한다고 해서 정답이 되는 것은 아닙니다. 이건 심각한 구조적 문제에요. 교착 상태 때문이 아니라 심각한 동시성 문제이기 때문이죠. 업데이트는 처음부터 차단되어서는 안 됩니다. 제가 생각하기에 이번 티켓은 실제로 "업데이트 잠금" 기능의 추가를 요청하는 티켓이 될 것 같네요. 이 기능이 없으면 MySQL은 교착 상태가 불필요하게 발생하며 높은 동시성 기반 애플리케이션으로 확장할 수 없습니다.
오호.. 그렇군요.. 작성자는 MySQL이 매뉴얼대로 동작하긴 하지만, 심각한 동시성 문제를 유발하기 때문에 꼭 해결되어야 한다고 생각하는 것 같습니다. 하지만, 아쉽게도 2009년에 작성된 해당 티켓에 대해 현재까지도 MySQL 측에서 답변을 주지 않았습니다. 작성자가 바라는 대로 변경될 여지는 없는 것 같네요.. 어쨌든, 버그 리포트에 등록은 되어 있으나 버그는 아닌 것으로 확인되었으니 해당 문제를 해결할 일만 남았습니다.
두 번째 테스트
위에서 정리한 내용을 토대로 교착 상태를 가장 간단하게 해결할 수 있는 방법인 외래키 제약조건을 삭제한 후 테스트를 진행해보았습니다.

테스트 결과 Deadlock이 발생하지 않았습니다! 그런데 이렇게 쉽게 참조 무결성을 포기해도 되는 걸까요? 실무에서는 여러가지 이유들로 외래키를 사용하지 않는 경우가 많다고 들었지만, 저에게는 Deadlock 이슈 이외에는 참조 무결성을 포기할 이유가 없다고 생각하였습니다. 따라서 외래키 제약조건을 삭제하지 않으면서도 Deadlock 문제를 해결할 수 있는 방법을 모색하기로 하였습니다.
보통 동시성 문제를 제어하기 위해서는 낙관적 락, 비관적 락, 분산락을 사용합니다. 프로젝트 환경에 따라 적절한 락을 선택해야 하고 선택에 대한 근거가 있어야 하기 때문에 하나하나씩 생각해 보았습니다.
낙관적 락

낙관적 락은 위와 같이 version 정보를 통해 동시성을 제어합니다. 하지만, 저의 경우에는 적용할 수 없는 방법입니다. 왜냐 하면 결국 version을 조작하는 UPDATE 쿼리의 배타락과 외래키에 의한 공유락이 충돌하게 되는 것은 변함이 없기 때문입니다. 고로 낙관적 락은 PASS!!
비관적 락

비관적 락은 SELECT FOR UPDATE
를 이용하여 데이터 조회 시점부터 배타락을 획득하여 처리하는 방법입니다. 배타락은 다른 잠금과 호환되지 않기 때문에 트랜잭션 시작부터 종료까지 자신 이외의 다른 잠금의 접근을 막음으로써 동시성 제어가 가능하게 됩니다. 조회 시점에 배타락을 획득함으로써 외래키에 의한 공유락이 설정되는 것을 막을 수 있기에 Deadlock도 해결이 가능합니다.
다만, 조회 시점부터 배타락을 걸기 때문에 다른 요청들은 Blocking 상태가 되므로 성능적으로 아쉬운 부분이 있을 수 있습니다. 그럼에도 목표하는 TPS가 나온다면 좋은 해결방안이 될 수 있기 때문에 1순위로 비관적 락 적용을 고민하였습니다.
💡 비관적 락에서도 Deadlock이 발생할 수 있습니다!
앞서 사전 지식에서 설명드린 예시가 바로 그 사례입니다. 저의 경우에는 하나의 자원을 바라보기 때문에 비관적 락을 사용하더라도 Deadlock이 발생하지 않습니다.
분산락

분산락은 서버 또는 DB가 분산된 환경에서 자주 사용되는 락입니다. MySQL의 네임드 락, Redis의 스핀락 or pub/sub 등으로 구현이 가능합니다. 세부적인 동작 방식에 차이는 있지만, 전체적으로는 위 그림과 같은 방식으로 동작합니다.
1. Lock을 제어하는 관리자(MySQL, Redis)로부터 락을 획득한다.
2. 나머지 요청들은 락 획득을 대기한다.
3. 락을 다 사용하면 관리자에게 반납한다.
4. 대기 중이던 요청이 락을 획득하여 작업을 재개한다.
(반복)

직접 사용해보진 않았지만 제가 생각하는 분산락의 장점은 위 코드와 같이 락을 획득해야만 서비스 로직이 실행되게 함으로써 DB의 커넥션을 효율적으로 사용할 수 있다는 점입니다. 동시에 여러 요청이 커넥션을 점유한 상태로 대기하는 것보다 락을 획득한 요청만 DB로부터 커넥션을 얻을 수 있게 함으로써 DB 부하를 줄일 수 있기 때문입니다.
다만, 저의 경우 분산환경 프로젝트가 아니고 서비스의 성숙도와 규모를 보았을때 당장 분산락이 필요하다고는 생각되지 않았습니다. 따라서 우선은 비관적 락을 이용하여 해결을 해보기로 결정하였습니다.
세 번째 테스트
비관적 락을 적용하기에 앞서, 위에서 다뤘던 MySQL 버그 리포트에 다음과 같은 내용이 있었습니다.
[17 Nov 2009 16:22] Heikki Tuuri
There is a workaround for this in the database application code. UPDATE FIRST the parent row
이에 대한 해결 방법이 데이터베이스 애플리케이션 코드에 있습니다. 부모 행을 먼저 업데이트 하세요.
업데이트를 먼저 하라는 내용이었는데, 생각해보니 UPDATE 동작이 먼저 이뤄지면 배타락이 먼저 걸리게 되고, 비관적 락과 거의 동일한 방식으로 Deadlock을 해결할 수 있게 됩니다. 이 방식이 조회 시점부터 배타락을 거는 비관적 락보다 락 점유 시간을 보다 짧게 가져갈 수 있기 때문에 성능적으로 우위에 있다고 생각하여 해당 방식을 적용하여 테스트를 진행하기로 하였습니다.
기존의 UPDATE 쿼리는 코드 라인 상으로는 INSERT 쿼리를 발생시키는 지점보다 앞서있지만, JPA의 Dirty Checking에 의해 발생하는 쿼리이기 때문에 트랜잭션 커밋 시점에 쿼리가 발생하여 INSERT 쿼리보다 늦게 동작하게 됩니다. 따라서 UPDATE 쿼리가 지연 없이 바로 발생할 수 있도록 위와 같이 업데이트 이후 entityManger.flush()
를 통해 즉각적으로 쿼리를 발생시키도록 하였습니다.
@Override
@Transactional
public void createReview(UserInfo userInfo,
Long restaurantId,
ReviewCreateRequest request,
List<MultipartFile> multipartFiles) {
User user = loadUserPort.getById(userInfo.userId());
Restaurant restaurant = loadRestaurantPort.getById(restaurantId);
Review review = Review.builder()
.rating(request.rating())
.content(request.content())
.restaurant(restaurant)
.reviewerId(user.getId())
.reviewerNickname(user.getNickname())
.build();
updateRestaurantWithoutDirtyChecking(restaurant);
Review savedReview = saveReviewPort.save(review);
if (Objects.isNull(multipartFiles) || multipartFiles.isEmpty()) return;
imageService.createImage(multipartFiles, ImageType.REVIEW, savedReview.getId());
}
private void updateRestaurnatWithoutDirtyChecking(Restaurant restaurnat) {
restaurant.updateAverageRating();
entityManager.flush();
}
또한, 코드상에 뜬금없이 EntityManger 동작이 추가되었기 때문에 다른 개발자들이 봤을 때도 이해할 수 있도록 위와 같이 private 메서드를 별도로 만들어 동작 의도를 나타내주었습니다.

테스트 결과 외래키 제약 조건을 삭제했던 두 번째 테스트와 거의 비슷한 TPS를 보여주고 있으며, Deadlock 문제도 발생하지 않는 것을 확인할 수 있었기 때문에 최종적으로는 위 방식을 적용하여 문제를 해결하기로 결정하였습니다.
마무리
Deadlock 문제는 해결하였으나 아직 한 가지 문제가 남아있습니다. 바로 목표 TPS입니다. 위에서 목표 TPS를 20 ~ 60으로 설정하였는데 현재 TPS는 11.3으로 아직 많이 부족한 상황입니다. 현재 TPS가 저조한 이유는 S3 버킷에 이미지를 업로드하는 과정까지 하나의 Transaction으로 묶여있기 때문입니다.
현재 '리뷰'라는 도메인에 '이미지'가 포함되어 있기 때문에 하나의 트랜잭션에서 다루는 것이 맞을 수도 있습니다. 하지만, ReviewImage로 생각하지 않고 '리뷰'와 '이미지'로 분리해서 생각하면 이미지 역시 별도의 도메인으로 볼 수 있습니다. 따라서 트랜잭션 분리와 함께 리뷰와 이미지의 강한 결합을 분리하는 것이 좋아보입니다.
이에 대한 내용은 다음 포스팅에서 자세히 다뤄보도록 하겠습니다!!
Reference
https://bugs.mysql.com/bug.php?id=48652
MySQL Bugs: #48652: Deadlock due to Foreign Key constraint
bugs.mysql.com
https://dev.mysql.com/doc/refman/8.3/en/innodb-deadlocks.html
MySQL :: MySQL 8.3 Reference Manual :: 17.7.5 Deadlocks in InnoDB
17.7.5 Deadlocks in InnoDB A deadlock is a situation where different transactions are unable to proceed because each holds a lock that the other needs. Because both transactions are waiting for a resource to become available, neither ever release the lock
dev.mysql.com
https://coding-business.tistory.com/32
DB Lock에 대한 이해와 MySQL Lock의 특징
개요 목적 이번 시간에는 DB Lock에 대해서 알아본다. 먼저 DB 락의 필요성과 역할을 알아본 후에 Lock종류와 Lock에서 발생할 수 있는 문제도 알아본다. 그리고 Lock 문제 중 데드락을 해결하는 방법
coding-business.tistory.com
낙관적 락과 비관적 락은 갱신 손실 문제를 어떻게 해결하는가
동작 원리와 선택 기준에 대하여
velog.io
'DB' 카테고리의 다른 글
조회 성능 최적화(4) - Cursor & Navigation (1) | 2024.03.16 |
---|---|
조회 성능 최적화(2) - 슬로우 쿼리 튜닝하기 (1) | 2024.02.25 |