3. 트랜잭션 이해

ChoiJaewoo·2026년 2월 23일

Spring DB 1편

목록 보기
3/6
  • 트랜잭션 - 개념 이해
  • 데이터베이스 연결 구조와 DB 세션
  • 트랜잭션 - DB 예제 1 - 개념 이해
  • 트랜잭션 - DB 예제 2 - 자동 커밋, 수동 커밋
  • 트랜잭션 - DB 예제 3 - 트랜잭션 실습
  • 트랜잭션 - DB 예제 4 - 계좌이체
  • DB 락 - 개념 이해
  • DB 락 - 변경
  • DB 락 - 조회
  • 트랜잭션 - 적용1
  • 트랜잭션 - 적용2

트랜잭션 - 개념 이해

데이터를 데이터베이스에 저장 하는 이유가 무엇일까?

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

트랜잭션? : 데이터베이스에서 트랜잭션이란, 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻한다.

→ 정상 처리 이후 : Commit

→ 비정상 처리 이후 : Rollback

트랜잭션 ACID

트랜잭션 ACID :

  • Atomicity : 원자성
  • Consistency : 일관성
  • Isolation : 격리성
  • Durability : 지속성

→ 4가지를 트랜잭션에서 보장해야 한다.

트랜잭션 격리 수준 - Isolation level

  • READ UNCOMMITED - 커밋되지 않은 읽기
  • READ COMMITED - 커밋된 읽기
  • REPEATABLE READ - 반복 가능한 읽기
  • SERIALIZABLE - 직렬화 가능

위로 갈수록 격리 수준이 낮아진다. 그만큼 안전성이 떨어질 수 있지만, 직렬화로 갈수록 처리속도가 늦어지고, 여러 제한이 발생한다. 대부분의 데이터베이스는 “ READ COMMITED “ 수준의 격리 수준을 따른다.

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

  • 사용자는 웹 애플리케이션 = WAS 나 DB 접근 툴 등으로 데이터베이스 서버에 접근할 수 있다. 이때 클라이언트는 데이터베이스 서버에 연결을 요청하고 (TCP/IP) 커넥션을 맺게 된다. 그리고 DB 서버 내부에 “세션” 이라는 것을 만든다. 그리고 앞으로 해당 커넥션에 대한 요청은 해당 세션을 통해서 실행된다.
  • 따라서 커넥션이 생성될 때, 해당 커넥션의 요청을 처리하는 세션을 만든다. 따라서 커넥션과 세션은 일반적으로 1:1 이다.
  • 이후 세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 그리고 이후 새로운 트랜잭션을 다시 시작할 수 있다.
  • 사용자가 사용을 마치고 커넥션을 닫거나, 또는 DBA가 세션을 강제로 종료하면, 이제 세션이 종료된다.

트랜잭션 - DB 예제 1 - 개념 이해

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

트랜잭션 사용법

  • 데이터 변경 쿼리를 실행하고 데이터베이스에 그 결과를 반영하려면, 커밋 명령어인 commit; 을 호출하고, 결과를 반영하고 싶지 않다면, 롤백 명령어인 rollback; 을 호출하면 된다.
  • 커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것이다.
  • 따라서 해당 트랜잭션으 시작한 세션 - 사용자 에게만 변경 데이터가 보이고 다른 세션 사용자에게는 변경 데이터가 보이지 않는다.
  • 등록, 수정, 삭제 모두 같은 원리로 동작한다.

트랜잭션 - DB 예제 2 - 자동 커밋, 수동 커밋

예제에 사용되는 스키마

drop table member if exists;
  create table member (
      member_id varchar(10),
      money integer not null default 0,
      primary key (member_id)
);

자동 커밋 vs 수동 커밋

트랜잭션을 사용하려면 먼저 자동 커밋과 수동 커밋을 이해해야 한다.

자동 커밋?

  • 각각의 쿼리문 실행 직후에 자동으로 커밋을 호출한다. 따라서 커밋이나 롤백을 직접 호출하지 않아도 되는 편리함이 있다.
  • 하지만 쿼리를 하나하나 실행할 때 마다 자동으로 커밋이 되어버리기 때문에 우리가 원하는 트랜잭션 기능을 제대로 사용할 수 없다.

자동 커밋 설정

set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋 insert into member(member_id, money) values ('data2',10000); //자동 커밋

따라서 commit, rollback 을 직접 호출하면서 트랜잭션 기능을 제대로 수행하려면 자동 커밋을 끄고 수동 커밋을 사용해야 한다.

