본문 바로가기

STUDY/스프링 철저 입문

2-2) 스프링 트랜잭션 관리: ACID, Dirty Read, Repeatable Read, Pantom Read, @EnableTransactionManagement, @Transactional, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE

 - 데이터베이스 트랜잭션

 

데이터베이스 트랙잭션이란, 데이터관리 시스템에서 사용되는 업무처리 단위를 말한다. 은행에는 송금이라는 기본적인 기능이 있다. 송금은 내부적으로 출금 계좌에서 돈을 꺼내는 과정과, 입금 계좌에 돈을 넣는 과정으로 나누어 볼 수 있다. 여기서 두개의 과정중 일부만 실패하거나 성공하는 경우는 일어나서는 안된다. 이처럼 트랜잭션은 지켜져야하는 조건이 있는데 이를 ACID라고 부른다.

 

 

 - RDB의 특징: ACID

 

최근 NoSQL과 같은 새로운 형태의 DB가 인기를 끌고 있긴 하지만, 여전히 대다수의 서비스에서는 관계형 데이터베이스(Relational Database), RDB를 사용한다. RDB는 키와 값들의 간단한 관계를 테이블화 시킨 데이터베이스이다. 그리고 RDB에서 지켜져야하는 다음과같은 4가지 특성이 있다. 이 4가지 특성을 앞글자를 따서 ACID라고 부른다.

  1. Atomicity (원자성) : 각 작업이 부분적으로 완료되지 않는다. All or Nothing

  2. Consistency (일관성) : DB의 데이터는 일관성을 유지하여야 한다. 같은 요청에는 같은 형식의 결과가 나와야한다.

  3. Isolation (독립성) : 각 작업은 독립적으로 실행되어야 한다. 한 작업이 실행중인 다른 작업에 영향을 주면 안된다.

  4. Durability (지속성) : Commit된 DB의 데이터는 계속 유지되어야 한다.

 

 

 - 서비스 개발중 발생하는 이슈: dirty read, repeatable read, pantom read

 

데이터베이스를 사용하는 서비스를 개발하다보면, 위의 ACID에 위배되는 동작을 목격할 수 있다. Dirty Read는 'ㄱ' DB 작업이 수행중일 때, 또 다른 'ㄴ' DB 작업이 'ㄱ' 작업의 중간 결과를 가져가는 경우를 말한다. 예를 들어서 다음과 같은 'ㄱ', 'ㄴ' 작업이 있다고 생각해보자.

 

원자성을 고려하면 'ㄱ' 작업이 실행되는 순간, A의 계좌 잔액은 300만원이 차감되거나 변경되지 않아야한다. 하지만 'ㄱ' 작업 중간에 'ㄴ' 작업이 이루어 진다면, A의 계좌 잔액이 100만원, 200 만원이 차감된 상태에서 조회할 수도 있을 것이다. 이 상황을 Dirty Read라고 부른다.

 

Non-Repeatable Read는 한 작업에서 여러번의 동일 데이터 조회가 있을 때, 그 결과가 다른 것을 말한다. 예를 들어서 다음과 같은 'ㄱ', 'ㄴ' 작업이 있다고 생각해보자.

 

'ㄱ' 작업에서는 두번의 A계좌 잔액 조회가 이루어진다. 먼저 이루어진 조회로 isOver100의 값이 정해지고, 그 다음 조회로 curBalance가 정해진다. 여기서 당연히 isOver100이 false이면 curBalance는 100만원을 넘어서는 안된다. 하지만 첫번째 조회 이후 'ㄴ' 작업이 완료되면, isOver100 = false이지만 curBalance는 100만원을 넘는 말도 안되는 결과가 반환될 수 있다. 이는 일관성과 독립성을 위반하는 결과다. 이 상황을 Non-Repeatable Read라고 한다.

 

마지막으로 Phantom Read는 여러번에 데이터 조회가 있을 때, 없던 데이터가 생기는 경우를 말한다. 예를 들어서 다음과 같은 'ㄱ', 'ㄴ' 작업이 있다고 생각해보자.

 

'ㄱ' 작업에서 전체 계좌를 여러 기준으로 정렬해서 그 정보를 반환해준다. 그렇다면 당연히 각 정렬된 정보들의 총 계좌 개수는 같아야 할 것이다. 하지만 'ㄱ' 작업 중간에 'ㄴ' 작업이 이루어진다면, 잔액&생성일 정렬된 총 계좌개수는 100개 지점 정렬된 총 계좌개수는 101개가 되는 상활이 생길 수 있다. 이 상황을 Phantom Read라고 한다. 위와 같은 이슈를 스프링에서는 @Transactional 어노테이션을 사용하여 비교적 간단하게 해결할 수 있다.  

 

 

 - 실습, 스프링에서의 트랜잭션 관리: 환경준비

 

