여는 글

때는 지난 5월 회사에서 지급대행 서비스를 개발하며 생긴 일입니다. 카드결제도 결제지만, 현금과 관련한 로직 개발엔 더욱 신중함과 예민함이 동반됩니다. 제 주 업무는 정산, 백오피스, 배치 시스템 개발인데 지급대행 서비스가 가맹점 사이트에 추가되며, 고민거리가 생겼는데요.

바로 트랜잭션입니다.

만들고자 하는 메소드를 너무 자세히 얘기드릴 수는 없겠지만, 메소드 내부에서 생기는 여러 작업들의 트랜잭션 보장은 필수였습니다. 예를 들어 “입금 기록 INSERT → 잔액 UPDATE”와 같은 작업에서 첫 번째는 성공하고 두 번째가 실패하면? 데이터 정합성이 깨지는 치명적인 상황이 발생합니다.

문제의 근원 : 레거시 DAO 라이브러리

트랜잭션 보장은 생각보다 어려운 게 아닐 수도 있습니다. 메소드 위에 @Transactional 어노테이션을 추가해주면, 트랜잭션 보장이 끝나게 될 때도 있으니까요. 하지만, 제가 겪은 문제는 그렇게 단순할 수도 없었고 단순하지도 않았습니다. 해당 모듈은 ORM이나 SQL Mapper를 사용하지 않고 초기 개발사의 컴파일된 DAO 라이브러리를 사용중이었기 때문입니다.

트랜잭션 관리 불가

첫 번째 문제는 AutoCommit이었습니다. 디컴파일해서 확인한 레거시 DAO 라이브러리의 HikariCP 설정이 AutoCommit = true로 되어 있었습니다. 따라서, 모든 SQL이 즉시 커밋되게 되는것이죠.

// 디컴파일된 JDBCManager.class
HikariConfig config = new HikariConfig();
config.setAutoCommit(true);  // 모든 SQL이 즉시 커밋됨

‘에이 설마, 그럼 그동안 CUD는 어떻게 했는데?’

기존 레거시 코드들은 각 메소드마다 DB 커넥션 획득부터 커밋, 리소스 정리까지 개별적으로 하드 코딩 되어 있습니다. 이는 SQL Exception에 관해서 해당 작업만 롤백이 되어있었고, int를 반환하는 것에 따라 if-else로 분기를 구성하고 있었습니다. 이런 구성은 비즈니스의 트랜잭션을 보장해주지 못해 데이터 정합성을 망침과 동시에 보일러 플레이트 코드가 많아지게 되었습니다.

// insert 예시
@RequsetMapping( ... )
public ... insert() {
    // ...
    conn = 레거시유틸.getInstance().getConnection();  // 커넥션 획득
    // ...
    conn.commit();  // 컨트롤러 계층에서 커밋
    // ...
}

@Transactional이 동작하지 않는 이유

레거시 모듈이라 해도 라이브러리들은 존재했기에 @Transactional 어노테이션을 사용할 수 있었습니다. 하지만, 메소드 위에 어노테이션을 작성해도 트랜잭션 보장은 되지 않았습니다.

이유는 커넥션 획득 방식에 있었습니다. 레거시 DAO는 Spring의 트랜잭션 관리와 무관하게 내부 코드에서 직접 커넥션을 획득하고 있었는데요.

Spring의 @Transactional은 AOP 프록시를 통해 동작하는데, 이 프록시가 관리하는 커넥션과 내부 코드에서 제공하는 커넥션은 완전히 별개입니다. 서로 다른 커넥션이기 때문에 레거시 DAO를 사용한 서비스 메소드에 해당 어노테이션을 덧붙인다 해도 트랜잭션 경계를 공유할 수 없었던 것입니다.

SQL Injection 취약점

지급대행 서비스가 가맹점 사이트에 추가 되기 때문에 보안에 더욱 신경을 써야 했습니다. 하지만, 기존 DAO 라이브러리는 사용자 입력값을 직접 문자열과 결합하고 있었고, PreparedStatement의 파라미터 바인딩이 아닌 Statement와 문자열 결합 방식이라 보안 위협에 매우 취약한 형태였습니다.

해결 방향 설정

MyBatis 또는 JPA 도입도 고려했었습니다. 하지만 JPA는 Entity 클래스가 있어야 하고, MyBatis는 모듈에 세팅해주어야 할 것들이 많습니다. 지급대행 서비스를 완성해야 할 기간이 있는데 작업 공수만 늘어나는 건 리스크가 컸습니다.

무엇보다 저의 목적은 레거시 DAO 라이브러리의 대체 프레임워크 도입이 아니라 트랜잭션 보장이기 때문에, 저의 욕심으로 라이브러리를 모듈에 추가한다는 것이 목적에 맞지 않았다고 느꼈고, 이미 모듈에는 Spring-JDBC 라이브러리가 존재했기 때문에 이를 활용하는 것만으로 충분하다고 판단을 내렸습니다.

