본문 바로가기
Computer Science/Database

트랜잭션 제대로 알고 계신가요?

by NewCodes 2025. 6. 25.

 

면접을 대비하거나, CS를 공부할 때 트랜잭션을 한 번쯤 접했을 것이다. 

 

트랜잭션에 대해 피상적으로만 학습하고 넘어가는 경우가 많을 거라 생각한다.  

 

 

이 글을 통해서 트랜잭션에 대해 더욱 깊게 이해했으면 한다. 

 

이 글은 최대한 신뢰할 수 있는 내용으로 전달하려 노력했다.

또, 직접 그린 그림으로 이해하기 쉽게 전달하려 했다. 

 

이제 본격적으로 시작해보자!

 


📍 개요

트랜잭션을 정립한 사람 

트랜잭션은 여러 개발자들로부터 자연스럽게 발전한 개념이다.

 

이를 이론적으로 정립한 건 미국의 컴퓨터 과학자인 Jim Gray이다.

https://en.wikipedia.org/wiki/Jim_Gray_(computer_scientist)

 

1992년에 『Transaction Processing: Concepts and Techniques』이라는 책을 출판했다. Jim Gray는 트랜잭션을 포함하여 데이터베이스에 기여한 공로를 인정받아 1998년 튜링상을 받기도 했다. 

 

트랜잭션이란 무엇인가?

트랜잭션이란 DB의 상태를 변경시키는 작업의 논리적 단위이다.

 

쉽게 말하면 여러 작업을 하나로 묶어서 실행하는 걸 트랜잭션이라 한다. 

 

한 번에 묶어서 실행해야 하는 이유는 뭘까?

부분실패로 인한 데이터 무결성(integrity)이 손상되는 걸 막기 위함이다. 하나로 묶었던 여러 작업 중에서 일부가 실패하면 데이터가 정확하게 저장되지 않을 수 있다.

 

예를 들어, 트랜잭션이 없는 쇼핑몰에서 티셔츠를 구매했다 생각해보자. 할인쿠폰 10%까지 적용해서 결제를 했다.

 

이때 서버에서는 할인쿠폰을 차감시키고, 티셔츠의 재고를 -1할 것이다. 하지만, 그 시점에서 티셔츠의 재고가 0개여서 티셔츠 구매에 실패했다고 해보자. 사용자는 구매 실패 에러 문구를 보게 된다. 다시 결제하려고 봤더니 할인쿠폰이 사라진 상태이다. 

 

 

결제가 실패되었지만, 할인쿠폰은 차감된 상태가 되었다. 즉, 데이터의 무결성이 깨진 것이다. 무결성(integrity)이란 데이터가 정확하고 완전한 상태를 의미한다. 말 그대로 결점이 없는 상태이다. 현실 세계의 데이터를 정확히 반영해야 하는데 이를 반영하지 못하고 할인쿠폰을 쓰지 않았는데도 썼다고 처리가 되어버린 것이다. 

 

위와 같은 문제를 해결하기 위해서 우리는 트랜잭션을 써야 한다.

 

트랜잭션의 핵심 기능여러 작업 중 부분 작업이 실패되었을 때 원래대로 되돌리는 것이다. 방금과 같은 상황에서도 할인쿠폰 차감한 작업을 다시 원래대로 되돌렸다면 무결성을 지킬 수 있었을 것이다. 

 


🎯 트랜잭션의 특성

트랜잭션은 ACID로 크게 4가지 특성을 지닌다. Jim Gray의 책을 기반으로 해서 하나씩 살펴보자. 이를 단순히 외운다기보다는 왜 이러한 특성이 필요할지 생각하면서 받아들이자. 

 

Atomicity (원자성)

원자성이란 트랜잭션 내 연산이 모두 반영되거나, 모두 반영되지 않아야 한다는 특성이다. 말 그대로 트랜잭션은 원자적으로 동작해야 한다. 

 

원자가 물질을 이루는 가장 작은 단위이듯이, 트랜잭션은 DB의 상태에 변화를 주는 가장 작은 논리적 단위이다. 더 이상 나눌 수 없고 하나의 원자처럼 취급해야 한다. 

 

원자성을 지키기 위해서 가장 중요한 게 있다. 그건 바로 '트랜잭션을 수행하면서 오류가 생겼을 때 이를 어보트하고 모든 내용을 롤백'하는 것이다. 이 특성이 지켜지지 않으면 아까 위에서 언급한 티셔츠 할인쿠폰이 차감되는 문제가 생긴다. 

 

