[Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드
![[Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드](https://blog.kakaocdn.net/dna/GQe4C/dJMcaci1eMy/AAAAAAAAAAAAAAAAAAAAAAU635Joc-AbqzhGENUjz23C337Ac8mHEXODYsQDZJZA/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1788188399&allow_ip=&allow_referer=&signature=dsvrZmtW2C7%2B2%2FfyWRMwWbrarp4%3D)
[Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드
스프링 함정들의 공통점은 조용하다는 거예요. @Transactional이 무시돼도, 캐시가 안 돼도, 비동기 예외가 사라져도 에러 한 줄 없이 돌아가요. 그래서 증상에서 원인으로 가는 빠른 지도가 필요해요. 이 글은 Spring 함정 시리즈의 진단 허브예요 — 증상에서 원인으로 가는 점검 순서와 한 줄 확인 도구를 한자리에 모았어요.
01. 공통 진단, 프록시 경로 5문
@Transactional·@Cacheable·@Async·@Retryable이 안 먹는 문제의 90%는 같은 원인이에요 — 프록시를 안 거친 거예요. 그래서 어느 애너테이션이든 점검 순서가 같아요.

- ① 스프링 빈인가 —
new로 만든 객체엔 프록시가 없어요. - ② 자기호출인가 —
this.method()는 프록시를 우회해요. 자기호출 편의 그 함정이에요. - ③ public인가 — private·final은 프록시가 가로챌 수 없어요. 다만 Spring 6.0부터는 클래스 프록시면 protected·패키지 가시성 메서드도 기본 적용돼요. 영영 안 되는 건 private(과 final)이고 인터페이스 프록시일 때만 public 제약이 남아요.
- ④ 기능이 켜져 있나 —
@EnableAsync·@EnableCaching·@EnableRetry누락은 조용히 무시돼요. - ⑤ 같은 스레드인가 — 트랜잭션·시큐리티·MDC는 ThreadLocal이라 스레드를 못 넘어요. @Async 편 참고예요.
02. 트랜잭션 증상들

예외가 났는데 롤백이 안 됐어요
두 갈래예요. 위 5문에 걸리는 프록시 미적용이거나, checked 예외(기본은 커밋!)예요. rollbackFor 또는 RuntimeException 통일 — @Transactional 편의 롤백 규칙이에요.
예외를 잡았는데도 롤백돼요 (UnexpectedRollbackException)
REQUIRED로 합류한 내부 트랜잭션이 rollback-only를 마킹한 거예요. 운명 분리가 필요하면 REQUIRES_NEW(커넥션 2개 주의) — 역시 1편이에요.
readOnly 트랜잭션인데 UPDATE가 나가요
readOnly가 제대로 걸렸다면 Hibernate가 flush를 꺼서 더티 체킹 UPDATE는 안 나가요. 그런데도 UPDATE가 나간다면 readOnly 자체가 안 걸린 경우예요. 자기호출로 프록시를 우회했거나, 바깥의 쓰기 트랜잭션에 REQUIRED로 합류해 힌트가 무시됐거나, saveAndFlush() 같은 명시적 쓰기가 있는 거죠. ①번(프록시)과 전파부터 점검해요.
03. 이벤트·비동기 증상들
롤백됐는데 알림·메일이 나갔어요
@EventListener의 즉시 실행이에요. 부수효과 리스너는 @TransactionalEventListener(AFTER_COMMIT)로 — 이벤트 편이에요. 반대로 AFTER_COMMIT 리스너 안의 save가 증발하면 REQUIRES_NEW 누락이고요.
이벤트 리스너가 아예 안 돌아요
@TransactionalEventListener는 트랜잭션 밖 발행을 기본 무시해요. 발행 지점이 정말 트랜잭션 안인지(자기호출로 빠져 있지 않은지) 확인해요.
비동기 작업이 침묵해요
셋 중 하나예요 — void 예외 증발(핸들러 등록), 기본 풀의 무한 큐 적체(풀 정의), 컨텍스트 미전파(파라미터 전달·TaskDecorator). 전부 @Async 편에 있어요.
기동이 안 되거나 느려요
순환 참조(설계 경고 — 끊기)와 @PostConstruct의 무거운 작업(ApplicationReadyEvent로 이동)을 봐요. 순환 참조 편이에요.
04. 추측 대신 확인, 한 줄 도구 셋

// ① 지금 트랜잭션 안인가?
log.info("tx active = {}",
TransactionSynchronizationManager.isActualTransactionActive());
// ② 어느 스레드에서 도는가? (@Async 판별)
log.info("thread = {}", Thread.currentThread().getName());
// ③ 이 빈은 프록시인가?
log.info("proxy = {}, class = {}",
AopUtils.isAopProxy(orderService), orderService.getClass());
프록시 버그는 조용해서 추측이 길어지기 쉬워요. 이 세 줄이면 "트랜잭션이 있는가 / 비동기인가 / 프록시인가"가 사실로 확정돼요. 의심되는 지점에 로그 한 줄 — 그게 이 시리즈에서 제일 실용적인 습관이에요. 테스트 코드에서 같은 방식으로 단언을 박아두면 회귀도 막을 수 있고요.
05. 함정을 줄이는 팀 컨벤션
진단보다 좋은 건 예방이에요. 시리즈에서 나온 규칙들을 컨벤션으로 묶으면 이래요.
- 비즈니스 예외는 RuntimeException 상속 — checked 롤백 함정 원천 차단.
- 트랜잭션 단위는 별도 빈으로 — 자기호출이 구조적으로 안 나옴.
- 부수효과(알림·외부 API)는 AFTER_COMMIT 리스너로 — 롤백 불일치 차단.
- @Async는 전용 풀 + 예외 핸들러 세트로만 — 기본 풀·침묵 금지.
- 순환 참조 허용 설정 금지 — 경고는 경고로 받기.
정리
스프링 함정 진단의 뼈대는 "프록시 경로 5문"이에요 — 빈인가, 자기호출인가, public인가, Enable을 켰나, 같은 스레드인가. 여기서 트랜잭션 증상은 롤백 규칙·전파로, 이벤트는 실행 타이밍으로, 비동기는 풀·예외·컨텍스트로 갈라져요. 무엇이든 한 줄 확인 도구로 사실부터 잡아요. 조용한 버그일수록 추측보다 측정이 답이에요.
그러니 막혔을 때 스스로에게 던질 질문은 결국 하나예요 — "지금 이 코드, 정말 프록시를 거쳐 트랜잭션 안에서 돌고 있나?" 로그 세 줄이면 답이 나와요.
이 시리즈는 @Transactional 동작 원리 · 프록시 자기호출 · 트랜잭션 이벤트 · @Async · 순환 참조와 초기화를 증상 관점에서 묶은 자리예요. JPA·MySQL 트러블슈팅 허브와 함께 보면 백엔드 진단 3종 세트가 완성돼요. 이어지는 시리즈는 분산 시스템 패턴이에요.
출처: Spring Framework — Proxying Mechanisms · Spring — Transaction Management
'백엔드 > 프레임워크 & 언어' 카테고리의 다른 글
| [Java] 타임존 버그 정리: LocalDateTime vs Instant, UTC 저장, 날짜 경계 함정 (0) | 2026.08.14 |
|---|---|
| [Java] 돈 계산에 double 쓰면 안 되는 이유: BigDecimal 사용법과 함정 (0) | 2026.08.13 |
| [Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점 (1) | 2026.08.04 |
| [Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파 (0) | 2026.08.03 |
| [Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍 (0) | 2026.07.31 |
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."