이 글은 Notion에서 더 잘 보이게 작성되어 있습니다!

여기에서 공개된 글로 보시면 편하게 보실 수 있습니다..!


개요

한정된 자원인 사물함에 대해 대여 서비스인 Cabi를 진행하면서 동시성 문제를 겪었었다.
트랜잭션과 격리수준에 대해 간략히 설명하고, 이전의 Nest.js에서 해결했던 방법과, 지금 새로이 Spring으로 포팅하면서 해결하고자 한 방법들에 대해서 써보려고 한다.

(장문 주의)


동시성 문제?

유명한 문제 중 하나인 **‘식사하는 철학자 문제’**와 같이, 정해진 자원에 대해, 스레드나 프로세스가 동시적인 점유와 조작을 시도했을 때, 의도하지 않은 결과가 발생하는 것을 의미한다.

자세한 내용은 이곳을 참조하면 좋을 것 같다.


문제 상황

까비에서 서비스로 제공하는 사물함의 종류는 1인 상한의 개인 사물함, 그리고 3인 상한의 공유 사물함이 있다.

한정된 자원에 대한 점유를 위해 여러 명이 동시에 시도하는 경우, 특별한 조치가 없다면 개인 사물함에 4명이 들어가고, 공유 사물함에 10명도 들어갈 수 있는 문제가 생길 수 있다.

이를 위해 서비스에서 사용될 수 있는 트랜잭션의 방법을 생각해보고, 적용하여 해결해야 한다.

트랜잭션?

**트랜잭션(Transaction)**은 시스템에서 사용되는 더 이상 쪼갤 수 없는 업무 처리의 최소 단위이다.

예를 들어, A라는 사람이 B라는 사람에게 1,000원을 지급하고 B가 그 돈을 받은 경우, 이 거래 기록은 더 이상 작게 쪼갤 수가 없는 하나의 트랜잭션을 구성한다.

만약 A는 돈을 지불했으나 B는 돈을 받지 못했다면 그 거래는 성립되지 않는다.

이처럼 A가 돈을 지불하는 행위와 B가 돈을 받는 행위는 별개로 분리될 수 없으며 하나의 거래내역으로 처리되어야 하는 단일 거래이다.

이런 거래의 최소 단위를 트랜잭션이라고 한다.

트랜잭션 처리가 정상적으로 완료된 경우 커밋(반영)을 하고, 오류가 발생할 경우 원래 상태대로 롤백(되돌리기)을 한다.

트랜잭션 더 알아보기

트랜잭션은, 의도에 따른 동작의 ACID를 보장하기 위해, 그리고 동시성 문제가 발생할 수 있는 다중 사용자 환경에 맞추어 데이터의 일관성과 무결성을 관리하기 위한 DB 기술이다.

ACID는 Atomicity(원자성), Consistency(일관성), Isolation(격리성), Durability(지속성)의 약어로, 데이터베이스의 안전한 동작을 위한 핵심 속성이다.

  1. 원자성(Atomicity):
    • 원자성은 트랜잭션의 모든 연산이 원자적인 작업 단위로 처리되는 속성을 의미한다.
    • 트랜잭션의 모든 연산은 전부 성공하거나 실패하며, 어느 하나라도 실패하면 트랜잭션 전체가 롤백되어 이전 상태로 복구된다. 예를 들어, 은행 계좌 이체 트랜잭션에서 출금과 입금 연산은 원자적으로 처리되어야 한다. 만약 출금은 성공했지만 입금이 실패하면, 출금된 금액은 다시 복구되어야 한다.
  2. 일관성(Consistency):
    • 일관성은 트랜잭션이 수행되기 전과 후에 데이터베이스가 항상 일관된 상태를 유지하는 속성을 의미한다.
    • 트랜잭션은 정의된 일관성 규칙을 준수하여 데이터의 무결성을 보장해야 한다. 예를 들어, 사물함의 좌표에서, 양수여야 하는 값이, 트랜잭션을 진행하면서 음수로 변경되는 등의 상황들은 일관성을 해치는 상황인 것이다. 트랜잭션이 완료된 후에도 정해 놓은 유효 범위(양수)임을 보장해서, 일관성을 지켜야한다.
  3. 격리성(Isolation):
    • 격리성은 동시에 실행되는 여러 트랜잭션이 서로에게 영향을 주지 않고 독립적으로 수행되는 속성을 의미한다.
    • 각 트랜잭션은 다른 트랜잭션의 연산을 간섭하지 않고 독립적으로 처리되어야 한다. 예를 들어, 동시에 실행되는 두 개의 트랜잭션이 각각 은행 계좌에서 금액을 조작하는 연산을 수행할 때, 격리성을 보장하기 위해 한 트랜잭션이 다른 트랜잭션의 변경을 볼 수 없어야 한다. 여기에서, 격리 수준을 설정함으로써 원하는 방식으로 트랜잭션을 설정할 수 있는 것이다.
  4. 지속성(Durability):
    • 지속성은 트랜잭션이 성공적으로 완료된 후에는 해당 변경 내용이 영구적으로 저장되는 속성을 의미한다.
    • 한 번 커밋된 트랜잭션은 시스템 장애 또는 전원 손실과 같은 예기치 않은 상황이 발생하더라도 영구적으로 저장되어야 한다. 예를 들어, 트랜잭션으로 데이터베이스에 새로운 사용자를 추가한 경우, 해당 정보는 지속성을 보장받아야 하므로 시스템 장애 후에도 데이터베이스에 계속 저장되어야 한다. 데이터의 영속화(Persist)와 동일한 의미라고 생각한다.

코드로서 구현되는 트랜잭션의 경우에, ACID중 일관성(Consistency)에서 에러가 나타나는 경우가 많으며 이 경우에 원자성(Atomicity)을 위해 트랜잭션을 롤백한다. 이것은 격리성의 정도(Isolation, 격리 수준)에 따라 다르게 나타날 수 있다. 또, 성공의 경우에 지속성(Durability)을 보장해야한다.

격리수준?

**격리 수준(Isolation Level)**은 동시에 실행되는 여러 트랜잭션 간의 상호 작용을 제어하는 데이터베이스의 속성이다. 일반적으로 지원되는 네 가지 격리 수준으로는 READ UNCOMMITTED, READ COMMITTED, REAPEATABLE READ, SERIALIZABLE이 있다.