만약 트랜잭션이 도중에 실패한다면 어떻게 롤백할까?

 

아래 그림을 통해 MySQL을 기준으로 알아보자. 참고로 다른 DB도 이와 유사한 원리로 동작한다.  

 

 

이는 보통 Undo Log(언두 로그)를 통해서 롤백한다. Undo Log란 변경되기 이전 버전의 데이터를 별도로 백업한 것을 의미한다. 트랜잭션에서 데이터를 변경할 때는 항상 기존 데이터를 Undo Log로 남겨둔다고 생각하면 된다. 

 

Buffer Pool디스크 데이터 파일과 인덱스 정보를 캐시하는 공간이다. 매번 디스크에서 곧바로 읽고 쓰면 오래 걸리니 메모리에서 빠르게 작업하기 위한 용도이다. 

 

예시를 통해 더 자세히 살펴보자. 트랜잭션을 시작하고 아래 INSERT문을 실행했다고 해보자. 

 

 

Buffer Pool의 데이터가 Disk에 반영되는 시점에 대해서는 특정 시점에 백그라운드 스레드가 옮겨준다는 정도로만 우선 알고있자. 

 

이 상태에서 UPDATE SQL을 하나 더 실행한다. 

 

 

 

이렇게 되면 이전 데이터Undo Tablespace에 저장된다. 이 데이터를 Undo log라고 한다. 

 

트랜잭션이 롤백되는 상황이라면, Undo Tablespace에 있는 Undo Log를 읽어서 다시 Buffer Pool로 옮긴다. 트랜잭션에서 롤백을 가능하게 해주는 친구는 Undo Log라는 것을 잊지말자. 

 

원자성은 트랜잭션에서의 아이덴티티이자 핵심이니 잘 기억해두도록 하자! 

 

Consistency (일관성)

일관성이란 상태의 정확한 변화를 의미한다. 이는 트랜잭션을 정립한 Jim Gray의 책에서 가져온 의미이다. 쉽게 말해서 트랜잭션을 통해 DB에 있는 값을 변경시킬 때 데이터에 오류가 없어야 한다. 즉, 상태를 변화할 때는 데이터 무결성을 지켜야 한다. 

 

예를 들어, 은행 계좌 테이블에서 모든 계좌에 잔고가 있어야 한다는 규칙이 있다고 해보자. 이때 잔고를 삭제하는 트랜잭션이 있다면 이는 중단하고 롤백되어야 한다. 왜냐하면 해당 트랜잭션은 규칙을 어기는 트랜잭션이기 때문이다. 

 

한번 정의한 규칙은 계속해서 일관되게 지켜야 한다는 게 트랜잭션의 일관성이다. 이를 지키기 위해서는 DB의 제약조건, 트리거 등이 쓰일 것이다. 또, 애플리케이션 개발자가 잘못된 쿼리 혹은 비즈니스 로직을 짜지 않는 것도 중요하다. 

 

Isolation (격리성)

격리성이란 하나의 트랜잭션이 다른 트랜잭션의 결과에 영향을 주어서는 안 된다는 특성이다. 즉, 트랜잭션은 독립적으로 작용해야 한다. 서로 개입할 수 없다. 

 

격리성은 다음과 같은 형태로 나타난다. 트랜잭션이 동시에 같은 레코드에 대해 작업할지라도 순서대로 실행된 것처럼 결과가 나타나야 한다.

 

예시를 통해 봐보자. 격리성이 지켜지지 않은 예시부터 살펴보자. 

 

 

Transaction2에서 결제가 실패되어서 실제 핫식스의 데이터는 10개로 다시 바뀌었다. 하지만 Transaction1은 그 사이에 개입해서 잘못된 데이터를 바탕으로 판단을 해버렸다. 

 

이를 DIRTY READ 문제라고 한다. 한 트랜잭션에서 아직 커밋되지 않은 레코드를 읽어 데이터 불일치가 생기는 문제이다. 결제 실패가 자주 일어나는 트랜잭션, 비즈니스 로직이라면 이 문제는 더더욱 심각해질 것이다. 

 

즉, 정리하자면 트랜잭션이 서로 격리되어 실행되지 않았기에 애플리케이션이 발주를 더 넣게되는 잘못된 판단을 했다. 

 

반면, 트랜잭션이 잘 격리되면 아래와 같이 실행된다. 

 

 

