Spring

[Spring] 트랜잭션에 대한 이해와 과거의 활용

이덩우 2023. 12. 13. 02:45

데이터를 저장할 때 단순 파일에 저장해도 되지만 현대의 웹 애플리케이션은 왜 데이터베이스에 저장할까?

이는 데이터베이스가 트랜잭션이라는 개념을 지원하기 때문이다.

 

현대의 우리는 스프링 프레임워크를 사용할 때 @Transactional 애노테이션을 통해 간단히 트랜잭션을 사용하지만,

과거 개발자들은 어떻게 트랜잭션을 사용했는지, 트랜잭션이란 무엇인지 알아보자.

 


트랜잭션이란?

- 트랜잭션의 개념

트랜잭션은 사전적으로 하나의 업무 단위, 혹은 거래 단위를 뜻한다.

웹 애플리케이션에서의 트랜잭션은 무엇을 의미할까?

동일하게 하나의 거래 단위로 해석할 수 있다. 아래의 예시를 살펴보자.

 

상황 : A가 B에게 2000원을 입금한다.

1. A의 계좌정보를 불러온다.

2. A의 계좌에서 2000원을 출금한다.

3. B의 계좌정보를 불러온다.

4. B의 계좌에 2000원을 입금한다.

 

A가 B에게 2000원을 입금하는 행위 자체를 하나의 트랜잭션으로 볼 수 있다.

그렇다면, 하나의 트랜잭션 안에는 4가지 단계로 이벤트가 발생하는데 정상적으로 처리가 되면 문제가 없지만 만약, A의 계좌에서 2000원을 출금했는데 이후 예기치 못한 오류로 B의 계좌에는 입금이 안되었다면? 

이는 정말 난감한 상황이다. 

 

따라서 트랜잭션은 모두 성공해 정상적으로 반영이 되거나, 혹시 예기치 못한 오류가 발생한다면 트랜잭션을 시작한 시점으로 되돌려져야 한다.

이 때 모든 작업이 성공해서 정상 반영되는 것을 Commit, 작업 중 하나라도 실패해서 이전으로 되돌리는 것을 Rollback이라한다.

 

- 트랜잭션 ACID

트랜잭션은 ACID라 하는 특성들을 보장해야한다.

  • Atomicity(원자성) : 트랜잭션 내에서 실행하는 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야한다.
  • Consistency(일관성) : 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야한다.
  • Isolation(격리성) : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. 예를 들어, 동시에 같은 데이터를 수정하지 못하도록 격리해야한다. 하지만 격리성은 동시성과 관련된 성능 이슈로 인해 격리 수준을 선택할 수 있다.
  • Durability(지속성) : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 DB 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 한다.

 

중요하게 볼 점은 격리성이다. 나머지는 당연한 말처럼 느껴지지만, 격리 수준을 선택한다는게 무슨 의미일까?

 

 

- 트랜잭션 격리 수준 (Isolation Level)

트랜잭션 간에 완벽하게 격리성을 보장하려면 여러 요청이 왔을 때 순차적으로 하나의 트랜잭션을 처리하면 된다.

하지만 현대 웹 애플리케이션처럼 멀티 스레드 구조를 갖는 상황에서 여러 요청이 왔을 때 하나씩 트랜잭션을 처리하게 되면 동시 처리 성능이 매우 나빠진다.

이런 문제로 ANSI 표준은 트랜잭션의 격리 수준을 4단계로 나누어 정의했다.

1번에서 4번으로 갈수록 동시 처리 성능은 좋지만 격리 수준은 낮아진다.

 

1. READ UNCOMMITED (커밋되지 않은 읽기)

2. READ COMMITED (커밋된 읽기)

3. REPEATABLE (반복 가능한 읽기)

4. SERIALIZABLE (직렬화 가능)

 

