ThreadLocal로 트랜잭션을 빼내기 (TM, DataSource + Transaction)

Jang990·2026년 1월 30일

만들기 전 정리

사용자 - 음식이 사용되는 주문 도메인에서 트랜잭션이 필요해졌다.
트랜잭션을 유지하기 위해서 DB 내부 구현 코드인 Connection이 Repository를 벗어나 Application 코드에 나타났다.

// 애플리케이션 서비스
public class FoodOrderService {
    private final DBConfig dbConfig; // 커넥션에 필요한 DB 정보
    ...


    public void order(long userId, FoodOrderRequests foodOrderRequests) {
        ...

        try(Connection conn = DriverManager.getConnection(...)) { // 커넥션 코드
            try {
                conn.setAutoCommit(false); // 커넥션 코드
				
                // 사용자 잔액 감소 + 음식 재고 감소 + 주문 생성
                
                orderRepository.save(conn, ...); // 커넥션 코드
                usersRepository.updateBalance(conn, ...); // 커넥션 코드
                for (Foods food : foods)
                    foodsRepository.updateStock(conn, ...); // 커넥션 코드
                conn.commit(); // 커넥션 코드
            } catch (SQLException | RuntimeException e) {
                conn.rollback(); // 커넥션 코드
                throw e;
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
}

이 코드는 애플리케이션에 관계없는 내부 구현에 의존한다는 단점이 있다.
만약 DB 연동 기술을 바꾼다면 애플리케이션 코드까지 갈아엎어야 한다.


TranscationManager

이제 TransactionManager를 만들면서 이 DB 내부 기술을 의식하지 않아도 애플리케이션 계층이 동작할 수 있게 만들겠다.
즉, Connection을 애플리케이션 계층에서 숨기겠다는 것이다.


파라미터로 주고 받던 Connection을 제거하기

Connection을 애플리케이션 계층에서 숨기려면, 파라미터로 주고 받던 Connection을 제거하면 된다.

어떻게 파라미터로 주지 않아도 Connection을 획득할 수 있을까?

ThreadLocal<Connection>을 사용해서, Connection을 주고 받으면 된다.
DataSource를 통해 커넥션을 얻는 방법을 정의해놨다.
트랜잭션이 시작했을 땐 ThreadLocal<Connection>에서 커넥션을 얻고,
트랜잭션이 없으면 그땐 커넥션을 생성해서 주면 되는 것이다.

ThreadLocal은 안티패턴이 아닐까?

파라미터는 처음에 파라미터를 넘긴 곳을 추적하면 되지만 ThreadLocal은 어디서 설정됐는지 추적하기 어렵다.
그리고 값이 어디서 변경되는지도 추적하기 어렵다.


아마 ThreadLocal트레이드 오프를 고려한 선택인것 같다.
애플리케이션 코드 전반으로 DB 세부 구현이 흩어지는 것보다, 내부적으로 해결하는 것이 더 낫다는 것이겠지.


구현

뭐 이렇게 만들면 된다.

public class MyTransactionManager {
    private final MyDataSource myDataSource;
    private static final ThreadLocal<Connection> txThreadLocal = new ThreadLocal<>();

    public MyTransactionManager(MyDataSource myDataSource) {
        this.myDataSource = myDataSource;
    }

    public void startTransaction() {
        try {
            Connection conn = myDataSource.getConnection();
            conn.setAutoCommit(false);
            txThreadLocal.set(conn);
        } catch (SQLException e) {
            throw new RuntimeException("트랜잭션 시작 예외", e);
        }
    }

    public void commit() {
        Connection conn = txThreadLocal.get();
        if(conn == null)
            return;

        try(conn) {
            conn.commit();
            conn.setAutoCommit(true);
        } catch (SQLException e) {
            throw new RuntimeException("트랜잭션 커밋 예외",e);
        } finally {
            txThreadLocal.remove();
        }
    }

    public void rollback() {
        ...
    }
}

finally에서 하지 않고 try 내에서 txThreadLocal.remove()를 하는 실수를 했다


ConnectionThreadLocal

MyTransactionManager에서 ThreadLocal에 설정한 커넥션을 DataSource에서 반환해줘야 하는데, ThreadLocal이 private으로 설정돼있다.

ThreadLocal<Connection>을 관리하는 클래스를 따로 만들자.

MyTransactionManager.txThreadLocal을 Public으로 돌려서 사용하면 안되나?

그럼 DataSource에서 MyTransactionManager을 써야하는데 DataSource 내부에서 쓰지도 않는 트랜잭션과 관련된 작업에 집중돼있다.
DataSource 입장에서는 집중이 안되니까 분리한다.

public class MyConnectionThreadLocal {
    private static final ThreadLocal<Connection> HOLDER = new ThreadLocal<>();

    public static void bindConnection(Connection conn) {
        HOLDER.set(conn);
    }

    public static Connection getConnection() {
        return HOLDER.get();
    }

    public static void clear() {
        HOLDER.remove();
    }
}

이제 MyTransactionManager에서는 ThreadLocal대신 ConnectionThreadLocal을 쓰면 된다.

MyTransactionManager => 트랜잭션에 집중
ConnectionThreadLocal => ThreadLocal<Connection>에 집중


프록시 만들기 - MyTransactionAwareDataSourceProxy

MyDataSource에서 어떻게 트랜잭션 커넥션을 반환하게 만들까?

나는 프록시를 사용하기로 했다.

public class MyTransactionAwareDataSourceProxy implements MyDataSource {
    private final MyDataSource delegate;

    public MyTransactionAwareDataSourceProxy(MyDataSource myDataSource) {
        this.delegate = myDataSource;
    }

    @Override
    public Connection getConnection() throws SQLException {
        Connection txConn = MyConnectionThreadLocal.getConnection();
        if(txConn == null)
            return delegate.getConnection();
        else
            return txConn;
    }
}

만약 ThreadLocal에 커넥션이 있다면 그 커넥션을 반환하는 프록시를 만들었다.
기존 코드에 변경사항은 딱히 없다. 주입만 프록시로 감싸서 해주면 된다.


프록시 Connection 반환 - close를 관리하자

		// Repository 코드 중 일부
        try (Connection conn = myDataSource.getConnection()) {
            updateBalance(conn, userId, balance);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }

기껏 트랜잭션을 유지한 Connection을 반환하게 만들었는데,
Repository 코드들은 모두 이렇게 Connection을 직접 닫는다.

MyTransactionAwareDataSourceProxy에서 close가 제어되는 프록시 Connection을 반환하도록 바꾸자.


Connection 만들기

  1. Connection을 구현하는 클래스를 직접 만들자.
  2. Jdk Dynamic Proxy를 활용해서 프록시 객체를 만들어서 반환하자.

2가지 방법이 있는데 나는 Connection을 직접 구현하는 방식을 생각했고 구현해보니, Connection이 제공하는 메소드가 많아서 코드가 너무 길었다.
그래서 Jdk Dynamic Proxy를 사용하는 방식으로 방향을 틀었다.

다음과 같이 MyTransactionAwareDataSourceProxy에 코드를 추가해주면 된다.

public class MyTransactionAwareDataSourceProxy implements MyDataSource {
    private final MyDataSource delegate;

    public MyTransactionAwareDataSourceProxy(MyDataSource myDataSource) {
        this.delegate = myDataSource;
    }

    @Override
    public Connection getConnection() throws SQLException {
        Connection txConn = MyConnectionThreadLocal.getConnection();
        if(txConn == null)
            return delegate.getConnection();
        else
            return suppressClose(txConn);
    }

    // Connection의 conn.close()가 동작하지 않도록 프록시 객체를 반환
    private Connection suppressClose(Connection conn) {
        // Proxy.newProxyInstance(loader, interfaces, handler) = 인터페이스 구현 프록시 객체를 생성
        return (Connection) Proxy.newProxyInstance(
                conn.getClass().getClassLoader(),
                new Class[]{Connection.class},
                (proxy, method, args) -> {
                    // close 메소드 호출이 오면 아무 동작도 하지 않음
                    if ("close".equals(method.getName()))
                        return null;
                    return method.invoke(conn, args); // 다른 메소드들은 conn 메소드로 정상 동작
                }
        );
    }
}
profile
개발 기록 아카이브

0개의 댓글