Technology/MySQL

트랜잭션 격리수준 Serializable 에 대한 고찰

ikjo 2024. 2. 13. 04:18

트랜잭션 격리수준 Serializable, 왜 사용할까?

온보딩 당시 비지니스 로직을 살펴 보는 중 서비스 레이어 계층에서 스프링이 제공하는 트랜잭션 AOP 기능을 적용 시 트랜잭션 격리수준이 Serializable 로 설정 되어있는 것을 확인할 수 있었다. 참고로, 스프링이 제공하는 트랜잭션 AOP 기능은 개발자가 별도로 트랜잭션 격리수준을 설정하지 않을 경우 데이터소스의 기본 트랜잭션 격리수준을 따르게 된다.

 

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {

	// ...

    /**
	 * The transaction isolation level.
	 * <p>Defaults to {@link Isolation#DEFAULT}.
	 * <p>Exclusively designed for use with {@link Propagation#REQUIRED} or
	 * {@link Propagation#REQUIRES_NEW} since it only applies to newly started
	 * transactions. Consider switching the "validateExistingTransactions" flag to
	 * "true" on your transaction manager if you'd like isolation level declarations
	 * to get rejected when participating in an existing transaction with a different
	 * isolation level.
	 * @see org.springframework.transaction.interceptor.TransactionAttribute#getIsolationLevel()
	 * @see org.springframework.transaction.support.AbstractPlatformTransactionManager#setValidateExistingTransaction
	 */
	Isolation isolation() default Isolation.DEFAULT;
    
    // ...   
}

 

이때, 데이터베이스는 MySQL InnoDB 스토리지 엔진 기반의 테이블을 사용하고 있었기에, 별도로 트랜잭션 격리수준을 지정하지 않을 경우 기본적으로 Repeatable Read 를 취하게 된다.

 

https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html

 

그런데, 여기서 궁금한 점이 생겼다. "해당 코드에선 왜 기본적으로 설정된 트랜잭션 격리수준인 Repeatable Read 대신 Serializable 을 사용했을까?" 좀 더 구체적으로는, 트랜잭션 격리수준을 Serializable 로 적용하기 전에는 어떤 문제가 있었고 Serializable 을 적용함으로써 어떻게 해당 문제를 해결할 수 있었는지가 궁금했다.

 

 

트랜잭션 격리수준 Serializable 에 대해 알아보자!

일단 사수분들께 질문을 드리기 전에 내가 트랜잭션 격리수준 Serializable 에 대해 제대로 알고 있는지 점검하고자 했다. 

 

MySQL 공식문서를 살펴보면, Serializable 의 경우 Repeatable Read 의 특성을 그대로 따르면서 모든 평이한 SELECT 작업 시 공유 잠금(shared lock)을 사용한다는 것을 확인할 수 있다.

 

https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html

 

즉, 특정 레코드에 공유 잠금을 건 트랜잭션이 종료(커밋 or 롤백)될 때까지 다른 트랜잭션에서는 해당 레코드를 변경하지 못하게 되는 것이다. 이로 인해, Serializable 이 일반적으로 다른 트랜잭션 격리 수준 보다 동시 처리 성능이 떨어진다고 하는 것이다.

 

이제 이러한 특성을 가진 Serializable 을 왜 적용했었는지 확인해보았다.

 

 

동시성 이슈(Lost Update)를 해결하기 위한 트랜잭션 격리수준?

수소문 끝에 트랜잭션 격리수준을 Serializable 로 적용한 이유는 동시성 이슈를 해결하기 위한 것으로 확인되었다. 그런데, 여기서 의문이 들었다. 트랜잭션 격리수준을 Serializable 로 한다고 동시성 이슈를 해결할 수 있을까?

 

일단, 동시성 이슈에 대한 대표적인 사례인 '재고 이슈'를 예시로 생각해보자.

 

 

위 상황에서는 트랜잭션 격리수준을 Serializable 로 하지 않았을 때를 상정한 것이다. 위 시나리오를 보면 데이터베이스 상에서 동일한 레코드에 대해 변경 작업(재고 1 감소)을 시도하는 서로 다른 트랜잭션 A 와 트랜잭션 B 가 거의 동시에 처리될 때, 트랜잭션 B 의 변경작업이 유실(Lost Update)되는 이슈가 발생한다는 것을 예측할 수 있다.

 

이번엔 트랜잭션 격리수준을 Serializable 로 설정했을 때로 상정해보자.

 

 