수동 커밋 설정

set autocommit false; //수동 커밋 모드 설정
insert into member(member_id, money) values ('data3',10000); insert into member(member_id, money) values ('data4',10000); commit; //수동 커밋

보통 자동 커밋 모드가 기본으로 설정된 경우가 많기 때문에, 수동 커밋 모드로 설정하는 것을 트랜잭션을 시작한다고 할 수 있다.

트랜잭션 - DB 예제3 - 트랜잭션 실습

1. 기본 데이터 입력

데이터 초기화 SQL

//데이터 초기화
set autocommit true;
delete from member;
insert into member(member_id, money) values ('oldId',10000);

2. 신규 데이터 추가 - 커밋 전

세션 1에서 신규 데이터 추가 SQL

//트랜잭션 시작
set autocommit false; //수동 커밋 모드
insert into member(member_id, money) values ('newId1',10000); insert into member(member_id, money) values ('newId2',10000);

실행 결과

Session1

Session 1 에서 데이터 조회

Session 2

세션1, 2 의 결과 이미지를 비교해보면, 아직 세션1 에서 커밋을 하지 않았기 때문에, 세션1에서는 입력한 데이터가 보이지만 세션2에서는 입력한 데이터가 보이지 않는 것을 확인할 수 있다.

3.1. 커밋 - commit

세션1 신규 데이터 추가 후 commit 완료

세션1에서 commit; 실행

이후 세션2 에서 데이터가 반영되어 실제 데이터가 조회 됨을 알 수 있다.

3.2. 롤백 - rollback

데이터 초기화

//데이터 초기화
set autocommit true;
delete from member;
insert into member(member_id, money) values ('oldId',10000);

세션1 신규 데이터 추가

//트랜잭션 시작
set autocommit false; //수동 커밋 모드
insert into member(member_id, money) values ('newId1',10000); insert into member(member_id, money) values ('newId2',10000);

DB 조회

SELECT * FROM MEMBER;

세션1 조회 결과

세션2 조회 결과

세션1이 아직 커밋하지 않은 상태이기 때문에, 세션1에서는 데이터가 보이지만, 세션2에서는 입력한 데이터가 보이지 않는다.

세션1 신규 데이터 추가 후 Rollback

rollback;

세션1, 2 DB 조회

세션1, 2 모두 새로운 데이터가 DB에 반영되지 않은 것을 확인할 수 있다.

트랜잭션 - DB 예제 4 - 계좌이체

  • 계좌이체 예제를 통해 트랜잭션이 어떻게 사용되는지 알아보자

계좌이체 3가지 Case

  • 계좌이체 정상
  • 계좌이체 문제 상황 - 커밋
  • 계좌이체 문제 상황 - 롤백

계좌이체 정상

기본 데이터 입력

set autocommit true;
  delete from member;
  insert into member(member_id, money) values ('memberA',10000);
  insert into member(member_id, money) values ('memberB',10000);

계좌이체 실행

set autocommit false;
  update member set money=10000 - 2000 where member_id = 'memberA';
  update member set money=10000 + 2000 where member_id = 'memberB';

계좌이체 실행 SQL - 성공

세션1

commit;

세션1,2 DB 조회

정상 동작 한것을 확인 할 수 있다.

계좌이체 문제 상황

기본 데이터 입력

set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);
insert into member(member_id, money) values ('memberB',10000);

계좌이체 실행

실행 SQL - 오류

set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA'; //성공
update member set money=10000 + 2000 where member_iddd = 'memberB'; //쿼리 예외

3번째 줄 → member_iddd 로 인해 3번째줄은 쿼리 실행에서 예외된다.

세션1 commit

commit;

세션1, 2 DB 조회 결과

→ memberA 의 돈은 2000원이 사라졌고, memberB 의 돈은 2000원 증가해야 하지만, memberB의 돈은 그대로이다. 즉 2000원이 사라졌다!

계좌이체 문제 상황 - 롤백

다시 데이터를 삭제하고 기본 데이터를 입력하자.

set autocommit true;
  delete from member;
  insert into member(member_id, money) values ('memberA',10000);
  insert into member(member_id, money) values ('memberB',10000);

계좌이체 실행 SQL - 오류

set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA'; //성공
update member set money=10000 + 2000 where member_iddd = 'memberB'; //쿼리 예외 발생
  • 여기서 문제는 memberA 의 돈은 2000원 줄었지만, memberB 의 돈은 2000원 증가하지 않았다.

