본문 바로가기
웹개발

메서드 이름이 트랜잭션을 결정한다 — Spring AOP 일괄 트랜잭션의 함정

by 꼼냥냥 2026. 9. 17.
728x90

AOP로 트랜잭션을 일괄 적용하는 Spring 프로젝트라 @Transactional이 거의 없다. 적용 기준이 클래스 이름이 Impl로 끝나고 메서드 이름이 insert·update·delete·callSP로 시작하는가다. savePayment라고 이름을 지으면 트랜잭션이 안 걸린다. 에러도 안 나고 단일 INSERT면 테스트도 통과한다. 차이는 여러 테이블을 쓰다가 예외가 났을 때만 드러난다. 앞의 것은 커밋되고 뒤에서 죽어, 원장에 반쪽짜리 데이터가 남는다.

구조

AOP 설정이 이렇다.

// 대상: com.example.app..*Impl.*(..)
// 메서드명이 insert*, update*, delete*, callSP* 로 시작하면 쓰기 트랜잭션
// 어떤 Exception 이든 롤백

처음 보면 깔끔하다. 서비스마다 어노테이션을 붙일 필요가 없다. 문제는 트랜잭션 여부가 코드에 안 보인다는 것이다.

1편에서는 트랜잭션 속성이 어느 DB로 갈지를 정했다. 이번엔 한 단계 앞이다. 이름이 트랜잭션 자체를 정한다.

두 가지가 이름에 걸려 있다

1. 클래스는 Impl로 끝나야 한다

포인트컷이 *Impl이다. 클래스 이름이 PaymentService면 안 걸린다. PaymentServiceImpl이어야 한다.

관례처럼 보이는 접미사가 실제로 동작을 결정한다.

2. 메서드는 정해진 접두어로 시작해야 한다

public void insertPayment(...)   // 트랜잭션 O
public void savePayment(...)     // 트랜잭션 X  ← 이름만 다르다
public void modifyPayment(...)   // 트랜잭션 X
public void registPayment(...)   // 트랜잭션 X

save, modify, regist, add, remove — 전부 자연스러운 이름인데 하나도 안 걸린다.

안 걸리면 어떻게 되나

에러가 안 난다. 그게 문제다.

트랜잭션이 없으면 MyBatis가 DAO 호출마다 커넥션을 얻어 자동 커밋한다. 단일 INSERT면 결과가 같다. 그래서 테스트가 통과한다.

차이는 여러 개를 쓸 때만 드러난다.

public void savePayment(...) {      // 트랜잭션 없음
    paymentDAO.insertPayment(...);  // 커밋됨
    trnDAO.insertTrn(...);          // 여기서 예외 → 롤백 안 됨
}

앞의 것은 커밋되고 뒤에서 죽는다. 원장에 반쪽짜리 데이터가 남는다. 예외는 위로 올라가 에러 화면이 뜨니 사용자는 "실패했다"고 알고, DB에는 절반이 들어가 있다.

그리고 이건 예외가 나야만 드러난다. 평소엔 아무 일도 없다.

왜 이렇게 만들었을까

짐작이지만, 서비스가 수십 개인데 어노테이션을 매번 붙이는 게 번거로웠을 것이다. 그리고 실제로 한 번 정해 두면 신경 쓸 게 없다는 장점은 진짜다.

문제는 규칙이 코드에 없다는 것이다. 새로 온 사람이 savePayment라고 쓰면 아무도 안 막아 준다. 컴파일도 되고 테스트도 통과한다.

그래서 이렇게 다룬다

새 쓰기 로직은 반드시 네 접두어 중 하나로 시작한다.

insert*   update*   delete*   callSP*

의미가 안 맞아도 이걸 쓴다. "저장"이면 insert, "수정"이면 update. 이름의 자연스러움보다 트랜잭션이 걸리는 게 중요하다.

여러 테이블을 쓰는 메서드는 특히 확인한다. 하나만 쓰면 안 걸려도 결과가 같지만 둘 이상이면 부분 커밋이 난다.

저장 프로시저 호출은 callSP로 시작한다. 이것도 같은 포인트컷에 들어 있다.

확인하는 방법

이름 규칙에 안 맞는 쓰기 메서드를 찾는 건 정적으로 가능하다. 서비스 구현체에서 DAO의 쓰기 메서드를 부르는데 자기 이름은 접두어에 안 맞는 경우를 뽑으면 된다.

더 확실한 건 실제로 예외를 던져 보는 것이다.

public void insertFoo(...) {
    fooDAO.insertA(...);
    if (true) throw new RuntimeException("rollback test");   // 임시
    fooDAO.insertB(...);
}

던진 뒤 A가 남아 있으면 트랜잭션이 안 걸린 것이다. 롤백은 테스트하지 않으면 확인할 방법이 없다.

일반화하면

이 프로젝트만의 얘기가 아니다. 관례가 동작을 결정하는 구조는 어디에나 있다.

  • 클래스 이름 접미사로 빈을 찾는다
  • 메서드 이름 접두어로 AOP를 건다
  • 패키지 경로로 스캔 범위를 정한다
  • 파일 이름으로 매퍼를 매칭한다

공통점은 어겼을 때 에러가 아니라 "아무 일도 안 일어남"으로 나타난다는 것이다. 에러는 고치면 되지만, 아무 일도 안 일어나는 건 한참 뒤에 발견된다.

이런 규칙이 있는 코드베이스에서는 규칙을 문서 맨 앞에 적어 두는 것이 최선이다. 우리도 저장소 가이드에 이렇게 박아 뒀다.

새 쓰기 DB 작업은 insert*/update*/delete*/callSP* 로 시작하는
서비스 *Impl 메서드 안에 있어야 한다. 아니면 트랜잭션이 안 걸린다.

정리

  • AOP로 트랜잭션을 걸면 트랜잭션 여부가 코드에 안 보인다.
  • 클래스 Impl 접미사와 메서드 접두어가 동작을 결정한다. 관례가 아니라 규칙이다.
  • 안 걸려도 에러가 안 난다. 단일 쓰기면 결과가 같아서 테스트도 통과한다.
  • 차이는 예외가 났을 때 여러 테이블을 쓴 경우에만 드러난다. 부분 커밋이 남는다.
  • 이름의 자연스러움보다 트랜잭션이 걸리는 쪽을 택한다.
  • 롤백은 실제로 예외를 던져 봐야 확인된다.

댓글