이번 포스팅에선 Testcontainers 내용이 잠깐 나옵니다.
Testcontainers 환경을 구성하는 방법은 이전 포스팅을 참고해 주시면 됩니다.
2024.09.16 - [Project] - [예약 대기 시스템] 4. 컨테이너 환경에서 테스트하기 (Testcontainers)
데드락 원인 찾기
데드락의 원인을 찾기 위해 MySQL을 확인해 볼 필요가 있습니다.
테스트 환경도 운영 환경과 동일하게 Docker로 구성되어 있고, 테스트는 Testcontainers를 이용해 테스트마다 독립된 컨테이너에서 실행됩니다.
MySQL 버전은 운영 환경과 동일한 8.0.39 버전입니다.
MySQL 컨테이너를 확인하기 전, Testcontainers 환경을 보겠습니다.
Testcontainers 환경
`application-test.yml`에선 `datasource`를 위 이미지처럼 설정했습니다.
얼핏 보기엔 username, password 부분을 읽고 Testcontainers가 설정대로 컨테이너를 생성할 것처럼 보이지만, 실제론 그렇지 않습니다.
`TC_INITSCRIPT`를 통해 초기화 스크립트를 실행하지 않기 때문에 Testcontainers가 기본값으로 컨테이너를 생성합니다.
Testcontainers 환경에서 MySQL 컨테이너 계정 기본값:
- username: root
- password: test
- database: test
위의 기본 설정값을 이용해 컨테이너에 접속을 할 수 있습니다.
MySQL 컨테이너에 접속하기
현재 띄워진 컨테이너 이름들을 보면 `Testcontainers-ryuk-a429d`와, `happy_mclaren` 이름으로 된 MySQL 컨테이너가 있습니다.
Testcontainers-ryuk 컨테이너는 테스트 중 생성된 컨테이너의 리소스를 정리해주는 컨테이너로 간략하게 알고 넘어가겠습니다.
저는 터미널을 통해 MySQL로 접속해보겠습니다. 위에서 설명드린 기본 계정으로 접근하면 됩니다.
MySQL에서 확인
show engine innodb status \G
MySQL에서 데드락이 발생한 정보를 확인해 보겠습니다.
`LATEST DETECTED DEADLOCK` 섹션에서 데드락 관련 정보를 찾을 수 있습니다.
(1) TRANSACTION (트랜잭션 아이디 2425) 관련 정보
(2) TRANSACTION (트랜잭션 아이디 2415) 관련 정보
데드락의 원인 분석
첫 번째 트랜잭션: 트랜잭션 아이디 2425
먼저 제일 먼저 보이는 트랜잭션 관련 정보를 보겠습니다.
((1) TRANSACTION, (1) HOLDS THE LOCK(S), (1) WAITING FOR THIS LOCK TO BE GRANTED)
- (1) TRANSACTION
- 트랜잭션 아이디 2425번은 `letter_analysis` 테이블에 `INSERT` 쿼리를 실행합니다.
(편지에 대한 감정 분석을 저장하는 쿼리입니다.)
- 트랜잭션 아이디 2425번은 `letter_analysis` 테이블에 `INSERT` 쿼리를 실행합니다.
- (1) HOLDS THE LOCK(S)
- `trx id 2425 lock mode S locks rec but not gap`: 잠금 종류는 `공유 락(S lock)`이며, `letter` 테이블의 `PRIMARY` 인덱스에서 잡고 있습니다. (편지 테이블에서 해당 레코드에 대한 `읽기 잠금`)
(참고로 MySQL InnoDB 스토리지 엔진은 레코드 자체를 잠그는 게 아니라 인덱스의 레코드를 잠급니다.)
그리고 `rec but not gap`표시가 있으므로 해당 잠금은 `갭 락`을 포함하지 않은 순수 레코드에 대해서만 잠금을 건 상태입니다. (단건 INSERT 를 수행하기 때문에 당연합니다.) - 왜 `letter` `PRIMARY` 인덱스를 잡고 있을까요?
그 이유는 바로 `letter_analysis` 테이블은 `letter` 테이블의 primary key를 외래키(FK)를 참조하고 있고, 참조 무결성을 수행하기 위해 `letter` 테이블에서 참조하는 레코드에 대한 `공유 락(S lock)`을 걸어야 하기 때문입니다.
- `trx id 2425 lock mode S locks rec but not gap`: 잠금 종류는 `공유 락(S lock)`이며, `letter` 테이블의 `PRIMARY` 인덱스에서 잡고 있습니다. (편지 테이블에서 해당 레코드에 대한 `읽기 잠금`)
- (1) WAITING FOR THIS LOCK TO BE GRANTED
- `letter_analysis` 테이블에서 인덱스 `UKoyy8rnymri4kke53b7ue5dknq`에 대해 `공유 락(S lock)`을 대기하고 있습니다.
이 부분에서 뭔가 이상함이 느껴지시나요?
쓰기 작업을 할 때는 `공유 락(S lock)`이 아니라 `배타 락(X lock)`이 필요합니다. 그런데 로그에선 `공유 락(S lock)`을 대기하고 있습니다. - 공유 락(읽기 락)은 여러 트랜잭션에서 동시에 획득할 수 있는 잠금입니다. 다른 트랜잭션에서 공유 락을 획득해도 데이터의 일관성을 보장하면서 레코드를 읽을 수 있습니다.
반면, 쓰기 작업을 할 땐 배타 락(쓰기 락)을 획득해야 합니다. `배타 락(X lock)`을 획득하기 위해선 `공유 락(S lock)`을 갖고있는 다른 트랜잭션들이 모두 커밋하거나 롤백을 해야만 합니다. - 정리하면, `공유 락 획득` → `배타 락 획득` → `INSERT` 순서대로 작업을 수행합니다.
따라서 트랜잭션 아이디 2425번은 `INSERT`를 수행하기 위해 `letter_analysis` 테이블의 `배타 락(X lock)`이 필요한데,
배타 락을 획득하기 위해 먼저 `letter_analysis` 테이블의 `공유 락(S lock)`을 기다리고 있는 상태입니다.
- `letter_analysis` 테이블에서 인덱스 `UKoyy8rnymri4kke53b7ue5dknq`에 대해 `공유 락(S lock)`을 대기하고 있습니다.
두 번째 트랜잭션: 트랜잭션 아이디 2415
두 번째로 보이는 트랜잭션 관련 정보를 보겠습니다.
((2) TRANSACTION, (2) HOLDS THE LOCK(S), (2) WAITING FOR THIS LOCK TO BE GRANTED)
- (2) TRANSACTION
- 트랜잭션 아이디 2415번은 `letter` 테이블에 `UPDATE` 쿼리를 실행합니다.
(편지와 1:N 관계인 데일리 리포트의 ID를 입력하기 위한 쿼리입니다.)
- 트랜잭션 아이디 2415번은 `letter` 테이블에 `UPDATE` 쿼리를 실행합니다.
- (2) HOLDS THE LOCK(S)
- `trx id 2415 lock mode X locks rec but not gap`: 잠금 종류는 `배타 락(X lock)`이며, `letter_analysis` 테이블에서 `UKoyy8rnymri4kke53b7ue5dknq` 인덱스의 레코드에 대해 잡고 있습니다. (감정 분석 테이블에서 해당 레코드에 대한 `쓰기 잠금`)
이제야 알았습니다! 위의 `트랜잭선 2425`에서 기다리고 있던 `letter_analysis` 테이블에 대한 `쓰기 잠금`을 이 `트랜잭션 2415` 가 갖고 있었습니다!
여기서도 `rec but not gap`표시가 있으므로 해당 잠금도 순수 레코드에 대한 잠금을 건 상태네요. (편지 한 건에 대한 UPDATE를 수행하기 때문에 `갭 락`이 포함되지 않았습니다.) - 이 트랜잭션은 왜 `letter_analysis` 테이블의 `배타 락(X lock)`을 들고 있을까요?
쿼리는 `letter` 테이블에 대한 `UPDATE`입니다. 쓰기 작업을 하려면 배타 락을 먼저 획득해야 합니다.
그런데, `letter` 테이블은 `letter_analysis`와 1:1 관계를 가지며, 데이터 일관성을 보장하기 위해 `letter_analysis` 테이블의 특정 레코드(외래 키로 참조하는 letterId)에 `배타 락(X lock)`이 발생합니다.
- `trx id 2415 lock mode X locks rec but not gap`: 잠금 종류는 `배타 락(X lock)`이며, `letter_analysis` 테이블에서 `UKoyy8rnymri4kke53b7ue5dknq` 인덱스의 레코드에 대해 잡고 있습니다. (감정 분석 테이블에서 해당 레코드에 대한 `쓰기 잠금`)
- (2) WAITING FOR THIS LOCK TO BE GRANTED
- `letter` 테이블에서 `PRIMARY` 인덱스에 대해 `배타 락(X lock)`을 대기하고 있습니다.
이제 이 부분도 이해할 수 있습니다.
쓰기 작업을 할 때는 `배타 락(X lock)`이 필요합니다. 배타 락을 획득하기 위해선 먼저 `공유 락(S lock)`을 획득해야 합니다.
하지만 `letter`테이블의 공유 락은 `트랜잭션 2425`가 잡고 있습니다.
- `letter` 테이블에서 `PRIMARY` 인덱스에 대해 `배타 락(X lock)`을 대기하고 있습니다.
결론
- `트랜잭션 2425`는 `letter_analysis` 테이블에 레코드를 삽입하기 위해 `letter_analysis`테이블의 `공유 락(S lock)`을 기다면서 동시에 `letter` 테이블의 `공유 락(S lock)`을 잡고 있습니다.
- `트랜잭션 2415`는 `letter` 테이블을 업데이트하려고 하기 위해 `배타 락(X lock)`을 기다면서 동시에 `letter_anlaysis` 테이블의 `배타 락(X lock)`을 잡고 있습니다.
- 따라서 두 트랜잭션이 서로의 락을 기다리는 상태가 돼서 데드락이 발생한 것입니다.
데드락 해결 방법들
데드락을 해결하는 방법은 여러 가지가 있습니다.
외래 키를 사용하지 않으면 데드락을 감소시킬 수 있지만, 두 트랜잭션이 같은 레코드나 인덱스를 수정할 때 외래 키 사용과 상관없이 데드락이 발생할 수 있습니다.
잠금을 이용한 방법으로 해결하기 전에, 잠금 종류를 알아보고 해결 방법에 적합한지 검토 해보겠습니다.
네임드 락
MySQL 엔진 레벨에서 지원하는 잠금입니다. 모든 스토리지 엔진에 영향을 미칩니다. 분산 환경에서 서버 간 동기화를 위해 사용되기도 합니다.
중첩된 네임드 락도 지원하지만, 모든 스토리지 엔진에 영향을 미치기 때문에 반드시 명시적인 잠금 해제가 필요합니다.
이번 데드락 문제 해결에 적합한 방법 중 하나로 보입니다.
테이블 잠금
테이블 자체에 잠금을 걸어서 해결할 순 있지만 외부 API의 응답 속도가 늦어지면 다른 서비스까지 성능 저하를 유발할 수 있고, 외부 API에 장애라도 발생한다면 테이블 잠금이 길어져 다른 서비스까지 성능 저하를 유발할 수 있기 때문에 제외합니다.
비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)
`INSERT` 작업은 새로운 레코드를 추가하는 작업입니다.
비관적 락의 경우 기존 데이터에 락을 거는 의미가 없습니다.
낙관적 락도 기존 데이터와 충돌을 감지하는 버전 번호가 없기 때문에 의미가 없습니다.
따라서 해당 잠금은 데드락을 해결할 때 도움이 되지 않습니다.
Redis 같은 외부 저장소를 통한 분산 락
외부 저장소를 이용한 분산 락은 간단히 해결할 수 있지만, 현재 서비스 규모에는 적합하지 않으므로 제외합니다.
결론
비교적 간단한 방법인 네임드 락을 이용해서 해결하고자 합니다.
다음 포스팅에선 JPA 활용하며 네임드 락을 이용한 데드락 해결 과정을 작성하겠습니다.
다음 포스팅: 네임드 락을 이용한 데드락 해결
2024.11.26 - [Project] - [포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락 해결기 (3)
긴 긁 읽어주셔서 감사합니다.
'Project' 카테고리의 다른 글
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (4) (2) | 2024.11.27 |
---|---|
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (3) (2) | 2024.11.26 |
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (1) (0) | 2024.11.25 |
[포텐데이 409-1pick] 올려올려 라디오 서비스 개발기 - 2 (2) | 2024.10.08 |
[포텐데이 409-1pick] 올려올려 라디오 서비스 개발기 - 1 (4) | 2024.10.07 |