배경
역량검사 결과지를 조회하는 과정에서 not unique result 에러가 발생했다. 에러가 발생한 지점을 찾고 실제 데이터베이스를 조회해보니, 역량검사 결과에 대한 엔티티가 2개가 생성이 되어있었다.
이러한 현상이 발생한 원인을 추측해보자면, 역량검사는 응시 완료(마지막 답변 제출) 시점에 바로 응답을 분석해서 결과지를 생성하려 시도한다. 이 과정에서 사용자의 실수였든 다른 변인이 있든 답, 마지막 답변 제출이 2번 연속으로 호출된 것으로 보인다. 흔히 말하는 따닥 현상으로 동시성에 대한 이슈였다.
발생 원인
구체적인 발생 원인은 결국 soft delete 때문이다. hard delete 였다면 가능했을 유니크 조건을 soft delete 때문에 제약 조건으로 걸지 못했고, 그로 인해 이와 같은 동시성 이슈가 발생한 것이다.
해당 로직을 조금 더 자세히 살펴보면, 답변 제출 시 답변 저장 → 답변 완료 여부 확인 → 역량검사 서비스에 분석 요청 순으로 이루어진다. 역량검사 서비스는 분석 요청을 받게 되면, 기존 결과지 있는지 조회 → 있다면 삭제(soft delete) → 응답 분석으로 동작한다.
동시성 문제는 여기서 두 개의 요청이 동시에 들어왔을 때, 다음과 같이 동작하는 경우 발생할 수 있다.
이와 같이 1번 혹은 2번까지 수행된 상태에서 문맥 전환이 발생해 다른 트랜잭션에 넘어간 경우, 트랜잭션 B에서 결과지 생성되고 이후 트랜잭션 A에서 결과지가 다시 생성되어 동일한 응시자에 2개 이상의 결과지가 생성되는 것이다.
해결 시도
기존의 해결 방식
사실 위 문제는 내가 회사에 들어오기 전에도 발생했던 문제로, 그 때 당시에는 엔티티 단 건 조회가 아인 List를 통해 조회를 수행하고 그 중 created 기준으로 가장 최근의 결과지를 조회하도록 하여 해결했다고 한다.
해결 방안 고민하기
하지만 나는 위 방법이 근본적인 해결 방법이 아니고, 이후의 개발자가 해당 로직을 보고 왜 이렇게 조회했지?라는 생각을 갖게 만든다는 점에서 별로 끌리지 않았다. 때문에 근본적으로 결과지가 2개 생성되지 않도록 막고 싶었고, 이를 해결할 방법으로 이전 프로젝트에서 시도 해보았던 방식이 떠올랐다.
1.
Redis를 통한 분산 락 제어
2.
격리 수준 Serializable로 격상하기
3.
비관적 Lock 방식(MySQL의 Gap Lock)
하지만 해당 API의 동시 호출 상황이 그리 자주 발생하지 않고, 그로 인해 Redis 분산 락을 쓰는 이점(락 제어를 넘겨 데이터베이스의 부하를 낮춤)이 없다고 판단하여 배제하였다.
테스트 코드를 통해 남은 두 가지 방식에 대해서 직접 동시성 문제 해결을 시도해보았다.
테스트 코드 작성하기
먼저 테스트 코드를 작성했다.
@Test
void append_동시성테스트_기존_결과값이_없는경우() throws InterruptedException {
// given
...
// when
final Thread thread1 = new Thread(() -> sut.append(capabilityV2, append));
final Thread thread2 = new Thread(() -> sut.append(capabilityV2, append));
final Thread thread3 = new Thread(() -> sut.append(capabilityV2, append));
thread1.start();
thread2.start();
thread3.start();
thread1.join();
thread2.join();
thread3.join();
final Optional<CapabilityV2Result> actualOptional = capabilityV2ResultRepository.findByCapabilityV2AndDeletedStatus(capabilityV2, NOT_DELETED);
// then
assertThat(actualOptional.isPresent()).isTrue();
final CapabilityV2Result actual = actualOptional.get();
assertAll(
...
);
}
Java
복사
@Test
void append_동시성테스트_기존_결과값이_있는경우() throws InterruptedException {
// given
...
capabilityV2ResultRepository.save(resultToBeReplaced);
// when
final Thread thread1 = new Thread(() -> sut.append(capabilityV2, append));
final Thread thread2 = new Thread(() -> sut.append(capabilityV2, append));
final Thread thread3 = new Thread(() -> sut.append(capabilityV2, append));
thread1.start();
thread2.start();
thread3.start();
thread1.join();
thread2.join();
thread3.join();
final Optional<CapabilityV2Result> actualOptional = capabilityV2ResultRepository.findByCapabilityV2AndDeletedStatus(capabilityV2, NOT_DELETED);
// then
assertThat(actualOptional.isPresent()).isTrue();
final CapabilityV2Result actual = actualOptional.get();
assertAll(
...
);
}
Java
복사
테스트를 작성하다보니, 기존에 결과값이 있는 경우와 없는 경우에 대해서 서로 다른 동작이 수행될 것 같아서 이와 같이 2가지 테스트를 작성했다.
격리수준 Serializable로 격상하기
전체 격리 수준을 올리면 성능이 너무 저하되기 때문에 권장되지 않는다. 하지만 스프링의 @Transactional 애노테이션의 기능 중 해당 비즈니스 로직(트랜잭션 범위)에만 Serializable로 격상하여 적용하는 방법으로 시도해보았다.
1.
기존에 저장된 결과지가 없는 경우
final List<CapabilityV2Result> all = capabilityV2ResultRepository.findAllByDeletedStatus(NOT_DELETED);
System.out.println("all = " + all.size());
Java
복사
테스트를 통과하고, 테스트 종료 전 저장된 데이터를 확인해보면, 결과가 단 1개만 저장되어 있는 것을 확인할 수 있다.
하지만 로그를 살펴보면, 별도의 스레드에서 실행된 동작에서 DeadLock이 발생한 것을 확인할 수 있었다.
실제 데이터베이스에 접속해 show engine innodb status 명령어를 수행하여 트랜잭션 로그를 확인해보면,
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-10-14 16:10:55 0xffff91229040
*** (1) TRANSACTION:
TRANSACTION 24891, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MariaDB thread id 6829, OS thread handle 281473116704832, query id 28040 172.20.0.1 root Update
insert into CapabilityV2Result (analysisFactor, analysisModel, capabilityV2Id, createdAt, customModel, deletedStatus, updatedAt, value) values ('{\"companyFitFactors\":null}', 'CUSTOM', 1, '2024-10-14 16:10:55.424209', 'HANA_BANK', 'NOT_DELETED', '2024-10-14 16:10:55.424209', '{\"completedAt\":null,\"capabilitySummary\":{\"totalSummary\":{\"score\":30.0,\"rank\":\"A\"},\"answerValidity\":null,\"personality\":{\"totalScore\":0.0,\"strength\":null,\"weakness\":null},\"meta\":{\"totalScore\":0.0,\"scores\":null},\"companyFit\":null,\"maladaptationPossibility\":null,\"jobSuitability\":null,\"jobWillingness\":null,\"workValues\":null,\"recommendedQuestions\":null,\"recommendedQuestionsV2\":null},\"personalitySummary\":null,\"metaSummary\":null,\"maladaptationPossibilitySummary\":null,\"jobSuitabilitySummary\":null,\"jobWillingnessSummary\":null,\"workValuesSummary\":null,\"workTendency\":null,\"conflictManaging\":null}
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1707 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 24891 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** CONFLICTING WITH:
RECORD LOCKS space id 1707 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 24890 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
RECORD LOCKS space id 1707 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 24891 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** (2) TRANSACTION:
TRANSACTION 24890, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MariaDB thread id 6828, OS thread handle 281473116336192, query id 28038 172.20.0.1 root Update
insert into CapabilityV2Result (analysisFactor, analysisModel, capabilityV2Id, createdAt, customModel, deletedStatus, updatedAt, value) values ('{\"companyFitFactors\":null}', 'CUSTOM', 1, '2024-10-14 16:10:55.424209', 'HANA_BANK', 'NOT_DELETED', '2024-10-14 16:10:55.424209', '{\"completedAt\":null,\"capabilitySummary\":{\"totalSummary\":{\"score\":30.0,\"rank\":\"A\"},\"answerValidity\":null,\"personality\":{\"totalScore\":0.0,\"strength\":null,\"weakness\":null},\"meta\":{\"totalScore\":0.0,\"scores\":null},\"companyFit\":null,\"maladaptationPossibility\":null,\"jobSuitability\":null,\"jobWillingness\":null,\"workValues\":null,\"recommendedQuestions\":null,\"recommendedQuestionsV2\":null},\"personalitySummary\":null,\"metaSummary\":null,\"maladaptationPossibilitySummary\":null,\"jobSuitabilitySummary\":null,\"jobWillingnessSummary\":null,\"workValuesSummary\":null,\"workTendency\":null,\"conflictManaging\":null}
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1707 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 24890 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n
SQL
복사
이와 같이 데드락이 발생한 것을 확인할 수 있다.
2.
기존에 저장된 결과지가 있는 경우
저장된 결과지가 있더라도 데이터는 1개만 잘 저장되지만,
마찬가지로 DeadLock이 발생한다.
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-10-14 16:12:48 0xffff91283040
*** (1) TRANSACTION:
TRANSACTION 25334, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 5 row lock(s), undo log entries 1
MariaDB thread id 6990, OS thread handle 281473117073472, query id 28474 172.20.0.1 root Update
insert into CapabilityV2Result (analysisFactor, analysisModel, capabilityV2Id, createdAt, customModel, deletedStatus, updatedAt, value) values ('{\"companyFitFactors\":null}', 'CUSTOM', 2, '2024-10-14 16:12:48.460405', 'HANA_BANK', 'NOT_DELETED', '2024-10-14 16:12:48.460405', '{\"completedAt\":null,\"capabilitySummary\":{\"totalSummary\":{\"score\":30.0,\"rank\":\"A\"},\"answerValidity\":null,\"personality\":{\"totalScore\":0.0,\"strength\":null,\"weakness\":null},\"meta\":{\"totalScore\":0.0,\"scores\":null},\"companyFit\":null,\"maladaptationPossibility\":null,\"jobSuitability\":null,\"jobWillingness\":null,\"workValues\":null,\"recommendedQuestions\":null,\"recommendedQuestionsV2\":null},\"personalitySummary\":null,\"metaSummary\":null,\"maladaptationPossibilitySummary\":null,\"jobSuitabilitySummary\":null,\"jobWillingnessSummary\":null,\"workValuesSummary\":null,\"workTendency\":null,\"conflictManaging\":null}
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1745 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25334 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** CONFLICTING WITH:
RECORD LOCKS space id 1745 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25331 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 8000000000000002; asc ;;
1: len 8; hex 8000000000000004; asc ;;
RECORD LOCKS space id 1745 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25334 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 8000000000000002; asc ;;
1: len 8; hex 8000000000000004; asc ;;
*** (2) TRANSACTION:
TRANSACTION 25331, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 5 row lock(s), undo log entries 1
MariaDB thread id 6988, OS thread handle 281473116336192, query id 28472 172.20.0.1 root Update
insert into CapabilityV2Result (analysisFactor, analysisModel, capabilityV2Id, createdAt, customModel, deletedStatus, updatedAt, value) values ('{\"companyFitFactors\":null}', 'CUSTOM', 2, '2024-10-14 16:12:48.458645', 'HANA_BANK', 'NOT_DELETED', '2024-10-14 16:12:48.458645', '{\"completedAt\":null,\"capabilitySummary\":{\"totalSummary\":{\"score\":30.0,\"rank\":\"A\"},\"answerValidity\":null,\"personality\":{\"totalScore\":0.0,\"strength\":null,\"weakness\":null},\"meta\":{\"totalScore\":0.0,\"scores\":null},\"companyFit\":null,\"maladaptationPossibility\":null,\"jobSuitability\":null,\"jobWillingness\":null,\"workValues\":null,\"recommendedQuestions\":null,\"recommendedQuestionsV2\":null},\"personalitySummary\":null,\"metaSummary\":null,\"maladaptationPossibilitySummary\":null,\"jobSuitabilitySummary\":null,\"jobWillingnessSummary\":null,\"workValuesSummary\":null,\"workTendency\":null,\"conflictManaging\":null}
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1745 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25331 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** CONFLICTING WITH:
RECORD LOCKS space id 1745 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25331 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 8000000000000002; asc ;;
1: len 8; hex 8000000000000004; asc ;;
RECORD LOCKS space id 1745 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25334 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 8000000000000002; asc ;;
1: len 8; hex 8000000000000004; asc ;;
*** WE ROLL BACK TRANSACTION (2)
SQL
복사
비관적 Lock 방식
1.
기존에 저장된 결과지가 없는 경우
비관적 Lock 방식을 적용하더라도 DeadLock이 발생한다.
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-10-14 16:14:14 0xffff91229040
*** (1) TRANSACTION:
TRANSACTION 25761, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 5 row lock(s), undo log entries 1
MariaDB thread id 7149, OS thread handle 281473116704832, query id 28864 172.20.0.1 root Update
insert into CapabilityV2Result (analysisFactor, analysisModel, capabilityV2Id, createdAt, customModel, deletedStatus, updatedAt, value) values ('{\"companyFitFactors\":null}', 'CUSTOM', 1, '2024-10-14 16:14:14.734704', 'HANA_BANK', 'NOT_DELETED', '2024-10-14 16:14:14.734704', '{\"completedAt\":null,\"capabilitySummary\":{\"totalSummary\":{\"score\":30.0,\"rank\":\"A\"},\"answerValidity\":null,\"personality\":{\"totalScore\":0.0,\"strength\":null,\"weakness\":null},\"meta\":{\"totalScore\":0.0,\"scores\":null},\"companyFit\":null,\"maladaptationPossibility\":null,\"jobSuitability\":null,\"jobWillingness\":null,\"workValues\":null,\"recommendedQuestions\":null,\"recommendedQuestionsV2\":null},\"personalitySummary\":null,\"metaSummary\":null,\"maladaptationPossibilitySummary\":null,\"jobSuitabilitySummary\":null,\"jobWillingnessSummary\":null,\"workValuesSummary\":null,\"workTendency\":null,\"conflictManaging\":null}
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1783 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25761 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** CONFLICTING WITH:
RECORD LOCKS space id 1783 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25759 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 8; hex 8000000000000001; asc ;;
RECORD LOCKS space id 1783 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25761 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 8; hex 8000000000000001; asc ;;
*** (2) TRANSACTION:
TRANSACTION 25759, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 5 row lock(s), undo log entries 1
MariaDB thread id 7148, OS thread handle 281473116336192, query id 28862 172.20.0.1 root Update
insert into CapabilityV2Result (analysisFactor, analysisModel, capabilityV2Id, createdAt, customModel, deletedStatus, updatedAt, value) values ('{\"companyFitFactors\":null}', 'CUSTOM', 1, '2024-10-14 16:14:14.734704', 'HANA_BANK', 'NOT_DELETED', '2024-10-14 16:14:14.734704', '{\"completedAt\":null,\"capabilitySummary\":{\"totalSummary\":{\"score\":30.0,\"rank\":\"A\"},\"answerValidity\":null,\"personality\":{\"totalScore\":0.0,\"strength\":null,\"weakness\":null},\"meta\":{\"totalScore\":0.0,\"scores\":null},\"companyFit\":null,\"maladaptationPossibility\":null,\"jobSuitability\":null,\"jobWillingness\":null,\"workValues\":null,\"recommendedQuestions\":null,\"recommendedQuestionsV2\":null},\"personalitySummary\":null,\"metaSummary\":null,\"maladaptationPossibilitySummary\":null,\"jobSuitabilitySummary\":null,\"jobWillingnessSummary\":null,\"workValuesSummary\":null,\"workTendency\":null,\"conflictManaging\":null}
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1783 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25759 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** CONFLICTING WITH:
RECORD LOCKS space id 1783 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25759 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 8; hex 8000000000000001; asc ;;
RECORD LOCKS space id 1783 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25761 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 8; hex 8000000000000001; asc ;;
*** WE ROLL BACK TRANSACTION (2)
SQL
복사
2.
기존에 저장된 결과지가 있는 경우
하지만 비관적 락을 적용하는 경우에는, 기존에 저장된 결과지가 있다면 DeadLock 없이 잘 동작한다.
교착상태 발생 상황 정리 및 원인 분석
발생 상황 정리
기존 결과지 존재 X | 기존 결과지 존재 O | |
Serializable 격상 | 1개의 데이터만결과 저장 성공
(DeadLock 발생 O) | 1개의 데이터만 결과 저장 성공
(DeadLock 발생 O) |
비관적 Lock | 1개의 데이터만 결과 저장 성공
(DeadLock 발생 O) | 1개의 데이터만 결과 저장 성공
(DeadLock 발생 X) |
데드락 원인 분석 - 결과지가 있는 경우
먼저 비관적 Lock 방식에서 기존 결과지가 존재하는 경우에 DeadLock 발생하지 않는 이유는 명확하다.
이와 같이 기존 결과지 자체에 X Lock이 걸려있고, 해당 Lock을 대기하기 때문에 교착상태 없이 순차적으로 종료되는 것이다.
반대로 Serializable 방식에서도 DeadLock이 발생하는 이유도 명확하다.
MySQL의 Serializable에서는 조회 발생 시 S Lock을 획득하게 되는데, 서로 다른 트랜잭션에서 동일한 레코드에 S Lock을 각각 획득한 상태에서 X Lock으로 업그레이드 하려고 시도하다보니 위처럼 교착 상태가 발생하는 것이다.
데드락 원인 분석 - 결과지가 없는 경우
그렇다면 기존의 데이터가 없는 경우에는 레코드가 없으니 S Lock이나 X Lock도 걸리지 않을텐데, 어떻게 동작하길래 교착상태가 발생하는 걸까에 대한 의문이 들었다.
위의 트랜잭션 로그를 살펴보면,
RECORD LOCKS space id 1745 page no 4 n bits 320 index FKgtw7781geerb62bac1u3rmg7p of table `testdb`.`CapabilityV2Result` trx id 25334 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
Java
복사
이와 같이 asc supremum에 레코드 락을 걸었다는 로그가 남아있다.
테스트 환경의 경우 매 테스트마다 데이터베이스가 비워진 상태로 시작하게 되는데, 이 때 결과지 없이 조회 시 빈 리프노드의 데이터에 Insert Intention Lock을 시도하게 된다. 이 과정에서 실제 데이터가 아닌 해당 노드의 가상 끝을 나타내는 supremum에 Gap Lock을 걸게 되고, 이후 다른 트랜잭션에서 조회 시에도 마찬가지로 해당 노드에 Gap Lock을 건다.
여기서 주목할 점은 Gap Lock은 순수 억제형으로 다른 트랜잭션에서 삽입하는 것을 방지하는게 목적이기 때문에, 이처럼 서로 충돌이 나는 상황에서도 락 획득이 가능하다. 때문에 서로 다른 두 트랜잭션이 Insert Intention Lock을 획득하려고 대기하다보니 교착상태가 발생하는 것이다.
Serializable 방식에서는 S Insert Intention Lock을, 비관적 락 방식에서는 X Insert Intention Lock을 획득하려고 하기 때문에 두 상황에서 모두 교착 상태가 발생한 것이다.
결론 및 느낀 점
결론
교착 상태의 원인이 Gap Lock을 사용하는 것이기 때문에, 이를 피하기 위해서는 격리 수준을 Read Committed로 내리거나 검색 조건을 외래 키가 아닌 PK를 통해 인덱스 eq 검색을 수행하도록 하는 방법이 있다.(인덱스 eq 검색은 해당 인덱스에만 Lock을 걸고, supremum이나 infimum에는 Lock을 걸지 않음)
하지만 위 두 가지 방법 모두 지금의 상황에서는 적절치 못하다고 느꼈다. 원인을 분석해보았을 때 데이터베이스 기술적인 방법으로는 해결이 어렵다고 생각이 들었다.
때문에 프론트엔드 코드에서 디바운스를 적용하여, 문제의 근원이 되는 따닥 현상을 방지하도록 하여 해결하였다.
여기서 해결 방법을 추가적으로 생각해보자면, 데이터베이스 기술 외적인 방식으로는 두 가지 정도 생각해볼 수 있을 것 같다.
1.
토큰 방식
Redis나 세션을 통해 특정 응시자에 대해 토큰을 관리하여 동시성을 해결하는 방법이다. 결과 저장 시도 시 토큰을 발급하고, 발급된 토큰이 있다면 요청을 대기하거나 요청에 대해 실패로 처리하는 등의 방식이다.
2.
보상 트랜잭션 방식
이 방식은 일단 저장하고, 저장 이후에 저장된 값을 다시 조회하여 정합성이 깨지는 경우 그에 대한 삭제하는 등의 보상 트랜잭션을 수행하는 방식이다.
반대로 데이터베이스 기술을 통해 해결해보고자 한다면, 테이블을 분리하는 것을 생각해보는 것이 어떨까 싶다. 결과지 테이블과 분리된 삭제된 결과지에 대한 테이블을 별도로 두고, 삭제 시에는 해당 데이터를 삭제 테이블에 저장 후 기존 테이블에서는 hard delete하는 방식으로 구현하는 것이다.
이와 같이 구성하게 되면 결과지 테이블에 유니크 조건을 추가하여 별도의 노력 없이 정합성을 지킬 수 있고, 조회 시에 현재 항상 delete 상태를 조건에 포함하여 조회를 수행하는 쿼리 또한 신경 쓸 필요가 없어지게 된다.
느낀 점
이 동시성 이슈와 교착상태에 대해 원인 분석과 해결 시도를 하다보니 soft delete 방식에 대해 다른 생각을 가지게 되었다. 데이터는 곧 자산이기 때문에 언제나 soft delete 방식이 옳다고 생각했는데, 유니크 제약 조건을 걸지 못했다는 이유 하나 때문에 이와 같은 교착 상태가 발생했다는 사실에 충격을 받았다.
원인 분석을 하면서 명확한 원인을 찾기가 힘들고 시간이 너무 오래 걸렸지만, 이번 기회를 통해 데이터베이스에 대해서 더 깊게 공부해보고 그 구체적인 동작 원리까지 이해할 수 있었던 것 같다.
추가적으로 관련 내용을 찾던 중에 MySQL 공식문서에서 다음과 같은 내용을 찾을 수 있었다.
Locking
MySQL extends metadata locks, as necessary, to tables that are related by a foreign key constraint. Extending metadata locks prevents conflicting DML and DDL operations from executing concurrently on related tables. This feature also enables updates to foreign key metadata when a parent table is modified. In earlier MySQL releases, foreign key metadata, which is owned by the child table, could not be updated safely.
MySQL에서는 필요한 경우에 외래 키 제약조건에 관련된 테이블에 잠금을 걸 수 있다는 내용이다. 이번 교착상태의 원인과는 무관하여 위의 내용에서는 빼두었지만, 과거에 Cabi 프로젝트에서 내가 들어오기 전에 발생했었던 교착상태의 원인이 이와 같은 외래 키 참조 시 Lock 전파 때문이였다고 한다. 리마인드 할 겸 내용을 추가해두었다.