위 상황은 앞선 상황에서 트랜잭션 격리수준을 Serializable 로 했을 뿐이다. 앞서 MySQL 공식문서에서 살펴봤듯이 평이한 SELECT 작업 시 공유 잠금을 사용하고 있는 것을 알 수 있다. 이때, 한 트랜잭션에서 특정 레코드에 공유 잠금을 걸었다면 다른 트랜잭션에서는 해당 레코드를 조회하거나 공유 잠금을 걸 수는 있으나, 쓰기 잠금이나 변경 작업은 할 수 없게 된다. (Lock waiting)

 

그렇기 때문에 트랜잭션 A 가 id = 5 에 해당하는 레코드에 대해 공유 잠금을 동반한 읽기를 했을 때, 트랜잭션 B 역시 해당 레코드에 대해 공유 잠금을 동반한 읽기를 할 수 있는 것이다. 하지만, 트랜잭션 A 가 해당 레코드에 대해 변경 작업을 하려는 순간 트랜잭션 B 가 건 공유 잠금 때문에 Lock waiting 에 빠지게 되며, 트랜잭션 B 역시 변경 작업 수행 시 앞서 트랜잭션 A 가 건 공유 잠금 때문에 Lock waiting 에 빠지게 된다. 즉, 데드락(Dead-lock)이 발생하게 된다.

 

InnoDB 스토리지 엔진의 경우, 내부적으로 잠금이 교착 상태에 빠지지 않았는지 체크하기 위해 잠금 대기 목록(Wait for list)을 관리한다. 이때, 별도의 데드락 감지 스레드가 주기적으로 해당 잠금 대기 목록을 검사해 교착 상태에 빠진 트랜잭션들을 찾아서 그 중 하나(또는 여러개)를 강제로 종료한다. 여기서 어느 트랜잭션을 먼저 강제 종료할 것인지를 판단하는 기준은 트랜잭션의 언두 로그 양인데, 적은 언두 로그 양을 가진 트랜잭션을 우선적으로 롤백시킨다.

 

결론적으로, 트랜잭션 격리수준을 Serializable 로 했을 때, 데드락을 발생시키고 다른 트랜잭션을 롤백시킴으로써 Lost Update 를 방지할 수는 있게 된다.

 

 

트랜잭션 격리수준 Serializable, 최선일까?

하지만, 데드락을 발생시키고 다른 트랜잭션을 롤백시킴으로써 Lost Update 를 방지하는 것이 좋은 방법일까 의문이 들었다. 아울러, 이러한 방법이 동시성 이슈를 해결하는 것이라고 말할 수 있는 것일까 의문이 들었다.

 

사실, 앞선 시나리오에서 발생할 수 있는 동시성 이슈의 근본적인 원인은 어떤 트랜잭션이 작업 중인 레코드에 대해 다른 트랜잭션에서도 동시 조회를 허용했기 때문이다. 즉, 트랜잭션 격리수준을 Serializable 로 하여 공유 잠금 읽기를 걸었다 한들 다른 트랜잭션에서 공유 잠금이 걸린 레코드를 조회할 수 있기에 이 데이터를 기반으로 Update 하는 행위 자체는 여전히 가능한 것이다. (물론, 동일한 또는 연관된 레코드에 대해 쓰기 작업을 수반하지 않거나 단순 조회 작업만 하는 트랜잭션이라면 동시 조회를 허용해도 상관 없을 수 있다.)

 

이미 한 트랜잭션이 재고 데이터를 수정하는 작업을 하고 있다면, 해당 재고 데이터는 불확실한 데이터이기에 애초에 다른 트랜잭션에서 읽어서는 안된다. 하지만, 트랜잭션 격리수준을 Serializable 로 설정하는 것은 해당 데이터를 읽고 그 다음 작업들까지 수행할 수 있기 때문에 불필요한 서버 리소스를 낭비하는 것에 불과하다. 어짜피 읽은 데이터는 불확실한 데이터이기에 이어서 진행하는 작업들은 의미가 없는(정합성에 맞지 않는) 작업들이며 쿼리가 데이터베이스까지 전달되고 데드락까지 발생하여 둘 중 한 트랜잭션은 롤백되기 때문이다.

 