여기서는 핫식스라는 레코드에 대해 락을 걸어 트랜잭션 간에 격리를 잘 시켜줬다. 이 덕분에 트랜잭션이 동시에 같은 레코드에 대해 작업할지라도 순서대로 실행된 것처럼 결과가 나타난다. 

 

Duration (지속성)

지속성이란 트랜잭션을 통해 한 번 커밋된 데이터는 장애가 있더라도 영구히 저장되어야 한다는 특성이다. 하드웨어에 결함이 생기거나, 데이터베이스 서버가 죽더라도 트랜잭션의 결과는 영구적으로 저장되어야 한다. 

 

DB에 장애가 생겨 종료됐다면, 트랜잭션을 실행하면서 아직 커밋되지 못한 데이터는 지속성은 어떻게 보장될까?

 

Write Ahead Log를 통해 변경하고자 했던 내용을 되살릴 수 있다. 이는 MySQL에서 redo log라고 한다. redo log는 DB 서버가 비정상적으로 종료됐을 때 데이터 파일(디스크)에 기록되지 못한 데이터를 잃지 않게 해주는 안전장치 역할을 한다. 

 

그런데 redo log가 굳이 필요한 이유가 뭘까?

그저 트랜잭션이 실행되는 대로 바로바로 디스크에 저장하면 안전한 것이 아닌가 하는 생각이 들 수 있다.

 

하지만 대부분의 DBMS는 쓰기보다 읽기 성능을 고려한 자료 구조를 가지고 있고, 쓰기는 디스크의 랜덤 액세스가 필요하다. 이는 큰 비용이 들기에 redo log를 통해 랜덤 액세스를 줄여야 한다. 또, redo log는 순차적으로 쓰는 방식을 채택하기에 디스크에 빨리 쓸 수 있다. 

 

그래서 MySQL을 포함한 대부분의 데이터베이스 서버에서는 데이터 변경 내용을 redo log로 먼저 기록해둔다. 

 


🚧 트랜잭션의 격리 수준

앞서서 트랜잭션의 격리성(Isolation)에 대해 알아봤다. 격리성이 지켜지지 않으면 동시성 문제인 race condition이 발생할 수 있다. race condition이란 하나의 공유 자원에 동시에 접근할 때 예상치 못한 결과가 나올 수 있는 문제이다. 

 

이 문제를 해결하기 위해서는 각각의 트랜잭션을 적절히 격리해줄 필요가 있다. 격리하기로 결정했다면, 얼마만큼 어떤 기준으로 격리해야 할지를 결정해야 한다. 이를 격리 수준이라고 한다.

 

격리 수준이란

  • 여러 트랜잭션이 동시에 실행될 때
  • 한 트랜잭션에서 변경하거나 조회하는 데이터를
  • 다른 트랜잭션에서는 얼만큼 볼 수 있게 할지 결정하는 것을 의미한다. 

이는 크게 4가지 수준으로 쪼개어 트랜잭션을 격리한다. 각각의 수준을 잘 이해하고 있어야 각 애플리케이션에서 적합한 격리 수준을 선택할 수 있을 것이다. 또한, 트랜잭션이 어떤 식으로 실행되는지 그 과정을 이해하는 데 도움이 될 것이다. 

 

1) READ UNCOMMITTED

READ UNCOMMITTED에서는 다른 트랜잭션에서 아직 커밋되지 않은 레코드도 읽을 수 있다.

 

즉, 커밋과 롤백에 상관없이 다른 트랜잭션에서 변경한 레코드를 즉시 읽을 수 있다. 이는 트랜잭션을 거의 격리하지 않겠다는 것과도 같다.

 

그렇기에 심각한 DIRTY READ라는 문제가 생긴다. 어떤 트랜잭션에서 아직 커밋되지 않은 중간 데이터를 다른 트랜잭션에서도 볼 수 있다. 아까 격리성에서 봤던 예시가 DIRTY READ 문제이다. 

 

 

이처럼 READ UNCOMMITTED는 데이터 정합성에 문제가 많이 생기기에 거의 사용되지 않는다. 

 

2) READ COMMITTED

READ COMMITTED에서는 다른 트랜잭션에서 커밋된 레코드만 읽을 수 있다.

 

이는 DIRTY READ 문제를 해결한다. 어떤 트랜잭션에서 특정 레코드를 변경했더라도 이를 아직 커밋하지 않았다면, 다른 트랜잭션에서는 변경하기 이전의 데이터를 읽기 때문이다. 

 

어떻게 변경하기 이전의 데이터를 읽을 수 있는 걸까? 바로 언두(Undo) 영역에 있는 백업된 레코드를 읽기 때문이다. 

 