격리수준 더 알아보기

격리 수준에 따라 발생하는 문제들(Dirty Read, Non-Repeatable Read, Phantom Read)에 대한 설명은 이 글에서는 생략한다.

  1. READ UNCOMMITTED (커밋되지 않은 읽기):
    • 가장 낮은 격리 수준. 다른 트랜잭션이 커밋하지 않은 변경 내용을 읽을 수 있다. 데이터의 일관성을 보장하지 않아서, 일반적으로 사용되지 않는다.
  2. READ COMMITTED (커밋된 읽기):
    • 트랜잭션이 커밋된 변경 내용만을 읽을 수 있다. 대부분의 데이터베이스 시스템의 기본 격리 수준이다.
  3. REPEATABLE READ (반복 가능한 읽기) ← 현재 격리 수준:
    • 트랜잭션 내에서 같은 쿼리를 반복 실행할 때, 항상 동일한 결과를 보장한다. 다른 트랜잭션에서 수정한 데이터에 대한 읽기 작업은 차단된다.
  4. SERIALIZABLE (직렬화 가능) ← 이전 격리 수준:
    • 가장 높은 격리 수준으로, 동시성을 최소화하여 데이터 일관성을 보장한다. 모든 읽기 및 쓰기 작업에 대해 락을 걸어 다른 트랜잭션의 접근을 완전히 차단한다. 하지만, 동시성 제어를 위해 가장 많은 락이 사용되어 성능 저하가 발생할 수 있다.

각 격리 수준은 트레이드오프 관계를 가진다. 격리 수준이 높을수록 데이터 일관성은 보장되지만 동시성은 제한된다. 그러므로, 격리 수준을 선택할 때는 데이터 일관성의 중요도와 동시에 다룰 트래픽의 양과 그 중요도를 고려해야 한다.


이전의 해결 방식 (Nest.js)

MariaDB를 사용하고, 기본 격리 수준인 **Repetable Read(하나의 트랜잭션에서 당시의 조회 결과를 커밋이 끝날 때까지 유지)**로 설정되어 있었고, 해당 읽기와 쓰기에 대한 별도의 상호 배제(Lock)가 없었다. 때문에, 대여와 반납에 대한 트랜잭션을 진행하더라도 아래와 같은 동시성 문제가 발생하였다.

비어 있는 개인 사물함(0/1)을 생각해보자.

  • 별도의 상호배제 조건이 없으므로, 두 클라이언트(C1, C2) 중 다른 세션(C1)이 트랜잭션을 수행하고 있어도, C2가 읽는 것이 가능하다.
  • C1, C2가 INSERT 이전의 대여자 수를 기준(0/1)으로 대여를 진행한다. (Repeatable Read)
  • 두 세션 다 사물함의 이용자 수가 (0/1)로 인식되므로, 대여가 성공하게 된다.
  • 따라서, (1/1)이어야 하는 개인 사물함의 이용자 수가, (2/1)이 되는 동시성 문제가 발생한다.

이에 대한 대처로 격리 수준을 높여, SERIALIZABLE(하나의 트랜잭션이 끝날때까지 다른 트랜잭션에서 해당 트랜잭션의 테이블에 업데이트를 못 하거나, 읽지 못 함) 로 설정했다. SERIALIZABLE에서 락(S Lock과 X Lock 등, 나중에 설명)을 통한 상호 배제로 해결하고자 하였고, 그 과정에서 Lock으로 인한 교착 상태(Dead Lock)와 같은 문제들을 기존의 트랜잭션 조정과 쿼리문 최적화로 해결했다.