먼저 DB를 만들고 접근할 수 있는 환경을 만들어보자. 이번에도 내장메모리 DB인 H2 DB를 사용하였고 Account 테이블을 생성하였다. 그리고 Account DB를 조작하는 AccountDAO를 만들었다.

 

CREATE TABLE Account (
	id INT(11) NOT NULL AUTO_INCREMENT,
	balance INT(11) NOT NULL DEFAULT 0,
	PRIMARY KEY (id),
	UNIQUE INDEX id (id)
);

INSERT INTO Account VALUES(0,1000);

 

@Configuration
@EnableTransactionManagement
public class AppContextConfig {
  ...
  @Bean
  public DataSource dataSource() {
    DataSource dataSource = new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2)
        .setScriptEncoding("UTF-8").addScript("classpath:schema.sql").build();
    return dataSource;
  }
  
  @Bean
  public PlatformTransactionManager transactionManager(DataSource dataSource) {
  	return new DataSourceTransactionManager(dataSource);
  }
  ...
}

 

package com.study.springjdbc.vo;

public class Account {
  private Integer id;
  private Integer balance;
  ...
}

 

package com.study.springjdbc.dao;

public class DAOSqls {
  public static final String ACCOUNT_SELECT_ALL = "select id, balance from Account";
  public static final String ACCOUNT_SELECT_BY_ID =
      "select id, balance from Account where id = :id";
  public static final String ACCOUNT_UPDATE_BY_ID =
      "update Account set balance = :balance where id = :id";
  public static final String ACCOUNT_DELETE_BY_ID = "delete from Account where id = :id";
}

 

package com.study.springjdbc.dao;

import static com.study.springjdbc.dao.DAOSqls.*;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.stereotype.Component;
import com.study.springjdbc.vo.Account;

@Component
public class AccountDAO {
  private NamedParameterJdbcTemplate jdbcTemplate;
  private SimpleJdbcInsert insert;

  public AccountDAO(DataSource dataSource) {
    jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
    insert = new SimpleJdbcInsert(dataSource).withTableName("Account").usingColumns("balance")
        .usingGeneratedKeyColumns("id");
  }

  public Integer insert(Account noun) {
    SqlParameterSource params = new BeanPropertySqlParameterSource(noun);
    return insert.executeAndReturnKey(params).intValue();
  }

  public List<Account> selectAll() {
    return jdbcTemplate.query(ACCOUNT_SELECT_ALL, Collections.EMPTY_MAP,
        BeanPropertyRowMapper.newInstance(Account.class));
  }

  public List<Account> selectById(Integer id) {
    Map<String, Object> params = new HashMap<String, Object>();
    params.put("id", id);
    return jdbcTemplate.query(ACCOUNT_SELECT_BY_ID, params,
        BeanPropertyRowMapper.newInstance(Account.class));
  }

  public Integer update(Account account) {
    SqlParameterSource params = new BeanPropertySqlParameterSource(account);
    return jdbcTemplate.update(ACCOUNT_UPDATE_BY_ID, params);
  }

  public Integer deleteById(Integer id) {
    Map<String, Object> params = new HashMap<String, Object>();
    params.put("id", id);
    return jdbcTemplate.update(ACCOUNT_DELETE_BY_ID, params);
  }
}

 

 

 - 실습 : Dirty Read 재현

 

먼저 dirty read를 재현하기 위해 AccountService를 만들었다. taskA 메소드에서는 100만원씩 3번 출금하여, 총 300만원을 출금한다. taskB에서는 현재 잔고를 출력한다.

package com.study.springjdbc.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.study.springjdbc.dao.AccountDAO;
import com.study.springjdbc.vo.Account;

@Service
public class AccountService {
  @Autowired
  private AccountDAO dao;

  public void taskA() {
    Account account = dao.selectById(0).get(0);
    account.setBalance(account.getBalance() - 100);
    dao.update(account);
    account.setBalance(account.getBalance() - 100);
    dao.update(account);
    account.setBalance(account.getBalance() - 100);
    dao.update(account);
  }

