본문으로 건너뛰기

트랜잭션이 이상해요..(feat. 트랜잭션 격리 수준, Isolation Level)

· 약 8분
Juhyeon Oh

내가 겪언던 문제들

이전에 회사에서 트랜잭션 격리 수준에 걸려서 고생한 적이 있다. 간단한 예를 들면 재고를 체크하는 부분에서 걸렸는데 아래와 같다.

1. 재고 조회
2. FOR 구매반복 {
3. 아이템 갯수 감소
4. 아이템 네이밍 숫자 증가
5. 아이템 생성
6. 재고 감소
7. 아이템 업데이트
8. 재고 업데이트
}

위에서 전체 재고를 가져온다. 그리고 구매를 반복하는데 구매하는 만큼 아이템 갯수를 줄여주고 아이템에 붙는 네이밍 숫자를 늘려준 다음 아이템을 생성한다. 마지막으로 아이템과 재고를 업데이트 해주고 새로운 반복을 진행한다.

새로운 반복을 진행한다.여기에서 문제가 발생한다. 가져온 재고가 만약 3개다. 첫 번째 반복문을 돌면서 재고가 1개 줄어들었을 때 과연 두 번째 반복문은 그것을 기억하고 있었나?

아니었다. 이게 뭔가 했는데 트랜잭션 격리 수준을 공부하면 알 수 있는 문제였다.

트랜잭션 격리 수준 이전에 알아야 할 것들

트랜잭션

그럼 트랜잭션 격리 수준은 무엇일까?를 궁금해 하기 전에 트랜잭션의 성질인 ACID를 알아야 한다. 아니 그 전에 트랜잭션은 무엇일까? 여기서부터 시작하는 게 옳겠다.

트랜잭션은 DBMS에서 수행되는 데이터베이스의 상태를 변화시키기 위해 수행되는 논리적인 작업 단위라고 말할 수 있다.

1. 재고 조회
2. FOR 구매반복 {
3. 아이템 갯수 감소
4. 아이템 네이밍 숫자 증가
5. 아이템 생성
6. 재고 감소
7. 아이템 업데이트
8. 재고 업데이트
}

이 예시를 다시 가져오자. 예를 들어서 내가 아이템 3개를 구매했다. 그럼 저 구매 반복문을 3번 돌아 쿼리가 모두 실행되어 데이터베이스에 적용됨이 마땅하다.

하지만 첫 번째 반복은 성공해서 아이템 하나가 정상적으로 생성되었으나 그 이후 두 번째 반복에서 실패해 두 번째 아이템부터 생성이 되지 않았다고 하자. 그럼 이 구매 실행은 어떻게 되어야 할까?

  1. 전체가 실패해야 한다.
  2. 첫 번째 아이템은 구매 성공, 이후는 실패

정답은 1번이다. 이런 트랜잭션의 성질은 이미 공부하기 좋게 정의되어 있다. ACID라 불리는 트랜잭션의 성질이다.

트랜잭션의 성질 ACID

  • Atomicity 원자성
  • Consistency 일관성
  • Isolation 격리, 고립성
  • Durability 지속성

각각 성질은 무엇을 뜻할까?

원자성은 하나의 트랜잭션이 모두 성공하거나 모두 실패해야 하는 성질을 말한다. 즉, 위에서 예시로 실행한 구매 작업은 전체가 실패하거나 전체가 성공해야 한다는 말이다. 때문에 위에 예시의 정답은 1번이 되겠다.

일관성은 트랜잭션이 완료된 이후에도 데이터베이스의 상태가 일관되어야 한다는 말인데 데이터베이스의 무결성을 말한다. 구매를 하고 구매 장부를 기록한다 했을 때 구매에 대한 구매 장부 데이터가 빠지면 안 된다. 이런 일관성을 말하기도 하고 기본 키, 왜리 키 같은 제약 조건을 지키면서 트랜잭션을 수행하는 것을 의미하기도 한다.

고립성은 하나의 트랜잭션이 작업을 수행중일 때 다른 트랜잭션이 끼어들지 못 하도록 보장해 트랜잭션끼리의 영향이나 간섭이 없도록 하는 것이다.

지속성은 트랜잭션이 성공적으로 수행되고 난 뒤에 트랜잭션에 대한 로그가 남아 결과가 안정적으로 보존되어야 하고 영구적으로 반영되어야 한다는 말이다.