1번의 커밋되지 않은 읽기는, 다른 트랜잭션에서 아직 커밋되지 않아도 직전 커밋된 정보를 가져와 사용할 수 있는 단계로, 동시 처리 성능은 가장 좋지만 데이터를 여러 곳에서 편집하게 된다는 위험성이 있다.

반면 4번의 직렬화 가능은 모든 트랜잭션 요청을 순차적으로 처리하기 때문에 가장 높은 격리 수준을 제공하지만, 동시 처리 성능은 가장 나쁘다. 

대부분의 관계형 데이터베이스에서는 2번 커밋된 읽기를 사용한다.

 

 


DB 세션과 연결 구조

트랜잭션을 조금 더 깊이 있게 이해하기 위해서는 DB 세션이라는 개념을 알아야한다. 아래 그림을 보자.

DB 연결 구조

 

우리가 DB에 접근하기 위해서는 커넥션이 필요하다는 내용은 이전 포스팅에서 알아봤다.

그럼 커넥션만 있으면 우리는 해당 DB 서버를 독점해서 사용하는 것인가? 

아니다! 바로 커넥션에 할당된 DB 세션을 통해 우리는 데이터를 주고 받을 수 있다.

쉽게 말하면, 개발자가 DB 접근 클라이언트를 통해 SQL문을 전달하면 현재 커넥션에 연결된 세션이 SQL을 실행한다.

세션은 트랙잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료한다.

 

H2 데이터베이스를 통해 세션이 할당된 것을 확인해보자.

H2

 

실제로 접속 URL을 보면, jsessionid를 통해 접속할 때마다 다른 세션ID를 가진 것을 확인할 수 있다.


자동 커밋, 수동 커밋

데이터베이스의 기본 옵션은 자동 커밋 모드이다.

우리는 단순히 SQL문을 작성하고 실행해 '커밋이 어디있나' 라고 할 수 있지만, 하나의 쿼리가 실행될 때마다 자동으로 커밋을 하는 방식이다.

 

하지만 우리는 여러 개의 작업을 하나의 트랜잭션으로 묶는 것이 목표이기 때문에 이러한 자동 커밋 방식은 어울리지 않는다.

따라서 자동 커밋 모드를 수동 커밋 모드로 전환하는 것이 트랜잭션의 시작이라고 할 수 있다.

 

쿼리를 통해 수동 커밋 모드로 전환하는 방법은 아래와 같다.

set autocommit false;

 


DB 락

여러 개의 트랜잭션이 있을 때 특정 세션이 트랜잭션을 먼저 시작하고 데이터를 수정하고 있다.

아직 커밋하지 않았는데, 다른 세션에서 같은 데이터를 이용해 트랜잭션을 시작하려고 한다면 여러가지로 복잡한 문제가 발생한다.

 

이런 문제를 방지하려면 특정 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 (커밋이나 롤백 전까지) 다른 세션에서 해당 데이터를 수정할 수 없게 막아야한다.

 

이 때 등장하는 개념이 락이다.

실제로 DB Row에는 락이 존재하는데, 트랜잭션에서 해당 Row 데이터를 변경할 때 락을 획득하게 된다.

이후 커밋이나 롤백을 하면 락을 되돌려주는 형식이다.

이게 무슨 의미일까?

 

세션A에서 RowA 에 대해서 락을 획득해 가지고있다면, 세션B가 RowA에 접근할 때는 락이 없기 때문에 락이 다시 돌아올 때 까지 기다려야한다. 나중에 락이 돌아온다면 그 때 작업을 시작하게된다.

 

하지만 무한정 기다리는 것은 아니다. 설정된 락 대기 시간을 넘어가면 락 타임아웃 오류가 발생한다!

 

- 조회쿼리는?

위에서 언급한 상황은 일반적으로 데이터 변경에 대한 시나리오였다.

단순 조회 쿼리일 때에도 락을 획득할 필요가 있을까? 큰 문제는 없을 듯 하지만, 특정 상황에서는 조회 쿼리에도 락을 획득해야할 수 있다.