자체 DAO 코드 작성

제가 선택한 방법은 JdbcTemplate 기반의 Wrapper 클래스를 직접 만들어 Spring 트랜잭션과 통합하는 것입니다. 작업 기한을 맞추고 점진적으로 레거시 코드를 트랜잭션이 보장될 수 있는 새 DAO로 교체하는 것을 목표로 했습니다.

이중 커넥션 풀 vs 단일 커넥션 풀

새 DAO를 위한 DataSource를 어떻게 구성할지 고민이 필요했습니다. 두 가지 선택지가 있었습니다.

선택지 1: 새로운 HikariCP 생성 (이중 풀)

[레거시 HikariCP]          [새 HikariCP]
  maxPoolSize=20            maxPoolSize=20
       │                         │
       └─────── 같은 DB ─────────┘

선택지 2: 레거시 HikariCP 공유 (단일 풀)

[레거시 HikariCP] ←── 리플렉션으로 획득
       │
       ├── 레거시 DAO (직접 getConnection)
       └── 새 DAO (Spring DataSourceUtils)

이중 풀도 가능하다

레거시 DAO는 new DAO() 방식으로 직접 커넥션을 획득하고, 새 DAO는 Spring이 관리합니다. 두 DAO가 같은 트랜잭션에 참여할 필요가 없으므로, 기술적으로 별도의 커넥션 풀을 사용해도 동작에는 문제가 없습니다.

그럼에도 단일 풀을 선택한 이유

이중 풀이 불가능한 것은 아니지만, 운영 효율성을 고려해 단일 풀 공유를 선택했습니다.

항목 이중 풀 단일 풀
Idle 커넥션 두 풀 각각 유지 (리소스 낭비) 한 세트만 유지
총 커넥션 수 합산 관리 필요 단일 설정으로 관리
모니터링 두 풀 모두 감시 필요 하나만 감시
max_connections 초과 위험 높음 낮음

HikariCP의 주요 설정을 보면 이중 풀의 비효율이 명확해집니다.

// HikariCP 주요 설정
HikariConfig config = new HikariConfig();
config.setMinimumIdle(5);       // 최소 유휴 커넥션 수
config.setMaximumPoolSize(20);  // 최대 풀 크기
config.setIdleTimeout(300000);  // 유휴 커넥션 유지 시간 (5분)
config.setMaxLifetime(1800000); // 커넥션 최대 수명 (30분)
설정 목적 이중 풀 영향
minimumIdle 최소 유휴 커넥션 유지 두 풀 합쳐서 2배 유지 (예: 5 × 2 = 10개)
maximumPoolSize 최대 커넥션 수 제한 합산 시 DB max_connections 초과 위험
idleTimeout 유휴 커넥션 정리 시간 두 풀 각각 관리 필요
maxLifetime 커넥션 갱신 주기 두 풀에서 각각 갱신 발생

예를 들어, DB의 max_connections이 50이고 다른 애플리케이션도 연결하고 있다면, 이중 풀(각 20개)은 커넥션 고갈 위험이 있습니다.

리플렉션으로 단일 풀 공유

레거시 라이브러리는 static으로 선언된 HikariDataSource를 사용하므로, 리플렉션으로 이를 가져와 Spring Bean으로 등록했습니다.

@Configuration
public class DataSourceConfig {
    @Bean
    public DataSource dataSource() throws Exception {
        // 레거시 라이브러리의 static HikariDataSource를 리플렉션으로 획득
        Field dsField = JDBCManager.class.getDeclaredField("ds");
        dsField.setAccessible(true);
        HikariDataSource legacyDs = (HikariDataSource) dsField.get(null);
        legacyDs.setAutoCommit(false);  // 레거시 DAO는 명시적 commit() 호출로 동작에 영향 없음
        return legacyDs;
    }
}

이렇게 하면 두 DAO가 동일한 커넥션 풀을 공유하면서, 각자의 방식대로 동작합니다.

구분 레거시 DAO 새 DAO
사용 방식 new DAO() Spring Bean (@Repository)
트랜잭션 ❌ 기존대로 (메소드별 커밋) ✅ Spring 트랜잭션 관리
커넥션 획득 직접 getConnection() DataSourceUtils (ThreadLocal 바인딩)

두 DAO가 같은 풀에서 커넥션을 획득하지만, 각각 별도의 커넥션을 사용합니다. 레거시 DAO의 동작에는 영향을 주지 않으면서, 새 DAO에서만 Spring 트랜잭션이 적용됩니다.

JdbcTemplate 기반 Wrapper 클래스

