TECH RETROSPECTIVE 2026.06

트랜잭션과 데이터 정합성 전수 조사 보고

데이터 정합성 확보와 시스템 안정성

Presenter: yskkkkkk
01. 서비스 리스크

우리가 당장 해결해야 할 문제

"고객의 돈은 정상적으로 빠져나갔는데, 우리 시스템에는 주문 내역이 없다면?"

현재 시스템의 결제 실패 시나리오

1. PG사 결제 승인
(고객 돈 출금 성공)
2. 시스템 내부 처리 중
(일시적 장애/에러 발생)
3. 복구 실패
(주문/재고 불일치 발생)

현재 구조에서는 에러 발생 시 상태를 이전으로 되돌리는 '안전망(Rollback)'이 작동하지 않아 고객 CS와 신뢰도 하락으로 직결될 위험이 있습니다.

02. 근본 원인

찢겨진 안전망: 데이터 정합성 파괴

데이터베이스에는 여러 작업이 한 몸처럼 움직이도록 묶어주는 '트랜잭션(Transaction)' 기능이 있습니다.
하나라도 실패하면 전체를 취소(Rollback)하여 사고를 막아줍니다.

현재: 파편화된 로직

현재 시스템은 주문 생성, 재고 차감, 로그 저장 등이 완전히 단절된 채 쪼개져서 실행됩니다. 중간에 에러가 나도 앞의 작업이 취소되지 않아 데이터가 꼬이게 됩니다.

목표: 거대한 하나의 흐름

결제 완료 후 일어나는 모든 DB 작업을 하나의 거대한 트랜잭션으로 묶어, 무조건 다 같이 성공하거나 다 같이 실패하도록 보호해야 합니다.

03. 실제 사례 (Case Study)

트랜잭션 파편화로 인한 데이터 오염

이벤트로 다건의 사은품을 지급하는 applyPromotionEventOrder 로직을 생각해보겠습니다.
개발자의 의도는 "사은품 중 하나라도 지급에 실패하면 전체를 취소(Rollback)하자" 였을 것입니다.

Loop: insertPromotionEventGift() (총 5개 지급 예정)
성공 사은품 1번 Insert (Auto-Commit 됨)
성공 사은품 2번 Insert (Auto-Commit 됨)
에러 발생 사은품 3번 Insert 실패! (Exception)

결과: 부분 커밋(Partial Commit)에 따른 데이터 불일치

하나의 트랜잭션으로 묶여있지 않기 때문에 롤백은 일어나지 않습니다.
결국 에러로 인해 시스템 프로세스는 중단되었지만, 이미 DB에 입력된 1번, 2번 사은품 내역은 그대로 남아 불완전한 상태의 데이터가 영구적으로 저장됩니다.

본 자료의 메서드 및 설정 명칭 등은 가명화 처리되었습니다.

Deep Dive

어쩌다 우리의 안전망은 찢어졌는가? (기술적 팩트 체크)

Deep Dive 01

작동하지 않는 어노테이션들

사실 코드를 살펴보면 나름대로 사은품 발급 실패 시 데이터 오염을 방지하기 위한 흔적이 있습니다.
앞서 본 applyPromotionEventOrder 로직 상단에는 @Transactional이 선언되어 있었습니다.
하지만 이는 데드 코드(Dead Code)였습니다.

EventServiceImpl.java
// 개발자는 이 로직이 에러 시 전체 롤백될 것이라 착각함 (AOP 프록시 생성 X) @Transactional(rollbackFor = Exception.class) public void applyPromotionEventOrder(...) throws Exception { // 사은품 지급 루프 (insertPromotionEventGift) 실행 부분 }

어노테이션이 작동하려면 설정 파일에 <tx:annotation-driven>이 존재해야 하지만 시스템엔 누락되어 있습니다.
대신, 구형 방식인 XML AOP 설정에 의해 강제로 트랜잭션이 묶여 있는 상태입니다. 코드 보기

클릭하여 닫기 ×

