[데이터베이스] 트랜잭션 격리 수준(Isolation Level) vs. 락(Lock)

Ooleem·2026년 1월 19일

DeepQuest

목록 보기
2/2
post-thumbnail

DeepQuest TRD 리뷰 중 언급되었던 낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock), 그리고 데이터베이스의 트랜잭션 격리 수준에 대해 좀 더 알아보고자 한다. 또한, 이번에는 왜 낙관적 락을 선택했는지에 대해서도 좀 더 자세히 서술하고자 한다.

트랜잭션 격리 수준 (Isolation Level)

트랜잭션 격리 수준이란?

  • "다른 사람이 작업 중인 데이터를 어디까지 보여 줄 것인가?" "내가 읽을 때 남의 작업이 보이느냐 마느냐?"에 초점을 맞춘 읽기 일관성 전략
  • 여러 사람이 동시에 같은 데이터를 보거나 고칠 때 "어디까지 허용해 줄 것인가"에 대한 규칙
  • 데이터베이스에서 자체적으로 결정

1. READ UNCOMMITED (가장 낮은 단계)

"남이 작업 중인 임시 데이터도 다 보여!"

이 수준에서는 다른 사람이 수정 중이고 아직 '저장(Commit)'하지 않은 데이터도 읽을 수 있다.

발생하는 문제: Dirty Read (오염된 읽기)

상황: 철수가 통장 잔액 10,000원을 0원으로 바꾸는 작업 시작 (아직 완료 버튼은 안 누름)

사건: 그 찰나에 영희가 철수의 잔액을 조회하니 0원으로 보임

반전: 철수가 마음이 변해 작업 취소(Rollback), 철수의 잔액은 다시 10,000원이 됨

결과: 영희는 존재하지도 않았던 '0원'이라는 가짜 데이터를 본 셈

2. READ COMMITED (가장 많이 쓰는 단계)

"확정된(Commit) 데이터만 보여줄게. 하지만 내 마음은 갈대 같아"

가장 일반적인 설정으로, 누군가 수정을 완료해서 '저장' 버튼을 누른 데이터만 읽을 수 있다. 하지만 한 트랜잭션 내에서 같은 데이터를 두 번 조회할 때 결과가 달라질 수 있다.

발생하는 문제: Non-Repeatable Read (반복 불가능한 읽기)

상황: 영희가 철수의 잔액을 조회, 10,000원이 출력됨

사건: 그 사이 철수가 편의점에서 과자를 사 먹고 잔액을 5,000원으로 바꾼 뒤 '저장'

문제: 영희가 같은 화면에서 새로고침을 누르니 갑자기 5,000원으로 바뀜

결과: "어? 아까 분명히 만 원이었는데 왜 바뀌었지?" -> 한 작업 안에서 일관성 깨짐

3. REPEATABLE READ (MySQL 기본값)

"내가 한 번 본 데이터는 내가 끝날 때까지 절대 안 변해!"

트랜잭션이 시작될 때의 데이터 상태를 기억해서, 그 트랜잭션이 끝날 때까지는 남이 데이터를 백번 고쳐도 나에게는 똑같은 모습으로 보인다.

발생하는 문제: Phantom Read (유령 읽기)

상황: 영희가 '우리 반 전체 학생 수'를 조회, 10명 출력

사건: 그 사이 선생님이 전학생 1명을 추가하고 '저장'

문제: 영희가 다시 학생 수를 조회하면 여전히 10명으로 보이지만, 학생 명단을 하나씩 수정하려고 하니 처음엔 없었던 11번째 학생(유령)이 갑자기 나타나 수정되는 등의 기괴한 현상 발생

결과: 데이터의 '값'은 고정되지만, '새로 생기거나 사라지는 레코드'까지는 막지 못함

4. SERIALIZABLE (가장 높은 단계)

"한 줄 서기! 앞사람 끝날 때까지 아무도 손대지 마!"

