개발을 하다보면 데이터베이스 서버에서 트랜잭션이라는 개념을 쉽게 마주하게 됩니다.
트랜잭션은 개발을 편하게 해주는 기능이지만, 이를 주의 없이 사용했다간 더 큰 문제를 마주하게 되기 마련이지요.
오늘은 그런 불상사를 미리 예방하는 차원에서 데이터베이스의 트랜잭션에 대해 알아보는 시간을 갖겠습니다!
트랜잭션은 무엇인가요?
트랜잭션은 데이터베이스에서 수행되는 작업의 논리적 단위로, 하나 이상의 데이터베이스 연산(읽기, 쓰기, 수정, 삭제 등)을 포함합니다.
트랜잭션을 사용하여서 데이터베이스의 일관성을 유지하고, 동시성 제어를 통해 여러 사용자가 동시에 데이터에 접근하더라도 데이터의 무결성을 보장할 수 있습니다.
예시를 들어볼까요?
은행에서 계좌 A에서 계좌 B로 돈을 이체하는 과정에서 데이터베이스에는 다음과 같은 과정이 생깁니다.
- 계좌 A에 잔액에서 출금한만큼의 돈을 뺀다
- 계좌 B에 잔액에 입금된 만큼의 돈을 더한다
이 두 작업이 하나의 트랜잭션으로 묶이지 않는다면, 어떻게 될까요?
계좌 A에 돈이 출금되었지만 계좌 B에 돈이 입금되지 않거나 혹은, 계좌 A의 돈이 출금되지도 않았는데 계좌 B에 돈이 입금되는
그야말로 돈이 살살 녹아버리거나, 돈이 무한 복사가 되는 현상이 발생할 것입니다.
이러한 일을 방지하고자 두 작업을 하나의 트랜잭션으로 묶어, 작업이 모두 성공적으로 수행되거나 모두 수행되지 않도록 하는 것이 좋습니다.
트랜잭션에 대해 헷갈리기 쉬운 개념이 있는데, 트랜잭션은 꼭 여러 개의 데이터베이스 연산이 함께 수행되었을 때 의미가 있는 것만은 아닙니다. 하나의 연산이 있더라도 해당 작업 자체가 온전히 적용되거나, 중간 실패시 아무것도 적용되지 않아야 함을 보장해주는 것입니다.
ACID
트랜잭션에 대해서 정확하게 이해하기 위해선 트랜잭션의 속성 ACID에 대해서 이해하는 것이 좋습니다.
ACID 속성은 다음과 같습니다
1. 원자성 (Atomicity)
원자성은 트랜잭션 내의 모든 작업이 완전히 수행되거나, 전혀 수행되지 않아야 한다는 성질입니다. 즉, 트랜잭션이 중간에 실패하면 모든 변경 사항이 원래 상태로 복구되는 Rollback
작업이 수행되어야 한다는 뜻입니다.
위에서 들은 예시와 같이 고객이 계좌A에서 계좌B로 돈을 이체하려고 할 때, 두 작업이 하나의 트랜잭션으로 묶여야 한다는 것이 원자성이 적용된 대표적 사례입니다. 해당 작업을 하나로 묶어 트랜잭션이 성공하면 두 작업이 모두 수행되고, 트랜잭션이 실패하면 두 작업이 모두 취소됩니다.
2. 일관성 (Consistency)
트랜잭션이 성공적으로 완료된 후에는 데이터베이스가 일관된 상태를 유지해야 합니다. 데이터베이스가 정의한 모든 규칙(무결성 제약 조건 등)을 준수해야 함을 의미합니다.
예를 들어 재고 관리 시스템을 구축하고 있다고 가정해보겠습니다. 고객이 상품을 주문했을 때, 해당 시스템 안에서는 먼저 재고에서 주문된 상품의 수량을 차감하고, 주문 테이블에 새 주문을 추가해야 합니다.
이 때 주문 후 재고의 수량이 음수가 되거나 실제 주문이 되었는데 주문 테이블에 데이터가 추가되지 않으면 각 데이터의 일관성이 떨어지고 더 나아가 전체적으로 시스템 신뢰도가 떨어지게 됩니다. 즉 트랜잭션이 완료되기 전 후에 데이터베이스 내 모든 데이터의 정합이 잘 맞아야 한다는 뜻이지요!
3. 격리성 (Isolation)
트랜잭션이 동시에 실행될 경우 서로 간섭하지 않도록 격리되어야 함을 말합니다. 각 트랜잭션은 다른 트랜잭션이 서로 영향을 미칠 수 없도록 독립적인 작업으로 수행됩니다. 이를 통해 한 트랜잭션의 중간 결과가 다른 트랜잭션에 보이지 않도록 합니다.
호텔 예약 시스템을 예시로 들어보면, 두 명의 고객이 101호 방을 동시에 예약하려고 시도합니다.
이 때 두 트랜잭션이 동시에 실행되더라도 각 트랜잭션이 독립적으로 실행되어야 합니다. 첫 번째 고객의 예약 트랜잭션이 완료될 때 까지 두 번째 고객의 트랜잭션은 101호 방을 예약할 수 없습니다. 첫 번째 고객의 트랜잭션이 끝난 후에 방이 예약 가능한지를 확인하고 진행되어야 합니다.
4. 지속성 (Durability)
지속성은 트랜잭션이 완료된 후 그 결과가 영구히 저장되어야 함을 뜻합니다. 이는 시스템에 문제가 발생하더라도 이미 완료된 트랜잭션의 결과가 손실되지 않음을 말합니다.
커머스 서비스를 예시로 들어보면, 고객이 상품을 구매하고 결제를 완료하는 프로세스에 있어서 결제 정보와 주문 내역 등의 데이터는 손실되지 않아야 합니다. 결제 트랜잭션이 성공적으로 완료되면 그 결과가 영구적으로 비휘발성 저장소인 데이터베이스에 저장되고 또 문제가 발생할 시 복구될 수 있게 되는 것이 지속성입니다.
MySQL에서의 트랜잭션
그렇다면 실제 저희가 자주 사용하는 MySQL에서 트랜잭션이 실제로 어떻게 동작하는지 살펴볼까요?
MySQL에서 트랜잭션을 사용하는 방법은 다음과 같습니다.
START TANSACTION
또는BEGIN
을 사용해서 트랜잭션을 시작합니다.- 데이터베이스에 필요한 작업을 수행합니다.
COMMIT
을 사용하여 모든 변경사항을 저장합니다.- 문제가 발생할 경우
ROLLBACK
을 사용하여 모든 변경 사항을 취소하고 원래 상태로 복구합니다.
간단한 예시를 살펴볼까요
-- 트랜잭션 시작
START TRANSACTION;
-- 계좌 A에서 100원을 출금
UPDATE accounts SET balance = balance - 100 WHERE account_id = 'A';
-- 계좌 B에 100원을 입금
UPDATE accounts SET balance = balance + 100 WHERE account_id = 'B';
-- 트랜잭션 커밋
COMMIT;
위 예시에서눈 계좌 A에서 100원을 출금하고 계좌 B에 100원을 입금하는 두 작업이 하나의 트랜잭션으로 묶여있습니다.
모든 작업이 성공하면 변경사항이 저장되고, 중간에 오류가 발생하면 ROLLBACK
명령어를 사용하여 모든 변경 사항이 취소될 수 있게 됩니다.
MySQL에서 트랜잭션을 사용할 때 주의해야할 점은 사용하는 엔진의 종류에 따라 트랜잭션을 지원하는 경우가 있고 아닌 경우가 있다는 것입니다. 대표적인 엔진인 MyISAM
엔진과 InnoDB
엔진을 비교해볼까요?
MyISAM 엔진
- 트랜잭션을 지원하지 않습니다. 따라서
COMMIT
이나ROLLBACK
과 같은 명령어가 효과가 없습니다. - 테이블 락킹을 사용하기 때문에 한 번에 하나의 작업만 테이블을 수정할 수 있습니다.
- 외래 키 제약 조건을 지원하지 않습니다.
- 단순한 읽기 작업에 유리합니다.
-- MyISAM 엔진 테이블 생성
CREATE TABLE myisam_table (
id INT AUTO_INCREMENT PRIMARY KEY,
value VARCHAR(100)
) ENGINE=MyISAM;
-- 트랜잭션 시도 (실제로는 트랜잭션이 지원되지 않음)
START TRANSATCION;
INSERT INTO myisam_table (value) VALUES ('Test1');
ROLLBACK;
-- 데이터 확인
SELECT * FROM myisam_table; -- 'Test1'이 존재함
InnoDB 엔진
- 트랜잭션을 완전히 지원하기 때문에
COMMIT
,ROLLBACK
등을 사용할 수 있습니다. - 행 수준 락킹을 사용하기 때문에 동시성 처리에 유리합니다.
- 외래 키 제약 조건을 지원하여 데이터 무결성을 보장합니다.
- 충돌 후 자동 복구 기능을 제공합니다.
-- InnoDB 엔진 테이블 생성
CREATE TABLE innodb_table (
id INT AUTO_INCREMENT PRIMARY KEY,
value VARCHAR(100)
) ENGINE=InnoDB;
-- 트랜잭션 사용
START TRANSACTION;
INSERT INTO innodb_table (value) VALUES ('Test2');
ROLLBACK; -- 트랜잭션이 취소됨
-- 데이터 확인
SELECT * FROM innodb_table; -- 'Test2'가 존재하지 않음
JPA를 사용했을 때 영속화를 보장할 수 있는 이유는 데이터베이스의 트랜잭션을 사용하기 때문입니다.
트랜잭션은 꼭 필요한 최소의 코드에만 적용되도록 범위를 최소화 하는 것이 중요합니다!
트랜잭션의 격리 수준
트랜잭션의 격리 수준이란 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 것입니다.
격리 수준은 크게 READ UNCOMMITTED
, READ COMMITTED
, REPEATABLE READ
, SERIALIZABLE
의 4가지로 나뉩니다. 하나씩 알아보도록 하겠습니다!
READ UNCOMMITTED (읽기 미완료)
READ UNCOMMITTED
는 트랜잭션이 완료되기 전에 다른 트랜잭션의 변경 사항을 읽을 수 있는 상태입니다.
하지만 보통 해당 격리 수준을 사용하는 경우는 거의 없다시피 하는데요. READ UNCOMMITTED
단계에서는 Dirty Read
문제가 발생할 수 있기 때문입니다.
예를 들어볼까요?!
accounts 라는 테이블에 두 개의 계좌가 있다고 가정해봅시다.
- 계좌 A: 잔액 1000
- 계좌 B: 잔액 2000
CREATE TABLE accounts (
account_id VARCHAR(10),
balance DECIMAL(10, 2)
);
INSERT INTO accounts (account_id, balance) VALUES ('A', 1000.00), ('B', 2000.00);
이 때 계좌 A에서 100원의 출금 요청이 발생하였습니다. 따라서 계좌 A의 잔액을 900으로 수정하는 트랜잭션이 발생합니다.
START TRANSACTION;
UPDATE accounts SET balance = 900.00 WHERE account_id = 'A';
이 쿼리까지 실행된 시점에서 accounts 테이블의 상태는 다음과 같습니다.
- 계좌 A: 잔액 900 (커밋되기 전)
- 계좌 B: 잔액 2000
이때 다른 트랜잭션에서 계좌 A의 잔액을 조회하는 상황이 생겼다고 가정해봅시다.
-- READ UNCOMMITTED 격리 수준 설정
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITED;
START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 'A';
-- 결과: 900.00 (Dirty Read 발생)
COMMIT;
이 트랜잭션은 트랜잭션 A가 커밋하지 않은 데이터를 읽었습니다. 이 때는 잔액 900원인 상태로 조회가 되겠죠.
만약 트랜잭션 A가 성공적으로 완료되지 않아 롤백되었다고 가정을 해보면 커밋되지 않은 유효하지 않은 데이터를 조회하게 되기 때문에 결국 시스템 내의 데이터 일관성을 깨뜨릴 수 있답니다. 그래서 매우 낮은 수준의 데이터 일관성이 필요할 때만 사용되는 격리 수준입니다.
이러한 문제는 READ COMMITED
이상의 격리 수준에서 해결이 가능합니다.
READ COMMITED (읽기 완료)
READ COMMITED
는 트랜잭션이 완료되고 커밋된 데이터만 읽을 수 있는 상태입니다.
일반적으로 사용되는 격리 수준이기도 한데요, 그렇다고 문제가 발생하지 않는 것은 아닙니다.Non-repeatable Read
문제와 Phantom Read
가 발생할 수 있기 때문입니다.
먼저 Non-repeatable Read
는 동일한 트랜잭션 내에서 동일한 데이터를 여러 번 읽을 때 중간에 다른 트랜잭션이 데이터를 수정하거나 삭제하여 읽은 데이터가 달라지는 현상을 말합니다.
아까와 같이 accounts 테이블에 두 개의 계좌가 있다고 가정해보겠습니다.
- 계좌 A: 잔액 1000
- 계좌 B: 잔액 2000
CREATE TABLE accounts (
account_id VARCHAR(10),
balance DECIMAL(10, 2)
);
INSERT INTO accounts (account_id, balance) VALUES ('A', 1000.00), ('B', 2000.00);
먼저 첫 번째 트랜잭션이 계좌 A의 잔액을 조회합니다.
START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 'A';
이 때 다른 트랜잭션이 생성되어 100원을 이체하는 요청을 하였고, 잔액이 900으로 수정되었습니다.
START TRANSACTION;
UPDATE accounts SET balance = 900.00 WHERE account_id = 'A';
COMMIT;
원래의 작업 트랜잭션은 다시 한번 계좌 A를 조회하고 트랜잭션을 커밋합니다.
SELECT balance FROM accounts WHERE account_id = 'A';
COMMIT;
첫번째 트랜잭션 작업 안에서 처음 읽기 요청과 두번째 읽기 요청의 잔액이 달라지게 됩니다.
이 예시에서는 큰 문제가 되지 않을 수 있지만, 금전 처리와 같이 민감한 작업 중 데이터 일관성이 깨질 수 있으므로 주의가 필요한 격리 수준입니다.
이 문제를 해결하기 위해선 REPEATABLE READ
격리 수준을 사용할 수 있습니다.
REPEATABLE READ (반복 읽기)
REPEATABLE READ
는 트랜잭션이 시작된 이후 다른 트랜잭션이 커밋한 데이터는 보이지 않도록 하는 격리 수준입니다.
아까 이야기한 Non-repeatable Read
문제를 예방할 수 있게 되지요. InnoDB 스토리지 엔진의 기본 격리 수준이기도 합니다.
그렇기 때문에 높은 수준의 정합성을 요구하는 서비스에서 사용하기에 좋습니다.
하지만 아까 READ COMMITTED
에서 언급했던 Phantom Read
문제는 역시 계속 발생하게 됩니다.
Phantom Read
는 동일한 트랜잭션 내에서 동일한 쿼리를 여러 번 실행할 때, 중간에 다른 트랜잭션이 데이터를 삽입하여 결과 집합이 달라지는 현상을 말합니다.
역시나 accounts 테이블에 두 계좌가 있다고 가정해보겠습니다.
- 계좌 A: 잔액 1000
- 계좌 B: 잔액 2000
CREATE TABLE accounts (
account_id VARCHAR(10),
balance DECIMAL(10, 2)
);
INSERT INTO accounts (account_id, balance) VALUES ('A', 1000.00), ('B', 2000.00);
첫 번째 트랜잭션이 실행되어 1000원 이상인 계좌를 모두 읽는 요청을 합니다.
START TRANSACTION;
SELECT * FROM accounts WHERE balance >= 1000; -- 결과: 계좌 A, B
이 때 신규 계좌 개설 요청이 들어왔습니다. 새로운 트랜잭션의 요청에 의해 기본 잔액이 1500원 들어있는 계좌 C가 생겼습니다.
START TRANSACTION;
INSERT INTO accounts (account_id, balance) VALUES ('C', 1500.00)'
COMMIT;
첫 번째 실행되었던 트랜잭션이 다시 한번 잔액이 1000 이상인 계좌를 조회합니다.
SELECT * FROM accounts WHERE balance >= 1000; -- 결과: 계좌 A, B, C
COMMIT;
이처럼 새롭게 생긴 계좌로 인해 하나의 트랜잭션에서의 같은 요청의 결과가 달라지는 현상이 발생하는데 이를 Phantom Read라고 합니다.
이 문제는 마찬가지로 격리 수준을 높이면 해결이 가능합니다.
SERIALIZABLE (직렬화 가능)
SERIALIZABLE
은 모든 트랜잭션들이 순차적으로 실행되는 것 처럼 동작합니다. 가장 높은 수준의 격리 수준이며 모든 트랜잭션이 직렬화 되어 동작하는 것 처럼 동작합니다.
이렇게 격리 수준을 높이면 위에 언급한 모든 문제가 발생하지 않게 되지만 당연하게도 성능 저하가 발생할 수 있습니다.
InnoDB 스토리지 엔진을 사용하는 경우에는 갭 락
과 넥스트 키 락
등을 사용해서 REPEATABLE READ
수준에서도 Phantom Read
문제를 예방할 수 있는데요, 이는 나중에 락에 대해서 다룰 때 설명하도록 하겠습니다.
따라서 실제로는 잘 사용하지 않는 격리 수준이기도 하며, 가장 높은 수준의 데이터 일관성이 필요할 때 사용됩니다.
스프링에서 격리 수준 설정 방법
Spring에서는 @Transactional 어노테이션으로 트랜잭션의 격리 수준을 설정할 수 있습니다.
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Service;
@Service
public class AccountService {
@Transactional(isolation = Isolation.READ_COMMITTED)
public void transferMoney(Account from, Account to, double amount) {
// 비즈니스 로직
}
}
이런식으로 어노테이션의 인자로 명시적으로 격리 수준을 지정할 수 있답니다.
트랜잭션의 격리 수준을 이해하는 것은 데이터베이스의 동시성 제어를 이해하기 위해 매우 중요합니다. 어플리케이션의 요구사항에 알맞은 적절한 격리 수준을 알고 적용하고 발생하는 문제들을 적절히 대처할 수 있도록 더욱 정진해야겠습니다~!
'Computer Science > Data 📊' 카테고리의 다른 글
데이터베이스 vs 데이터 웨어하우스 vs 데이터레이크 (0) | 2024.03.08 |
---|
안녕하세요, 저는 주니어 개발자 박석희 입니다. 언제든 하단 연락처로 연락주세요 😆