🤔 트랜잭션이 뭐고, 왜 필요함?

트랜잭션(Transaction)을 직역하면 “거래” 라는 뜻이다. DB에서의 트랜잭션은 “하나의 거래를 안전하게 처리하도록 보장해주는 것” 을 말한다.

트랜잭션을 설명할 때 등장하는 흔한 예시인 계좌 이체를 떠올리면 트랜잭션이 왜 필요한지 바로 체감된다. 계좌 이체라는 일종의 거래는 송금한 사람의 잔고가 줄어든 만큼 입금된 사람의 잔고가 늘어나야 한다. 이 2가지의 작업 중 하나라도 문제가 생긴다면 대참사가 일어날 것이다.

이때 데이터베이스가 제공하는 트랜잭션의 기능을 사용하면 2가지 작업이 모두 성공해야 저장(Commit)하고, 하나라도 문제가 생기면 거래 이전의 상태로 돌아가게(Rollback) 할 수 있다.

 

📝 트랜잭션 ACID

트랜잭션은 아래 4가지를 보장해야 한다.

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

  2. 일관성(Consistency): 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다.

  3. 격리성(Isolation): 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리해야 한다.

  4. 지속성(Durability): 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록돼야 한다.

 

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

클라이언트는 DB 서버에 연결을 요청하고 커넥션을 맺게 되면, DB 서버는 내부에 세션이라는 것을 만든다. 만약 커넥션 풀이 10개의 커넥션을 생성하면 세션도 10개 만들어진다. 하여간 이 DB 세션이라는 친구가 트랜잭션을 생성하는 것부터 시작해서 SQL 쿼리를 실행하고 커밋을 하든 롤백을 하든 하는 거다. 사용자가 커넥션을 닫아버리거나 세션을 강제로 종료하면 세션은 종료된다.

 

🏒 DB로 트랜잭션 동작 확인해보기

트랜잭션 사용법은 간단하다. 데이터 조작 쿼리문을 실행하고 그 결과를 최종 반영하고 싶으면 커밋(Commit), 결과를 반영하고 싶지 않으면 롤백(Rollback)을 시전하면 된다. 커밋이나 롤백을 하지 않으면 임시로 데이터가 저장된다. 따라서 해당 트랜잭션을 시작한 세션에게만 변경 데이터가 보이고 다른 세션에게는 변경 데이터가 보이지 않는다. 만약 보인다면 데이터 정합성에 아주 큰 문제가 발생할 것이다. 아래 그림에서 커밋이나 롤백을 해야 다른 세션도 비로소 최종 처리된 데이터를 볼 수 있다.

고민 사항) 보통의 데이터베이스들은 오토 커밋 모드로 설정되어 있는데, 만약 중간에 문제가 생기면 어떻게 될까?

 

🔒 DB 락

DB에서는 원자성이 상당히 중요하다. 이 원자성을 충족하기 위해서는 하나의 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 다른 세션이 해당 데이터를 건들 수 없게 해야 한다. 이때 바로 락(Lock)이라는 개념이 제공된다.

메커니즘은 간단하다. 해당 데이터를 수정하고 싶으면 그 데이터의 락을 누구보다 빠르게 획득하면 된다. 그렇지 않으면 해당 데이터의 락을 선점한 사람이 커밋 혹은 롤백할 때까지 기다려야 한다. 이때 무한정 기다리는 것은 아니고, 락 대기 시간(설정 가능)을 넘어가면 락 타임아웃 오류가 발생한다.

 

🧩 트랜잭션 적용

그래서 트랜잭션을 어디에 걸어야 할까?

트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 왜냐하면 해당 비즈니스 로직이 잘못되어 결과가 이상하게 반영된다면 해당 부분을 함께 롤백해야 하기 때문이다. 이때 트랜잭션을 사용하려면 결국 커넥션도 서비스 계층에서 만들어주고 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다. 하지만 애플리케이션에서 DB 트랜잭션을 적용하려면 서비스 계층이 엄청 지저분해지고, 코드가 매우 복잡해진다. 스프링에게 도움을 요청할 순간이 온 것이다.

 