가장 엄격한 단계로, 누군가 데이터를 읽기만 해도 다른 사람은 그 데이터를 수정하거나 추가할 수 없다.

발생하는 문제: 매우 성능이 낮아짐

위에서 말한 모든 문제(Dirty, Non-Repeatable, Phantom Read)가 해결되긴 하지만, 한 사람이 데이터를 조회하고 있을 경우 다른 사람은 데이터 조회조차 불가능해지기 때문에 성능이 매우 느려짐

락이 필요한 이유 : 두 번의 갱신 분실 문제 (Lost Update)

앞서 설명했듯, 트랜잭션 격리 수준은 읽기 일관성에만 관여하기 때문에, 두 사용자가 한 대상을 동시에 수정할 경우에 대해서는 방어해주지 않는다.

Lost Update가 발생하는 상황

현재 재고가 10개인 상품이 있고, 사용자 A와 B가 동시에 이 상품을 1개씩 사려고 할 경우를 생각해 보자.

단계별 흐름

사용자 A (T1 시작): 재고 조회 -> 10개 출력

사용자 B (T2 시작): 재고 조회 -> 10개 출력 (둘 다 10개라고 믿게 됨)

사용자 A: 1개를 샀으니 10 - 1 = 9로 계산하고, 재고를 9로 업데이트 후 커밋

사용자 B: 1개를 샀으니 10 - 1 = 9로 계산하고, 재고를 9로 업데이트 후 커밋

결과

두 사람이 각각 1개씩 샀으므로 재고는 8개가 되어야 하지만, 최종 데이터베이스에는 9개가 남게 된다. 사용자 A가 수정한 내용이 사용자 B의 덮어쓰기에 의해 분실(Lost)된 것이다.

왜 격리 수준은 이걸 못 막는가?

DB는 보통 업데이트를 할 때 현재 시점의 실제 값을 기준으로 처리하는 게 아니라, 어플리케이션이 계산해서 보낸 값(SET stock = 9)을 그대로 쓰게 된다.
REPEATABLE READ의 경우 "트랜잭션을 시작했을 때 본 데이터를 끝까지 유지"하므로, 사용자 B는 트랜잭션 내내 재고를 10으로 보게 되며, 따라서 10-1=9로 계산하여 업데이트하게 된다.
READ COMMITTED의 경우 "커밋된 데이터만 읽기"이기 때문에, 위 상황에서 사용자 B가 만약 업데이트를 날리기 직전에 다시 한번 조회를 했다면, A가 커밋한 직후이므로 9를 보게 되며, "어? 바뀌었네?" 하고 8로 다시 계산할 기회라도 생기게 된다. 하지만 보통 다른 사람이 동시에 업데이트를 하고 있는지 알 방법이 없으므로 곧바로 업데이트를 날리게 되고, 그대로 Lost Update가 발생하게 된다.

락 (Lock)

락이란?

  • "내 작업이 남의 작업을 덮어쓰느냐 마느냐"에 초점을 맞춘 쓰기 일관성 전략
  • 동일한 데이터의 동시 수정을 막기 위한 전략
  • 애플리케이션 또는 ORM 레벨에서 선택 (실제로 데이터베이스의 기능을 이용해 잠금을 거는 것이 아니라, 애플리케이션 레벨에서 논리적으로 관리하는 방식)

비관적 락 (Pessimistic Lock)

"데이터는 언제든 수정될 수 있어! 일단 문부터 잠그자."

충돌이 발생할 것이라고 미리 가정하고, 데이터를 읽을 때부터 아예 잠금(Lock)을 걸어버리는 방식으로, 내가 데이터를 다 쓸 때까지 아무도 손대지 못하게 하는 것이다. (SERIALIZE와 비슷한 효과)

특징

방법: 데이터베이스의 SELECT FOR UPDATE 구문 등을 사용하여 로우(Row)에 락을 검

장점: 데이터 정합성이 완벽하게 보장되므로, 충돌이 잦은 환경에서 안전함