이전의 대여와 반납에서 겪었던 문제는 sichoi님의 블로그(**https://velog.io/@sichoi/동시-대여반납-문제**)에 상세하게 나타나있다.


현재의 해결 방식 (Spring)

이전의 해결방식에서 SERIALIZABLE 격리 수준을 사용하면서 이후에 생기는 문제들을 해결했지만, 팀원들과 다음과 같은 의문점을 공유하게 되었다.

“빈번하게 일어나지 않는 케이스를 위해서 매번 Lock을 거는 경우는 비효율적인 것이 아닐까?” ”직접적인 상호배제 없이 해결할 수 있는 방법은 없을까?”

낙관적 락?

그렇게 방법을 찾아보았고, REPEATABLE READ 격리 수준에서 낙관적 락을 사용하여 해결할 수 있다는 것을 팀원들과 알게 되었다.

**낙관적 락(Optimistic Locking)**은 데이터의 충돌이 발생할 가능성이 적다고 가정하고, 트랜잭션의 충돌 검출을 트랜잭션 완료 시점으로 미루는 전략이다.

낙관적 락에서는 트랜잭션이 데이터를 읽어오고 수정하기 전에는 락을 걸지 않는다. 대신, 트랜잭션이 수정을 시도할 때 다른 트랜잭션이 해당 데이터를 이미 수정했는지 확인한다.

데이터를 수정하는 시점에 충돌이 발생하면, 해당 트랜잭션은 롤백되거나 충돌을 해결하는 방식으로 처리한다.

  • 비관적 락?비관적 락에서는 데이터를 읽어오는 시점에 락을 걸어 다른 트랜잭션의 접근을 제한한다. 이로써 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 보장한다.
  • 위에 써놓은 SERIALIZABLE이 비관적 락을 사용하는 대표적인 격리수준이다.
  • **비관적 락(Pessimistic Locking)**은 데이터의 충돌이 발생할 가능성이 높다고 가정하고, 트랜잭션의 충돌 검출을 가능한 빨리 수행하는 전략이다.

낙관적 락을 적용했다면..

일반적으로 낙관적 락을 사용할 때, 버전(Version) 컬럼을 추가하여 관리한다. 간단히 얘기하자면, 해당 테이블에 변경사항(수정)이 생겼을 때 버전이 올라가는 것이다.

다음과 같은 방식으로 낙관적 락이 수행된다.

  1. 트랜잭션이 데이터를 읽을 때, 해당 데이터의 버전 정보도 함께 읽어온다.
  2. 트랜잭션이 데이터를 수정할 때, 데이터의 현재 버전과 트랜잭션 시작 시점의 버전을 비교한다.
    • 버전이 일치하면(0 → 0, 누군가 수정하지 않았다면), 트랜잭션을 계속 진행하고, 트랜잭션의 결과로 데이터를 업데이트하고 버전을 증가시다.
    • 버전이 일치하지 않으면(0 → 1, 누군가 수정했다면), 충돌이 발생한 것으로 간주하고, 트랜잭션을 롤백하거나 충돌을 해결하는 방식으로 처리한다.

이렇게 낙관적 락을 구현하면, 동시에 실행되는 트랜잭션들이 동일한 데이터를 수정하려고 할 때 충돌을 감지하게 되고, 이에 대한 처리를 할 수 있는 것이다.

우리의 케이스인 REPEATABLE READ의 격리 수준에서 낙관적 락을 적용했다고 생각하고, 위의 문제를 다시 살펴보자.

비어 있는 개인 사물함(0/1)이 이있다. 버전은 0인 상태라고 하자.

  • 별도의 상호배제 조건이 없으므로, 두 클라이언트(C1, C2) 중 다른 세션(C1)이 트랜잭션을 수행하고 있어도, C2가 읽는 것이 가능하다.
  • C1, C2가 INSERT 이전의 대여자 수를 기준(0/1)으로 대여를 진행한다. (Repeatable Read) → 이 때, C1과 C2는 동일한 버전 값(0)을 갖게 된다.
  • 두 세션 다 사물함의 이용자 수가 (0/1)로 인식되므로, 대여를 적용하여 커밋한다. → 이 때, C1과 C2는 버전 컬럼을 동일하게 증가시키므로, 결과적으로 같은 버전 값(1)을 갖게 된다.
  • 따라서, (1/1)이어야 하는 개인 사물함의 이용자 수가, (2/1)이 되는 동시성 문제가 발생한다. 따라서, 커밋 시점에 이미 같은 버전으로 업데이트 되어 있으므로, 충돌이 일어난다. 이에 대해서 롤백하거나 재요청 처리를 하면 동시성 문제를 해결할 수 있다.

테스트로 알아보자(Spring) - 문제 상황

@Test
	void 동시_대여_테스트_대여자_존재() throws InterruptedException {
		// 4명이 동시에 3인 상한의 공유 사물함을 대여하는 경우
		int nThreads = 4;
		ExecutorService executorService = Executors.newFixedThreadPool(nThreads);
		// 대여 가능한 상황의 유저 ID = [23, 24, 25, 26]
		Long user1 = 23L;
		Long user2 = 24L;
		Long user3 = 25L;
		Long user4 = 26L;
		// 대여하려는 (1/3) 상태의 공유 사물함 ID =  12
		Long cabinetId = 12L;

		// 스레드를 이용해, 동시적으로 대여를 시도한다.
		Future<?> future1 = executorService.submit(
				() -> lentService.startLentCabinet(user1, cabinetId));
		Future<?> future2 = executorService.submit(
				() -> lentService.startLentCabinet(user2, cabinetId));
		Future<?> future3 = executorService.submit(
				() -> lentService.startLentCabinet(user3, cabinetId));
		Future<?> future4 = executorService.submit(
				() -> lentService.startLentCabinet(user4, cabinetId));
		try {
			future1.get();
			future2.get();
			future3.get();
			future4.get();

		} catch (Exception e) {
		}

		// (3/3)이 우리가 원하는 상황이다.
		assertEquals(3, lentRepository.findAllActiveLentByCabinetId(cabinetId).size());
		executorService.shutdown();
	}

사용자 수가 (1/3)인 공유 사물함의 상황에서, 4개의 스레드가 동시적으로 대여를 시도했을 때, 3명만 대여를 하는 것을 목표로 했다.

하지만, 4명 모두 대여를 해버리는 상황이 발생했다. 심지어, 5명이 찼음에도 사물함의 상태가 FULL이 아닌, AVAILABLE(대여가 가능한 상태) 상태로 남아 있었다.


문제 이유 살피기

@Override
	public void startLentCabinet(Long userId, Long cabinetId) {
   // 1. 대여에 필요한 데이터 가져오기
		Date now = new Date();
		Cabinet cabinet = cabinetRepository.getCabinet(cabinetId);
		User user = userRepository.getUser(userId);
		int userActiveLentCount = lentRepository.countUserActiveLent(userId);
		List<BanHistory> userActiveBanList = banHistoryRepository.findUserActiveBanList(userId,
				DateUtil.getNow());

		// 2. 대여 가능한 유저인지 확인
		lentExceptionHandler.handlePolicyStatus(
				lentPolicy.verifyUserForLent(user, cabinet, userActiveLentCount,
						userActiveBanList));
		List<LentHistory> cabinetActiveLentHistories = lentRepository.findAllActiveLentByCabinetId(
				cabinetId);

		// 3. 대여 가능한 캐비넷인지 확인
		lentExceptionHandler.handlePolicyStatus(
				lentPolicy.verifyCabinetForLent(cabinet, cabinetActiveLentHistories, now));

		// 4. 캐비넷 상태 변경
cabinet.specifyStatusByUserCount(lentRepository.countCabinetActiveLent(cabinetId) + 1);
		Date expiredAt = lentPolicy.generateExpirationDate(now, cabinet,
				cabinetActiveLentHistories);
		LentHistory lentHistory = LentHistory.of(now, userId, cabinetId);

		// 5. 만료 시간 적용 및 대여
		lentPolicy.applyExpirationDate(lentHistory, cabinetActiveLentHistories, expiredAt);
		lentRepository.save(lentHistory);
	}

주석과 코드에 따라 흐름을 살펴보자.

  1. Cabinet과 User, BanHistory를 통해서, 사물함과 대여를 시도한 해당 유저가 대여가 가능한지에 대한 여부를 체크한다.
  2. 대여가 가능한 유저인지 확인한다.
  3. 대여 가능한 사물함인지 확인한다.

(데이터에 변화가 생기는 부분은 후에, 4 - 5번에서 나타난다.)

  1. 현재 사물함에 대한 대여 row의 수(대여자 수)에 + 1을 하여 사물함의 상태를 변경한다. → 현재의 (1/3)인 경우에, 모든 세션은 2명의 결과를 갖게 되고(기존 1 + 본인 1), 이에 따라서 사물함의 상태는 변하지 않는다(AVAILABLE) → 즉, Cabinet 엔티티의 상태는 변경(UPDATE) 되지 않는다.
  2. 정책에 따라 만료시간을 설정하고 save, 즉 새로 생성된 LentHistory 엔티티를 INSERT 한다. → 4개의 새로운 row가 INSERT 된다.

우리의 정책(공유 사물함은 최대 3인)이 무너지는 동시성 문제가 발생한다.


해결하기 - Version으로 낙관적 락 적용하기

Spring Data에는 @Version 어노테이션이 있다. 이는 위에서 설명한 낙관적 락 수행을 위한 Version 컬럼임을 나타낸다. 이 어노테이션을 이용하면 자동적으로 해당 엔티티의 상태가 변할 때 버전을 증가시키고, 동일한 버전이 있다면 현재 연결된 DB 어플리케이션에서 충돌을 일으킨다.

@Entity
@Table(name = "CABINET")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Cabinet {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "CABINET_ID")
	private Long cabinetId;

	/**
	 * 사물함의 상태가 변경될 때 증가하는 버전입니다.
	 * <p>
	 * 동시성 문제 해결을 위한 낙관적 락을 위해 사용됩니다.
	 */
	@Version // <---------------- 추가된 Version 컬럼
	@Getter(AccessLevel.NONE)
	private Long version = 1L;

 // ..........생략..........

위와 같이 설정할 수 있다.

하지만 이 설정만으로는 부족하다. 왜냐하면, **위의 테스트 상황에서 Cabinet 엔티티의 상태는 변경되지 않기 때문(Version 증가 X)**이다.

즉, 매 대여마다 Cabinet 엔티티의 상태를 바꾸어 Version을 증가시켜야, 위의 동시성 문제를 해결할 수 있는 것이다.

이를 위해, 매 대여마다 업데이트 될 수 있게끔, 사용자 수인 user_count라는 컬럼을 Cabinet 테이블에 추가하고, 엔티티를 수정해보자.

@Entity
@Table(name = "CABINET")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Cabinet {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "CABINET_ID")
	private Long cabinetId;

	/**
	 * 사물함의 상태가 변경될 때 증가하는 버전입니다.
	 * <p>
	 * 동시성 문제 해결을 위한 낙관적 락을 위해 사용됩니다.
	 */
	@Version
	@Getter(AccessLevel.NONE)
	private Long version = 1L;

	// ..........중략..........	

	@Column(name = "USER_COUNT", nullable = false) // <--------- 매 대여시 업데이트되는 컬럼 추가
	private Integer userCount;

매 대여시마다 Cabinet 엔티티의 상태(userCount)를 변경시켜야 하므로, 대여 코드를 변경한다.

@Override
	public void startLentCabinet(Long userId, Long cabinetId) {
   // 1. 대여에 필요한 데이터 가져오기
		Date now = new Date();
		Cabinet cabinet = cabinetRepository.getCabinet(cabinetId);
		User user = userRepository.getUser(userId);
		int userActiveLentCount = lentRepository.countUserActiveLent(userId);
		List<BanHistory> userActiveBanList = banHistoryRepository.findUserActiveBanList(userId,
				DateUtil.getNow());

		// 2. 대여 가능한 유저인지 확인
		lentExceptionHandler.handlePolicyStatus(
				lentPolicy.verifyUserForLent(user, cabinet, userActiveLentCount,
						userActiveBanList));
		List<LentHistory> cabinetActiveLentHistories = lentRepository.findAllActiveLentByCabinetId(
				cabinetId);

		// 3. 대여 가능한 캐비넷인지 확인
		lentExceptionHandler.handlePolicyStatus(
				lentPolicy.verifyCabinetForLent(cabinet, cabinetActiveLentHistories, now));

		// 4. 캐비넷 상태 변경
cabinet.increaseUserCount(); //<------------------ 추가된 부분
cabinet.specifyStatusByUserCount(cabinet.getCountUser()); //<------------------ 변경된 부분
		Date expiredAt = lentPolicy.generateExpirationDate(now, cabinet,
				cabinetActiveLentHistories);
		LentHistory lentHistory = LentHistory.of(now, userId, cabinetId);

		// 5. 만료 시간 적용 및 대여
		lentPolicy.applyExpirationDate(lentHistory, cabinetActiveLentHistories, expiredAt);
		lentRepository.save(lentHistory);
	}

cabinet.increaseUserCount()를 통해서, Cabinet 엔티티의 상태(userCount)를 변경한다. 이를 통해 Version이 증가하고, 동시 대여가 수행될 때, 버전 충돌을 일으켜 롤백하는 방식으로 동시성 문제를 해결할 수 있을 것이다.


자 이제, 버전 컬럼을 통한 낙관적 락이 잘 수행되는지 기존의 테스트를 통해 다시 살펴보자.

@Test
	void 동시_대여_테스트_대여자_존재() throws InterruptedException {
		// 4명이 동시에 3인 상한의 공유 사물함을 대여하는 경우
		int nThreads = 4;
		ExecutorService executorService = Executors.newFixedThreadPool(nThreads);
		// 대여 가능한 상황의 유저 ID = [23, 24, 25, 26]
		Long user1 = 23L;
		Long user2 = 24L;
		Long user3 = 25L;
		Long user4 = 26L;
		// 대여하려는 (1/3) 상태의 공유 사물함 ID =  12
		Long cabinetId = 12L;

		// 스레드를 이용해, 동시적으로 대여를 시도한다.
		Future<?> future1 = executorService.submit(
				() -> lentService.startLentCabinet(user1, cabinetId));
		Future<?> future2 = executorService.submit(
				() -> lentService.startLentCabinet(user2, cabinetId));
		Future<?> future3 = executorService.submit(
				() -> lentService.startLentCabinet(user3, cabinetId));
		Future<?> future4 = executorService.submit(
				() -> lentService.startLentCabinet(user4, cabinetId));
		try {
			future1.get();
			future2.get();
			future3.get();
			future4.get();

		} catch (Exception e) {
		}

		// 낙관적 락을 이용해서, 먼저 커밋한 버전만 성공하고, 이외의 것들은 실패한다. -> 하나만 성공한다. (2/3)
		assertEquals(1, lentRepository.findAllActiveLentByCabinetId(cabinetId).size());
		executorService.shutdown();
	}

낙관적 락을 수행하는 경우, 먼저 커밋한 하나의 대여가 성공하고, 그 이후에 commit되는 대여 시도들은 버전 충돌이 일어난다. 따라서, 사용자 수가 (1/3)이었던 공유 사물함에 4명의 동시 대여가 요청 됐을 때, 한명만 성공하고 나머지 셋은 실패하는 (2/3)이 되어야 하는 것이다.

성공!!!!!…. 인 줄 알았다.

하지만..


또 다른 문제상황 - 교착상태(Deadlock)의 발생

갑자기 데드락이 등장했다..!

먼저 이 문제를 이해하기 위해서는 S LockX Lock, 그리고 데드락에 대해 이해해야 한다.

  • S Lock과 X Lock 알아보기
    1. S Lock (Shared Lock, 공유 락):
      • 여러 트랜잭션이 동시에 데이터를 읽을 수 있도록 허용한다.
      • S Lock이 획득된 데이터는 동시에 다른 트랜잭션에서 읽을 수 있지만, 수정할 수는 없다.
    2. X Lock (eXclusive Lock, 배타 락):
      • X Lock은 트랜잭션이 데이터를 읽고 수정하는 것을 방지한다.
      • X Lock은 단일 트랜잭션이나 작업만 획득할 수 있으며, 다른 트랜잭션이 X Lock을 획득하려고 시도하면 충돌이 발생한다.
    • 정리 S Lock은 읽기에 사용되며, 여러 트랜잭션에서 동시에 얻을 수 있다. X Lock은 쓰기에 사용되며, 하나의 트랜잭션만 얻을 수 있다. S Lock이 걸린 데이터에 대해서 X Lock을 획득하기 위해서는, S Lock이 해제될 때 까지 대기해야 한다..! X Lock이 걸린 데이터에 대해 X Lock 획득을 시도하면 충돌이 발생한다..!
  • 데드락 알아보기데드락은 다음 상황에 주로 발생한다:
    1. 상호 배제 (Mutual Exclusion, mutex): 자원(한정된)은 한 번에 한 작업만이 사용할 수 있다.
    2. 점유와 대기 (Hold and Wait): 작업이 이미 자원을 점유한 상태에서 다른 자원을 기다리고 있다.
    3. 비선점 (No Preemption): 다른 작업이 자원을 강제로 뺏을 수 없다.
    4. 순환 대기 (Circular Wait): 작업 간에 순환 형태로 자원을 기다리고 있다.
  • 데드락(Deadlock)은 두 개 이상의 작업 또는 프로세스가 서로가 점유하고 있는 자원을 기다리며 무한히 대기하는 상태를 말한다. 이러한 상황에서는 어떤 작업도 진행되지 못하고, 시스템이 멈추거나 정체될 수 있다. (하지만 현대 대부분의 프로그램들은 데드락에 대해서 인지하고, 에러 핸들링을 수행하는 경우가 많다 - 위 테스트도 데드락이 처리된 것)

**“아니.. REPEATABLE READ 격리 수준에서 S Lock을 걸지도 않았는데, 데드락이 나타난다고..?”**가 이 로그를 보고 처음 든 생각이었다.

나의 시나리오는 이랬다.

  1. (S Lock 없이) Cabinet 엔티티를 가져온다.
  1. 대여를 시도하고, Cabinet 엔티티가 업데이트(userCount++)된다.
  2. 동시 대여가 이뤄질 때, 버전 충돌이 일어나서 하나의 트랜잭션만 성공하고, 나머지는 실패한다.

하지만 버전 충돌이 아닌 데드락이 날 반겼다..

그리고 이 문제에 대한 삽질과 탐구가 계속되었다..

마침내 다음과 같은 사실들을 알 수 있었다..


InnoDB(MySQL, MariaDB)는 기본 형태의 SELECT 쿼리에서 별도의 잠금을 사용하지 않는다.

현재 애플리케이션은 MariaDB를 사용하고 있다. 문제 해결을 위해 검색을 하는 와중, REPEATABLE_READ일 때, SELECT의 경우 S Lock을 획득한다는 말을 보았다. 당연한 얘기처럼 느껴지지만, 이후에 공식문서, MySQL 서적을 찾아보아도 그런 말은 없었으며, 오히려 아무런 Lock을 획득하지 않는다는 사실을 알게되었다.

 

- MySQL 공식문서

이것(Repeatable Read)은 InnoDB의 기본 격리 수준이다. 동일한 트랜잭션 내에서 Repeatable Read는 첫 번째 읽기에 의해 설정된 스냅샷(Undo)을 읽는다. 즉, 동일한 트랜잭션 내에서 일반(Non-Locking) SELECT 문을 여러 개 실행하는 경우 이러한 SELECT 문은 서로에 대해서도 일관성을 유지한다. 섹션 15.7.2.3, **"일관된 비잠금 읽기(Non-Locking Consistent Reads)"**를 참조.

 

-MariaDB 공식문서

  • MariaDB 공식문서Repeatable Read는 모든 읽기 행에 대한 공유 잠금을 획득하지 않으며, WHERE 절과 일치하는 누락된 값에 대한 범위 잠금을 획득하지도 않습니다.

“REPEATABLE-READ 이하의 트랜잭션 격리 수준에서 InnoDB 테이블에 대한 SELECT 쿼리는 기본적으로 아무런 잠금을 사용하지 않는다”

  • 개발자와 DBA를 위한 Real MySQL, p.710

    문제 해결을 차근차근 하기 위해서는, 토대가 되는(흔들릴 여지가 없는) 전제가 필요했고, 이 경우에서는 SELECT시의 S Lock 획득의 여부였다. 기본적인 SELECT는 Lock을 가지지 않는다.

InnoDB는 외래키 제약 조건을 기본적으로 검사한다.

외래키 제약 조건(Foreign Key Constraints)은 두 테이블의 데이터 간 연결을 설정, 적용하여 외래 키 테이블에 저장될 수 있는 데이터를 제어하는 데 사용되는 규칙이다.

난데없이 외래 키 제약 조건 검사를 왜 얘기하냐면…

외래 키 검사의 경우 관련 테이블에 대해 공유 읽기 전용 잠금(LOCK TABLES READ)이 수행됩니다.” 라고 공식문서에 써있다.

즉, 외래 키 제약조건으로 인해서, 기본적으로 외래 키 제약 조건을 체크하는 InnoDB의 경우, 데이터 정합성을 위한 잠금이 연관된 테이블로 전파되면서 데드락이 발생할 수 있다.

예를 들어, 우리의 경우 cabinet_id(42)를 FK로 갖는 lent_history(id = 1)의 새로운 로우를 INSERT할 때, 해당 트랜잭션 세션 내에서는 id=1인 lent_history row에 대해서 X Lock을, cabinet 테이블의 cabinet_id가 42인 레코드에 대해서 S Lock을 획득하는 것이다..!!


데드락 살펴보기

기존에는 Lock을 획득하지 않는 SELECT, 단순한 UPDATE로 커밋 이후의 버전 충돌로 해결될 줄 알았다.

하지만 위의 내용을 살펴보면, **‘외래 키 제약조건으로 인한 잠금 전파’**로 인해 S Lock과 X Lock 획득 시도로 인해 데드락이 발생하는 것으로 의심해볼 필요가 있다.

이를 알아보기 위해, 테스트를 진행하는 MariaDB 컨테이너에서 데드락 로그를 파보았다.

# 번잡한 정보들은 생략하였다.
# show engine innodb status;
| InnoDB |      | 
=====================================
2023-06-06 00:04:47 0xffff90b9c130 INNODB MONITOR OUTPUT
=====================================
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-06-05 23:56:23 0xffff90a25130
*** (1) TRANSACTION:
TRANSACTION 1960, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 340, OS thread handle 281473109221680, query id 2819 172.26.0.1 root Updating
update cabinet set cabinet_place_id=6, col=1, row=0, lent_type='SHARE', max_user=3, memo=null, status='AVAILABLE', status_note=null, title=null, user_count=2, version=18, visible_num=12 where cabinet_id=12 and version=17
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 7 page no 3 n bits 184 index PRIMARY of table `test_db`.`cabinet` trx id 1960 lock_mode X locks rec but not gap waiting
Record lock, heap no 108 PHYSICAL RECORD: n_fields 15; compact format; info bits 0

*** (2) TRANSACTION:
TRANSACTION 1963, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 337, OS thread handle 281473108300080, query id 2821 172.26.0.1 root Updating
update cabinet set cabinet_place_id=6, col=1, row=0, lent_type='SHARE', max_user=3, memo=null, status='AVAILABLE', status_note=null, title=null, user_count=2, version=18, visible_num=12 where cabinet_id=12 and version=17

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 7 page no 3 n bits 184 index PRIMARY of table `test_db`.`cabinet` trx id 1963 lock mode S locks rec but not gap
Record lock, heap no 108 PHYSICAL RECORD: n_fields 15; compact format; info bits 0

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 7 page no 3 n bits 184 index PRIMARY of table `test_db`.`cabinet` trx id 1963 lock_mode X locks rec but not gap waiting
Record lock, heap no 108 PHYSICAL RECORD: n_fields 15; compact format; info bits 0

*** WE ROLL BACK TRANSACTION (2)

첫 번째 로그 - T1 (트랜잭션 ID: 1960):

  • 이 트랜잭션은 cabinet 테이블의 레코드를 업데이트하려고 한다.
  • 해당 테이블에서 **cabinet_id**가 12이고 **version**이 17인 레코드에 대한 업데이트를 시도한다.
  • 이 트랜잭션은 **cabinet_id가 12인 레코드**에 X Lock 을 요청하고 있다.
  • 그러나, 이 레코드에는 이미 다른 트랜잭션(1963)이 **S Lock**을 보유하고 있다.
  • 따라서, 이 트랜잭션은 레코드 락을 기다리고, 다른 트랜잭션의 완료를 기다린다.

두 번째 로그 - T2 (트랜잭션 ID: 1963):

  • 이 트랜잭션도 cabinet 테이블의 레코드를 업데이트하려고 한다.
  • 해당 테이블에서 **cabinet_id**가 12이고 **version**이 17인 레코드에 대한 업데이트를 시도한다.
  • 이 트랜잭션이 먼저 **S Lock**을 보유하고 있다. (HOLDS THE LOCK(S))
  • 그러나, 이 트랜잭션은 **X Lock**을 요청하고 있다. (WAITING FOR THIS LOCK TO BE GRANTED)
  • 이는 자신이 **보유하고 있는 S Lock**에서 **X Lock으로 락 업그레이드를 시도**하는 것을 의미한다.

정리하면, T1은 (1)T2가 S Lock을 걸어 놓은 (2)cabinet record의 X Lock을 얻기 위해 대기하고 있고, (3)T2는 T1이 획득할 X Lock을 얻기 위해 대기하는 교착 상태에 빠지는 것이다.

 

(TMI)근데 왜 트랜잭션(1960)은 S Lock 획득을 하지 않은 것이지?

그렇다고 한다

중간 정리

너무 많은 헤매임이 있었다. 천천히 정리해보자.

  1. 동시 대여 문제를 해결하기 위해, 낙관적 락을 적용하였다.
  2. 낙관적 락은, version 컬럼이 있는 엔티티(Cabinet)에 변경사항이 있어야 하므로 userCount라는 컬럼을 추가했다.
  3. 매 대여 시마다, userCount가 바뀌므로(UPDATE), Version이 변경된다.
  4. 처음 성공한 트랜잭션이 커밋되고, 그 이후에 버전 충돌이 일어나 다른 트랜잭션들은 롤백된다.
  5. 하지만 데드락이 발생한다. → 그 이유는, lent_history의 row를 insert하고(여기까지 문제없음), 외래 키 제약조건으로 연관되어 있는 cabinet row의 값을 READ(S Lock 획득), UPDATE(X Lock)할 때 상호 대기 상태에 놓여있게 되기 때문이다.

그림으로 살펴보면 다음과 같다.

다시 해결해보기 - 비관적 락과 낙관적 락

비관적 락으로 해결하는 방법은 꽤 간단하다.

애초에 S Lock을 걸지 못하게, 처음부터 대여 시에 X Lock을 획득한 상태로 처리되게끔 하는 것이다. 이런 식으로 진행한다면, X Lock을 획득하고자 하는 후속 트랜잭션들이 차례차례 처리될 것이다.

@Lock(LockModeType.PESSIMISTIC_WRITE) //<---- 추가된 annotation
	@Query("SELECT c "
			+ "FROM Cabinet c "
			+ "WHERE c.cabinetId = :cabinetId")
	Optional<Cabinet> findByIdForUpdate(Long cabinetId);

기존의 대여 서비스에서 Cabinet 엔티티를 CabinetRepository에서 받아오는 메서드를, 위와 같이 @Lock을 걸어주면 각 세션에서 X Lock을 획득하여 이용할 수 있다.

물론, 격리 수준을 더 높게 설정해도 비슷하겠지만, 필요한 부분에 대해서 예외적으로 잠금을 통해 처리한다면 평소에 잠금으로 인한 비용을 더 줄일 수 있을 것이다.

@Override
	public void startLentCabinet(Long userId, Long cabinetId) {
		System.out.println("startLentCabinet!!");
		Date now = new Date();
//		Cabinet cabinet = cabinetRepository.getCabinet(cabinetId);
		Cabinet cabinet = cabinetRepository.getCabinetForUpdate(cabinetId); // <---- @Lock이 설정된 Cabinet
		User user = userRepository.getUser(userId);
		int userActiveLentCount = lentRepository.countUserActiveLent(userId);
		List<BanHistory> userActiveBanList = banHistoryRepository.findUserActiveBanList(userId,
				DateUtil.getNow());

MySQL에서 사용하는 FOR UPDATE와 동일하게, 해당 읽어온 레코드에 대해서 X Lock을 부여한 상태로 대여 로직을 진행하게 되면, 트랜잭션 하나하나에서 해당 Cabinet에 대한 X Lock을 대기하고, 대기하는 순서대로 처리된다.

getCabinet을 getCabinetForUpdate로 변경해서, 해당 트랜잭션 세션에서 Cabinet 엔티티에 대해 X Lock을 획득하도록 설정한다.

@Test
	void 동시_대여_테스트_대여자_존재() throws InterruptedException {
		// 3명이 동시에 3인 상한의 공유 사물함을 대여하는 경우
		// 대여 가능한 상황의 유저 ID = [23, 24, 25]
		Long user1 = 23L;
		Long user2 = 24L;
		Long user3 = 25L;

		// 대여하려는 (1/3) 상태의 공유 사물함 ID =  12
		Long cabinetId = 12L;

		// 스레드를 이용해, 동시적으로 대여를 시도한다.
		CompletableFuture<Void> future1 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user1, cabinetId));
		CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user2, cabinetId));
		CompletableFuture<Void> future3 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user3, cabinetId));

		try {
			CompletableFuture.allOf(future1, future2, future3).join();
		} catch (Exception e) {
			e.printStackTrace();
		}

		// 비관적 락을 통해, 동시적 대여가 X Lock을 획득하는 순으로 처리된다. (3/3)
		assertEquals(3, lentRepository.findAllActiveLentByCabinetId(cabinetId).size());
	}