🧱 애플리케이션 구조

애플리케이션 구조는 아래 그림이 국룰이다.

여기서 가장 중요한 부분은 당연히 서비스 계층이다. 비즈니스 로직은 최대한 고결해야 한다. 위와 같이 계층을 나눈 이유도 다 서비스 계층을 보호하기 위함이다. 프레젠테이션 계층이 클라이언트가 접근하는 UI와 관련된 부분을 담당해주고, 데이터 접근 계층이 데이터를 저장하고 관리하는 기술을 담당해줌으로써 서비스 계층을 이중으로 보호한다.

 

package springdb.jdbc.service;

import java.sql.SQLException;
import lombok.RequiredArgsConstructor;
import springdb.jdbc.domain.Member;
import springdb.jdbc.repository.MemberRepositoryV1;

@RequiredArgsConstructor
public class MemberServiceV1 {

    private final MemberRepositoryV1 repository;

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

        repository.update(fromId, fromMember.getMoney() - money);
        repository.update(toId, toMember.getMoney() + money);
    }
}

기존 MemberServiceV1 코드를 보면 JDBC에 의존하고 있기는 하지만 나름 순수한 비즈니스 로직만 들어 있다. 향후 비즈니스 로직의 변경이 필요하면 이 부분만 변경하면 되는 것이다. 여기서 트랜잭션을 걸면 아래 MemberServiceV2로 수정할 수 있을 것 같다.

package springdb.jdbc.service;

import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import springdb.jdbc.domain.Member;
import springdb.jdbc.repository.MemberRepositoryV2;

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {

    private final DataSource dataSource;
    private final MemberRepositoryV2 repository;

    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 {
            releaseConnection(con);
        }
    }

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

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

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

근데 여전히 JDBC에 의존적으로 코드를 작성해야 한다. 트랜잭션을 시작하고, 커밋과 롤백, 자원을 반납하는 로직들… 비즈니스 로직과 관련 없는 코드들에 벌써부터 짜증이 몰려온다. 이거 만약 나중에 JPA를 도입한다고 하면 위 서비스 코드를 상당 부분 갈아 엎어야 한다.

 

현재 문제점들을 다시 나열해보자면,

  • JDBC 구현 기술이 서비스 계층에 누수되고 있음

  • 트랜잭션 동기화 문제

    • 같은 트랜잭션을 유지하기 위해 커넥션을 파라미터로 넘기고 있다.
  • 트랜잭션 시작, 커밋/롤백, 자원 해제와 같은 코드가 반복되고 있다.

 

👥 트랜잭션 추상화

일단 간단한 해결책은 트랜잭션 기능 자체를 아래처럼 인터페이스로 빼버리면 되지 않을까?

public interface TxManager {
	begin();
	commit();
	rollback();
}

이 인터페이스를 구현해서 구현체로 끼워 넣으면 된다.

위 그림처럼 JDBC 트랜잭션 기능을 사용하려면 JdbcTxManager를 서비스에 주입하고, JPA 트랜잭션 기능을 사용하려면 JpaTxManager를 서비스에 주입해서 사용하면 된다. 다행스럽게도 스프링 형님은 트랜잭션 추상화 기술을 아래와 같이 마련하셨다.

트랜잭션 추상화의 핵심인 PlatformTransactionManager의 코드는 아래와 같다.

public interface PlatformTransactionManager extends TransactionManager {
	TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
	void commit(TransactionStatus status) throws TransactionException;
	void rollback(TransactionStatus status) throws TransactionException;
}

getTransaction()을 통해 트랜잭션을 시작할 수도 있고, 진행 중인 트랜잭션에 참여할 수도 있다. commit()rollback()은 뭐 당연하지.

 

⌚ 트랜잭션 동기화

스프링이 제공하는 트랜잭션 매니저는 크게 2가지 역할을 한다.

  • 트랜잭션 추상화
  • 리소스 동기화

