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접미사와 메서드 접두어가 동작을 결정한다. 관례가 아니라 규칙이다. - 안 걸려도 에러가 안 난다. 단일 쓰기면 결과가 같아서 테스트도 통과한다.
- 차이는 예외가 났을 때 여러 테이블을 쓴 경우에만 드러난다. 부분 커밋이 남는다.
- 이름의 자연스러움보다 트랜잭션이 걸리는 쪽을 택한다.
- 롤백은 실제로 예외를 던져 봐야 확인된다.
'웹개발' 카테고리의 다른 글
| HTTPS 리다이렉트 무한 루프 — 앱이 원래 프로토콜을 모르기 때문이다 (0) | 2026.09.28 |
|---|---|
| 프론트 검증은 UX지 보안이 아니다 — 서버 검증 누락이 남긴 것 (0) | 2026.09.28 |
| 차단했는데 12시간을 더 쓴다 — 세션 인증에는 무효화가 없다 (0) | 2026.09.21 |
| npm ci와 npm install 차이 — 속도가 아니라 락 파일을 대하는 태도다 (0) | 2026.09.14 |
| Spring readOnly 트랜잭션을 리더로 보내면 생기는 read-after-write 문제 (0) | 2026.09.13 |
댓글