가장 중요한 부분은 커넥션 획득 방식입니다. Spring의 JdbcTemplate은 내부적으로 DataSourceUtils.getConnection()을 사용하기 때문에, 이를 활용한 Wrapper 클래스를 만들었습니다.

@Repository
public class CustomDao {
    private final NamedParameterJdbcTemplate jdbcTemplate;

    @Autowired
    public CustomDao(DataSource dataSource) {
        this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
    }

    @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
    public int insert(String tableName, Map<String, Object> params) {
        String sql = buildInsertSql(tableName, params);
        return jdbcTemplate.update(sql, params);
    }

    @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
    public int update(String tableName, Map<String, Object> params,
                      String whereClause, Map<String, Object> whereParams) {
        String sql = buildUpdateSql(tableName, params, whereClause);
        Map<String, Object> allParams = new HashMap<>(params);
        allParams.putAll(whereParams);
        return jdbcTemplate.update(sql, allParams);
    }

    public List<Map<String, Object>> select(String sql, Map<String, Object> params) {
        return jdbcTemplate.queryForList(sql, params);
    }
}

JdbcTemplate은 내부적으로 DataSourceUtils를 통해 Spring의 TransactionSynchronizationManager와 연동됩니다. 이 매니저는 ThreadLocal을 사용해서 현재 스레드의 트랜잭션 컨텍스트에 커넥션을 바인딩합니다.

  • 트랜잭션이 활성화된 상태에서 getConnection() 호출 → 바인딩된 동일 커넥션 반환
  • 트랜잭션이 없는 상태에서 호출 → 새 커넥션 생성

이 메커니즘 덕분에 서비스 레이어에서 시작한 트랜잭션에 DAO의 모든 작업이 참여할 수 있게 됩니다. 또한 레거시 코드의 Map<String, Object> 기반 인터페이스를 유지하면서도 Spring의 트랜잭션 관리와 완벽하게 통합할 수 있었습니다.

ThreadLocal 기반 트랜잭션의 한계

ThreadLocal은 현재 스레드에만 바인딩되므로, 비동기 처리 시 트랜잭션이 전파되지 않습니다. 예를 들어 @Async 메소드나 CompletableFuture로 별도 스레드에서 실행되는 작업은 원래 트랜잭션에 참여하지 못합니다.

@Transactional
public void processWithAsync() {
    dao.insert(...);           // 메인 트랜잭션에 참여
    asyncService.doAsync();    // 별도 스레드 → 트랜잭션 전파 안 됨!
}

지급대행 서비스는 동기 처리가 기본이었기 때문에 이 한계가 문제되지 않았지만, 비동기 처리가 필요한 경우에는 별도의 트랜잭션 전략이 필요합니다.

트랜잭션 경계 설정: TransactionTemplate

저는 @Transactional을 서비스 계층에 직접 붙이지 않았습니다. 이유는 두 가지입니다.

  1. 세밀한 트랜잭션 경계 제어: 비즈니스 로직에 따라 일부 작업은 실패해도 커밋되어야 하는 경우가 있습니다.
  2. 명시적인 트랜잭션 범위: 코드만 보고도 어디서 트랜잭션이 시작되고 끝나는지 명확히 알 수 있습니다.

대신 Spring에서 제공하는 TransactionTemplate을 활용해 프로그래밍 방식으로 트랜잭션을 관리합니다.

@Service
public class TransferService {
    @Autowired
    private TransactionTemplate transactionTemplate;

    @Autowired
    private CustomDao dao;

    public void processTransfer(TransferDto dto) {
        transactionTemplate.execute(status -> {
            dao.insert("TB_TRANSACTION", txMap);      // 같은 트랜잭션에 참여
            dao.update("TB_BALANCE", balanceMap, whereClause, params);  // 같은 트랜잭션에 참여
            // 둘 중 하나라도 실패하면 전체 롤백
            return null;
        });
    }
}

Propagation.REQUIRED의 역할

서비스 메소드가 아닌 DAO 메소드에 @Transactional을 붙였는데, 여기에는 몇 가지 이유가 있습니다.

@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public int insert(String tableName, Map<String, Object> params) { ... }

단독 호출 시에도 트랜잭션 보장

모든 서비스 메소드에서 TransactionTemplate을 사용하는 것은 보일러플레이트 코드가 많아지는 부담이 있었습니다. 단순한 단건 조회나 로깅성 INSERT의 경우, DAO 메소드 자체에서 트랜잭션을 보장하면 서비스 코드가 깔끔해집니다.

세밀한 트랜잭션 경계 제어

비즈니스 요구사항에 따라 특정 작업은 메인 트랜잭션과 별개로 반드시 커밋되어야 하는 경우가 있었습니다. 예를 들어 이체 처리 중 실패하더라도, 시도 이력 자체는 롤백되지 않고 남아야 했습니다. 이런 경우 Propagation.REQUIRES_NEW와 조합해 독립적인 트랜잭션으로 분리할 수 있습니다.