아까도 말했다시피 트랜잭션을 유지하기 위해서는 같은 데이터베이스 커넥션을 유지해야 한다. 이때 커넥션을 파라미터로 전달하게 되면 코드가 굉장히 지저분해졌다.

그림을 보다시피 스프링은 트랜잭션 동기화 매니저를 제공하는데, 이 친구는 ThreadLocal을 사용해서 커넥션을 동기화해준다. 이제부터 파라미터로 커넥션을 건네주지 않고 그냥 트랜잭션 동기화 매니저를 통해 커넥션을 획득하면 된다.

  1. 트랜잭션 매니저는 데이터소스를 통해 트랜잭션을 시작한다.

  2. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.

  3. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다.

  4. 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고, 커넥션도 닫는다.

 

아래는 실제 애플리케이션 코드에 트랜잭션 매니저를 적용한 코드다.

package springdb.jdbc.repository;

import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;
import springdb.jdbc.domain.Member;

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

/**
 * 트랜잭션 매니저 도입
 * DataSourceUtils.getConnection()
 * DataSourceUtils.releaseConnection()
 */
@Slf4j
public class MemberRepositoryV3 {

    private final DataSource dataSource;

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

    public Member save(Member member) throws SQLException {
        String sql = "INSERT INTO member (member_id, money) VALUES (?, ?)";
        
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error("DB 에러: ", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    public Member findById(String memberId) throws SQLException {
        String sql = "SELECT * FROM member WHERE member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            rs = pstmt.executeQuery();

            if (rs.next()) {
                Member member = new Member();
                member.setMemberId(rs.getString("member_id"));
                member.setMoney(rs.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(con, pstmt, rs);
        }
    }

    public void update(String memberId, int money) throws SQLException {
        String sql = "UPDATE member SET money = ? WHERE member_id = ?";
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setInt(1, money);
            pstmt.setString(2, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error("DB ERROR", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    public void delete(String memberId) throws SQLException {
        String sql = "DELETE FROM member WHERE member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error("DB ERROR", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);

        // 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
        DataSourceUtils.releaseConnection(con, dataSource);
    }

    private Connection getConnection() throws SQLException {
        // 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
        Connection connection = DataSourceUtils.getConnection(dataSource);
        log.info("get connection={}, class={}", connection, connection.getClass());
        return connection;
    }
}
package springdb.jdbc.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import springdb.jdbc.domain.Member;
import springdb.jdbc.repository.MemberRepositoryV2;
import springdb.jdbc.repository.MemberRepositoryV3;

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

/**
 * 트랜잭션 - 트랜잭션 매니저
 */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {

    private final PlatformTransactionManager transactionManager;
    private final MemberRepositoryV3 repository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        // 트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            bizLogic(fromId, toId, money);
            transactionManager.commit(status);  // 성공 시 커밋
        } catch (Exception e) {
            transactionManager.rollback(status);  // 실패 시 롤백
            throw new IllegalStateException(e);
        }
    }

    private void bizLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = repository.findById(fromId);
        Member toMember = repository.findById(toId);

        repository.update(fromId, fromMember.getMoney() - money);
        repository.update(toId, toMember.getMoney() + money);
    }

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

 

📋 트랜잭션 템플릿

근데 트랜잭션이 적용된 로직을 보면 다 비슷하게 생겼다.

// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

try {
    bizLogic(fromId, toId, money);
    transactionManager.commit(status);  // 성공 시 커밋
} catch (Exception e) {
    transactionManager.rollback(status);  // 실패 시 롤백
    throw new IllegalStateException(e);
}

애플리케이션에서 서비스 로직이 1개만 있는 것도 아니고, 수많은 서비스 로직에서 이런 반복되는 코드가 작성되어 있는 것이다. 템플릿 콜백 패턴을 활용하여 이런 반복 문제를 깔끔하게 해결해보자.

 

public class TransactionTemplate extends DefaultTransactionDefinition
		implements TransactionOperations, InitializingBean {

	protected final Log logger = LogFactory.getLog(getClass());

	@Nullable
	private PlatformTransactionManager transactionManager;

	public TransactionTemplate() {
	}

	public TransactionTemplate(PlatformTransactionManager transactionManager) {
		this.transactionManager = transactionManager;
	}

	public TransactionTemplate(PlatformTransactionManager transactionManager, TransactionDefinition transactionDefinition) {
		super(transactionDefinition);
		this.transactionManager = transactionManager;
	}

	public void setTransactionManager(@Nullable PlatformTransactionManager transactionManager) {
		this.transactionManager = transactionManager;
	}

	@Nullable
	public PlatformTransactionManager getTransactionManager() {
		return this.transactionManager;
	}

	@Override
	public void afterPropertiesSet() {
		if (this.transactionManager == null) {
			throw new IllegalArgumentException("Property 'transactionManager' is required");
		}
	}

	@Override
	@Nullable
	public <T> T execute(TransactionCallback<T> action) throws TransactionException {
		Assert.state(this.transactionManager != null, "No PlatformTransactionManager set");

		if (this.transactionManager instanceof CallbackPreferringPlatformTransactionManager cpptm) {
			return cpptm.execute(this, action);
		}
		else {
			TransactionStatus status = this.transactionManager.getTransaction(this);
			T result;
			try {
				result = action.doInTransaction(status);
			}
			catch (RuntimeException | Error ex) {
				// Transactional code threw application exception -> rollback
				rollbackOnException(status, ex);
				throw ex;
			}
			catch (Throwable ex) {
				// Transactional code threw unexpected exception -> rollback
				rollbackOnException(status, ex);
				throw new UndeclaredThrowableException(ex, "TransactionCallback threw undeclared checked exception");
			}
			this.transactionManager.commit(status);
			return result;
		}
	}

	private void rollbackOnException(TransactionStatus status, Throwable ex) throws TransactionException {
		Assert.state(this.transactionManager != null, "No PlatformTransactionManager set");

		logger.debug("Initiating transaction rollback on application exception", ex);
		try {
			this.transactionManager.rollback(status);
		}
		catch (TransactionSystemException ex2) {
			logger.error("Application exception overridden by rollback exception", ex);
			ex2.initApplicationException(ex);
			throw ex2;
		}
		catch (RuntimeException | Error ex2) {
			logger.error("Application exception overridden by rollback exception", ex);
			throw ex2;
		}
	}

	@Override
	public boolean equals(@Nullable Object other) {
		return (this == other || (super.equals(other) && (!(other instanceof TransactionTemplate template) ||
				getTransactionManager() == template.getTransactionManager())));
	}

}

위와 같이 스프링은 TransactionTemplate이라는 템플릿 클래스를 제공하고 있다. 위의 템플릿을 사용하여 반복되는 부분을 제거하면 아래 코드와 같아진다.

package springdb.jdbc.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import springdb.jdbc.domain.Member;
import springdb.jdbc.repository.MemberRepositoryV3;

import java.sql.Connection;
import java.sql.SQLException;

/**
 * 트랜잭션 - 트랜잭션 템플릿
 */
@Slf4j
public class MemberServiceV3_2 {

    private final TransactionTemplate transactionTemplate;
    private final MemberRepositoryV3 repository;

    public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 repository) {
        this.transactionTemplate = new TransactionTemplate(transactionManager);
        this.repository = repository;
    }

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        // 트랜잭션 관련 코드가 엄청 깔끔해졌다...
        transactionTemplate.executeWithoutResult((status) -> {
            try {
                bizLogic(fromId, toId, money);
            } catch (SQLException e) {
                throw new IllegalStateException(e);
            }
        });
    }

    private void bizLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = repository.findById(fromId);
        Member toMember = repository.findById(toId);

        repository.update(fromId, fromMember.getMoney() - money);
        validateTransfer(toMember);
        repository.update(toId, toMember.getMoney() + money);
    }

    private static void validateTransfer(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체 중 오류 발생!");
        }
    }
}

