DataBase

[DB/트랜잭션] 스프링-디비1 3. 트랜잭션 이해

ydin 2024. 1. 31. 12:19

트랜잭션 개념

데이터 저장으로 데이터베이스를 사용하는 이유는 트랜잭션을 지원하기 때문이다.

데이터베이스에서 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 것을 의미한다. 작업의 단위

  • A → B 5000원 계좌이체
    • A의 잔고 5000원 감소
    • B의 잔고 5000원 증가

→ 위 두 작업은 2가지 단계로 나뉘기는 하지만, 계좌이체라는 하나의 작업에서 모두 수행되어야 할 로직이다. 이를 트랜잭션을 이용해 하나로 묶는다면 A의 잔고는 정상적으로 감소하고, B의 잔고는 정상적으로 증가할 것이다.

 

트랜잭션 용어

  • commit : 모든 작업이 성공해 DB에 정상 반영되는 것
  • rollback : 작업 중 하나라도 실패해 거래 이전으로 되돌리는 것

 

트랜잭션의 ACID

트랜잭션은 ACID라는 특징을 보장해야 하는데, 각각의 특징은 다음과 같다.

  • Atomicity(원자성)
    • 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야 한다
    • 여러 쿼리를 마치 하나의 작업인 것처럼 처리할 수 있다
  • Consistency(일관성)
    • 모든 트랜잭션은 일관성 있는 DB 상태를 유지해야 한다. 예를 들어 DB에서 정한 무결성 제약 조건을 항상 만족해야 한다
  • Isolation(격리성)
    • 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다
    • 예를 들어 동시에 같은 데이터를 수정하지 못하도록 해야한다
    • 격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준을 선택할 수 있다
  • Durability(지속성)
    • 트랜잭션을 성공적으로 끝내면 그 결과가 항상 영구 보존되어야 한다
    • 중간에 시스템 문제가 발생해도 DB 로그 등을 사용해 성공한 트랜잭션 내용을 복구해야 한다
  • 트랜잭션 격리 수준 - Isolation Level
    • 격리 수준은 4가지가 있고, 보통 READ COMMITTED를 기본으로 사용한다
    • READ UNCOMMITTED - 커밋되지 않은 읽기
    • READ COMMITTED - 커밋된 읽기
    • REPEATABLE READ - 반복 가능한 읽기
    • SERIALIZABLE - 직렬화 가능

 

데이터베이스 연결 구조와 DB 세션

 

  • 사용자가 WAS, DB 접근 툴로 DB에 연결 요청 후 커넥션을 맺게 된다.
  • 이때 DB 내부에는 세션을 만드는데, 커넥션을 통한 모든 요청은 이 세션을 통해 실행된다
  • 즉, 전체 과정은 사용자 → 클라이언트(커넥션) → 서버(커넥션, 세션) 이렇게 된다.
  • 사용자가 커넥션을 닫거나 DBA(DB 관리자)가 세션을 강제로 종료하면 세션이 종료된다

 

 

트랜잭션 - DB

 

트랜잭션 사용법

  • 데이터 변경 실행 후 DB에 결과를 반영하려면 commit을, 결과를 반영하고 싶지 않으면 rollback을 호출하면 된다
  • commit을 호출하기 전까지는 임시로 데이터를 저장한다. commit을 호출해야 DB에 반영된다.
  • 따라서 트랜잭션을 시작한 세션(사용자)에게만 변경 데이터가 보이고, 다른 세션(사용자)에게는 변경 데이터가 보이지 않는다

 

예제

격리 수준이 READ COMMITTED라는 가정하에 진행한다. 이 예제는 사용자(세션)이 두 명이 있다고 생각하고, 사용자1에서는 트랜잭션 내에서 데이터 변경(생성, 수정, 삭제)를 수행하고 사용자2에서는 commit 된 데이터를 확인하는 용도로 진행한다. 만약 사용자1이 변경내역을 commit 하지 않는다면 사용자2는 해당 내역을 확인할 수 없는 것을 확인할 수 있다.

위 상황도 트랜잭션의 격리 수준에 따라 달라질 수 있는데, 만약 격리 수준이 READ UNCOMMITTED라면 다른 세션에서도 커밋되지 않은 데이터를 조회할 수 있다. 하지만 이는 그리 좋은 방법은 아니기에(뒤에서 설명하겠다) 격리 수준이 READ COMMITTED라는 가정하에 진행하겠다.

 

 

1. 데이터 변경 전

 

2. 세션1에서 새로운 회원 추가

  • 사용자1이 새로운 회원을 추가한다. 이때 추가한 정보는 세션1에서만 확인이 가능하고, 아직 커밋을 하지 않았기에 세션2에서는 확인이 불가능하다.

 

READ UNCOMMITTED일 때 발생하는 문제

READ UNCOMMITTED인 경우 커밋되지 않은 데이터를 조회할 수 있는데, 이 경우 데이터 정합성이 맞지 않는 문제가 발생할 수 있고, 이는 심각한 문제이다.

 

예를들어 세션1이 회원을 추가하는 중에 세션2에서 해당 회원 정보를 조회한 후 관련 작업을 진행한다. 만약 세션1이 갑자기 회원 정보를 롤백하면 세션2에서 있다고 생각한 회원 정보가 갑자기 사라지는 문제가 발생하고, 이는 결국 데이터가 정확하지 않은 문제로 이어질 수 있다.

따라서 트랜잭션 격리 수준은 최소 READ COMMITTED가 되어야 한다!!

 

3. 세션1에서 commit

  • 세션1에서 commit 한 뒤 반영된 데이터는 이제 세션2에서도 조회가 가능하다

 