context-transaction.xml
<aop:config> <aop:pointcut id="requiredTx" expression="execution(* com.shop..service.impl.*ServiceImpl.*Tx(..))" /> <aop:advisor advice-ref="txAdvice" pointcut-ref="requiredTx" /> </aop:config>
본 자료의 메서드 및 설정 명칭 등은 가명화 처리되었습니다.
Deep Dive 02

구형 아키텍처(XML)의 한계와 비효율

즉, 현재 시스템은 이름이 "Tx"로 끝나는 메서드만 무조건 트랜잭션을 거는 구형 XML 방식을 사용 중입니다.
이로 인해 불필요한 단일 쿼리에도 트랜잭션이 발동하는 낭비가 발생하고 있습니다.

무의미한 단일 CUD

단일 INSERT, UPDATE, DELETE 쿼리는 DB의 Auto-Commit으로 스스로 원자성이 보장되므로 스프링 트랜잭션이 무의미합니다.

불필요한 조회(SELECT)

데이터 변경이 없는 단순 조회(SELECT)임에도 트랜잭션 세션이 생성되어 불필요한 트랜잭션 세션 오버헤드 및 커넥션 점유 시간이 증가하여 리소스가 낭비됩니다.

3배의 통신 오버헤드

~Tx 규칙 때문에 쿼리 1회 실행을 위해 SET autocommit=0과 COMMIT 통신이 추가되어 Round-trip이 3배로 증가합니다.

Deep Dive 03

외부 연동과 트랜잭션의 잘못된 만남

추가 전수 검사 결과, 내부 데이터 정합성 문제와는 별개로
외부 결제(PG) 연동 인터페이스에도 @Transactional이 선언되어 있는 것을 확인했습니다.