// 기존의 Future가 꽤 오래된 라이브러리인 것을 찾아보다가 알게 되어서, 최신 라이브러리인 CompletableFuture로 변경했다.

낙관적 락과는 다르게, 요청되는 사항들에 대해서 순차적으로 처리하므로, 3명이 꽉 차고 끝나는 것을 확인할 수 있다.

그림으로 설명하자면 아래와 같다.


낙관적 락을 사용하여 해결해보자.

이 문제를 해결하려면, S Lock과 X Lock 획득요청으로 인한 데드락을 막아야하므로, S Lock이나 X Lock이 걸리는 상황을 제거해야 할 것이다.

하지만, UPDATE를 하는 레코드에 대해서 X Lock은 수반되므로, X Lock을 배제할 수는 없고, S Lock을 배제하여야 한다.

이를 위해서는, S Lock을 사용하지 않게끔 설정하는 방식을 생각해보아야 한다.

다음과 같은 방법을 생각해보자.

  1. lent_history의 cabinet에 대한 외래키 제약 조건을 포기한다. → 외래키 제약조건으로 인해서, cabinet의 레코드까지 S Lock이 전파된 것이므로, 외래키로 이어진 연관관계를 끊으면 S Lock이 걸리지는 않을 것이다. 하지만, 이는 데이터 정합성을 위한 부분을 별도적으로 처리해주어야 하는 방식임과 동시에, RDB의 큰 기능을 포기하는 것으로 느껴져서 직관적으로 썩 좋은 방법같지는 않다.
  2. S Lock과 X Lock이 같이 요청되지 않게끔 코드를 재구성한다. → 사실 이렇게 하는 방법을 생각하지 못하고 있다가, 문득 떠올렸다. 단순히 DB의 구조로 인한 방식이 문제라기보다는, 여러 가지 코드적인 부분에서 조정하여서 사용할 수 있을 것이라는 생각이 들었다.