  public void taskB() {
    Account account = dao.selectById(0).get(0);
    System.out.println(account.getBalance());
  }
}
public class App {
  public static void main(String[] args) throws Exception {
    ApplicationContext context = new AnnotationConfigApplicationContext(AppContext.class);
    final AccountService accountService = context.getBean(AccountService.class);
    Thread t1 = new Thread() {
      public void run() {
        for (int i = 0; i < 10; i++) {
          accountService.taskA();
        }
      }
    };
    Thread t2 = new Thread() {
      public void run() {
        for (int i = 0; i < 10; i++) {
          accountService.taskB();
        }
      }
    };

    t1.start();
    t2.start();
  }
}

 

 

그리고 main에서 두개의 thread를 만들어서 각각 taskA와 taskB를 10번씩 실행하도록 하였다. taskA는 한번 실행마다 잔고를 300씩 출금하므로 원자성이 지켜진다면, taskB에는 300씩 줄어드는 값이 출력되어야 한다.

 

1000
1000
1000
800
700
700
600
400
400
300

하지만 실제 출력값은 위처럼 원자성이 지켜지지 않았다. 

 

 

 - 실습 : DirtyRead 해결

 

Service의 taskA, taskB 메소드에 @Transactional 어노테이션을 붙여주면 dirty read 문제는 해결된다. 여기서 isolation은 얼마나 엄격하게 트랜잭션을 관리하는 것을 말한다.  Isolation.READ_COMMITTED는 수정중에있는 데이터는 읽지 않겠다는 말이고, 이는 즉 dirty read를 막는다는 말이다.

...
@Service
public class AccountService {
  @Autowired
  private AccountDAO dao;

  @Transactional(isolation = Isolation.READ_COMMITTED)
  public void taskA() {
    ...
  }

  @Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
  public void taskB() {
    ...
  }
}

 

실제로 다시한번 main을 실행시켜 보면, 값들이 300씩 줄어드는 것을 확인할 수 있다.

 

1000
1000
700
400
400
100
100
-200
-500
-500

 

 

 - 실습 : Repeatable Read 재현

 

먼저 repeatable read를 재현하기 위해 AccountService의 taskB 메소드를 수정하였다. taskB에서는 잔고를 조회하서 잔고가 100을 넘는지, 다시 잔고를 조회해서 현재 잔고를 Map에 넣고 그 값을 출력한다.

 

...
@Service
public class AccountService {
  @Autowired
  private AccountDAO dao;

  @Transactional(isolation = Isolation.READ_COMMITTED)
  public void taskA() {
    ...
  }

  @Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
  public Map<String, Object> taskB() {
    Map<String, Object> result = new HashMap<String, Object>();
    Account account = dao.selectById(0).get(0);
    result.put("isOver100", account.getBalance() > 100);
    Account cur_account = dao.selectById(0).get(0);
    result.put("curBalance", cur_account.getBalance());
    System.out.println(result);
    return result;
  }
}

 

일관성과 독립성이 지켜진다면, isOver100이 true이고, curBalance가 100인 경우는 존재할 수 없다. 하지만 그 결과를 보면 6번째 실행에서, 불가능한 결과가 나왔다.

 

{isOver100=true, curBalance=1000}
{isOver100=true, curBalance=1000}
{isOver100=true, curBalance=700}
{isOver100=true, curBalance=400}
{isOver100=true, curBalance=400}
{isOver100=true, curBalance=100}
{isOver100=false, curBalance=100}
{isOver100=false, curBalance=-200}
{isOver100=false, curBalance=-500}
{isOver100=false, curBalance=-500}

 

 

 - 실습 : Repeatable Read 해결

 

@Transactional의 isolation을  Isolation.REPEATABLE_READ으로 수정하면 문제는 해결된다. REPEATABLE_READ는 dirty read와 repeatable read를 막을 수 있다.

 

...
@Service
public class AccountService {
  @Autowired
  private AccountDAO dao;

  @Transactional(isolation = Isolation.READ_COMMITTED)
  public void taskA() {
    ...
  }

  @Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
  public void taskB() {
    ...
  }
}

 

{idOver100=true, curBalance=1000}
{idOver100=true, curBalance=700}
{idOver100=true, curBalance=700}
{idOver100=true, curBalance=400}
{idOver100=false, curBalance=100}
{idOver100=false, curBalance=-200}
{idOver100=false, curBalance=-500}
{idOver100=false, curBalance=-800}
{idOver100=false, curBalance=-1100}
{idOver100=false, curBalance=-1400}

 

 

 - 실습 : Pantom Read 재현

 