트랜잭션은 commitrollback을 통해 하나의 작업을 성공적으로 완료 혹은 작업 취소를 결정할 수 있으며 Auto commit 옵션이 적용이 Default인 경우가 있어 잘 체크하고 사용해야 한다.

트랜잭션 격리 수준

트랜잭션 격리 수준의 정의들

트랜잭션 격리 수준은 비슷하거나 똑같은 말이지만 이렇게 정의한다.

  • 동시에 여러 트랜잭션이 처리될 때 트랜잭션끼리 얼마나 서로 고립되어 있는지를 나타내는 것.
  • 특정 트랜잭션이 다른 트랜잭션에 변경한 데이터를 볼 수 있도록 허용할지 말지 결정하는 것.
  • 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 여부를 결정하는 것.
  • 여러 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 말지를 결정하는 것.

요약하자면 트랜잭션이 서로 고립된 정도를 나타내며 다른 트랜잭션에서 변경한 데이터를 볼 것인지 결정하는 것.쯤 되겠다.

트랜잭션 격리 수준 구분

그럼 그 고립된 정도를 나타낸다는 지표는 어떻게 구분되는지 보자면 아래와 같다.

트랜잭션 격리 수준은 위에서 아래로 높아진다고 보면 된다.

  • Read uncommited (0)
  • Read committed (1)
  • Repeatable read (2)
  • Serializable (3)

주의해야 하는 부분

트랜잭션 격리 수준이 높아질 수록 격리성으로 인한 이슈는 적게 발생하겠지만 동시 처리 성능이 떨어지기 때문에 성능 요구사항과 데이터 무결성을 고려해 적절한 수준을 찾아야 한다.

간단히 격리 수준에 대해서 알아보자면 아래와 같다.

Read uncommitted

Read uncommitted는 commit이나 rollback 상관 없이 다른 트랜잭션에서 조회할 수 있다. 이는 데이터 정합성에 문제가 있기 때문에 권장되지 않는다.

Read committed

DBMS에서 가장 많이 사용되는 격리 수준으로 트랜잭션 변경 내용이 commit 되어야만 다른 트랜잭션에서 조회할 수 있다.

Repeatable read

트랜잭션이 시작되기 전에 커밋된 내용에 대해서만 조회할 수 있는 격리 수준이다.

Serializable

InnoDB에서 순수한 SELECT는 어떤 잠금도 없이 동작하는데 이때 공유 잠금을 설정하여 다른 트랜잭션에서 레코드 변경을 할 수 없게 만든다. 즉, SELECT가 사용되는 데이터들에 Shared Lock을 걸기 때문에 동시 처리 능력이 떨어지고 성능 저하가 발생된다.

트랜잭션 격리성으로 생길 수 있는 이슈들

Dirty Read

Read Uncommitted 격리 수준에서 다른 트랜잭션이 아직 커밋되지 않은 데이터를 읽을 수 있는 이슈이다.

Non-Repeatable Read

Read committed 단계에서 다른 트랜잭션이 커밋한 데이터를 읽을 수 있는 것을 의미한다. 한 트랜잭션에서 쿼리로 2번 이상 조회했을 때 그 결과가 상이한 이슈를 말한다.

Phantom Read

Repeatable read 격리 수준에서 한 트랜잭션이 동일한 쿼리를 두 번 실행할 때, 두 번째 실행에서는 다른 트랜잭션이 커밋한 데이터를 읽을 수 있는 이슈이다.

결국 해결한 방법

결국 해결한 방법 조회 쿼리에 트랜잭션을 적용해 주었다.

func (r userRepositoryImpl) FindByNickname(ctx context.Context, req *ent.User) (*ent.User, error) {
return r.User.Query().
Where(user.NicknameEQ(req.Nickname)).
Only(ctx)
}

Golnag 예시를 보면 지금 이 Repository 로직은 트랜잭션을 사용하지 않는 조회이다.

func (r userRepositoryImpl) FindByNickname(ctx context.Context, req *ent.User, tx *ent.Tx) (*ent.User, error) {
return tx.User.Query().
Where(user.NicknameEQ(req.Nickname)).
Only(ctx)
}

이 Repository 로직은 트랜잭션을 사용하는 조회 로직이다. Spring에서 @Transactional이라는 어노테이션을 사용해서 트랜잭션을 관리하지만 Golang의 Ent Orm에서는 Tx라는 트랜잭션을 직접 생성하여 주입해 주어야 한다.

조회 부분에 추가되는 트랜잭션은 Repeatable read 격리 수준에서 차이가 나기 때문에 이를 적용해 주어 해결했다.