1번에 대한 생각은 접었으니, 코드를 바꿔보자.

@Override
	public void startLentCabinet(Long userId, Long cabinetId) {
		Date now = new Date();
		Cabinet cabinet = cabinetExceptionHandler.getCabinet(cabinetId); // <---- 지금은 Non-Locking이지만, Commit시에 S Lock이 걸릴 것이다.
//		Cabinet cabinet = cabinetExceptionHandler.getCabinetForUpdate(cabinetId);
		User user = userExceptionHandler.getUser(userId);
		int userActiveLentCount = lentRepository.countUserActiveLent(userId);
		List<BanHistory> userActiveBanList = banHistoryRepository.findUserActiveBanList(userId,
				DateUtil.getNow());

		// 대여 가능한 유저인지 확인
		lentExceptionHandler.handlePolicyStatus(
				lentPolicy.verifyUserForLent(user, cabinet, userActiveLentCount,
						userActiveBanList));
		List<LentHistory> cabinetActiveLentHistories = lentRepository.findAllActiveLentByCabinetId(
				cabinetId);

		// 대여 가능한 캐비넷인지 확인
		lentExceptionHandler.handlePolicyStatus(
				lentPolicy.verifyCabinetForLent(cabinet, cabinetActiveLentHistories, now));

		// 캐비넷 상태 변경
		cabinet.increaseUserCount();
		cabinet.specifyStatusByUserCount(cabinet.getUserCount()); // <---- 사물함 엔티티의 상태 변경 => UPDATE 예정
		cabinetRepository.saveAndFlush(cabinet); // <---- JPA에서 제공하는 기본 메서드, 실행되는 부분에서 알아서 동기화 해준다.

		Date expiredAt = lentPolicy.generateExpirationDate(now, cabinet,
				cabinetActiveLentHistories);
		LentHistory lentHistory = LentHistory.of(now, userId, cabinetId);

		// 연체 시간 적용
		lentPolicy.applyExpirationDate(lentHistory, cabinetActiveLentHistories, expiredAt);
		lentRepository.save(lentHistory);
	}

