프로필사진

Go, Vantage point

가까운 곳을 걷지 않고 서는 먼 곳을 갈 수 없다.


Github | https://github.com/overnew/

Blog | https://everenew.tistory.com/





티스토리 뷰

반응형

 

 

 

* 김영한님의 스프링 DB 1편 강좌를 수강하며 정리한 글입니다. *

 

스프링 DB 1편 - 데이터 접근 핵심 원리 - 인프런 | 강의

백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., - 강의

www.inflearn.com

 

 

 

이전 글에서는 커넥션 풀과 DataSource에 대해 정리하였다.

 

 

데이터베이스 트랜잭션

 

 

 

데이터베이스 트랜잭션 - 위키백과, 우리 모두의 백과사전

 

ko.wikipedia.org

 

 

트랜잭션은 ACID를 보장해야한다.

ACID는 데이터베이스의 트랜잭션이 안전하게 수행되는 것을 보장하기 위한 성질이다.

트랜잰션에서 실패한 작업이 하나라도 있다면 commit(DB에 반영) 하지 않고 rollback(작업 이전으로 모두 되몰림)하게 된다.

 

아래는 위키백과의 ACID에 대한 내용이다.

데이터베이스에서 데이터에 대한 하나의 논리적 실행단계를 트랜잭션이라고 한다. 예를 들어, 은행에서의 계좌이체를 트랜잭션이라고 할 수 있는데, 계좌이체 자체의 구현은 내부적으로 여러 단계로 이루어질 수 있지만 전체적으로는 '송신자 계좌의 금액 감소', '수신자 계좌의 금액 증가'가 한 동작으로 이루어져야 하는 것을 의미한다.

원자성(Atomicity)은 트랜잭션과 관련된 작업들이 부분적으로 실행되다가 중단되지 않는 것을 보장하는 능력이다. 예를 들어, 자금 이체는 성공할 수도 실패할 수도 있지만 보내는 쪽에서 돈을 빼 오는 작업만 성공하고 받는 쪽에 돈을 넣는 작업을 실패해서는 안된다. 원자성은 이와 같이 중간 단계까지 실행되고 실패하는 일이 없도록 하는 것이다.

일관성(Consistency)은 트랜잭션이 실행을 성공적으로 완료하면 언제나 일관성 있는 데이터베이스 상태로 유지하는 것을 의미한다. 무결성 제약이 모든 계좌는 잔고가 있어야 한다면 이를 위반하는 트랜잭션은 중단된다.

독립성(Isolation)은 트랜잭션을 수행 시 다른 트랜잭션의 연산 작업이 끼어들지 못하도록 보장하는 것을 의미한다. 이것은 트랜잭션 밖에 있는 어떤 연산도 중간 단계의 데이터를 볼 수 없음을 의미한다. 은행 관리자는 이체 작업을 하는 도중에 쿼리를 실행하더라도 특정 계좌간 이체하는 양 쪽을 볼 수 없다. 공식적으로 고립성은 트랜잭션 실행내역은 연속적이어야 함을 의미한다. 성능관련 이유로 인해 이 특성은 가장 유연성 있는 제약 조건이다. 자세한 내용은 관련 문서를 참조해야 한다.

지속성(Durability)은 성공적으로 수행된 트랜잭션은 영원히 반영되어야 함을 의미한다. 시스템 문제, DB 일관성 체크 등을 하더라도 유지되어야 함을 의미한다. 전형적으로 모든 트랜잭션은 로그로 남고 시스템 장애 발생 전 상태로 되돌릴 수 있다. 트랜잭션은 로그에 모든 것이 저장된 후에만 commit(DB에 반영하는) 상태로 간주될 수 있다.

출처: https://ko.wikipedia.org/wiki/ACID

 

 

 

트랜잭션의 격리 수준, Isolation Level

 

독립성(격리성)을 예를 들면, 하나의 메서드가 동작 중에 해당 메서드가 사용 중인 변수의 값을 외부에서 변경을 했다고 가정하자.

이러면 결국 기대한 값과 다른 결과가 나오는데, 이를 방지하기 위해서는 연산 작업 중에는 외부에서 수정을 할 수 없도록 접근을 막아야 한다. (Lock 작업과 unlock 작업이 필요하게 됨.) 따라서 작업 중인 변수에 접근하려면, 해당 작업이 마무리될 때까지 대기해야 하는데, 이 때문에 DB에서는 수많은 작업들이 병렬 처리를 못하고 대기하게 되면서 처리 효율이 급감한다.

 

