최근에 AI 에이전트들과 과외를 시작했습니다.

주요 과목은 자바 스프링과 코틀린인데, 특히 코틀린은 아직 제가 모르는 부분이 너무 많아서 기초부터 차근차근 배우고 있습니다.

오늘도 에이전트와 트랜잭션, 그리고 AOP 주입에 관해 이런저런 이야기를 나누고 있었습니다.

내부 메서드에서만 @Transactional을 거는 설계는 자가 호출 구조에서는 무조건 실패하니 스프링 컨테이너 관리에 들어갈 수 있게 처리해야 한다는 설명을 듣고, 저희 코드를 같이 분석해보았습니다.

interface와 controller에서 @Transactional을 선언하는 코드였는데, 각 코드가 가진 위험성을 듣고 스프링 설정상 무시될 가능성도 언급해 주더라고요. 그리고 추가로 저희 코드도 직접 분석해주었습니다.

“결론부터 말씀드리면, 질문하신 내용대로 **현재 이 프로젝트의 eventReq 컨트롤러에 선언된 @Transactional은 100% 아무런 동작도 하지 않는 완전한 데드 코드(Dead Code)**가 맞습니다.”

이후 제가 직접 코드를 확인해보았는데 결과는 진짜였습니다. 스프링 설정 파일에 있어야 할 <tx:annotation-driven> 설정이 완벽하게 누락되어 있었던 겁니다.

이로 인해 코드 곳곳에 안전망처럼 걸어둔 @Transactional 어노테이션들이 전부 작동하지 않는 상태로 방치되고 있었습니다.


구체적으로 어떤 상황이었는지 확인해 보겠습니다.

작동하지 않는 어노테이션과 숨어있던 위협

문제의 시작은 다건의 사은품을 지급하는 selectApplyTicketEventOrder 로직이었습니다.

개발할 당시에는 “사은품 중 하나라도 오류가 나면 전체를 취소(Rollback)하자”는 명확한 의도로 메서드 상단에 @Transactional(rollbackFor = Exception.class)을 야심 차게 적어두었습니다.

하지만 이 어노테이션을 감지하고 프록시를 생성해 주는 <tx:annotation-driven> 설정이 설정 파일 어디에도 없었습니다.

결국 이 메서드는 트랜잭션 없이 각 쿼리가 실행되는 족족 Auto-Commit 방식으로 DB에 박히고 있었습니다.

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

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

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

물론 시스템이 아예 엉망으로 굴러가진 않았습니다.

과거에 이름 규칙으로 묶어둔 구형 XML AOP 설정 덕분에, 메서드 이름이 Tx로 끝나는 일부 로직들은(예: ~ServiceImpl.*Tx(..)) 트랜잭션이 작동하고 있긴 했습니다.

다만 이것도 가성비가 썩 좋지 않았습니다.

단일 쿼리만 실행하는 단순 메서드조차 이름 끝에 Tx가 붙어 있다는 이유로 굳이 트랜잭션이 발동되어, 네트워크 통신 시 SET autocommit=0과 COMMIT 통신이 강제로 얹어지면서 통신 낭비(Round-trip 3배 증가)가 발생하고 있었습니다.

편지 봉투 한 장 배달하는데 굳이 현금 수송 차량을 부르는 격이었달까요.


데드 코드가 만든 ‘역설적인 안전’

하지만 진짜 아이러니는 외부 결제(PG사) 연동 인터페이스에서 일어났습니다.

코드 전수 조사를 하다 보니 외부 통신을 담당하는 TossInterface.cardApproval 메서드에도 @Transactional이 자랑스럽게 붙어 있는 것을 보았습니다.

이건 외부 API를 호출하는 네트워크 I/O 작업이라 시간이 꽤 걸리는 부분입니다.

만약 이 어노테이션이 실제로 작동했다면 어떤 일이 벌어졌을까요?

  1. 커넥션 즉시 획득: 메서드가 시작되자마자 DB 커넥션 풀에서 귀한 커넥션을 먼저 획득하게 됩니다.
  2. 외부 I/O 홀딩: 이 커넥션을 손에 꼭 쥔 채로, 외부 PG사 응답이 올 때까지 3~5초 동안 네트워크 I/O 대기 상태로 들어갑니다.
  3. 커넥션 풀 고갈: 피크 타임에 결제 요청이 수십 개만 동시에 몰려도 모든 커넥션이 대기 상태에 묶여 풀(Pool)이 순식간에 말라버립니다.
  4. 서버 셧다운 (Hang): 결국 결제와 아무 상관 없는 단순 메인 화면 조회 요청조차 커넥션을 얻지 못해 무한 응답 대기 상태로 들어가며 전체 서버가 뻗어버리게 됩니다.

다행히 스프링 설정 누락으로 @Transactional이 완벽하게 무효화되어 있었던 덕분에, 외부 I/O가 돌 때 DB 커넥션을 점유하지 않아 시스템 전체가 셧다운되는 최악의 장애는 피해 갈 수 있었습니다.

데이터 정합성은 찢어졌는데, 인프라의 목숨은 건진 웃픈 ‘역설적인 안전’이 유지되고 있었던 겁니다.


의외의 곳에서 과외 공부 효과를 톡톡히 보고 있네요.

이번 트랜잭션 회고 내용은 잘 정리해서 다가올 6월 팀 회고 때 공유할 생각입니다.

이후에는 또 어떤 잠재 버그들이 발각될지 벌써부터 기대 반 두려움 반입니다…

ps. 발표 자료 정리 도중 잠재버그는 하루만에 한개 더 나와버리는데…