실행 결과

롤백

이럴때는 롤백을 호출해서 트랜잭션을 시작하기 전 단계로 데이터를 복구해야 한다.

세션1 롤백

rollback;

세션1, 2 DB 조회

정리

원자성 : 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것 처럼 모두 성공하거나, 모두 실패해야 한다.

이를 통해 여러 SQL 명령어를 마치 하나의 작업인 것 처럼 처리할 수 있다. 성공하면 한번에 반영하고, 중간에 실패해도 하나의 작업 처럼 되돌리는 것이 필요하다.

→ 이를 위해 트랜잭션을 수동 커밋 모드로 작업해야 하고, 작업의 처리에 따라 성공 시 commit, 실패 시 rollback 해야 한다.

DB 락 - 개념 이해

세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 진행하지 않았는데, 세션2에서 동시에 같은 데이터를 수정하게 되면 여러 문제가 발생한다. → 트랜잭션의 원자성이 깨진다.

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

DB 락 - 변경

실습

기본 데이터 입력 - SQL

set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);

변경과 락

세션1

set autocommit false;
update member set money=500 where member_id = 'memberA';

락1 실행 과정

  1. 트랜잭션 시작
  2. lock 획득
  3. update 500

세션2

SET LOCK_TIMEOUT 60000;
set autocommit false;
update member set money=1000 where member_id = 'memberA';
  • 세션2는 memberA의 데이터를 1000원으로 수정하려 한다.
  • 세션1이 트랜잭션을 커밋하거나 롤백해서 종료하지 않았으므로 아직 세션1이 락을 가지고 있다.
  • 따라서 세션2는 락을 획득하지 못하였기 때문에 데이터를 수정할 수 없고
  • 세션1이 락을 반환하여 세션2가 락을 획득 할 수 있을 때 까지 기다리는데, 이 시간의 타임아웃을 60초로 설정하였다.

세션2 가 60초 내에 락을 획득하지 못하고 timeout 발생한 결과

세션1이 락을 반납하여 세션2에서 처리된 결과

세션1 - commit or rollback

commit;
// 또는,
//rollback;

세션2에서 락을 획득하여 데이터를 변경

실행 결과 - 세션1,2 DB 조회

세션2 에서 commit

commit;

실행 결과

DB 락 - 조회

일반적인 조회는 락을 사용하지 않는다.

→ 그럼에도 조회에 락을 사용해야 하는 case?

조회한 데이터로, 새로운 데이터를 만들어야 할 때가 그 예시로 들 수 있다.

예시 상황

매장 pos 의 정산 → 하루의 매출을 계산하여 정산집계표를 만들어야 하는데, 이 과정 중에 매출의 수정이 발생하면? → 새로운 정산집계표가 틀린 정보가 된다. 따라서 해당 테이블 (db 데이터) 를 수정하는 것이 아님에도 조회에 락이 필요한 상황이 된다.

앞서 데이터 설정과 수정은 알아봤으니, 어떻게 조회에 락을 걸 수 있는지에 대한 코드만 제시한다~

세션1 조회 - 락

set autocommit false;
select * from member where member_id='memberA' for update;
  • select for update 구문을 사용하면 조회를 하며 동시에 선택한 로우의 락도 획득한다.

트랜잭션 - 적용 1

실제 애플리케이션에서 DB 트랜잭션을 사용해서 계좌이체 같이 중요한 비즈니스 로직을 어떻게 구현하는지 알아보자.

먼저 트랜잭션 없이 단순한 계좌이체 비즈니스 로직만 구현해보자.
MemberService1

package hello.jdbc.mycode.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.sql.SQLException;

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV1 {
    private final MemberRepositoryV1 memberRepository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(fromId);

        Member toMember = memberRepository.findById(toId);

        memberRepository.update(fromId, fromMember.getMoney() - money);
        log.info("my log : from member 감소 ");

        log.info("validation before");
        // -> 오류 발생 시킬 것임.
        validation(toMember);
        log.info("validation after");
        // -> toMember의 id 가 ex 이면 예외 발생됨.

        memberRepository.update(toId, toMember.getMoney() + money);
        log.info("my log : to member 증가 ");
    }

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

}

MemberServiceV1Test

package hello.jdbc.mycode.service;

import com.zaxxer.hikari.HikariDataSource;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import hello.jdbc.service.MemberServiceV1;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.sql.SQLException;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.*;

class MemberServiceV1Test {