기존과 다른 부분은, UPDATE 쿼리를 수행할(X Lock이 걸릴) 부분에 대해서, repository.save()가 아닌, repository.saveAndFlush()를 추가했다는 점이다.

**save()**는 엔티티를 영속성 컨텍스트(작은 커밋)에 추가하고, 트랜잭션을 커밋할 때까지 데이터베이스에 실제 저장되지 않는 것이고(지연 저장), **saveAndFlush()**는 엔티티를 저장하고, 영속성 컨텍스트를 즉시 플러시하여 데이터베이스에 변경 내용을 동기화하는 메서드라는 것이다. 즉, 트랜잭션의 과정에서 맨 나중에 UPDATE하는 것이 아닌, 미리 UPDATE를 먼저 진행하는 것이다. → 이 방법을 이용하면, 마지막에 S Lock 이후에 X Lock을 요청하는 기존의 상황이 아닌, 미리 UPDATE를 진행하여 외래 키 제약 조건으로 인한 cabinet의 S Lock과 update로 인한 X Lock이 겹칠 일이 없게끔 할 수 있고, 이를 통해서 데드락이 발생하지 않게 하는 것이다.

 

saveAndFlush()를 이용하면 데드락이 걸리지 않는 이유

간단히 말하자면, 미리 동기화를 통해(X Lock은 DB 레벨에서 수행, JPA 설정에 따라 다름) 변경 내용을 업데이트 해놓는 것이고, 이로 인해서 메서드가 끝나는 시점에 JPA가 지연 저장을 하는 시점(Query를 여러개 보내는 시점)에 UPDATE를 실행하지 않으므로 X Lock을 사용하지 않게 되고, 데드락이 발생하지 않게 되는 것이다.