아래 그림을 살펴보며 이해해보자. 

 

 

위처럼 Undo Tablespace를 통해 이전 버전의 레코드를 백업해둘 수 있다. 한 트랜잭션에서 변경한 해당 레코드를 커밋하지 않았다면 Undo Log를 읽게 되는 원리이다. 

 

Undo Log의 역할은 두 가지이다. 

  1. 트랜잭션을 롤백하여 원자성 보장 
  2. 트랜잭션을 격리하여 동시성 향상

첫 번째는 아까 원자성 파트에서 살펴봤었다. 이 맥락에서는 트랜잭션을 격리하여 이전의 데이터를 읽게 하는 용도로써 작용했다. 

 

READ COMMITTED온라인 서비스에서 가장 많이 선택되며, 대표적으로 Oracle에서 사용하는 격리 수준이다. 일반적인 온라인 서비스 용도로 READ COMMITTED와 REPEATABLE READ 2가지 중 하나를 사용한다. 

 

하지만 이러한 READ COMMITTED에서도 발생하는 문제가 있다. 그건 바로 NON REPEATABLE READ 문제이다. 한 트랜잭션에서 같은 레코드를 여러 번 읽을 때 그 결과가 매번 달라질 수 있다는 문제이다. 

 

아까 위 그림에서도 보이듯이 Transaction2에서 핫식스에 대해 2번 반복해서 조회했지만, 그 결과는 서로 달랐다. 이게 NON REPEATABLE READ 문제이다. '이 문제가 심각한 것이냐?' 하는 의문이 들 수 있다. 이는 조금 더 뒤에서 다시 살펴보자. 

 

3) REPEATABLE READ

REPEATABLE READ에서는 다른 트랜잭션의 커밋에 상관없이 레코드를 여러 번 조회해도 계속해서 같은 결과를 볼 수 있다. REPEATABLE READ는 MySQL InnoDB에서 기본적으로 사용하는 격리 수준이다.

 

REPEATABLE READ에서도 마찬가지로 변경하기 이전의 데이터를 읽는다. 하지만 READ COMMITTED와의 차이점몇 단계 이전의 데이터를 읽을 것인가이다.

 

READ COMMITTED는 가장 최신에 커밋된 레코드를 읽는 반면,

REPEATABLE READ는 트랜잭션을 시작하기 이전의 커밋된 레코드 중 가장 최신을 읽는다.

 

즉, REPEATABLE READ 수준에서 시작한 트랜잭션은 특정 레코드에 대해 여러 번 읽어도 언제나 같은 값이 나오게 된다. 아래 그림처럼 말이다. 

 

 

Transaction2에서 조회한 핫식스의 재고는 계속해서 10개인 걸 볼 수 있다. 그래서 이는 NON REPEATABLE READ 문제를 해결한다. 

하지만, REPEATABLE READ에서도 문제는 있다. 다른 트랜잭션에 수행한 INSERT 때문에 레코드가 보였다 안 보였다 하는 문제이다. 이를 PHANTOM READ라고 한다.

 

예를 들어, 한 트랜잭션에서 재고가 10개 이상인 음료를 조회하는 쿼리를 실행했다고 해보자. 이때, 다른 트랜잭션에서 재고가 20개인 밀키스를 추가했다. 이후 다시 첫 트랜잭션에서 다시 조회하면 그 결과가 밀키스를 포함하여 나오게 된다. 즉, 첫 번째에서는 없었던 레코드가 추가된 것이다. 이게 바로 PHANTOM READ이다.  

 

하지만 MySQL InnoDB에서는 PHANTOM READ 문제가 발생하진 않는다. 바로 갭 락(Gap Lock) 덕분이다. 참고로 갭 락이란 레코드와 레코드 사이를 잠그는 것이다. 그래서 레코드와 레코드 사이에 데이터가 추가되지 않도록 한다. 

 

4) SERIALIZABLE

SERIALIZABLE에서는 트랜잭션이 동시에 실행되는 경우가 없도록 하나씩 순서대로 실행한다. 트랜잭션은 실행되기 위해서는 공유 잠금을 획득해야 한다. 이 잠금을 획득하지 못한 트랜잭션은 대기하고 있어야 한다. 

 

마치 트랜잭션을 줄을 세워서 한 번에 딱 하나의 트랜잭션만 실행하는 게 SERIALIZABLE이다.

 

