
웹 개발 진행 중에
JavaScript에서 ajax를 사용해 약 24건의 데이터를 Controller로 전송하는데
계속 2~3건 정도만 UPDATE 성공하고 Rollback 되어버리는 현상이 발생했다.
콘솔에 찍힌 에러 메시지는 아래와 같았다.
### Error updating database. Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
### The error may exist in file [D:\project\basic\build\resources\main\mappers\BasicMapper.xml]
### The error may involve defaultParameterMap
### The error occurred while setting parameters
### SQL: UPDATE TB_BASIC SET ex1 = ?, ex2 = ?, ex3 = ?, ex4 = ?, ex5 = ?, ex6 = ? WHERE basic_seq = ?
### Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
; Deadlock found when trying to get lock; try restarting transaction
다른 작업 시에도 ajax로 API 호출해 데이터 여러 건을 한 번에 UPDATE 했었는데
왜 얘만 이럴까 답답했다.
찾아보니 트랜잭션 격리 수준(Transaction Isolation Level) 때문이랜다.
CS 공부할 때 많이 봤었는데 실무에서 만나게 될 줄이야...
트랜잭션 격리 수준에 대한 상세 설명은 Transaction과 Lock 참고

MySQL의 기본 격리 수준은 REPEATABLE READ라고 한다.
(cf. Oracle의 기본 격리 수준은 READ_COMMITTED)
격리 수준은 SELECT trx_isolation_level FROM information_schema.innodb_trx;로 확인할 수 있다.
보통 실무에서 MySQL 사용 시에는 READ COMMITTED로 변경해 사용한다고 한다.
MySQL에서 격리 수준을 변경하고 싶다면 아래와 같이 설정하면 된다.
// READ_UNCOMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
// READ_COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
// REPEATABLE_READ
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
// SERIALIZABLE
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
MySQL 사용 중 Dead Lock이 발생한다면 Gap Lock으로 인한 문제가 크다고 한다.
Gap Lock이라는 용어를 처음 들어서 찾아보게 되었다.
MySQL에만 있는 특별한 기능인데
레코드가 아닌, 레코드와 레코드 사이의 간격을 잠금으로써 레코드의 생성, 수정 및 삭제를 제어하는 Lock이다.
(참고 : MySQL Gap Lock)
아래와 같은 테이블에 A 트랜잭션에서 index 2, 4~5에 Gap Lock을 걸어놓았다면
B 트랜잭션에서는 해당 공간에 INSERT를 할 수 없다.

그러나...
격리 수준을 변경해보려고도 했지만, 개인 프로젝트가 아니라서
Controller와 Service 메서드에@Transactional적용하는 것으로 해결해야 했다...
MySQL의 격리 수준을 변경하지 못 한 채 @Transactional로 해결하려 했더니
이번에는 아래와 같은 예외가 발생했다.
org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
뭐야 왜 롤백이야
메서드에 @Transactional 걸어놨음에도 불구하고
UnexpectedRollbackException이 터지거나 Exception 없이 DB 값이 UPDATE 되지 않는 현상이 발생했다.
열심히 디버그 모드 돌려가며 문제가 되는 코드를 찾아보려 했지만
내 능력으로는 찾을 수 없었고, 결국 ajax 통신 부분을 수정하기로 했다.
ajax 송신 시 다양한 옵션을 설정할 수 있는데,
찾아보니 async 옵션도 있다고 한다.
지금까지 ajax를 많이 사용해봤지만 async 기본값이 true라는 것은 처음 알았다..!
결론적으로, 아래와 같이 ajax async=false 설정 후 API를 1번만 호출해서
Controller에서 List<Map<String, Object>>로 받아 반복문을 돌리며 DB UPDATE 하는 것으로 문제를 해결했다.
const formGroup = $('div.menu-list');
const formList = [];
for (let idx = 0; idx < formGroup.length; idx++) {
const list = $(`[data-seq="${idx}"]`);
const menuSeq = list.find('#menuSeq').val();
const listYn = list.find('#listYn').is(':checked');
const insYn = list.find('#insYn').is(':checked');
const updtYn = list.find('#updtYn').is(':checked');
const delYn = list.find('#delYn').is(':checked');
const prtYn = list.find('#prtYn').is(':checked');
const dwnYn = list.find('#dwnYn').is(':checked');
const formData = {
menuSeq: menuSeq,
listYn: listYn,
insYn: insYn,
updtYn: updtYn,
delYn: delYn,
prtYn: prtYn,
dwnYn: dwnYn
};
formList.push(formData);
}
$.ajax({
url: url,
type: 'POST',
async: false,
contentType: 'application/json',
data: JSON.stringify(formList),
dataType: 'json',
success: function (result) {
showConfirm(result.rstMsg, {
confirmCallback: () => {
if (result.rstCd === 0) {
location.reload();
}
},
showCancel: false,
closeOnClickOutside: false,
});
},
error: function (xhr, status, error) {
console.error('Error:', error);
}
})
@PostMapping("")
public ResponseEntity<Result> modify(HttpEntity<String> httpEntity) {
Result result = new Result();
String body = httpEntity.getBody();
if (body == null || body.isEmpty()) {
result.setError(-1, "요청 값이 없습니다.");
return new ResponseEntity<>(result, HttpStatus.BAD_REQUEST);
}
try {
List<Map<String, Object>> dataList = objectMapper.readValue(body, new TypeReference<>() {});
for (Map<String, Object> data : dataList) {
String menuSeq = String.valueOf(data.get("menuSeq"));
boolean listYn = (boolean) data.get("listYn");
boolean insYn = (boolean) data.get("insYn");
boolean updtYn = (boolean) data.get("updtYn");
boolean delYn = (boolean) data.get("delYn");
boolean dwnYn = (boolean) data.get("dwnYn");
boolean prtYn = (boolean) data.get("prtYn");
service.modify(menuSeq, listYn, insYn, updtYn, delYn, dwnYn, prtYn);
}
result.setRstMsg("수정되었습니다.");
} catch (Exception e) {
result.setError(-1, e.getMessage());
return new ResponseEntity<>(result, HttpStatus.INTERNAL_SERVER_ERROR);
}
return new ResponseEntity<>(result, HttpStatus.OK);
}