4. 세션1에서 rollback

  • 만약 세션1에서 rollback을 호출한다면 이전 상태로 돌아가게 된다. 다시 1번의 상태로 돌아가게 된다.

 

자동 커밋

자동 커밋으로 설정하면 각각의 쿼리 실행 직후에 자동으로 커밋을 호출해서 데이터 변경 내역을 DB에 바로 반영한다.

  • 장점
    • 커밋이나 롤백을 직접 호출 하지 않아도 되는 편리함
  • 단점
    • 쿼리 하나 실행할 때마다 자동으로 커밋 되어버리기 때문에 우리가 원하는 트랜잭션 기능을 제대로 수행할 수 없다

→ 따라서 우리가 원하는 로직을 실행하기 위해서 & 데이터 정합성을 보장해야하는 경우에는 수동 커밋(자동 커밋 끈 상태)으로 설정 해야 한다.

 

트랜잭션 시작

자동 커밋 모드에서 수동 커밋 모드로 전환하는 것을 트랜잭션을 시작한다고 표현한다.

 

 

DB 락 개념

동일한 row에 변경 작업이 동시에 들어올 경우를 생각해보자. 이를 제어하지 않으면 사용자 입장에서는 내가 변경한 데이터가 제대로 반영되지 않을 수 있다. 세션1은 money를 10000에서 500으로 바꾸고 싶은데 중간에 세션2에서 money를 10000에서 1000으로 바꾼다면 세션1이 의도한 money는 500이 아닌 1000이 될 수 있다.

DB는 이런 문제를 해결하기 위해 락(Lock) 개념을 제공한다.

 

 

락 - 데이터 변경

락을 획득해야 로우의 데이터를 변경할 수 있다.

락을 얻고, row를 변경하기 위해서는 다음 과정을 거쳐야 한다.

  • 트랜잭션을 시작한다
  • 남아있는 락이 있을 경우 락을 획득한다(남아있는 락이 없다면 timeout 시간만큼 대기해야 한다)
    • timeout 시간 동안 대기해도 락을 얻지 못하면 lock timeout 오류가 발생한다
  • 획득한 락으로 row 데이터를 변경한다
  • 작업을 마치고 commit하면 락이 반환되고, 다른 세션이 락을 획득할 수 있게 된다

 

락 - 데이터 조회

일반적으로 조회에는 락을 사용하지 않는다. 즉, 세션1에서 락을 획득하고 데이터를 변경하고 있어도 세션2에서 데이터 조회는 가능하다.

  • select for update
    • 데이터 조회 시에도 락을 획득해 다른 세션의 접근을 막고 싶을 때가 있을 수 있는데, 이때 select for update 구문을 사용하면 된다
    • select for update를 사용하면 조회 시점에 락을 가져가기 때문에 다른 세션에서 락을 얻지 못해 데이터를 변경할 수 없다
  • 코드 예시
set autocommit false;
select *
from member
where member_id = 'memberA' for update;

 

 

 

트랜잭션 - 적용

트랜잭션을 통해 계좌이체와 같이 원자성이 중요한 작업의 데이터 정합성을 보장할 수 있다는 것을 배웠다. 그럼 이 트랜잭션을 애플리케이션에 적용해야하는데, 다음과 같은 질문이 생긴다.

 

애플리케이션에서 트랜잭션을 어떤 계층에 걸어야 할까? 트랜잭션을 어디에서 시작하고, 어디에서 커밋해야할까?

 

 

 

 

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

  • 트랜잭션 동기화와 동일한 커넥션
    • 트랜잭션을 시작하려면 커넥션이 필요하고, 서비스 계층에서 커넥션을 만들고 커밋과 커넥션을 종료해야한다
    • 여러 쿼리를 실행해 하나의 작업을 할 때는 하나의 트랜잭션에서 실행해야하기 때문에 동일한 커넥션으로 진행해야 한다. 그래야 같은 세션을 사용할 수 있다.
    • 여기서 생각하는 방법은 커넥션을 파라미터로 전달해서 같은 커넥션이 사용되도록 유지하는 것이다

 

 

  • 트랜잭션을 고려한 서비스 로직
/**
* 트랜잭션 - 파라미터 연동, 풀을 고려한 종료 */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {
    private final DataSource dataSource;
    private final MemberRepositoryV2 memberRepository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Connection con = dataSource.getConnection();
        try {
            con.setAutoCommit(false); //트랜잭션 시작 //비즈니스 로직
            bizLogic(con, fromId, toId, money);
     
            con.commit(); //성공시 커밋 
        } catch (Exception e) {
            con.rollback(); //실패시 롤백
            throw new IllegalStateException(e);
        } finally {
            release(con);
        }
    }

     private void bizLogic(Connection con, String fromId, String toId, int money) 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 void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
           throw new IllegalStateException("이체중 예외 발생"); 
        }
    }

    private void release(Connection con) {
        if (con != null) {
             try {
                  con.setAutoCommit(true); //커넥션 풀 고려 con.close();
            } catch (Exception e) {
                 log.info("error", e);
            } 
        }
    }

 

 

정리

데이터 정합성을 위해 트랜잭션을 공부하고 적용해보았다. 트랜잭션으로 데이터를 정확하게 처리할 수 있게 되었지만, 서비스 계층에 트랜잭션 로직이 녹아있어 지저분하고 복잡한 코드가 되었다. 그리고 커넥션을 유지하도록 코드를 변경하는 것도 쉽지 않다. 이 문제를 스프링에서 해결해주는데 다음 포스트에서 이어가도록 하자.

 

 

Reference

인프런 김영한 - '스프링 DB 1 - 데이터 접근 핵심 원리'