    public static final String MEMBER_A = "memberA";
    public static final String MEMBER_B = "memberB";
    public static final String MEMBER_EX = "ex";

    private MemberRepositoryV1 memberRepository;
    private MemberServiceV1 memberService;

    @BeforeEach
    void before() {
        //커넥션 풀링
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(URL);
        dataSource.setUsername(USERNAME);
        dataSource.setPassword(PASSWORD);

        memberRepository = new MemberRepositoryV1(dataSource);
        memberService = new MemberServiceV1(memberRepository);
    }

    @AfterEach
    void after() {
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {

        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);

        memberRepository.save(memberA);
        memberRepository.save(memberB);

        //when

        assertThatCode(() -> memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000)).doesNotThrowAnyException();

        //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(MEMBER_A, 10000);
        Member memberEx = new Member(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());

        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}

→ MyCode : 성공 시 assertThatCode 사용하여 검증.

실행 과정

  • memberA → memberEx 로 2000원 계좌 이체 한다.
    • memberA 의 금액이 2000원 감소한다
    • memberEx 회원의 ID는 ex 이므로 중간에 예외가 발생한다 → 이 부분이 중요!
  • 계좌이체는 실패하고, memberA의 돈만 2000원 줄어든다.

트랜잭션 - 적용 2

  • 애플리케이션에서 트랜잭션을 어떤 계층에 걸어야 할까?, 트랜잭션을 어디서 시작하고 어디서 커밋해야 하나?
  • → 트랜잭션은 비즈니스 로직이 있는 곳. 즉 서비스 계층에서 시작해야 한다.
  • 그런데 트랜잭션을 시작하려면 커넥션이 필요하다.
  • 그러면 서비스 계층에서 커넥션을 만들고 트랜잭션 커밋 이후에 커넥션을 종료해야 한다.

→ 같은 커넥션을 유지하려면, 커넥션을 파라미터로 전달해서 같은 커넥션을 사용하도록 하자.

package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.support.JdbcUtils;

import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;

/**
 * JDBC - ConnectionParam
 */
@Slf4j
public class MemberRepositoryV2 {

    private final DataSource dataSource;

    public MemberRepositoryV2(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values (?,?)";

        Connection connection = null;
        PreparedStatement preparedStatement = null;

        try {
            connection = getConnection();
            preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setString(1, member.getMemberId());
            preparedStatement.setInt(2, member.getMoney());
            preparedStatement.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(connection, preparedStatement, null);
        }
    }

    public Member findById(String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";

        Connection connection = null;
        PreparedStatement preparedStatement = null;
        ResultSet resultSet = null;

        try {
            connection = getConnection();
            preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setString(1, memberId);

            resultSet = preparedStatement.executeQuery();

            if (resultSet.next()) {
                Member member = new Member();
                member.setMemberId(resultSet.getString("member_id"));
                member.setMoney(resultSet.getInt("money"));
                return member;
            } else {
                throw new NoSuchElementException("member not found memberId = " + memberId);
            }

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(connection, preparedStatement, resultSet);
        }
    }

    public Member findById(Connection con, String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";

        PreparedStatement preparedStatement = null;
        ResultSet resultSet = null;

        try {

            preparedStatement = con.prepareStatement(sql);
            preparedStatement.setString(1, memberId);

            resultSet = preparedStatement.executeQuery();

            if (resultSet.next()) {
                Member member = new Member();
                member.setMemberId(resultSet.getString("member_id"));
                member.setMoney(resultSet.getInt("money"));
                return member;
            } else {
                throw new NoSuchElementException("member not found memberId = " + memberId);
            }

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            JdbcUtils.closeResultSet(resultSet);
            JdbcUtils.closeStatement(preparedStatement);
            // 커넥션은 여기서 닫지 않는다!
        }
    }

    public void update(String memberId, int money) throws SQLException {
        String sql = "update member set money=? where member_id=?";

        Connection connection = null;
        PreparedStatement preparedStatement = null;

        try {
            connection = getConnection();
            preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setInt(1, money);
            preparedStatement.setString(2, memberId);
            int resultSize = preparedStatement.executeUpdate();
            log.info("resultSize={}", resultSize);
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(connection, preparedStatement, null);
        }
    }

