에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/프레임워크 & 언어

[Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드

반응형
[Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드

[Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드

스프링 함정들의 공통점은 조용하다는 거예요. @Transactional이 무시돼도, 캐시가 안 돼도, 비동기 예외가 사라져도 에러 한 줄 없이 돌아가요. 그래서 증상에서 원인으로 가는 빠른 지도가 필요해요. 이 글은 Spring 함정 시리즈의 진단 허브예요 — 증상에서 원인으로 가는 점검 순서와 한 줄 확인 도구를 한자리에 모았어요.

01. 공통 진단, 프록시 경로 5문

@Transactional·@Cacheable·@Async·@Retryable이 안 먹는 문제의 90%는 같은 원인이에요 — 프록시를 안 거친 거예요. 그래서 어느 애너테이션이든 점검 순서가 같아요.

스프링 애너테이션 미동작 공통 진단 5문. 스프링 빈인가, 자기호출인가, public인가, EnableAsync 같은 기능 활성화가 됐나, 같은 스레드인가를 순서대로 점검하면 프록시 미적용의 대부분이 잡힌다
  • ① 스프링 빈인가new로 만든 객체엔 프록시가 없어요.
  • ② 자기호출인가this.method()는 프록시를 우회해요. 자기호출 편의 그 함정이에요.
  • ③ public인가 — private·final은 프록시가 가로챌 수 없어요. 다만 Spring 6.0부터는 클래스 프록시면 protected·패키지 가시성 메서드도 기본 적용돼요. 영영 안 되는 건 private(과 final)이고 인터페이스 프록시일 때만 public 제약이 남아요.
  • ④ 기능이 켜져 있나@EnableAsync·@EnableCaching·@EnableRetry 누락은 조용히 무시돼요.
  • ⑤ 같은 스레드인가 — 트랜잭션·시큐리티·MDC는 ThreadLocal이라 스레드를 못 넘어요. @Async 편 참고예요.

02. 트랜잭션 증상들

스프링 함정 증상 매핑. 롤백 안 됨은 checked 예외나 프록시 미적용, 잡았는데 롤백은 rollback-only 마킹, 롤백됐는데 알림 발송은 EventListener 즉시 실행, 비동기 침묵은 예외 증발과 무한 큐, 기동 실패는 순환 참조다

예외가 났는데 롤백이 안 됐어요

두 갈래예요. 위 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. 추측 대신 확인, 한 줄 도구 셋

스프링 진단 한 줄 도구. isActualTransactionActive로 트랜잭션 여부를, 스레드 이름으로 비동기 동작을, AopUtils.isAopProxy로 프록시 적용 여부를 즉시 확인한다
// ① 지금 트랜잭션 안인가?
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

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."