그렇기에 이는 매우 비효율적인 격리 수준이기에 온라인 서비스에서는 잘 사용되지 않는다. PHANTOM READ 문제를 해결하긴 하지만 성능이 아쉽다는 게 큰 단점이다.

 

또, MySQL에서는 REPEATABLE READ만으로도 PHANTOM READ가 발생하지 않기에 MySQL에서 이를 굳이 사용할 필요는 없다. 

 


🏏 REPEATABLE READ가 만능일까? 

REPEATABLE READ가 언뜻 보기에는 가장 좋아보인다. 트랜잭션을 적절히 격리하기도 하고, 성능도 크게 떨어지지 않기 때문이다. 하지만 이게 과연 최선일까?

 

이번에는 REPEATABLE READ에서 생기는 문제에 대해서 알아볼 것이다. 이를 통해 해결 방안과 교훈까지 뽑아보려 한다. 

 

네이버페이에서 실제로 발생했던 문제를 가져왔다.

전반적으로 원래의 글보다 좀 더 읽기 쉽게 정리해봤으니 차근차근 읽어나가보자. 

 

1) 사전 정보

네이버페이 신규 결제 시스템을 개발하면서 생긴 문제이다. 과거엔 Oracle을 썼지만, 현재는 MySQL을 쓰고 있다고 한다.

 

아래 나오는 코드들은 실제 결제 로직을 토대로 한 의사코드이다. 우선 간단한 결제 처리 코드를 살펴보자. 

@Transactional
public void processPayment(Long userId) {
    // 트랜잭션 내에서 해당 유저를 DB 락으로 가져옴.
    // 두 번째 이후 요청은 여기서 결제를 하지 않고 대기
    User user = lockUserById(userId);
    
    // 이후 결제 처리 로직 수행 (예시)
    long originalBalance = getBalanceById(userId);
    buisinessLogic(userId, originalBalance);
    
    // 결제 완료 후 사용자의 잔액 업데이트
    updateUserBalance(userId, balanceToUpdate, originalBalance);
}

 

`processPayment()`는 userId를 토대로 해당 유저를 lock하고 결제를 처리한 다음, 유저의 잔액을 업데이트한다. 

 

여기서 마지막에 `updateUserBalance()`를 할 때는 아래 SQL문을 실행한다. 

UPDATE user_balance
SET balance = {balance_to_update}
WHERE user_id = {user_id_to_update} AND balance = {original_balance};

 

위 SQL에서 굳이 추가적으로 `AND balance = {original_balance}`를 붙였을까?

 

이는 race condtion을 방지하기 위해서이다. 동일한 유저에게 동시에 여러 요청이 들어오면 race condition이 발생할 수 있다. 즉, 여러 트랜잭션이 동시에 같은 레코드에 접근하면 예상치 못한 결과가 나올 수 있다. 

 

위 AND 조건을 붙이면 '이전에 조회했던 유저의 잔액' '결제 직전 DB에 들어있는 유저의 잔액'일치할 때만 UPDATE를 한다. 즉, 내가 예상하고 있는 값과 실제값이 일치할 때만 값을 변경시키기에 race condtion을 해결할 수 있다. 이를 CAS(Compare And Swap) 알고리즘이라고도 한다. 

 

2) 추가된 요구사항

지금까지는 결제 로직에 큰 문제가 없어보인다. 하지만, 여기에 추가 요구사항 `ensureUserBalance()`가 붙게 된다. 

@Transactional
public void processPayment(Long userId) {
    /* 추가 요구사항
    * lock을 잡기 직전 사용자에게 해당 잔액 정보가 있는지 확인. 없으면 insert
    */
    ensureUserBalance(userId);
    
    // 트랜잭션 내에서 해당 유저를 DB 락으로 가져옴.
    // 두 번째 이후 요청은 여기서 결제를 하지 않고 대기
    User user = lockUserById(userId);
    
    // 이후 결제 처리 로직 수행 (예시)
    long originalBalance = getBalanceById(userId);
    buisinessLogic(userId, originalBalance);
    
    // 결제 완료 후 사용자의 잔액 업데이트
    updateUserBalance(userId, balanceToUpdate, originalBalance);
}

 

`ensureUserBalance()`는 user의 잔액을 조회하고 잔액 정보가 없으면 기본 잔액 0원으로 초기화하는 함수이다. 