이를 위해 4단계의 표준 격리 수준을 제공한다.

  • READ UNCOMMITTED (전혀 격리되지 않음)
  • READ COMMITTED 
  • REPEATABLE READ
  • SERIALIZABLE (엄격한 격리로 효율이 매우 떨어짐)

 

 

DB 세션

 

DB서버 입장에서는 자신에게 요청을 보내는 클라이언트는 애플리케이션 서버가 된다.

클라이언트와의 각 커넥션마다 연결되는 세션이 DB 서버에 생성되고, 세션을 통해 모든 요청을 실행하게 된다.

따라서 트랜젝션을 실행하는 주체는 세션이 된다.

 

 

 

트랜잭션

 

데이터의 변경 점이 발생했다면 이를 반영하는 Commit을, 결과를 반영하지 않으려면 rollback의 호출이 필요하다.

 commit 전에는 수정 중인 데이터가 있을 텐데, 이는 임시로 저장되어 해당 트랜잭션을 시작한 클라이언트(세션)만 접근이 가능하다. 따라서 다른 클라이언트는 commit 전까지 이를 볼 수 없다.

 

만약에 commit 전에 데이터를 누구나 접근이 가능하다면 동시성 문제가 발생한다.

이러한 격리 수준은 READ UNCOMMITTED으로 성능면에서는 유리하지만, 심각한 문제가 발생할 수 있다.

예를 들어 A가 생성한 데이터를 commit 전에 B가 접근하여 수정하다가, A가 rollback을 한다면 B는 생성도 되지 않은 데이터를 접근한 것이 된다.

 

 

 

Commit

 

이전 글에서 DB를 사용할 때 commit을 직접 실행한 적이 없었다.

이는 자동으로 commit이 실행되었기 때문이다.

 

h2는 기본적으로 자동 commit 모드로 설정이 되어 있다.

이 설정은 각 쿼리가 한번 실행될 때마다 변경 결과를 commit 한다.

이 기능은 편하긴 하지만, 위의 수신자와 송신자의 계좌 금액 변경은 원자적으로 이뤄져야 한다. (한쪽만 먼저 변경되면 안 됨)

따라서 트랜잭션의 구현을 위해서 수동 commit 모드를 사용해야 한다.

 

 

트랜잭션의 시작 시 수동 commit으로 바꾸기 때문에 관례상 수동 commit 설정이 트랜잭션의 시작을 의미한다.

수동 모드로 설정했다면, 종료 전에 반드시 commit 혹은 rollback을 호출해주자.

(DB들은 둘 다 호출이 되지 않고 timeout을 넘으면 자동으로 rollback을 호출한다.)

 

 

실제로 다른 세션에서 수동 commit 설정 후 데이터를 조회하면 아직 커밋되지 않은 데이터는 해당 세션에서만 조회된다.

세션 A가 데이터 추가
세션 B

 

 

 

 

계좌이체 트랜잭션

 

member 라는 테이블에 key: ID, value: balance 값을 저장하는 DB라고 가정해보자.

계좌이체를 위해서는 트랜잭션이 원자적으로 두 회원의 잔액을 Update 해야 한다.

 

A가 B로에게 송금을 한다고 가정하면 다음과 같은 두 가지 update문이 실행돼야 한다.

update member set money= A의 잔액 - 송금액 where member_id = 'memberA';
update member set money= B의 잔액 + 송금액 where member_id = 'memberB';

수동 모드로 변경 후, 두 쿼리를 실행하고 commit을 하면 트랜잭션이 완료된다.

 

만약 두 쿼리 중 하나에 실행 오류가 발생했다면, commit이 아닌 rollback으로 트랜잭션 시작 전 상태로 복구시켜주어야 한다. 상태를 되돌리는 rollback도 원자적으로 한 번에 수행이 되어야 한다.

 

 

 

 

DB Lock

 

A가 데이터를 변경 중에, 같은 데이터를 B가 변경한다고 생각해보자.

결국에 두 세션 중 나중에 Commit을 한 데이터가 다른 세션이 commit 한 데이터를 덮어 씌워버릴 것이다.

이처럼 접근 중인 데이터에 다른 세션의 접근은 원자성을 위반한다.

 

따라서 트랜잭션 중에 접근하는 데이터는 Lock을 걸어 다른 세션이 접근하지 못하도록 막아야 한다.

(OS가 동시성 문제를 해결하는 방법과 거의 유사하다.)

 

서로 다른 세션이 트랜잭션 중에 동시에 같은 데이터(table의 row)에 접근한다.