@Test
	void 동시_대여_테스트_대여자_존재() throws InterruptedException {
		// 3명이 동시에 3인 상한의 공유 사물함을 대여하는 경우
		// 대여 가능한 상황의 유저 ID = [23, 24, 25]
		Long user1 = 23L;
		Long user2 = 24L;
		Long user3 = 25L;

		// 대여하려는 (1/3) 상태의 공유 사물함 ID =  12
		Long cabinetId = 12L;

		// 스레드를 이용해, 동시적으로 대여를 시도한다.
		CompletableFuture<Void> future1 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user1, cabinetId));
		CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user2, cabinetId));
		CompletableFuture<Void> future3 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user3, cabinetId));

		try {
			CompletableFuture.allOf(future1, future2, future3).join();
		} catch (Exception e) {
			e.printStackTrace();
		}

		// 버전 충돌을 이용한 낙관적 락을 수행한다. -> 먼저 커밋된 하나만 성공한다. (2/3)
		assertEquals(2, lentRepository.findAllActiveLentByCabinetId(cabinetId).size());
	}

낙관적 락을 수행한 것에 알맞게, 먼저 커밋이 성공한 한명만 대여를 성공하고, 나머지는 실패했다. + 버전 충돌로 인한 ObjectOptimisticLockingFailureException이 발생한 것을 볼 수 있다.