public void ensureUserBalance(Long userId) {
    UserBalance balance = userBalanceRepository.findByUserId(userId);
    
    if (balance == null) {
        // 잔액 정보가 없으면 기본 값으로 생성
        UserBalance newBalance = new UserBalance(
            userId,
            0L // 기본 잔액
        );
        userBalanceRepository.save(newBalance);
        System.out.println("User balance created for userId: " + userId);
    } else {
        System.out.println("User balance exists for userId: " + userId + 
                          ", amount: " + balance.getAmount());
    }
}

 

이 함수가 추가된 이유는 언급되진 않았지만, 추측을 하자면 다음과 같다.

 

외부 결제 시스템을 사용하는 것이 아닌 네이버 포인트와 같은 도메인 자체 포인트로 결제를 하는 상황일 것이다. 여기서 새로운 사용자가 결제를 시도할 때, 혹여나 잔액이 초기화되지 않았다면 각종 예외를 방지하기 위해 0원으로 초기화하는 역할이라 생각한다. 

 

3) 문제 상황

새로운 메서드를 추가하면서 생긴 문제는 무엇일까?

 

그건 바로 서로 다른 두 결제 트랜잭션이 같은 유저에 대해 동시에 실행될 때 문제가 발생한다. 첫 번째로 실행된 트랜잭션은 잘 실행되어 결제가 되지만, 두 번째는 결제가 완료되지 않는다.  

 

두 번째 트랜잭션에서는 왜 결제가 안 되는 걸까?

 

그건 바로 MySQL의 REPEATABLE READ를 잘못 사용했기 때문이다. 문제의 상황은 아래처럼 펼쳐진다. 

 

REPEATABLE READ 오용 사례

 

REPEATABLE READ에서는 한 레코드에 대해 이전에 읽었던 값과 항상 동일한 값을 읽게 된다. 그렇기에 Transaction2에서는 Lock을 대기하기 전에 읽었던 잔액인 10,000원을 계속해서 읽게 된다. 이는 실제 잔액을 가져오지 못하게 되는 문제를 가져온다. 그래서 결국 잔액 UPDATE에 실패하는 걸 볼 수 있다. 

 

4) 해결 방안

이를 해결하기 위해서는 어떤 방법이 있을까? 

 

첫 번째는 Lock 안에서만 잔액을 읽도록 하는 방법이 있다. 아래와 같이 'ensureUserBalance()'의 위치를 Lock 안으로 옮긴다. 

@Transactional
public void processPayment(Long userId) {
    // 트랜잭션 내에서 해당 유저를 DB 락으로 가져옴.
    // 두 번째 이후 요청은 여기서 결제를 하지 않고 대기
    User user = lockUserById(userId);
    
    /* Lock을 얻은 이후에 User 잔액 조회하기 */
    ensureUserBalance(userId);
    
    // 이후 결제 처리 로직 수행 (예시)
    long originalBalance = getBalanceById(userId);
    buisinessLogic(userId, originalBalance);
    
    // 결제 완료 후 사용자의 잔액 업데이트
    updateUserBalance(userId, balanceToUpdate, originalBalance);
}

 

이렇게 하면 다른 트랜잭션이 COMMIT된 이후에 Lock을 얻고, User의 잔액을 읽을 수 있다. 이로써 유저의 잔액을 정확히 반영할 수 있다. 

 

두 번째는 해당 트랜잭션의 격리 수준을 READ COMMITTED로 변경하는 것이다. 

@Transactional(isolation = Isolation.READ_COMMITTED)
public void processPayment(Long userId) {
    /* 추가 요구사항
    * lock을 잡기 직전 사용자에게 해당 잔액 정보가 있는지 확인. 없으면 insert
    */
    ensureUserBalance(userId);
    
    // 트랜잭션 내에서 해당 유저를 DB 락으로 가져옴.
    // 두 번째 이후 요청은 여기서 결제를 하지 않고 대기
    User user = lockUserById(userId);
    
    // 이후 결제 처리 로직 수행 (예시)
    long originalBalance = getBalanceById(userId);
    buisinessLogic(userId, originalBalance);
    
    // 결제 완료 후 사용자의 잔액 업데이트
    updateUserBalance(userId, balanceToUpdate, originalBalance);
}

 

이렇게 하면 Transaction1에서 커밋한 5000원 잔액을 곧바로 읽을 수 있기에 문제가 생기지 않는다. 

 


🏏 READ COMMITTED의 허점

READ COMMITTED 또한 잘 이해하고 있어야 문제없는 코드를 작성할 수 있다. 

 

1) 사전 정보

이 또한 문제 상황을 토대로 알아보자.

 

