Spring/DB

스프링 DB 1편 -3장. 트랜잭션 이해

광터틀 2022. 9. 19. 13:00

스프링 DB 1편 -3장. 트랜잭션 이해 (인프런 - 김영한 강사님) 

 

 

트랜잭션이란? 

 

트랜잭션(번역하면 거래 라는 뜻이다.) 이란, 데이터베이스의 상태를 변화시키기 해서 수행하는 작업의 단위를 뜻한다.

데이터베이스의 상태를 변화시킨다는 것은 무얼 의미하는 것일까?

간단하게 말해서 아래의 질의어(SQL)를 이용하여 데이터베이스를 접근 하는 것을 의미한다.

  • SELECT
  • INSERT
  • DELETE
  • UPDATE

작업의 단위는 질의어 한문장이 아니다. 

작업단위는 많은 질의어 명령문들을 사람이 정하는 기준에 따라 정하는 것을 의미한다.

 

(우리가 수업시간에 배웠던 예제에 접목하자면,

A가 10000원, B가 10000원 있을 때, B가 A에게 2000원 송금하면

우리는 A에 +2000원, B에 -2000원 해줘야 한다.

이때, A에 +2000원이 작업 하나, B에 -2000원 해주는 작업이 또 다른 하나가 아니라 두개가 합쳐서 하나의 작업이다.)  

 

 

게시판을 예로 들어보자. 

게시판 사용자는 게시글을 작성하고, 올리기 버튼을 누른다. 그 후에 다시 게시판에 돌아왔을때, 

게시판은 자신의 글이 포함된 업데이트된 게시판을 보게 된다.

이러한 상황을 데이터베이스 작업으로 옮기면, 사용자가 올리기 버튼을 눌렀을 시, Insert 문을 사용하여

사용자가 입력한 게시글의 데이터를 옮긴다. 그 후에, 게시판을 구성할 데이터를 다시 Select 하여 최신 정보로

유지한다. 여기서 작업의 단위는 insert문과 select문 둘다 를 합친것이다. 이러한 작업단위를 하나의 트랜잭션이라 한다.

관리자나 개발자가 하나의 트랜잭션 설계를 잘하는 것이 데이터를 다루는 것에 많은 이점이 있다.

 

우리는 트랜잭션을 이용하여 작업들을 하나로 묶어서 한번에 커밋, 롤백한다. 

 

출처: https://mommoo.tistory.com/62 [개발자로 홀로 서기:티스토리]

 


특징 4가지. ACID

 

원자성 : 트랜잭션이 데이터베이스에 모두 반영되든가, 전혀 반영되지 않든가 

일관성 : 트랜잭션이 일어난 이후의 데이터베이스는 이전과 유효하게, 데이터베이스의 제약이나 규칙을 만족해야 한다.                    (데이터베이스에서 정한 무결성 제약조건을 항상 만족해야한다 등) 

격리성 : 둘 이상의 트랜잭션이 동시에 실행되고 있을 경우, 서로에게 영향을 끼치지 않도록 격리한다. 

지속성 : 트랜잭션이 성공적으로 완료됐을 경우, 결과는 영구적으로 반영되어야 한다. 

 

트랜잭션은 위 4가지 중에서 원자성, 일관성, 지속성을 보장한다. 

다만 격리성의 경우, 격리성을 완벽히 보장하려면 트랜잭션을 거의 순서대로 실행해야 하는데 이럴 경우, 동시 처리 성능이 매우 나빠진다. 

 

격리 수준에 따라서 4가지로 나뉜다.

- 커밋되지 않은 읽기

- 커밋된 읽기

- 반복 가능한 읽기

- 직렬화 기능

 

직렬화는 격리성을 완벽히 지킬 수 있으나 너무 느려진다.

일반적으로는 커밋된 읽기 (Read Committed) 단계를 많이 사용한다. 


데이터베이스 연결구조 1 (커넥션풀 이용X) 

 

사용자는 웹 애플리케이션 서버(WAS)나 DB접근 툴 같은 클라이언트를 사용해서 데이터베이스 서버에 접근할 수 있다. 클라이언트는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺게 된다. 이때 데이터베이스 서버는 내부에 세션이라는 것을 만든다. 앞으로 해당 커넥션을 통한 모든 요청은 이 세션을 통해서 실행하게 된다. 세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 종료한다. 

 

즉, 개발자는 클라이언트를 통해 SQL을 전달하면 현재 커넥션에 연결된 세션이 SQL을 실행한다. 

 


데이터베이스 연결구조 2 (커넥션풀 이용O) 

 