이때 조금이라도 요청이 먼저 들어온 세션이 lock을 획득하고, 다른 세션은 lock 획득을 시도하면서 대기한다.

물론 lock을 계속 대기하는 것은 비효율적이므로 일정 시간 후에는 타임아웃 오류가 자동으로 발생하면서 rollback 한다.

 

만약 대기 중에 커밋으로 DB에 데이터가 반영되면 lock이 해제되고 다른 세션이 lock을 획득할 수 있다.

 

 

 

이러한 기능은 추가적으로 구현할 필요 없이 DB가 자동으로 지원해준다.

 

세션 A와 B가 같은 tabel row의 접근 시, 다른 세션이 lock을 반환(commit) 하지 않으면 대기 중인 세션은 결국 timeout이 발생함을 확인할 수 있다.

 

 

대기 중에 반환된다면 바로 대기 중이던 쿼리가 실행된다.

 

timeout 시간은 DB가 기본으로 설정해 두지만, 직접 설정도 가능하다.

SET LOCK_TIMEOUT 60000; //60초간 대기

 

 

 

SELECT FOR UPDATE

 

물론 단순한 데이터 조회는 락이 된 상태의 데이터를 읽더라도 진행되는 트랜잭션에 변화는 없기 때문에 조회는 가능하다.

따라서 update 상태의 데이터는 락이되어도 commit전의 데이터는 다른 세션이 select로 조회할 수 있다.

 

단, 데이터의 상태를 체크하고 상태에 따라 변경을 진행하는 트랜잭션이 있을 수 있다.

예를 들면 결제 트랜잭션은 대상자의 잔액을 확인하고 잔액이 충분할 경우에만 결제가 진행될 것이다.

 

하지만, 조회 직후 update를 하려는 시점에 다른 트랜잭션이 먼저 락을 걸고 데이터를 변경했다고 가정하자.

이 변경된 데이터는 update를 하려는 상태에 부합하지 않을 수 있지만, update를 진행하게 된다.

따라서 원자적인 실행을 위해 데이터 조회에도 lock을 걸 수 있어야 한다.

 

이를 위한 SQL 구문이 SELECT FOR UPDATE 이다.

 

select * from table where ... for update;

 

 

트랜잭션 적용

 

DB의 sql이 아닌 Spring의 java 코드에서는 어떻게 적용해야 할까?

비즈니스 로직이 시작되는 부분에서 트랜잭션이 시작되고 로직이 끝나는 시점에 commit이나 rollback이 되어야 한다.

sql 문의 트랜잭션 시작 부분이 set autocommit false 이므로, 일단 DB 서버와의 커넥션이 필요하다.

또한 트랜잭션 중에는 원자적으로 실행돼야 하므로, 같은 세션 유지를 위해 같은 커넥션을 사용한다.

 

 

간단한 방법은 생성된 Connection 인스턴스를 비즈니스 로직에서 모두 동일하게 사용하도록 만드는 것이다.

시작 지점에서 생성된 Connection을 매개변수로 넘겨 사용하되, 중간에 해당 connection을 getConnection()으로 새로 만들거나 실수로 닫으면 안 된다.

모든 로직이 끝나면 commit 후에 닫도록 하자.

 

public void accountTransfer(String fromId, String toId, int money) throws SQLException {
	// connection 획득(커낵션 풀에서)
    Connection con = dataSource.getConnection();
    
    try {
        con.setAutoCommit(false);   //오토커밋 false로 트랜잭션을 시작
        //비지니스 로직 시작
        bizLogic(con, toId, money, fromId); //획득한 Connection 전달
        
        con.commit(); //세션에 commit 명령 전달
    }catch (Exception e){
        con.rollback();	// rollback 명령
        throw new IllegalStateException(e);
    }finally {
        //이 비지니스 로직이 시작한 connect이므로 해제해줌
        release(con);
    }

}

 

 

이때  해당 비즈니스 로직에서 트랜잭션 시작 시 AutoCommit을 해제하였으므로 커넥션 풀에 반환 전에 원상 복구 해주자.

private void release(Connection con) {
    if(con != null){
        try {
            //풀에 반환 시 오토 커밋 모드를 해제해야함
            con.setAutoCommit(true);
            con.close();	//커넥션 풀에서는 반납이 이뤄짐
        }catch (Exception e){
            log.info("error",e);
        }
    }
}

 

 

하지만 connection 인스턴스를 넘기는 도록 코드를 변경하는 것은 쉽지 않다.

 

 

 

 

 

반응형
댓글
반응형
인기글
Total
Today
Yesterday
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함