먼저 pantomread를 재현하기 위해 AccountService의 taskA, taskB 메소드를 수정하였다. taskA에서는 새로운 계좌를 만들어서 추가한다. taskB에서는 모든 계좌정보는 3번 불러와서, 각각의 크기를 map에 넣고 출력한다. 

 

...
@Service
public class AccountService {
  @Autowired
  private AccountDAO dao;

  @Transactional(isolation = Isolation.REPEATABLE_READ)
  public void taskA() {
    Account account = new Account();
    account.setBalance(0);
    dao.insert(account);
  }

  @Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
  public Map<String, Object> taskB() {
    Map<String, Object> result = new HashMap<String, Object>();
    List<Account> accounts_order1 = dao.selectAll();
    result.put("size1", accounts_order1.size());
    List<Account> accounts_order2 = dao.selectAll();
    result.put("size2", accounts_order2.size());
    List<Account> accounts_order3 = dao.selectAll();
    result.put("size3", accounts_order3.size());
    System.out.println(result);
    return result;
  }
}

 

{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=3, size1=2, size2=2}
{size3=5, size1=4, size2=5}

각각의 size 값들은 모두 동일해야 하지만 main을 실행해보면 그 값이 다른 경우가 생긴다. pantom read가 발생한 것이다.

 

 

 - 실습 : Pantom Read 해결

 

@Transactional의 isolation을  Isolation.SERIALIZABLE으로 수정하면 문제는 해결된다. SERIALIZABLE는 dirty read,  repeatable read와 pantom read를 막을 수 있다.

 

...
@Service
public class AccountService {
  @Autowired
  private AccountDAO dao;

  @Transactional(isolation = Isolation.SERIALIZABLE)
  public void taskA() {
  	...
  }

  @Transactional(isolation = Isolation.SERIALIZABLE, readOnly = true)
  public Map<String, Object> taskB() {
    ...
  }
}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=1, size1=1, size2=1}
{size3=3, size1=2, size2=3}
{size3=6, size1=5, size2=5}

 

???? Isolation.SERIALIZABLE를 적용 했으나 pantom read가 막히지 않았다. h2database 공식 사이트에 들어가보니, 다음과 같은 문구가 있었다.

 

The default MVStore engine supports read uncommitted, read committed, repeatable read, 
snapshot, and serializable (partially, see below) isolation levels:

 

SERIALIZABLE은 일부만 지원한다고 나와있는 것을 보니, h2database에서는 SERIALIZABLE이 온전히 동작하지 않는 것으로 판단된다. 이처럼 @Transactional을 통한 트랜잭션 관리는 데이터베이스에 적용 가능한 수준이 달라진다.  

 

 

 - 마무리

 

스프링은 트랜잭션 관리를 위한 강력한 도구를 제공해주었다. 그렇기에 개발자들은 큰 부담없이 @Transactional을 남발하여 여러 문제를 막을 수 있게 되었지만, @Transactional은 각각의 동작들을 조건에 따라 중단하고 막는 것이기 때문에, 프로그램의 전체적인 성능을 떨어뜨린다. 따라서, 적재적소에 필요한 만큼만 트랜잭션을 관리하는 것이 중요하다.

 

 

+ 근본은 결국 DB

 

309 Query	SELECT ROOM_ID, ROOM_NAME, CAPACITY FROM ROOM WHERE ROOM_ID = 0
309 Query	UPDATE ROOM SET ROOM_NAME = 'room1', CAPACITY = 13039 WHERE ROOM_ID = 0
309 Query	UPDATE ROOM SET ROOM_NAME = 'room1', CAPACITY = 13040 WHERE ROOM_ID = 0
309 Query	UPDATE ROOM SET ROOM_NAME = 'room1', CAPACITY = 13041 WHERE ROOM_ID = 0

 

319 Query	SELECT @@tx_isolation
319 Query	SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
319 Query	set autocommit=0
319 Query	SELECT ROOM_ID, ROOM_NAME, CAPACITY FROM ROOM WHERE ROOM_ID = 0 FOR UPDATE
319 Query	UPDATE ROOM SET ROOM_NAME = 'room1', CAPACITY = 13042 WHERE ROOM_ID = 0
319 Query	UPDATE ROOM SET ROOM_NAME = 'room1', CAPACITY = 13043 WHERE ROOM_ID = 0
319 Query	UPDATE ROOM SET ROOM_NAME = 'room1', CAPACITY = 13044 WHERE ROOM_ID = 0
319 Query	COMMIT
319 Query	set autocommit=1
319 Query	SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ
728x90