// 이체 시도 이력은 메인 트랜잭션과 독립적으로 반드시 커밋
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public int insertTransferHistory(Map<String, Object> historyParams) {
    String sql = buildInsertSql("TB_TRANSFER_HISTORY", historyParams);
    return jdbcTemplate.update(sql, historyParams);
}

이렇게 하면 메인 이체 로직이 실패해 롤백되더라도, 시도 이력은 별도 트랜잭션으로 커밋되어 남게 됩니다.

Propagation.REQUIRED의 동작 방식

  • 기존 트랜잭션이 있으면 → 해당 트랜잭션에 참여
  • 기존 트랜잭션이 없으면 → 새 트랜잭션 시작

따라서 TransactionTemplate 안에서 호출되면 상위 트랜잭션에 참여하고, 단독으로 호출되면 자체 트랜잭션으로 동작합니다.

트랜잭션 흐름 비교

flowchart LR
    subgraph LEGACY["레거시 DAO"]
        direction TB
        L1["insert()"] --> L2["commit ✓"]
        L3["update()"] --> L4["❌ 실패"]
        L2 -.-> L5["롤백 불가 ⚠️"]
    end

    subgraph NEW["TransactionTemplate"]
        direction TB
        N1["트랜잭션 시작"] --> N2["insert()"]
        N2 --> N3["update()"]
        N3 --> |실패| N4["전체 rollback ✓"]
        N3 --> |성공| N5["전체 commit ✓"]
    end

    LEGACY -.->|개선| NEW

SQL Injection 방지

이 부분은 JdbcTemplate을 사용함으로써 자연스럽게 해결되었습니다. NamedParameterJdbcTemplate은 내부적으로 PreparedStatement를 사용하고, Map<String, Object>의 값들을 자동으로 안전하게 바인딩합니다.

// NamedParameterJdbcTemplate 사용 예시
String sql = "INSERT INTO users (name, email) VALUES (:name, :email)";
Map<String, Object> params = new HashMap<>();
params.put("name", userInput);   // 사용자 입력이 안전하게 바인딩됨
params.put("email", emailInput);

jdbcTemplate.update(sql, params);  // SQL Injection 원천 차단

레거시 코드가 문자열 결합 방식("SELECT * FROM users WHERE id = " + userId)을 사용했던 것과 달리, Named Parameter 방식은 사용자 입력이 SQL 구문으로 해석될 여지를 원천 차단합니다.

맺는 글

금융 도메인에서 가장 중요한 것은 원자성(Atomicity) 보장이라 생각했고, 가장 먼저 해결하고자 했던 것은 ‘트랜잭션 보장’이었습니다.

핵심 해결 포인트

이 프로젝트의 핵심은 레거시는 그대로 두고, 새 DAO에서 트랜잭션을 보장하는 것이었습니다.

  • 레거시 DAO: 수정 불가, 기존대로 동작 (메소드별 커밋)
  • 새 DAO: JdbcTemplate + Spring 트랜잭션으로 원자성 보장
  • 커넥션 풀 공유: 리플렉션으로 레거시 HikariCP를 가져와 이중 풀 방지

앞으로 작성되는 코드는 새 DAO를 사용하고, 기존 레거시 코드는 점진적으로 교체해 나가는 전략을 취했습니다.

라이브러리 분석 경험에서 얻은 것

이번 프로젝트에서 가장 큰 수확은 디컴파일을 통한 역엔지니어링 경험이었습니다.

분석 대상 발견한 내용 해결 방향 도출
JDBCManager.class HikariConfig.setAutoCommit(true) 새 DAO에서 Spring 트랜잭션 활용
레거시DAO.class 메소드별 conn.commit() 하드코딩 새 DAO에서 TransactionTemplate으로 경계 통합
HikariDataSource 필드 private static 선언 리플렉션으로 가져와 커넥션 풀 공유

소스 코드가 없는 상황에서 .class 파일을 디컴파일하여 @Transactional이 동작하지 않는지 원인을 추적했고, 이 과정에서 Spring의 트랜잭션 동작 원리(DataSourceUtils, TransactionSynchronizationManager, ThreadLocal 바인딩)를 깊이 이해하게 되었습니다.

“동작하지 않는다”에서 멈추지 않고 “왜 동작하지 않는가”를 파고든 경험은, 이후 다른 레거시 시스템을 다룰 때도 큰 자산이 되었습니다.

마무리

글을 쓰는 현재는 90%가 새로 만든 DAO로 교체되었습니다.

이가 없으면 잇몸으로라도 씹어야 할 때가 있습니다. 완벽하지 않았지만, 현재까지도 계속 개선해나가며 저의 선택과 기술을 의심하며 해당 기능을 확장 시켜나가고 있습니다.