단점: 다른 사용자가 대기해야 하므로 성능(처리량)이 떨어질 수 있고, 서로가 서로의 자원을 기다리는 데드락(Deadlock) 상태에 빠질 위험이 있음

낙관적 락 (Optimistic Lock)

"대부분의 경우 별 일 없을 거야. 만약 충돌하면 그때 해결하자!"

낙관적 락은 실제로 데이터베이스의 기능을 이용해 잠금을 거는 것이 아니라, 어플리케이션 레벨에서 논리적으로 관리하는 방식이다. 충돌이 거의 없을 것이라고 가정해서 일단 수정하고, 마지막에 저장할 때 "내가 처음에 봤던 그 데이터가 맞나?"를 확인한다.

특징

방법: 데이터에 version 컬럼을 추가하여 관리

  1. 데이터를 읽을 때 version이 1인 것을 확인
  2. 수정 후 저장할 때 WHERE version = 1 조건으로 업데이트
  3. 그 사이 누군가 수정해서 version이 2가 되었다면 업데이트 실패

장점: 실제로 락을 걸지 않으므로 비관적 락보다 성능이 좋음

단점: 충돌이 빈번하게 발생하면 계속해서 재시도(Retry) 로직을 수행해야 하므로 오히려 성능이 저하될 수 있음

핵심 : 왜 이번에는 낙관적 락을 선택했는가?

보통 결제 기능의 경우 데이터 정합성이 매우 중요하므로 비관적 락을 선택한다고 알려져 있고, 그렇게만 알고 이번 포인트 시스템 TRD를 작성할 때 비관적 락을 적용하도록 하였다.
하지만, 지금 상황과 맥락에서는 비관적 락이 적합하지 않은 부분들이 있었다.

1. Prisma의 경우 FOR UPDATE를 지원하지 않으며, 락 순서가 꼬여 데드락이 발생하는 경우도 감지해주지 않는다
: Prisma의 철학은 "DB에 의존적인 기능보다는 어플리케이션 레벨에서 안전하게 처리하는 것"에 가깝다고 한다. 만약 굳이 비관적 락을 적용하고자 한다면, 다음과 같이 복잡하게 작성해야 한다.

const result = await prisma.$transaction(async (tx) => {
  // 1. Raw SQL로 특정 로우에 락을 겁니다.
  const [product] = await tx.$queryRaw<Product[]>`
    SELECT * FROM "Product" WHERE id = ${id} FOR UPDATE
  `;

  // 2. 이후 비즈니스 로직 수행 (여기서 다른 트랜잭션은 해당 로우 접근 불가)
  if (product.stock > 0) {
    await tx.product.update({
      where: { id },
      data: { stock: product.stock - 1 }
    });
  }
});

2. Supabase를 사용하는 Serverless 환경에서는 Lock으로 잠기는 상황이 많아질 경우 connection pool 고갈 위험을 생각해야 한다
: 글이 너무 길어져 connection pool 관련 글은 따로 작성할 예정..

3. 결정적으로, "동시 수정이 이루어지는 경우가 있는가?"를 생각해봐야 한다
: 기술적인 내용보다 중요한 것은, 지금 적용하고자 하는 기능의 맥락을 생각해야 한다는 것이었다.
지금 MVP 단계에서 구현하고자 하는 기능은 "사용자가 계좌이체를 통해 금액을 입금하면, 운영자가 수동으로 포인트를 충전한다"이므로, 비관적 락을 적용할 때 얻는 이점보다 성능 손해가 더 크다는 것이 명백했다.

결론

  • 특정 기술을 적용할 때의 이유는, 철저히 "지금 만들고 있는 프로젝트"의 맥락에서 생각해야 한다. "일반적으로 ~~한 상황에서 좋기 때문에"가 아니라, "지금 이 프로젝트는 ~~한 상황이고, 이럴 때 좋기 때문에"라고 말할 수 있어야 한다.
profile
개발 / 성장 노트

0개의 댓글