트랜잭션은 데이터베이스의 상태를 변화시키는 하나의 논리적 작업 단위입니다. 가장 대표적인 예시로 계좌이체를 살펴보겠습니다:
@Transactional
public void transfer(String fromId, String toId, long amount) {
Account from = accountRepository.findById(fromId).orElseThrow();
Account to = accountRepository.findById(toId).orElseThrow();
from.withdraw(amount); // 출금
to.deposit(amount); // 입금
accountRepository.save(from);
accountRepository.save(to);
}
출금과 입금은 반드시 함께 성공하거나 함께 실패해야 합니다. 트랜잭션이 없다면 출금은 성공했는데 입금이 실패하는 경우 돈이 증발하게 됩니다.
데이터베이스는 여러 트랜잭션이 동시에 접근할 때 데이터 일관성을 보장하면서도 동시성을 확보하기 위해 MVCC를 사용합니다. PostgreSQL을 예로 들면:
이런 방식의 장점:
참고로 주요 데이터베이스들은 모두 MVCC를 구현하지만 그 방식에는 차이가 있습니다:
데이터베이스 세션은 클라이언트(애플리케이션)와 데이터베이스 서버 간의 연결을 의미합니다. @Transactional 메서드가 시작되면 커넥션을 가져와 세션이 수립되고, 트랜잭션이 종료되면 세션도 종료됩니다.
격리 수준은 세 가지 레벨에서 설정 가능합니다:
-- PostgreSQL
ALTER SYSTEM SET default_transaction_isolation = 'read committed';
-- PostgreSQL
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;
@Transactional(isolation = Isolation.READ_COMMITTED)
public void someMethod() {
// 이 메서드의 트랜잭션은 READ COMMITTED 레벨에서 실행
}
각 데이터베이스의 기본(default) 격리 수준입니다:
Dirty Read: 커밋되지 않은 데이터를 읽는 현상
// Transaction A
@Transactional
public void writeData() {
account.setBalance(1000); // 아직 커밋되지 않음
// 이 지점에서 롤백될 수 있음
}
// Transaction B (다른 스레드에서 실행)
@Transactional(isolation = READ_UNCOMMITTED)
public void readData() {
int balance = account.getBalance(); // 1000을 읽음
// A가 롤백되면 이 데이터는 잘못된 값
// => Dirty Read 발생
}
Non-Repeatable Read: 같은 트랜잭션 내에서 같은 데이터를 두 번 읽었을 때 결과가 다른 현상
// Transaction A
@Transactional(isolation = Isolation.READ_COMMITTED)
public void readData() {
User user1 = userRepository.findById(1L); // name: "John"
sleep(3000); // 트랜잭션 B가 동작하는 동안 대기
User user2 = userRepository.findById(1L); // name: "Tom"
// 같은 데이터를 두 번 읽었는데 결과가 다름
// => Non-Repeatable Read 발생
}
// Transaction B (다른 스레드에서 실행)
@Transactional
public void writeData() {
User user = userRepository.findById(1L);
user.setName("Tom");
userRepository.save(user); // UPDATE 후 커밋
}
Phantom Read: 범위 조회시 다른 트랜잭션이 데이터를 추가(INSERT)한 경우 발생할 수 있는 현상
// Transaction A
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void readData() {
// 처음 조회
List<User> youngUsers1 = userRepository.findByAgeBetween(20, 30); // 10명
sleep(3000); // 트랜잭션 B가 동작하는 동안 대기
// 같은 범위를 다시 조회
List<User> youngUsers2 = userRepository.findByAgeBetween(20, 30); // 여전히 10명
// 트랜잭션 시작 시점의 스냅샷을 보므로 동일한 결과
}
// Transaction B (다른 스레드에서 실행)
@Transactional
public void writeData() {
// 20-30세 범위에 새로운 사용자 추가
User user = new User("Tom", 25);
userRepository.save(user); // INSERT 후 커밋
}
// Transaction C (새로운 트랜잭션)
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void readDataAgain() {
// 새로운 트랜잭션에서 조회
List<User> youngUsers = userRepository.findByAgeBetween(20, 30); // 11명
// 트랜잭션 B가 추가한 데이터가 보임 => Phantom Read
}
Dirty Read, Non-Repeatable Read, Phantom Read 모두 발생하지 않음
// Transaction A
@Transactional(isolation = Isolation.SERIALIZABLE)
public void readData() {
List<Account> accounts = accountRepository.findAll();
// 이 트랜잭션이 실행 중일 때
// 다른 트랜잭션의 accounts 테이블 수정/추가가 막힘
}
// Transaction B (다른 스레드에서 실행)
@Transactional
public void writeData() {
// accounts 테이블에 데이터를 추가하려고 하면
// Transaction A가 끝날 때까지 대기하거나
// 에러가 발생할 수 있음
accountRepository.save(new Account());
}
트랜잭션은 본질적으로 데이터베이스의 기능입니다. JDBC로 직접 구현한다면 다음과 같은 코드가 필요합니다:
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false); // 트랜잭션 시작
// SQL 실행
conn.commit(); // 커밋
} catch (Exception e) {
conn.rollback(); // 롤백
}
스프링의 @Transactional은 이런 트랜잭션 관리 코드를 자동으로 처리해줍니다:
1. @Transactional 메서드 호출
2. 트랜잭션 시작
3. 데이터베이스 커넥션을 가져와서 자동 커밋을 false로 설정
4. 실제 비즈니스 로직 수행
5. 정상 완료되면 커밋, 예외 발생시 롤백
6. 커넥션을 커넥션 풀에 반환
다음 편에서는 @Transactional의 다양한 속성들과 실제 활용 방법에 대해 자세히 알아보도록 하겠습니다.