
현재 우리 서버는 한창 알림을 만드는 중이다.
이 과정에서 알림을 보내기 위한 기본 정보들이 DB에 저장이 안 되는 일이 발생했다.
what?

분명 코드적으로 문제가 없는 것 같은데 왜 저장이 안되는 걸까??
처음에는 이유를 알 수 없었다.
@TransactionalEventListener
public void onArticleArrived(ArticleArrivedEvent event) {
try {
ArticleArrivalNotification articleArrivalNotification = ArticleArrivalNotification.builder()
.articleId(event.articleId())
.memberId(event.memberId())
.newsletterName(event.newsletterName())
.articleTitle(event.articleTitle())
.status(NotificationStatus.PENDING)
.attempts(0)
.isRead(false)
.build();
articleArrivalNotificationRepository.save(articleArrivalNotification);
log.info("아티클 도착 알림 저장 완료: 멤버 ID={}, 뉴스레터={}, 아티클 제목={}",
event.memberId(), event.newsletterName(), event.articleTitle());
} catch (Exception e) {
// ...
}
}
코드는 위와 같이 생겼다.
아티클이 저장된 뒤, AFTER_COMMIT 시점에서 알림용 엔티티를 저장하는 구조다.
로그를 봐도 save()가 이후 로그가 정상적으로 호출되었고, 예외도 보이지 않았다.
분명 AFTER_COMMIT 이후에 저장이 되어야하는데 되지 않는것이었다.
그리고 에러하나 뜨지 않았다.

실제 DB를 열어보면 이렇게 텅 비어 있었다.

1. 문제 상황 정리
로그를 자세히 보면, 해당 로그는 분명 save() 호출 이후에 찍힌 로그였다.
즉, “엔티티를 저장하라”는 명령 자체는 JPA까지는 잘 전달된 상황이었다.
하지만 실제 DB를 열어보면 테이블이 텅 비어 있었다.
그래서 제일 먼저 떠올린 생각은
“정말로 DB에 INSERT 쿼리가 실행되었는가?”
물론 당연히 `save()` 메서드가 동작했다고 해서 무조건 DB에 저장되는 것은 아니다.
JPA 입장에서 save()는 영속성 컨텍스트에 엔티티를 올려두는 작업일 뿐이고,
실제 DB에 반영되는 시점은 flush + commit이 일어날 때다.
INSERT SQL / flush 로그 확인
그렇다면 가장 먼저 할 일은 INSERT SQL이 찍혔는지 확인하는 것이다.
이게 제일 확실하다.
하지만 안타깝게도, 이 서버는 운영 이메일 서버라 SQL 쿼리 로그를 별도로 켜지 않은 상태였다.
대신, flush 로그를 통해 우회적으로 확인해보기로 했다.
Hibernate는 flush 시점에 몇 개의 엔티티가 반영되었는지 다음과 같이 로그를 남긴다.

여기서 중요한 점은 insertions(혹은 creations)가 0이었다는 것
즉, flush 시점에 “새로 추가된 엔티티”가 하나도 없었다는 것이다
정리하면,
- save()는 호출되었다. 엔티티는 영속성 컨텍스트에 올라감
- 하지만 flush 시점에 insert 대상 엔티티가 하나도 없었다.
- 그래서 DB로 날아간 INSERT SQL도 없었고,
- 결국 DB에는 아무것도 저장되지 않았다.
이 시점에서는 외부 트랜잭션이 rollback 된 것도 아니고, readOnly 트랜잭션도 아니었고, 다른 DB를 바라보고 있는 것도 아니었다.
즉, "전형적인" 원인들은 아니었다.
그래서 팀 슬랙에 상황을 공유해두고 계속 로그를 뜯어보다가 진짜 원인을 발견했다.


진짜 원인: AFTER_COMMIT 리스너 + 트랜잭션 없음
문제의 핵심은 이 로직이 `@TransactionalEventListener` 를 사용하고 있었다는 점이다.
이 애너테이션의 기본 `phase` 값은 AFTER_COMMIT 이다.
즉, 기존 트랜잭션이 커밋된 이후에 리스너가 실행된다.
이미 기본 트랜잭션은 끝났고, 영속성 컨텍스트도 닫힌 상태다.
이 시점에서 별도의 `@Transactional` 이 없다면, 트랜잭션 없이 save()를 호출하는 상황이 된다.