가령, 24시간마다 DB의 데이터를 조회해 특정 통계를 내는 작업이 있다고 했을 때 작업이 진행되는 동안 해당 데이터들은 변경되면 안될 것이다. 따라서 이런 경우는 조회에도 락이 필요하다.

 

조회쿼리에 락을 추가하는 방식은 아래이 select for update 구문을 사용하면 된다.

select * from member where member_id=000 for update;

 

 


트랜잭션 직접 구현

트랜잭션을 직접 구현하는 것은 매우 귀찮은 일이다.

실제로는 @Transactional을 사용하지만, 그래도 직접 구현해보며 내부 동작에 대해 확실히 알아가보자.

@RequiredArgsConstructor
@Slf4j
public class MemberServiceV2 {

    private final MemberRepositoryV2 memberRepository;
    private final DataSource dataSource;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Connection con = dataSource.getConnection();
        try {
            // 트랜잭션 시작
            con.setAutoCommit(false);
            //실행할 비지니스 로직
            bizLogics(fromId, toId, money, con);
            con.commit();
        } catch (Exception e) {
            con.rollback();
            throw new IllegalStateException(e);
        } finally {
            release(con);
        }
    }

    private void bizLogics(String fromId, String toId, int money, Connection con) throws SQLException {
        Member fromMember = memberRepository.findById(con, fromId);
        Member toMember = memberRepository.findById(con, toId);

        memberRepository.update(con, fromId, fromMember.getMoney() - money);
        validation(toMember);
        memberRepository.update(con, toId, toMember.getMoney() + money);
    }

    private static void release(Connection con) {
        if (con != null) {
            try {
                con.setAutoCommit(true);
                con.close();
            } catch (Exception e) {
                log.info("error", e);
            }
        }
    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

 

첫 번째 포인트는 트랜잭션을 거는 계층이다.

Repository에는 DB에 데이터를 조회하고 수정하는 메소드를 만들어놨고, 

Service단에서는 이러한 메소드들을 호출하여 특정 작업단위를 만들기 때문에, 트랜잭션은 Service단에서 구현하는 것이 옳다.

 

두 번째 포인트는 트랜잭션 내에서는 모두 같은 커넥션을 사용해야한다는 점이다.

이전 버전의 코드대로라면, Repository의 메소드들에게 접근할 때마다 새로운 커넥션을 생성해 DB에 접근했지만, 그렇게 한다면 모두 다른 세션에서 동작하기 때문에 트랜잭션을 적용할 수 없다.

따라서 커넥션을 Service단에서 생성하고 파라미터에 같은 커넥션을 넣어 공유하는 방식으로 만들었다.

 

전체적인 코드 흐름을 살펴보자.

우선 try-catch-finally 구문을 활용했다.

try에는 수동 커밋모드로 바꿔주면서 트랜잭션을 시작하고, 비지니스 로직을 수행한 뒤 커밋하는 방식으로 이루어진다.

이것이 정상 흐름이지만, 트랙잭션 작업 중 예외가 발생한다면 catch 문으로 들어가게 된다.

이 때는 단순하게 트랜잭션이 실패했으므로 rollback 시켜주면 된다.

마지막으로 finally 문에는 Service단에서 직접 생성한 커넥션을 닫아주는 로직이 구현되어있다.

 

이로써 작업이 모두 성공하면 commit, 하나라도 실패하면 rollback시키는 기능이 구현되었다.

 

하지만 코드를 보면 알다시피, 서비스단에 주요 목적인 비지니스 로직을 작성하는 것 외에도 트랜잭션을 위한 코드들이 너무 지저분하게 많다. 다음 포스팅에서는 이러한 지저분한 코드를 스프링에서는 어떻게 트랜잭션을 지원해주며, 깨끗하게 만들 수 있는지에 대해 알아보자.

 

 

 

 

 

출처 : 인프런, 김영한의 스프링 DB 1편 - 데이터 접근 원리 핵심