TransactionTemplate 코드를 보면 알 수 있듯이 TransactionTemplate을 사용하려면 transactionManager를 주입 받아야 한다. 보다시피 트랜잭션을 시작하고 커밋/롤백하는 로직이 모두 제거된 것을 확인할 수 있다.

하지만 위 코드도 완전하지 않다. 분명 서비스 로직인데 트랜잭션 코드가 껴있기 때문이다. 서비스 로직 입장에서는 트랜잭션 관련 코드는 그저 들러리일 뿐이다. 이제 이 문제를 해결해보자.

 

🎭 트랜잭션 AOP

트랜잭션을 편리하게 처리하기 위해 트랜잭션 추상화를 도입하고, 반복적인 트랜잭션 로직을 상당 부분 생략하기 위해 트랜잭션 템플릿까지 도입했다. 남은 건 하나. 서비스 로직을 최대한 고결한 상태로 둬야 한다. 스프링 AOP를 통해 프록시를 도입하면 이 문제를 깔끔하게 해결할 수 있다.

여기서 프록시(Proxy)란 간단히 말하자면 진짜 객체(타깃 객체) 앞에 세워두는 대리 객체를 뜻한다. 클라이언트(호출하는 쪽)는 진짜 객체를 직접 호출하는 것처럼 보이지만, 실제로는 프록시를 먼저 호출하고, 프록시가 중간에서 추가 작업을 끼워 넣은 뒤 진짜 객체를 호출하는 메커니즘이다.

 

