디컴파일에서 시작된 트랜잭션 통합 — 레거시 트랜잭션 개선기
AutoCommit, 커넥션 풀 공유, Spring 트랜잭션까지 한 번에 해결한 이야기
여는 글
때는 지난 5월 회사에서 지급대행 서비스를 개발하며 생긴 일입니다. 카드결제도 결제지만, 현금과 관련한 로직 개발엔 더욱 신중함과 예민함이 동반됩니다. 제 주 업무는 정산, 백오피스, 배치 시스템 개발인데 지급대행 서비스가 가맹점 사이트에 추가되며, 고민거리가 생겼는데요.
바로 트랜잭션입니다.
만들고자 하는 메소드를 너무 자세히 얘기드릴 수는 없겠지만, 메소드 내부에서 생기는 여러 작업들의 트랜잭션 보장은 필수였습니다. 예를 들어 “입금 기록 INSERT → 잔액 UPDATE”와 같은 작업에서 첫 번째는 성공하고 두 번째가 실패하면? 데이터 정합성이 깨지는 치명적인 상황이 발생합니다.
문제의 근원 : 레거시 DAO 라이브러리
트랜잭션 보장은 생각보다 어려운 게 아닐 수도 있습니다. 메소드 위에 @Transactional 어노테이션을 추가해주면, 트랜잭션 보장이 끝나게 될 때도 있으니까요.
하지만, 제가 겪은 문제는 그렇게 단순할 수도 없었고 단순하지도 않았습니다. 해당 모듈은 ORM이나 SQL Mapper를 사용하지 않고 초기 개발사의 컴파일된 DAO 라이브러리를 사용중이었기 때문입니다.
첫번째 문제 : AutoCommit
첫 번째 문제는 AutoCommit이었습니다. 디컴파일해서 확인한 레거시 DAO 라이브러리의 HikariCP 설정이 AutoCommit = true로 되어 있었습니다. 따라서, 모든 SQL이 즉시 커밋되게 되는것이죠.
// 디컴파일된 JDBCManager.class
HikariConfig config = new HikariConfig();
config.setAutoCommit(true); // 모든 SQL이 즉시 커밋됨
AutoCommit = true가 의미하는 건 단순합니다. 모든 SQL 문이 실행 즉시 커밋된다는 것입니다. 즉, “입금 기록 INSERT → 잔액 UPDATE”처럼 논리적으로 하나여야 할 작업이 각각 독립된 트랜잭션으로 처리됩니다. INSERT는 성공했는데 UPDATE에서 예외가 발생하면? INSERT는 이미 커밋되었기 때문에 롤백할 수 없고, 데이터 정합성이 깨지는 치명적인 상황이 발생합니다.
‘에이 설마, 그럼 그동안 CUD는 어떻게 했는데?’
이 의문에 대한 답은 두 번째 문제로 이어집니다.
두번째 문제 : 레거시 시스템의 잘못된 트랜잭션 범주 설정
기존 코드에서는 AutoCommit 문제를 우회하기 위해, 각 CUD 메소드마다 직접 커넥션을 획득하고 오토커밋을 끈 뒤 try-catch-finally로 커밋과 롤백, 리소스 정리까지 하드코딩하고 있었습니다.
// insert 예시
@RequsetMapping( ... )
public ... insert() {
// ...
conn = 레거시유틸.getInstance().getConnection(); // 커넥션 획득
// ...
conn.commit(); // 컨트롤러 계층에서 커밋
// ...
}
하드코딩 자체가 문제의 본질은 아닙니다. 진짜 문제는 트랜잭션의 범주가 개별 SQL 단위로 설정되어 있다는 것입니다. 각 CUD 메소드가 자기 자신의 커넥션을 획득하고 커밋하기 때문에, 여러 CUD를 하나의 비즈니스 트랜잭션으로 묶을 수 없었습니다. 의도하지 않은 SQLException에 대해서는 해당 작업만 롤백이 가능했지만, int를 반환(영향받은 rows)하는 결과값에 따라 if-else로 분기하는 트랜잭션 구성이었기에 비즈니스 레벨의 트랜잭션 보장은 불가능했습니다.
@Transactional이 동작하지 않는 이유
레거시 모듈이라 해도 Spring 라이브러리들은 존재했기에 @Transactional 어노테이션을 작성할 수 있었습니다. 하지만, 메소드 위에 어노테이션을 작성해도 트랜잭션 보장은 되지 않았습니다.
이유는 커넥션 획득 방식에 있었습니다. 레거시 DAO는 Spring의 트랜잭션 관리와 무관하게 내부 코드에서 직접 커넥션을 획득하고 있었는데요.
Spring의 @Transactional은 AOP 프록시를 통해 동작하는데, 이 프록시가 관리하는 커넥션과 내부 코드에서 제공하는 커넥션은 완전히 별개입니다. 서로 다른 커넥션이기 때문에 레거시 DAO를 사용한 서비스 메소드에 해당 어노테이션을 덧붙인다 해도 트랜잭션 경계를 공유할 수 없었던 것입니다.
해결 방향 설정
MyBatis 또는 JPA 도입도 고려했었습니다. 하지만 JPA는 Entity 클래스가 있어야 하고, MyBatis는 모듈에 세팅해주어야 할 것들이 많습니다. 지급대행 서비스를 완성해야 할 기간이 있는데 작업 공수만 늘어나는 건 리스크가 컸습니다.
무엇보다 저의 목적은 레거시 DAO 라이브러리의 대체 프레임워크 도입이 아니라 트랜잭션 보장이기 때문에, 저의 욕심으로 라이브러리를 모듈에 추가한다는 것이 목적에 맞지 않았다고 느꼈고, 이미 모듈에는 Spring-JDBC 라이브러리가 존재했기 때문에 이를 활용하는 것만으로 충분하다고 판단을 내렸습니다.
Wrapper 코드 작성
제가 선택한 방법은 JdbcTemplate 기반의 Wrapper 클래스를 직접 만들어 Spring 트랜잭션과 통합하는 것입니다. 작업 기한을 맞추고 레거시 DAO로도 트랜잭션이 보장될 수 있게 하는 것을 목표로 했습니다.
이중 커넥션 풀 vs 단일 커넥션 풀
트랜잭션 통합 전 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의 트랜잭션 관리와 완벽하게 통합할 수 있었습니다.
트랜잭션 경계 설정
CustomDao는 내부적으로 JdbcTemplate을 사용하고, JdbcTemplate은 DataSourceUtils.getConnection()을 통해 커넥션을 획득합니다. 이 메커니즘 덕분에 서비스 레이어에 @Transactional을 선언하는 것만으로 트랜잭션 경계를 설정할 수 있습니다.
@Service
public class TransferService {
@Autowired
private CustomDao dao;
@Transactional(rollbackFor = Exception.class)
public void processTransfer(TransferDto dto) {
dao.insert("TB_TRANSACTION", txMap);
dao.update("TB_BALANCE", balanceMap, whereClause, params);
// 둘 중 하나라도 실패하면 전체 롤백
}
}
@Transactional이 선언된 메소드가 호출되면, Spring은 AOP 프록시를 통해 트랜잭션을 시작하고 TransactionSynchronizationManager에 커넥션을 바인딩합니다. 이후 해당 스레드에서 DataSourceUtils.getConnection()이 호출될 때마다 바인딩된 동일한 커넥션이 반환되기 때문에, CustomDao의 모든 작업이 하나의 트랜잭션에 참여하게 됩니다.
레거시 DAO에 @Transactional을 붙여도 동작하지 않았던 이유가 바로 여기에 있습니다. 레거시 DAO는 DataSourceUtils가 아닌 자체 코드로 커넥션을 획득했기 때문에, Spring이 바인딩한 커넥션과 무관하게 동작했던 것입니다.
외부 API 호출과 트랜잭션 분리: TransactionTemplate
지급대행 서비스 특성상, 하나의 메소드 안에 외부 API 호출(VAN사 통신 등)과 DB 작업이 함께 존재하는 경우가 있었습니다. 이런 메소드에 @Transactional을 걸면 외부 API 호출까지 트랜잭션 범위 안에 들어가게 됩니다.
// 문제: 외부 API 호출이 트랜잭션 안에 포함됨
@Transactional(rollbackFor = Exception.class)
public void processTransfer(TransferDto dto) {
ApiResponse response = vanClient.requestTransfer(dto); // 외부 호출 중 DB 커넥션 점유
dao.insert("TB_TRANSACTION", txMap);
dao.update("TB_BALANCE", balanceMap, whereClause, params);
}
외부 API 응답이 지연되거나 타임아웃이 발생하면, 그 시간 동안 DB 커넥션을 불필요하게 점유하게 됩니다. 이는 커넥션 풀 고갈로 이어질 수 있는 위험한 구조입니다.
이런 경우에 한해 TransactionTemplate을 사용해, 트랜잭션이 필요한 블록만 정확하게 감쌌습니다.
@Service
public class TransferService {
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private CustomDao dao;
public void processTransfer(TransferDto dto) {
// 1단계: 외부 API 호출 (트랜잭션 밖)
ApiResponse response = vanClient.requestTransfer(dto);
// 2단계: DB 작업 (트랜잭션 안)
transactionTemplate.execute(status -> {
dao.insert("TB_TRANSACTION", txMap);
dao.update("TB_BALANCE", balanceMap, whereClause, params);
return null;
});
}
}
TransactionTemplate은 프록시 기반이 아닌 직접 호출 방식이기 때문에, 같은 클래스 내 메소드 호출(self-invocation)에서도 트랜잭션이 정상 동작합니다. @Transactional은 AOP 프록시를 통해 동작하므로 this.method() 형태의 내부 호출 시 프록시를 거치지 않아 트랜잭션이 적용되지 않는 문제가 있는데, TransactionTemplate은 이 제약에서 자유롭습니다.
Propagation 활용: 독립 트랜잭션이 필요한 경우
비즈니스 요구사항에 따라 특정 작업은 메인 트랜잭션과 별개로 반드시 커밋되어야 하는 경우가 있었습니다. 예를 들어 이체 처리 중 실패하더라도, 시도 이력 자체는 롤백되지 않고 남아야 했습니다.
이런 경우 Propagation.REQUIRES_NEW를 사용해 독립적인 트랜잭션으로 분리했습니다.
@Repository
public class TrRepository {
// 기본 CUD: 상위 트랜잭션이 있으면 참여, 없으면 새로 시작
@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.REQUIRES_NEW, rollbackFor = Exception.class)
public int insertTransferHistory(Map<String, Object> historyParams) {
String sql = buildInsertSql("TB_TRANSFER_HISTORY", historyParams);
return jdbcTemplate.update(sql, historyParams);
}
}
| 구분 | REQUIRED (기본값) |
REQUIRES_NEW |
|---|---|---|
| 기존 트랜잭션 있을 때 | 해당 트랜잭션에 참여 | 기존 트랜잭션을 일시 중단하고 새 트랜잭션 시작 |
| 기존 트랜잭션 없을 때 | 새 트랜잭션 시작 | 새 트랜잭션 시작 |
| 롤백 시 | 상위 트랜잭션과 함께 롤백 | 독립적으로 커밋/롤백 |
| 사용 예시 | 일반적인 CUD 작업 | 로깅, 이력 기록 등 반드시 남아야 하는 작업 |
DAO 메소드에 @Transactional(propagation = Propagation.REQUIRED)를 붙인 이유는, 서비스 레이어에서 @Transactional이나 TransactionTemplate으로 트랜잭션을 시작한 경우에는 해당 트랜잭션에 참여하고, 단독으로 호출되는 경우에도 자체 트랜잭션을 생성해 안전하게 동작하도록 하기 위함입니다.
서비스 메소드가 아닌 DAO 메소드에 @Transactional을 붙였는데, 여기에는 몇 가지 이유가 있습니다.
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public int insert(String tableName, Map<String, Object> params) { ... }
트랜잭션 흐름 비교
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
맺는 글
금융 도메인에서 가장 중요한 것은 원자성(Atomicity) 보장이라 생각했고, 가장 먼저 해결하고자 했던 것은 ‘트랜잭션 보장’이었습니다.
핵심 해결 포인트
이 프로젝트의 핵심은 레거시는 그대로 두고, 새 DAO에서 트랜잭션을 보장하는 것이었습니다.
| 문제 | 원인 | 해결 |
|---|---|---|
@Transactional 무효 |
레거시 DAO가 자체 커넥션 획득 | JdbcTemplate 기반 CustomDao로 DataSourceUtils 연동 |
| 이중 커넥션 풀 위험 | 새 DataSource 추가 시 풀 분산 | 리플렉션으로 레거시 HikariCP 공유 |
| 외부 API + DB 혼재 | @Transactional 메소드 단위 경계 |
TransactionTemplate으로 DB 블록만 분리 |
| 이력 데이터 롤백 방지 | 메인 트랜잭션과 함께 롤백됨 | Propagation.REQUIRES_NEW로 독립 커밋 |
앞으로 작성되는 코드는 새 DAO를 사용하고, 기존 레거시 코드는 점진적으로 교체해 나가는 전략을 취했습니다.
라이브러리 분석 경험에서 얻은 것
이번 프로젝트에서 가장 큰 수확은 디컴파일을 통한 역엔지니어링 경험이었습니다.
| 분석 대상 | 발견한 내용 | 해결 방향 도출 |
|---|---|---|
JDBCManager.class |
HikariConfig.setAutoCommit(true) |
새 DAO에서 Spring 트랜잭션 활용 |
레거시DAO.class |
메소드별 conn.commit() 하드코딩 |
서비스 레이어 @Transactional로 경계 통합 |
HikariDataSource 필드 |
private static 선언 |
리플렉션으로 가져와 커넥션 풀 공유 |
소스 코드가 없는 상황에서 .class 파일을 디컴파일하여 왜 @Transactional이 동작하지 않는지 원인을 추적했고, 이 과정에서 Spring의 트랜잭션 동작 원리(DataSourceUtils, TransactionSynchronizationManager, ThreadLocal 바인딩)를 이해하게 되었습니다.
“동작하지 않는다”에서 멈추지 않고 “왜 동작하지 않는가”를 파고든 경험은, 이후 다른 레거시 시스템을 다룰 때도 큰 자산이 되었습니다.
마무리
글을 쓰는 현재는 새로 만든 DAO를 통해 트랜잭션 관리를 하고 있습니다.
이가 없으면 잇몸으로라도 씹어야 할 때가 있습니다. 완벽하지 않았지만, 현재까지도 계속 개선해나가며 저의 선택과 기술을 의심하며 해당 기능을 확장 시켜나가고 있습니다.