옵저버 패턴, Spring ApplicationEvent로 실무에 써먹기
결론부터
회원가입 하나 처리하는데 이메일 발송, 포인트 적립, 통계 갱신까지 한 메서드 안에 줄줄이 엮여 있다면, 그건 옵저버 패턴이 필요하다는 신호다. 옵저버 패턴은 어떤 상태 변화(주체, Subject)가 생겼을 때 그걸 지켜보는 쪽(관찰자, Observer)들에게 통지만 하고, 각자 알아서 반응하게 하는 구조다. 스프링에서는 이걸 직접 인터페이스로 구현할 필요 없이 ApplicationEvent와 @EventListener로 바로 쓸 수 있다.
문제: 회원가입 메서드가 하는 일이 너무 많다
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final EmailService emailService;
private final PointService pointService;
private final StatisticsService statisticsService;
@Transactional
public Member signUp(SignUpRequest request) {
Member member = memberRepository.save(Member.of(request));
emailService.sendWelcomeEmail(member); // 가입과 무슨 상관?
pointService.grantSignUpBonus(member); // 이것도 마찬가지
statisticsService.incrementSignUpCount(); // 통계까지 여기서?
return member;
}
}
가입 로직에 온갖 부가 기능이 직접 의존성으로 끼어 들어가 있다.
MemberService는 회원가입이 되면 무슨 일이 벌어지는지 전부
알아야 하고, 새 부가 기능이 추가될 때마다 이 메서드를 또 고쳐야 한다.
게다가 이메일 발송이 실패하면 트랜잭션 전체가 흔들릴 위험도 있다.
해결: 이벤트를 발행하고, 필요한 쪽에서 알아서 듣는다
먼저 "회원가입이 일어났다"는 사실 자체를 이벤트로 정의한다.
public record MemberSignUpEvent(Long memberId, String email) {}
MemberService는 이제 저장하고 이벤트만 발행하면 끝이다.
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Member signUp(SignUpRequest request) {
Member member = memberRepository.save(Member.of(request));
eventPublisher.publishEvent(new MemberSignUpEvent(member.getId(), member.getEmail()));
return member;
}
}
이메일·포인트·통계는 각자 @EventListener로 독립적으로 반응한다.
서로의 존재를 모른 채 동작한다.
@Component
public class WelcomeEmailListener {
@EventListener
public void handle(MemberSignUpEvent event) {
emailService.sendWelcomeEmail(event.email());
}
}
@Component
public class SignUpPointListener {
@EventListener
public void handle(MemberSignUpEvent event) {
pointService.grantSignUpBonus(event.memberId());
}
}
새 부가 기능(예: 슬랙 알림)이 필요하면 MemberService를 건드릴
필요 없이 리스너 하나만 추가하면 된다. 개방-폐쇄 원칙이 자연스럽게 지켜진다.
실무 함정 1. 기본은 동기 처리다
@EventListener는 별다른 설정이 없으면 같은 스레드에서
동기적으로 실행된다. 즉 이메일 발송이 느리면 회원가입 API 응답도
그만큼 느려진다. 리스너를 별도 스레드로 돌리려면 @Async를
붙이고, 스프링 설정에 @EnableAsync를 활성화해야 한다.
@Component
public class WelcomeEmailListener {
@Async
@EventListener
public void handle(MemberSignUpEvent event) {
emailService.sendWelcomeEmail(event.email());
}
}
실무 함정 2. 트랜잭션 커밋 전에 이벤트가 처리될 수 있다
signUp() 내부에서 이벤트를 발행하면, 아직 트랜잭션이 커밋되기
전에 리스너가 실행될 수 있다. 리스너가 방금 저장한 회원
데이터를 다시 조회하려는데, 아직 커밋 안 된 데이터라 못 찾는 상황이
생길 수 있다는 뜻이다. 이럴 때는 @TransactionalEventListener로
바꿔서 트랜잭션이 성공적으로 커밋된 후에만 실행되게 한다.
@Component
public class WelcomeEmailListener {
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(MemberSignUpEvent event) {
emailService.sendWelcomeEmail(event.email());
}
}
실무 함정 3. 리스너의 예외가 조용히 묻힐 수 있다
동기 리스너의 예외는 발행자 쪽 트랜잭션에도 영향을 줄 수 있지만,
@Async 리스너의 예외는 별도 스레드에서 터지기 때문에
호출부에서는 아무것도 모른 채 넘어간다. 비동기 리스너에는 반드시
try-catch와 로깅을 넣고, 필요하면 AsyncUncaughtExceptionHandler를
등록해 누락 없이 잡아내야 한다.
언제 옵저버 패턴(이벤트)을 쓰면 안 되나
- 호출자가 결과를 즉시 알아야 하는 로직이라면 이벤트로 빼면 안 된다. 재고 차감처럼 실패 시 즉시 롤백해야 하는 핵심 로직은 동기 호출로 명시적으로 처리하는 게 맞다.
- 리스너가 하나뿐이고 앞으로도 늘어날 일이 없다면, 굳이 이벤트로 우회하지 않고 그냥 서비스 메서드를 직접 호출하는 게 더 읽기 쉽다. 이벤트는 "이 상태 변화에 반응하는 대상이 여럿이거나 늘어날 가능성이 있을 때" 쓰는 도구다.
마무리
옵저버 패턴의 핵심은 "무엇을 할지"와 "언제 할지"를 분리하는 것이다. 스프링의 ApplicationEvent는 이 패턴을 인터페이스 설계 없이 애노테이션 몇 개로 바로 실무에 쓸 수 있게 해준다. 다만 동기/비동기, 트랜잭션 타이밍, 예외 처리라는 세 가지 함정을 모르고 쓰면 "분명 이벤트 보냈는데 왜 반응이 없지" 하는 새로운 버그를 만들 뿐이다. 부가 기능이 하나둘 늘어나 서비스 메서드가 비대해지고 있다면, 오늘 정리한 구조부터 적용해보자.
'Programming > Design Pattern' 카테고리의 다른 글
| 빌더 패턴 vs Lombok @Builder, 언제 직접 구현할까 (0) | 2026.08.07 |
|---|---|
| 어댑터 패턴 완벽 정리: Spring Boot 외부 API 연동을 분리하는 방법 (0) | 2026.07.29 |
| 전략 패턴으로 if-else 걷어내기 (0) | 2026.07.16 |
| 📌Java static vs Singleton 완벽 비교: 언제 어떤 걸 써야 할까? (1) | 2025.07.31 |
| ✅ PRG(Post-Redirect-Get) 패턴 – 새로고침 중복방지와 공유 가능한 웹 설계의 핵심 (1) | 2022.10.31 |