(이름 자체가 AFTER_COMMIT인데 왜 트랜잭션이 있다고 생각했지??? .. 하...)
그렇다면 JPA/Hibernate는 트랜잭션 밖에서 save()를 호출하면 어떻게 될까?
내부에서는 `entityManager.persist()` 까지는 호출된다.
하지만 자동 flush는 “활성화된 트랜잭션”이 있을 때만 일어난다.
commit 역시 트랜잭션이 있어야만 발생한다.
결국, 트랜잭션이 없으면 flush/commit이 발생하지 않는다.
그래서 이 경우는 이렇게 정리된다.
- JPA는 엔티티를 영속성 컨텍스트에 올리기만 하고
- 실제 DB로 INSERT를 날릴 기회를 갖지 못하고
- 그 상태로 메서드가 끝나버린다.
결과적으로 INSERT SQL이 준비되긴 했지만, 트랜잭션이 없어 커밋되지 않았고 DB에는 아무 것도 들어가지 않은 것이다.
❗️AFTER_COMMIT 리스너의 특징 정리
`@TransactionalEventListener(phase = AFTER_COMMIT)` 의 특징을 정리하면 다음과 같다.
- 이미 원래 트랜잭션이 끝난 시점에 실행된다.
- 이때 별도의 @Transactional 을 붙이지 않으면 새로운 트랜잭션이 생성되지 않고 모든 변경 작업은 DB에 반영되지 않는다.
- 영속성 컨텍스트도 이미 닫혀 있어서 단순 조회는 가능하지만 Lazy 로딩은 Detached 상태라 LazyInitializationException 이 발생한다.
그래서 트랜잭션 없이 AFTER_COMMIT 리스너에서 할 수 있는 일은 사실상 조회 외에는 거의 없다.
✔ 저장(save) → 거의 100% DB 반영 안 됨
✔ 수정(update) → Dirty checking이 안 돼서 반영 안 됨
✔ 삭제(delete) → flush/commit 없어서 반영 안 됨
✔ 조회(find) → 가능은 하지만 Lazy 관계는 터진다
2. 해결 방안
이제 “왜 저장이 안 되었는지”는 알게 되었으니,
남은 것은 “그렇다면 어떻게 고쳐야 하는가”였다.
1. 리스너를 본 트랜잭션 안에서 돌리기
첫 번째 후보는 리스너를 기존 트랜잭션 안에서 함께 돌리는 방법이다.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void onArticleArrived(ArticleArrivedEvent event) { ... }
BEFORE_COMMIT으로 설정하면, 아티클 저장 트랜잭션이 커밋되기 전에 리스너가 실행되고, 알림 저장도 같은 트랜잭션 안에서 함께 커밋/롤백된다.
하지만 이 방법은 바로 제외했다. 이유는 명확하다.
- 알림 저장에서 예외가 발생하면 → 원본 아티클 트랜잭션까지 같이 롤백된다.
- 우리 도메인에서는 → “알림은 실패해도 상관없지만, 아티클 저장은 반드시 성공해야 한다.”
알림은 본 비즈니스의 부가 기능일 뿐인데,
부가 기능이 실패했다고 해서 핵심 흐름까지 같이 실패하게 만들고 싶지는 않았다.
그래서 트랜잭션을 분리하기 위해 계속 AFTER_COMMIT을 유지하기로 했다.
2. 리스너에서 새 트랜잭션을 열어주기
두 번째 방법은 리스너에서 아예 새 트랜잭션을 여는 것이다.
해당 메서드에 `@Transactional(propagation = Propagation.REQUIRES_NEW)` 애너테이션만 붙이면 해결이 가능하다.
이렇게 하면 아티클 트랜잭션이 정상적으로 커밋된 이후에 리스너가 완전히 별도의 트랜잭션을 열고 알림을 저장한 뒤 그 트랜잭션만 따로 커밋한다.
즉 아티클 저장 성공 여부와 알림 저장 성공 여부가 완전히 독립된다.
알림 로직에서 예외가 터져도,
이미 끝난 아티클 트랜잭션에는 전혀 영향을 주지 않는다.
그래서 우리는 2번 방식, 즉 “리스너에서 새 트랜잭션 열기”를 선택했다.
❗️그런데 왜 옵션을 `Propagation.REQUIRES_NEW` 로 했을까?
“사실 @Transactional 만 붙여도 되지 않나?
기본 전파 옵션(REQUIRED)이면 트랜잭션이 없을 때 새로 만들어주는데?”
실제로 지금 구조에서는 아래 코드만 있어도 정상 동작한다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional // propagation = REQUIRED 기본값
public void onArticleArrived(ArticleArrivedEvent event) {
...
}
AFTER_COMMIT 시점에는 이미 기존 트랜잭션이 끝난 상태라,
진행 중인 트랜잭션이 없고, REQUIRED는 새 트랜잭션을 생성한다.
그래서 버그 자체만 놓고 보면, 기본값으로도 해결은 된다.
그런데도 불구하고 우리는 REQUIRES_NEW를 명시적으로 선택했다.
그 이유는 한마디로 말하면,
“이 로직은 항상 독립된 트랜잭션에서 돌려야 한다”는
의도를 코드에 박아두고 싶었기 때문이다.
즉, REQUIRES_NEW는 단순히 “동작시켜 보니 되더라” 수준이 아니라,
- “이 메서드는 언제나 새 영속성 컨텍스트 + 새 트랜잭션에서 돌려야 한다”
- “알림 저장은 하나의 작은 단위 작업으로 묶여서 커밋되어야 한다”
- “메인 비즈니스와는 철저히 분리된 사이드 이펙트다”
라는 설계 의도를 코드만 보고도 바로 이해할 수 있게 해주는 장치다.
이런 면에서 REQUIRES_NEW는 일종의 자기 문서화(self-documenting) 도구 역할을 한다.
나중에 팀원이 코드를 보더라도,
“아, 이건 꼭 독립 트랜잭션이어야 하는 작업이구나”
라는 걸 주석 없이도 바로 읽어낼 수 있다.
📌 한 걸음 더
여기서 또 주제에 벗어나지만 또 고려하면 좋을게 있다.
AFTER_COMMIT을 하더라도 하나의 스레드에서 동작한다 그럼 과연 동기로계속 유지하는게 좋을까? 아니면 비동기로 유지하는게 좋을까?
이건 다음시간에...