이에 따라 버전 충돌에 대해 원하는 후 처리를 설정하면 될 것이다.

그림으로 보면 다음과 같다.


정리

간단하게 해결할 수 있는 동시성 문제인줄 알았으나, 막상 찾아보니 알아야 할 것들이 너무나 많았다.

하지만 치명적인 문제임과 더불어서 항상 정답은 없되 올바른 판단을 내려야하는 사항이다보니, 더 집중해서 해결해보려고 했다.

비록 이전의 대여/반납 방식은 기존 사용자들이 알아서 반납하고, 알아서 대여하는 방식이어서 낙관적 락이 더 유효했다.

하지만, 곧 바뀔 방식은 특정 시간에 대여 가능한 사물함이 개방되는 방식을 구상하고 있어서, 동시성 문제가 일어날 여지가 기존보다 높다.

더 고민해보고 낙관적 락을 적용할지, 비관적 락을 적용할지 결정할 것 같다.

장장 일주일에 거쳐서 테스트를 수십번 찍어보고, 무한 구글링과 GPT, 공식문서와 참고 도서를 왔다갔다 했는데, 제발 내가 이해한 게 맞았으면 좋겠다.

참고자료

동시 대여/반납 문제

동시성 문제

데이터베이스 | 동시성 제어 - 트랜잭션 격리 수준

트랜잭션 - 해시넷

JPA의 낙관적 락과 비관적 락을 통해 엔티티에 대한 동시성 제어하기

16. Isolation Level에 따른 트랜잭션 처리 방법

JPA 에서 낙관적 락(Optimistic-Lock)을 이용해 동시성 처리하기

JPA & Mysql 환경에서 데드락 관련 이슈

CannotAcquireLockException과 Deadlock

mysql general_log 설정하기

MySQL 낙관적 락과 데드락(dead lock) With JPA Hibernate

MySQL Bugs: #48652: Deadlock due to Foreign Key constraint

Java8 자바 안정적인 비동기 처리 - CompletableFuture

MySQL :: MySQL 8.0 Reference Manual :: 15.7.2.1 Transaction Isolation Levels

MariaDB Transactions and Isolation Levels for SQL Server Users

PRIMARY KEY 및 FOREIGN KEY 제약 조건 - SQL Server

복사했습니다!