커넥션 풀이 10개의 커넥션을 생성하면 세션도 10개 만들어진다. 

 

 

 

 

커넥션 풀이란? 


트랜잭션 동작을 예제를 통해 확인해보자. 

 

커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것이다. 결과를 반영하려면 commit, 반영하고 싶지 않으면 rollback을 호출한다. 커밋을 하기 전에는 해당 트랜잭션을 시작한 세션(사용자)에게만 변경 데이터가 보이고 다른 세션(사용자)에게는 변경 데이터가 보이지 않는다. 

 

위 그림 상태에서 세션 1에서 커밋하면 세션 2에서도 데이터를 볼 수 있고, 롤백하면 newId1, newId2를 추가하기 전으로 복구된다. 


자동커밋 VS 수동커밋 

 

자동커밋은 커밋이나 롤백을 직접 호출하지 않아도 되기 때문에 편리하지만, 쿼리를 하나하나 실행할 때마다 자동으로 커밋이 되어버리기 때문에 트랜잭션 기능을 제대로 사용할 수 없다. 

수동커밋으로 설정하는 것에서부터 트랜잭션을 시작하는 것이다. 


DB 락 

 

세션 1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋하지 않았다면, 세션 2가 동시에 같은 데이터를 수정할 경우 트랜잭션의 원자성이 깨지며, 특히 세션 1이 중간에 롤백을 하면 세션 2는 잘못된 데이터를 수정하는 꼴이 된다. 

 

이런 문제를 방지하기 위해 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋, 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 락을 걸어야 한다. 

 

 

다만 변경이 아니라 조회를 할 때는 락을 사용하지 않는다. 세션 1이 락을 획득하고 데이터를 변경하고 있어도 세션 2에서 데이터를 조회할 수는 있다. 

만약 조회할 때도 락을 획득하고 싶은 경우, select for update 구문을 사용하면 된다. 

 


 

트랜잭션 적용

 

트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다. 

 

트랜잭션을 시작하려면 커넥션이 필요하므로 서비스 계층에서 커넥션을 만들고, 트랜잭션 커밋 이후에 커넥션을 종료해야 한다. 

애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다. 그래야 같은 세션을 사용할 수 있다. 

 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 
Connection con = dataSource.getConnection(); // 트랜잭션을 시작하기 위해 커넥션을 호출한다. 
 
con.setAutoCommit(false); //자동커밋을 꺼줌으로서 트랜잭션 시작 
 
bizLogic(con, fromId, toId, money); 
//트랜잭션이 시작된 커넥션을 전달한다. bizLogic를 따로 짬으로서 트랜잭션을 관리하는 로직과 실제 비즈니스 로직을 구분한다.
 
con.commit(); //성공시 커밋 
con.rollback(); //실패시 로백 
 
release(con); 
//finally{}를 사용해서 커넥션을 모두 사용하고 나면 안전하게 종료한다. 
//그런데 커넥션 풀을 사용하면 con.close()를 호출했을 때 커넥션이 종료되는 것이 아니라 풀에 반납된다. 
//현재 수동커밋모드로 동작하기 때문에 풀에 돌려주기 전에 자동 커밋모드로 변경하는 것이 안전하다. 
cs

 

 

밑의 코드는 각각 정상이체와 이체중 예외 발생 메소드이다. 

예외가 발생하여 계좌이체가 실패하면 롤백한다. 

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
31
32
33
34
35
36
37
38
39
40
41
42
    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        //given
        Member memberA = new Member("memberA"10000);
        Member memberB = new Member("memberB"10000);
        memberRepository.save(memberA);
        memberRepository.save(memberB);
        
        //when
        memberService.accountTransfer(memberA.getMemberId(),
        memberB.getMemberId(), 2000);
        
        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberB.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
        }
-----------------------------------------------------------------------------------
    @Test
    @DisplayName("이체중 예외 발생")
    void accountTransferEx() throws SQLException {
        //given
        Member memberA = new Member("memberA"10000);
        Member memberEx = new Member("ex"10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);
        
        //when
        assertThatThrownBy(() ->
        memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
        .isInstanceOf(IllegalStateException.class);
        
        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
        
        //memberA의 돈이 롤백 되어야함
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
        }
cs

위와 같이 애플리케이션에서 DB 트랜잭션을 적용하려면 서비스 계층이 매우 지저분해지고, 코드가 복잡해진다. 커넥션을 유지하도록 코드를 변경하는 것도 쉽지 않다. 

 

스프링이 이를 해결해준다.