아울러, 데드락으로 인해 롤백되어 희생되는 트랜잭션(victim)이 발생하는 것도 서버 입장에서 고민거리이다. 서버는 데드락으로 인해 해당 트랜잭션이 롤백되었을 때 발생하는 예외를 별도로 처리해주어야 하기 때문이다. 만일, 서버가 동시성 이슈가 발생할 때마다 클라이언트에 에러 응답을 반환하게 되면, 클라이언트 입장에서는 이러한 동시성 이슈가 발생할 때마다 불필요한 에러 응답을 받게 된다. 그리고 이는 클라이언트의 동일한 작업에 대한 재요청으로 이어질 확률이 높다.

 

또한, 트랜잭션 격리수준을 Serializable 로 지정함으로써 동시성 제어 대상이 아닌 테이블에도 락이 걸리기 때문에 이는 데드락 감지 스레드의 성능을 저하시켜 데이터베이스 병목의 원인이 될 수도 있다.

 

 

동시성 이슈를 해결하기 위한 근본적인 방법

트랜잭션은 근본적으로 동시성을 제어하기 위한 것이 아니라 작업의 완전성을 보장하기 위한 것이다. 즉, 논리적인 작업들을 모두 완벽하게 처리하거나, 에러가 발생했을 경우 원 상태로 복구해서 작업의 일부만 적용되는 Partial update 가 발생하는 것을 방지하기 위한 것이다.

 

https://dev.mysql.com/doc/refman/8.0/en/glossary.html

 

반면에, 동시성을 제어하기 위해서 필요한 것은 잠금(Lock)이다. 잠금은 여러 커넥션에서 동시에 동일한 자원(레코드, 테이블 등)을 요청할 경우 순서대로 한 시점에는 하나의 커넥션만 변경할 수 있게 해주는 역할을 한다.

 

https://dev.mysql.com/doc/refman/8.0/en/glossary.html

 

앞서 계속 언급해왔던 트랜잭션 격리수준은 하나의 트랜잭션 내에서 또는 여러 트랜잭션 간의 작업 내용을 어떻게 공유하고 차단할 것인지를 결정하는 레벨인데, 이때, Serializable 에 해당하는 격리수준은 조회한 레코드에 대해 공유 잠금을 걸음으로써, 다른 트랜잭션에서 해당 데이터를 수정하지 못하도록 한다. 잠금을 사용했기에, (실제로 Lost Update 자체는 막을 수 있었듯이) 동시성을 제어할 수 있는 것이라고 생각할 수 있지만 앞서 언급했었던 side effect 가 불가피하다.

 

동시성 이슈를 해결하기 위한 방법은 굉장히 많고, 상황에 따라 그 방법이 천차만별이다.

 

가장 간단한 방법으로는 배타 잠금(exclusive lock)이 있다. 배타 잠금은 공유 잠금과 마찬가지로 SELECT 작업 시 사용하는 것인데, 공유 잠금과 달리 배타 잠금이 걸린 레코드에는 다른 트랜잭션에서 쓰기 작업뿐만 아니라 조회 작업도 허용하지 않는다. 아울러, 공유 잠금과 배타 잠금을 걸 수도 없다.

 

 

위 상황은 앞선 시나리오에서 배타 잠금을 적용한 경우이다. 이전과 달리 트랜잭션 B 는 트랜잭션 A 의 작업이 끝날 때까지 기다리고 있는 것을 볼 수 있다. 이를 통해 재고가 순차적으로 1씩 차감되리라(5 → 4 → 3) 기대해볼 수 있다. 앞서 트랜잭션 격리수준을 Serializable 로 적용했을 때와 달리 트랜잭션 B 는 불필요한 작업을 수행하지 않으며, 데드락을 유도하지 않아 둘 중 한 트랜잭션이 롤백될 일도 없다. 즉, 서버 리소스를 보다 효율적으로 사용할 수 있게 되는 것이다.

 

참고로, 공유 잠금이나 배타 잠금은 MySQL 수준에서 제공하는 잠금 기능으로 비관적 락(Pessimistic lock)이라고도 부른다. race condition 이 발생할 일이 거의 없다고 판단되는 등 경우에 따라서는 애플리케이션 수준에서 잠금 처리를 구현하는 낙관적 락(Optimistic lock)을 적용해볼 수도 있다. 또한, 분산 데이터베이스 환경에서는 MySQL 에서 제공하는 네임드 락(Named lock) 이나 Redis 를 활용한 분산 잠금 기법을 검토해야한다.

 

 

참고자료

  • 위키북스 "Real MySQL 8.0 - 1"