Coupang에서 결제하는 상황을 가정해보자. Coupang에서는 물건의 가격이 동적으로 변하곤 한다. 이를 바탕으로 물건을 결제할 때 어떤 문제가 생기는지 알아보자. 

 

참고로 외부 결제 시스템을 사용하지 않고 Coupang 포인트를 이용해 자체적으로 결제를 하는 상황이다. 

 

2) 문제 상황

아래 코드는 물건 결제 코드이다. 주석과 함께 차근차근 읽어보자. 

@Transactional
public void processPayment(Long userId, Long productId) {
    // 1. 물품 금액 조회 (첫 번째 읽기)
    Product product = productRepository.findById(productId);
    int productPrice = product.getPrice(); // 예: 10000원
    
    // 2. 현재 유저의 잔액 조회
    User user = userRepository.findById(userId);
    int userBalance = user.getBalance(); // 예: 12000원
    
    // 3. 결제 가능 여부 확인
    if (userBalance < productPrice) {
        throw new InsufficientBalanceException();
    }
    
    // === 이 시점에서 다른 트랜잭션이 상품 가격을 변경할 수 있음 ===
    // 예: 10000원 → 15000원으로 가격 상승
    
    // 4. 결제 처리 비즈니스 로직 수행
    log.info("결제 처리 시작 - 상품ID: {}, 예상금액: {}", productId, productPrice);
    
    // 5. 실제 잔액 업데이트 (문제 발생 지점)
    updateUserBalance(userId, productId);
}

 

`updateUserBalance()`는 아래와 같다. 

public void updateUserBalance(Long userId, Long productId) {
    // 6. 실제 결제 처리 시 가격 재조회 (두 번째 읽기)
    Product currentProduct = productRepository.findById(productId);
    int currentPrice = currentProduct.getPrice(); // 15000원으로 변경됨!
    
    // 7. 유저 정보 재조회
    User user = userRepository.findById(userId);
    int userBalance = user.getBalance(); // 12000원
    
    // 8. 유저 잔액 업데이트 - 변경된 가격으로 차감
    user.setBalance(userBalance - currentPrice); // 12000 - 15000 = -3000원!
    userRepository.save(user);
    
    // 결과: 처음 검증할 때는 충분했지만, 실제 차감 시에는 음수가 됨
}

 

중간에 물건 가격이 상승함으로써, 유저의 잔액이 음수가 나와버렸다.

 

나올 수 없는 수가 나왔으니 이 결제는 실패할 것이다. 사용자는 물건을 정상적으로 결제하려고 했지만 실패돼서 다시 결제하려 봤더니 물건의 가격이 올라 불쾌함을 느끼게 될 것이다. 

 

3) 해결 방안

이를 해결하는 방법은 크게 두 가지이다. 

 

첫 번째는 조회한 물건의 가격을 재사용하는 것이다. 

public void updateUserBalance(Long userId, Long productPrice) {    
    // 6. 유저 정보 재조회
    User user = userRepository.findById(userId);
    int userBalance = user.getBalance(); // 12000원
    
    // 7. 유저 잔액 업데이트 - 변경된 가격으로 차감
    user.setBalance(userBalance - productPrice); // 12000 - 10000 = 2000원!
    userRepository.save(user);
    
    // 결과: 올바르게 유저의 잔액이 업데이트됨
}

 

productPrice를 직접 조회하는 게 아니라 이전에 조회했던 값을 파라미터로 넣어주는 방법이다. 

 

두 번째는 REPEATABLE READ를 사용하는 것이다. 

@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processPayment(Long userId, Long productId) {
    // 1. 물품 금액 조회 (첫 번째 읽기)
    Product product = productRepository.findById(productId);
    int productPrice = product.getPrice(); // 예: 10000원
    
    // 2. 현재 유저의 잔액 조회
    User user = userRepository.findById(userId);
    int userBalance = user.getBalance(); // 예: 12000원
    
    // 3. 결제 가능 여부 확인
    if (userBalance < productPrice) {
        throw new InsufficientBalanceException();
    }
    
    // === 이 시점에서 다른 트랜잭션이 상품 가격을 변경할 수 있음 ===
    // 예: 10000원 → 15000원으로 가격 상승
    
    // 4. 결제 처리 비즈니스 로직 수행
    log.info("결제 처리 시작 - 상품ID: {}, 예상금액: {}", productId, productPrice);
    
    // 5. 실제 잔액 업데이트 (문제 발생 지점)
    updateUserBalance(userId, productId);
}

 