PgPaymentInterface.java
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class) public PgPaymentResultVO cardApproval(ParamMap paramMap) throws Exception { // PG사 API 호출 등 외부 네트워크 I/O 발생 지점 }

만약 이 어노테이션이 실제로 작동했다면,
외부 API 대기 시간과 맞물려 시스템 인프라 전반에 걸친 심각한 병목 현상을 유발했을 것입니다.

본 자료의 메서드 및 설정 명칭 등은 가명화 처리되었습니다.
Deep Dive 04

데드 코드가 만들어낸 '역설적인 안전'

아이러니하게도 방금 본 PG 결제 로직의 @Transactional 조차 설정 누락으로 인한 데드 코드였기 때문에,
우리는 커넥션 풀 고갈이라는 시스템 다운 장애를 피할 수 있었습니다.

만약 외부 결제 통신에 트랜잭션이 진짜로 작동했다면?

  • 트랜잭션이 시작되며 즉시 DB 커넥션 획득
  • 외부 결제 통신망 대기(3~5초) 내내 커넥션을 놓지 않고 점유
  • 피크 타임에 수십 건만 몰려도 DB 커넥션 풀 100% 점유 (Starvation)
  • 결제와 무관한 단순 화면 조회 요청조차 응답 불가 (서버 Hang)

* 결과적으로 데이터 정합성은 깨졌지만, 시스템 셧다운은 면한 기이한 상태가 유지된 것입니다.

Solution

트랜잭션과 외부 통신의 완벽한 분리

이 거대한 안전망을 구축하고 커넥션 풀을 보호하기 위한 아키텍처 개편안입니다.
외부 네트워크 통신(PG사 결제)과 내부 데이터베이스 작업을 철저하게 분리(Facade Pattern)해야 합니다.

앞으로의 결제 처리 구조
// 1. [트랜잭션 밖] PG사 외부 통신부터 안전하게 완료 (DB 점유 없음) PaymentResult result = pgPaymentInterface.cardApproval(request); // 2. [트랜잭션 안] 통신이 성공했을 때만! 관련 DB 작업을 하나의 흐름으로 강하게 묶음 if(result.isSuccess()) { orderService.processOrderCompleteTx(order, stock, log); }
본 자료의 메서드 및 설정 명칭 등은 가명화 처리되었습니다.
Performance Deep Dive 01

이중 커넥션 점유 흐름과 원인 분석

[추가 보고] 기능 정합성 검토 외에, 추가 전수 조사 중 포착한 인프라 리소스 누수 현상입니다.

아래의 단계별 카드(탭)를 클릭하시면, 실제로 작동하고 있던 설정 및 소스코드를 확인하실 수 있습니다.

Step 01. 빈 선언

datasource 설정

Step 02. 이름 맵핑

app.properties

Step 03. 리소스 바인딩

Tomcat context.xml

Step 04. 이중 AOP 발동

transaction.xml AOP

context-datasource.xml (스프링 빈 정의)
<!-- 1. 주 DB용 빈 선언 --> <bean id="dataSource" class="org.springframework.jndi.JndiObjectFactoryBean"> <property name="jndiName" value="${mainDbsource}"/> </bean> <!-- 2. 매출 DB용 빈 선언 (실제 사용하지 않는 데드 설정) --> <bean id="dataSourceSub" class="org.springframework.jndi.JndiObjectFactoryBean"> <property name="jndiName" value="${subDbsource}"/> </bean>
본 자료의 메서드 및 설정 명칭 등은 가명화 처리되었습니다.
Performance Deep Dive 02

AOP 정리 기대효과 및 안전성 검증

이 문제는 비즈니스 로직(자바 파일) 수정이 일절 필요치 않은 순수 스프링 XML 설정상의 제거 작업입니다.
매우 낮은 리스크로 커넥션 리소스 효율성을 극대화할 수 있습니다.

리팩토링 기대효과

  • 커넥션 풀 리소스 최대 2배 절약: 동시 처리 효율성 극대화
  • 불필요한 빈 트랜잭션 세션 연결/종료 통신 차단
  • 피크 타임 시 커넥션 고갈로 인한 서버 지연 리스크 원천 차단

안전성 전수 조사 결과

  • 자바 소스코드 내 dataSourceSub, txManagerSub 사용처 0건
  • MyBatis 팩토리 sqlSessionFactorySub 사용처 0건
  • 안전성 검증 완료: 서비스 로직 영향도 없이 XML 수정만으로 배포 가능
본 자료의 메서드 및 설정 명칭 등은 가명화 처리되었습니다.
Log Analysis

@Transactional 무시 실측 로그

컨트롤러 메서드(예시: /guest/cart-migration) 진입 시 선언된 @Transactional이 무시되어,
단 하나의 트랜잭션으로 묶여야 할 로직이 메서드 단위로 쪼개져 총 5회의 트랜잭션이 제각각 실행되는 실증 로그

🔄 [1st Transaction] selectMemberSystemTx
2026-06-25 11:07:16 [exec-47] Creating new transaction with name [...selectMemberSystemTx]
2026-06-25 11:07:16 [exec-47] Releasing JDBC Connection after transaction
🔄 [2nd Transaction] updateGuestCartTx
2026-06-25 11:07:16 [exec-47] Creating new transaction with name [...updateGuestCartTx]
2026-06-25 11:07:16 [exec-47] Releasing JDBC Connection after transaction
🔄 [3rd Transaction] updateDefaultCartTx
2026-06-25 11:07:16 [exec-47] Creating new transaction with name [...updateDefaultCartTx]
2026-06-25 11:07:16 [exec-47] Releasing JDBC Connection after transaction
🔄 [4th Transaction] insertCartDbTx
2026-06-25 11:07:16 [exec-47] Creating new transaction with name [...insertCartDbTx]
2026-06-25 11:07:16 [exec-47] Releasing JDBC Connection after transaction
🔄 [5th Transaction] deleteAllGuestCartTx
2026-06-25 11:07:16 [exec-47] Creating new transaction with name [...deleteAllGuestCartTx]
2026-06-25 11:07:16 [exec-47] Releasing JDBC Connection after transaction
본 자료의 메서드 및 설정 명칭 등은 가명화 처리되었습니다.
Log Analysis

이중 커넥션 점유 실측 로그

로그인 이력 병합 메서드(saveLoginHistoryTx) 실행 시 단 한개의 MERGE 쿼리만 날림에도
물리 커넥션 2개(주 DB + 매출 DB)가 동시 점유되는 실증 로그

🔹 [주 DB] Connection@462e310e (정상 트랜잭션 개시)
2026-06-25 09:00:32 [exec-47] Creating new transaction ... [MemberServiceImpl.saveLoginHistoryTx]
2026-06-25 09:00:32 [exec-47] Acquired Connection@462e310e for JDBC transaction
// [대기] 안쪽 트랜잭션(매출 DB)이 시작되면서 주 DB 트랜잭션 일시 보류 (Suspended)
⚠️ [매출 DB] Connection@2c9afeb0 (🚨 무의미한 중복 점유 및 낭비)
2026-06-25 09:00:32 [exec-47] Creating new transaction ... [MemberServiceImpl.saveLoginHistoryTx]
2026-06-25 09:00:32 [exec-47] Acquired Connection@2c9afeb0 for JDBC transaction
🟢 [주 DB] 실제 비즈니스 쿼리(SQL) 실행 구간
2026-06-25 09:00:32 [exec-47] INFO j.sqlonly - MERGE INTO T_LOGIN_HISTORY ...
// [매출 DB] SQL 쿼리 없이 즉시 커밋 및 커넥션 반납
2026-06-25 09:00:32 [exec-47] Releasing JDBC Connection@2c9afeb0 after transaction
// [복귀] 보류되었던 주 DB 트랜잭션 재개 (Resumed) 및 최종 반환
2026-06-25 09:00:32 [exec-47] Resuming suspended transaction ...
2026-06-25 09:00:32 [exec-47] Releasing JDBC Connection@462e310e after transaction
본 자료의 메서드 및 설정 명칭 등은 가명화 처리되었습니다.
Conclusion

앞으로 수정해 나갈 과제 (단기)

1

트랜잭션 미적용 문제 개선 (3-Step Action)

• 1단계 (어노테이션 제거): 기존에 동작하지 않고 방치된 @Transactional 데드 코드를 전수 정리 (물리 영향도 0)
• 2단계 (서비스 로직 분리): 쿠폰 발급 루프 등 정합성이 보장되어야 하는 순수 DB CUD 코드를 단일 Service 메서드로 안전하게 묶음
• 3단계 (어노테이션 활성화 및 적용): <tx:annotation-driven> 설정을 활성화하고, 추출한 신규 Service 메서드에만 어노테이션을 부여하여 제한적(Targeted)으로 안전하게 활성화
2

매출 DB 데드 설정 및 AOP 제거

• 데드 빈 설정 삭제: 실 사용처가 없는 dataSourceSub, txManagerSub, sqlSessionFactorySub 빈 정의를 제거하여 리소스 낭비 원천 차단
• 중복 AOP Advisor 제거: context-transaction.xml에서 requiredSubTx AOP 설정을 제거하여 이중 커넥션 점유 현상을 근본적으로 해결
Conclusion

앞으로 수정해 나갈 과제 (장기)

1

어노테이션 기반 트랜잭션 점진적 확대

단기 조치로 검증된 어노테이션 기반 트랜잭션을 전체 서비스 레이어로 점진적으로 확대 적용합니다.
추후 서비스 분리나 멀티 데이터소스 도입 시, 이중 커넥션 점유 장애가 재발하지 않도록 명확한 개발 가이드라인을 수립합니다.

2

구형 XML 트랜잭션 설정 완전 제거

단기 조치에서 매출 DB AOP를 제거한 데 이어, 주 DB용 구형 AOP(requiredTx) 설정까지 완전히 걷어냅니다.
명칭 규칙(~Tx) 기반의 자동 결합 방식을 끝내고, 필요한 곳에만 @Transactional을 선언하는 명시적 체계로 전환을 완료합니다.