프록시를 도입하기 전과 후의 상황을 관찰하면서 프록시의 역할을 음미해보자.

프록시를 도입하기 전에는 클라이언트가 그냥 서비스 로직에 바로 트랜잭션 관련 로직을 때려박았다. 그래서 트랜잭션 관련 코드와 비즈니스 로직이 뒤섞여 유지보수에 불편함이 있었다는 것을 기억해야 한다. 이제 프록시를 도입한 그림을 보자.

서비스 계층 앞쪽에 뭔가가 들어섰다. 흐름을 간단히 살펴보면 클라이언트가 프록시의 트랜잭션을 시작하는 메서드를 호출하면 프록시는 해당 작업을 수행하다가 순수 비즈니스 로직만 들어있는 서비스 객체를 호출하는 것이다. 그 비즈니스 로직이 끝나면 다시 프록시가 커밋이나 롤백과 같은 부가 기능을 수행하고 마무리하는 흐름이다. 즉 프록시가 가로채서 뭔가를 더 하고 넘기는 구조로 생각하면 편하다.

 

그래서 스프링이 제공하는 AOP 기능을 어떻게 사용하냐? 여러 방법이 있지만 그냥 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다. 그럼 한번 @Transactional 애노테이션을 적용한 코드를 만들어보자.

package springdb.jdbc.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import springdb.jdbc.domain.Member;
import springdb.jdbc.repository.MemberRepositoryV3;

import java.sql.SQLException;

/**
 * 트랜잭션 - @Transactional AOP
 */
@Slf4j
public class MemberServiceV3_3 {

    private final MemberRepositoryV3 repository;

    public MemberServiceV3_3(MemberRepositoryV3 repository) {
        this.repository = repository;
    }

    @Transactional
    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        bizLogic(fromId, toId, money);
    }

    private void bizLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = repository.findById(fromId);
        Member toMember = repository.findById(toId);

        repository.update(fromId, fromMember.getMoney() - money);
        validateTransfer(toMember);
        repository.update(toId, toMember.getMoney() + money);
    }

    private static void validateTransfer(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체 중 오류 발생!");
        }
    }
}

완전 웅장하다… 서비스 클래스 그 어디에도 트랜잭션과 관련된 코드는 찾아볼 수가 없다. 위와 같이 @Transactional 애노테이션은 메서드에 붙여도 되고, 클래스에 붙여도 된다. 클래스에 붙이면 외부에서 호출 가능한 public 메서드가 AOP 적용 대상이 된다.

profile
판교 함 가보자

0개의 댓글