Dead Lock? Gap Lock?

김도비·2024년 11월 18일
post-thumbnail

웹 개발 진행 중에
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 Isolation Level)

트랜잭션 격리 수준에 대한 상세 설명은 Transaction과 Lock 참고


MySQL: Repeatable Read

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: Gap Lock

MySQL 사용 중 Dead Lock이 발생한다면 Gap Lock으로 인한 문제가 크다고 한다.

Gap Lock이라는 용어를 처음 들어서 찾아보게 되었다.

MySQL에만 있는 특별한 기능인데
레코드가 아닌, 레코드와 레코드 사이의 간격을 잠금으로써 레코드의 생성, 수정 및 삭제를 제어하는 Lock이다.

(참고 : MySQL Gap Lock)


아래와 같은 테이블에 A 트랜잭션에서 index 2, 4~5에 Gap Lock을 걸어놓았다면
B 트랜잭션에서는 해당 공간에 INSERT를 할 수 없다.


Gap Lock 사용 이유

  1. REPEATABLE READ 격리 수준 보장
    • MySQL의 경우 innoDB를 사용하면,
      Repeatable Read에서도 Phantom Read가 발생하지 않는다고 한다
  2. Replication 일관성 보장
  3. Foreign Key 일관성 보장



그러나...
격리 수준을 변경해보려고도 했지만, 개인 프로젝트가 아니라서
Controller와 Service 메서드에 @Transactional 적용하는 것으로 해결해야 했다...


Ajax async=false

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);
}
profile
Java Backend

0개의 댓글