    public void update(Connection con, String memberId, int money) throws SQLException {
        String sql = "update member set money=? where member_id=?";

        PreparedStatement preparedStatement = null;

        try {
            preparedStatement = con.prepareStatement(sql);
            preparedStatement.setInt(1, money);
            preparedStatement.setString(2, memberId);
            int resultSize = preparedStatement.executeUpdate();
            log.info("resultSize={}", resultSize);
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            JdbcUtils.closeStatement(preparedStatement);
            // 커넥션은 여기서 닫지 않는다!
        }
    }

    public void delete(String memberId) {
        String sql = "delete from member where member_id = ?";
        Connection connection = null;
        PreparedStatement preparedStatement = null;

        try{
            connection = getConnection();
            preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setString(1, memberId);
            preparedStatement.executeUpdate();
            log.info("delete memberId={}", memberId);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        } finally {
            close(connection, preparedStatement, null);
        }
    }

    private void close(Connection connection, Statement statement, ResultSet resultSet) {
        JdbcUtils.closeResultSet(resultSet);
        JdbcUtils.closeStatement(statement);
        JdbcUtils.closeConnection(connection);
    }

    private Connection getConnection() throws SQLException {
        Connection connection = dataSource.getConnection();
        log.info("get connection ={}, class ={}", connection, connection.getClass());
        return connection;
    }
}

→ Repository 에서 커넥션 유지가 필요한 곳?

: findById, update

findById(Connection con, String memberId)
update(Connection con, String memberId, int money)

MemberService2

package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV2;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

/**
 * 트랜잭션 - 파라미터 연동, 풀을 고려한 종료.
 */

@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 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("이체 중 예외 발생");
        }
    }
}
  • Connection con = dataSource.getConnection();
    • 트랜잭션을 시작하려면 커넥션이 필요하다
  • con.setAutoCommit(false);
    • 트랜잭션 시작 → 자동 커밋 모드 끄기
  • bizLogic(con, fromId, toId, money);
    • 트랜잭션이 시작된 커넥션을 전달하면서 비즈니스 로직을 수행한다.
  • con.commit();
    • 성공 시 커밋
  • con.rollback();
    • 실패 시 롤백
  • release(con)
    • finally 를 사용해서 커넥션을 모두 사용 후 종료한다. 그러나 커넥션 풀을 사용하면 con.close() 를 호출 했을 때 커넥션이 종료되는 것이 아니라 풀에 반납된다.
    • 풀에 반납 전 기본 값이 자동 커밋 모드로 변경 후 반납.

MemberServiceV2TEST

package hello.jdbc.service;

import com.zaxxer.hikari.HikariDataSource;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import hello.jdbc.repository.MemberRepositoryV2;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

import java.sql.SQLException;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.*;

/**
 * 트랜잭션 - 커넥션 파라미터 전달 방식 동기화
 */

@Slf4j
class MemberServiceV2Test {

    private MemberRepositoryV2 memberRepository;
    private MemberServiceV2 memberService;

    public static final String MEMBER_A = "memberA";
    public static final String MEMBER_B = "memberB";
    public static final String MEMBER_EX = "ex";

    @BeforeEach
    void before() {
        //커넥션 풀링
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(URL);
        dataSource.setUsername(USERNAME);
        dataSource.setPassword(PASSWORD);
        memberRepository = new MemberRepositoryV2(dataSource);
        memberService = new MemberServiceV2(dataSource, memberRepository);
    }

    @AfterEach
    void after() {
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberB);

        //when
        log.info("START TX");
        memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
        log.info("END TX");

        //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(MEMBER_A, 10000);
        Member memberEx = new Member(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());

        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);

    }
}

이체 중 예외 발생 case

  • 데이터 저장 및 테스트 준비
    • memberA - 10000
    • memberEx - 10000
  • 계좌 이체 로직 실행
    • memberService.accountTransfer() 실행
    • 커넥션 생성 후 트랜잭션 시작
    • memberA → memberEx 로 2000원 계좌이체
      • memberA 금액 2000원 감소 → 8000
      • memberEx → 예외 발생
    • 예외 발생 후 트랜잭션 롤백!
  • 계좌이체 실패. 롤백 후 memberA 돈이 기존 10000 으로 복구 됨.

→ 트랜잭션 적용으로 인해 계좌이체가 실패 시 롤백을 통해 모든 데이터가 정상적으로 초기화 됨!

🚨남은 문제!

애플리케이션에서 DB 트랜잭션을 적용하려면 서비스 계층이 지저분해지고 복잡한 코드가 필요하다. 또한 커넥션을 유지하도록 코드를 변경하는 것도 쉽지 않다. → 이를 스프링을 사용해 해결해 보자!

0개의 댓글