이를 통해 물건의 가격은 처음 조회한 가격 그대로 유지되면서 문제가 해결된다. 

 

4) 교훈

위 2가지 문제를 통해 우리가 얻을 수 있는 교훈을 정리해보자. 

 

애플리케이션 레벨에서 데이터 작업 관련 코드를 짤 때, 트랜잭션 격리 수준을 잘 이해하고 있어야 한다. 그래야 데이터 정합성을 지킬 수 있고, 정확한 상태 변경을 할 수 있다. 그럼으로써 유저로 하여금 불편을 야기하는 일이 없어질 것이다. 

 

본인이 작성한 코드를 충분히 소화하고, 관련 기반 개념을 잘 이해하고 있어야 한다. 위에서 제시한 해결 방안은 다소 단순해 보이기도 한다. 하지만, 실무에서 개발하다가 막상 이 문제를 맞닥뜨렸을 때 배경 지식이 충분치 않다면 어땠을까? '격리 수준'이 원인이라는 걸 알아채기까지 꽤 시간이 걸렸을 것이다. 

 

이를 좀 더 일반화하자면 애플리케이션 코드를 짤 때에는 관련 기반 개념, CS 지식 등을 잘 알고 응용할 줄 알아야 한다는 교훈을 얻을 수 있다. 

 

AI가 많은 코드를 작성해주는 시대에도 여전히 CS와 기본기는 유효하다. AI가 짜준 코드로 네이버페이 결제를 맡길 수 있겠는가? 잘못 맡겼다가는 상상할 수 없는 손해를 회사에게 안겨다 줄 수도 있다. 

 


🎨 마무리

요약

  1. 트랜잭션이란 DB에 상태를 변화시키는 여러 작업을 묶은 하나의 논리적 단위이다. 
  2. 트랜잭션은 원자적으로 작동해야 하며, 서로 격리해서 실행해야 하며, 그 결과가 일관적이며 영구히 반영되어야 한다. 
  3. READ COMMITTEDREPEATABLE READ는 상황에 따라 적절하게 선택해서 사용해야 한다.

 

소감

너무 완벽한 글을 써야겠다는 강박에 글 쓰는 시간이 다소 오래 걸린 것 같다. 또, 담고 싶은 내용을 온전히 다 담지는 못해서 아쉬운 느낌도 살짝 있다. 하지만 그래도 이전보다 더 글을 잘 쓰기 위해 노력했던 과정들이 의미 있었다.

 

예전에는 주로 구글 슬라이드를 통해서 그림 자료를 만들었지만, 지금은 피그잼으로 만들고 있다. 피그잼이 훨씬 더 편하고 보기에도 예쁘며, 담을 수 있는 정보의 양을 더 유연하게 조절할 수 있다는 점이 좋다. 사지방에서 피그잼을 하면 뭔가 랙 걸릴 것 같아서 시도조차 안 했는데 생각보다 잘 돼서 앞으로도 이렇게 만들 생각이다. 

 

이번 글에서 스스로에게 칭찬하고 싶은 점은 크게 두 가지이다. 첫째는 그림 자료를 열심히 만들었다는 것. 둘째는 트랜잭션이라는 개념에 대해 이론적으로만 보는 게 아니라 어떻게 써먹을 수 있는지 적용 맥락을 살펴본 것. 

 

그림 자료 만드는 게 생각보다 시간이 오래 걸린다. 아무래도 내가 그리고자 하는 그림이 구체적으로 어떤 건지 구상을 안 하고 그려서일 것이다. 다음부터는 먼저 내가 표현하고자 하는 걸 간단히 스케치하고 그려봐야겠다. 

 

적용 맥락 살펴본 것 자체는 정말 잘한 것 같다. 하지만 이는 내가 직접 적용해본 건 아니다. 다음에는 내가 직접 경험하고 해본 걸 바탕으로 글을 쓰고 싶다. 하지만, 군대에서 절대적인 시간이 부족한지라 그게 좀 아쉽다 ㅠㅠ 그래도 다음에는 작은 것이라도 시도해봐야겠다. 

 

다음에는 인덱스의 원리에 대해 글을 써볼 예정이다. B-Tree를 쓰며, 어떻게 조회 성능을 높이는지 대략은 알지만 그래도 더 정확하고 자세하게 학습하며 써볼 생각이다. 

 

레퍼런스

 

반응형

'Computer Science > Database' 카테고리의 다른 글

Index는 왜 빠를까?  (4) 2025.07.11