1. 스프링 이벤트 메커니즘 개요
스프링 프레임워크는 프로세스 내에서 이벤트를 발행하고 구독할 수 있는 애플리케이션 이벤트 기능을 기본적으로 제공합니다. 이 메커니즘은 다음과 같은 목적으로 활용됩니다.
- 스프링 컨텍스트 생명주기 이벤트를 감지하여 애플리케이션 동작 확장
- 컴포넌트 간의 결합도를 낮추고 의존성 역전 원칙 적용
- 도메인 주도 설계(DDD)에서의 도메인 이벤트 구현
2. 이벤트 구독 및 발행 방법
2.1 이벤트 리스너 구현 방식
이벤트를 수신하는 방법은 크게 인터페이스 구현과 어노테이션 사용 두 가지로 나뉩니다.
2.1.1 ApplicationListener 인터페이스 구현
전통적인 방식으로, 특정 이벤트 타입에 대한 리스너 인터페이스를 구현합니다.
public class UserActionHandler implements ApplicationListener<UserActionEvent> {
@Override
public void onApplicationEvent(UserActionEvent event) {
// 이벤트 발생 시 수행할 로직
System.out.println("User action detected: " + event.getActionType());
}
}
특징: 애플리케이션의 전체 생명주기 동안 유효하지만, 이벤트가 반드시 ApplicationEvent를 상속해야 하며 하나의 리스너가 여러 종류의 이벤트를 유연하게 처리하기 어렵다는 단점이 있습니다.
2.1.2 @EventListener 어노테이션 활용
스프링 4.2부터 도입된 어노테이션 기반으로, 더 유연하고 낮은 결합도를 제공합니다.
public class SystemEventObserver {
@EventListener(ContextClosedEvent.class)
public void handleShutdown() {
log.info("애플리케이션 컨텍스트가 종료됩니다.");
}
@EventListener
public void handleUserAction(UserActionEvent event) {
log.info("사용자 이벤트 수신: {}", event);
}
@EventListener(condition = "#event.actionType.name() == 'CREATE'")
public void handleCreateAction(UserActionEvent event) {
log.info("생성 이벤트 필터링: {}", event);
}
}
특징: 이벤트 객체가 특정 클래스를 상속할 필요가 없으며, SpEL(Spring Expression Language)을 사용한 조건부 필터링과 @Async를 통한 비동기 처리가 용이합니다. 단, 빈 초기화 단계의 이벤트는 포착하지 못할 수 있습니다.
2.2 이벤트 발행
이벤트를 발생시키기 위해서는 ApplicationEventPublisher를 주입받아 사용합니다.
public class EventDispatcher {
private final ApplicationEventPublisher publisher;
public EventDispatcher(ApplicationEventPublisher publisher) {
this.publisher = publisher;
// 초기화 단계 이벤트 발행
this.publisher.publishEvent(new UserActionEvent(this, ActionType.INIT, "System Started"));
}
public void triggerAction(String payload) {
// ApplicationEvent 상속 객체 발행
this.publisher.publishEvent(new UserActionEvent(this, ActionType.UPDATE, payload));
// 일반 POJO 객체 발행
this.publisher.publishEvent(new SimpleNotification(ActionType.UPDATE, payload));
}
}
3. 실무 적용 사례
3.1 우아한 종료(Graceful Shutdown) 처리
애플리케이션이 종료될 때 ContextClosedEvent를 구독하여 리소스를 안전하게 정리할 수 있습니다.
public class ResourceCleanupService {
private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
@EventListener(ContextClosedEvent.class)
public void onShutdown() throws InterruptedException {
executor.shutdown();
if (!executor.awaitTermination(15, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
}
}
3.2 로컬 캐시와 분산 캐시 동기화 (Caffeine + Ignite)
Spring Cache(Caffeine)를 사용하면서 다중 인스턴스 환경에서 캐시 동기화를 위해 이벤트와 Apache Ignite를 결합한 예시입니다.
1단계: 캐시 래퍼(Decorator)를 통한 이벤트 발행
로컬 캐시의 변경 사항을 감지하여 이벤트를 발행하는 데코레이터를 작성합니다.
public class EventAwareLocalCache<K, V> implements Cache<K, V> {
private final String cacheName;
private final Cache<K, V> delegate;
private final ApplicationEventPublisher publisher;
public EventAwareLocalCache(String cacheName, Cache<K, V> delegate, ApplicationEventPublisher publisher) {
this.cacheName = cacheName;
this.delegate = delegate;
this.publisher = publisher;
}
@Override
public void invalidate(K key) {
delegate.invalidate(key);
publisher.publishEvent(CacheModificationEvent.builder()
.cacheName(cacheName)
.action(CacheModificationEvent.Action.EVICT)
.keys(List.of(key))
.build());
}
@Override
public void invalidateAll(Iterable<? extends K> keys) {
delegate.invalidateAll(keys);
List<K> keyList = new ArrayList<>();
keys.forEach(keyList::add);
publisher.publishEvent(CacheModificationEvent.builder()
.cacheName(cacheName)
.action(CacheModificationEvent.Action.EVICT)
.keys(keyList)
.build());
}
// 순환 참조 방지를 위한 로컬 전용 제거 메서드
public void evictLocally(Collection<? extends K> keys) {
delegate.invalidateAll(keys);
}
// 나머지 메서드는 delegate에 위임
}
2단계: CacheManagerCustomizer를 통한 래퍼 등록
캐시 매니저 초기화 시 위에서 만든 데코레이터를 적용합니다.
@Component
public class LocalCacheManagerConfig implements CacheManagerCustomizer<CaffeineCacheManager> {
private final CacheProperties properties;
private final ApplicationEventPublisher publisher;
public LocalCacheManagerConfig(CacheProperties properties, ApplicationEventPublisher publisher) {
this.properties = properties;
this.publisher = publisher;
}
@Override
public void customize(CaffeineCacheManager cacheManager) {
if (properties.getDefinitions() == null) return;
for (CacheDefinition def : properties.getDefinitions()) {
Caffeine<Object, Object> builder = Caffeine.from(def.getSpec());
Cache<Object, Object> rawCache = builder.build();
EventAwareLocalCache<Object, Object> wrappedCache =
new EventAwareLocalCache<>(def.getName(), rawCache, publisher);
cacheManager.registerCustomCache(def.getName(), wrappedCache);
}
}
}
3단계: Ignite를 활용한 분산 메시지 동기화
로컬에서 발생한 캐시 변경 이벤트를 Ignite 메시징을 통해 다른 노드로 전파하고, 수신된 메시지로 로컬 캐시를 업데이트합니다.
@Component
public class DistributedCacheSyncManager implements CacheManagerCustomizer<CaffeineCacheManager> {
private static final String TOPIC = "cache.sync.topic";
private final Ignite ignite;
private final ObjectMapper mapper;
private final ApplicationEventPublisher publisher;
private CaffeineCacheManager cacheManager;
public DistributedCacheSyncManager(Ignite ignite, ObjectMapper mapper, ApplicationEventPublisher publisher) {
this.ignite = ignite;
this.mapper = mapper;
this.publisher = publisher;
this.ignite.message().localListen(TOPIC, this::handleRemoteMessage);
}
@EventListener(ContextClosedEvent.class)
public void cleanup() {
ignite.message().stopLocalListen(TOPIC, this::handleRemoteMessage);
}
@Async
@EventListener
public void broadcastCacheChange(CacheModificationEvent event) {
try {
String payload = mapper.writeValueAsString(event);
ignite.message().send(TOPIC, payload);
} catch (Exception e) {
log.error("캐시 동기화 메시지 발행 실패", e);
}
}
private boolean handleRemoteMessage(UUID nodeId, Object message) {
if (!(message instanceof String payload)) return true;
try {
CacheModificationEvent event = mapper.readValue(payload, CacheModificationEvent.class);
if (event == null || event.getCacheName() == null) return true;
// 자기 자신이 보낸 메시지는 무시
if (!nodeId.equals(ignite.cluster().localNode().id())) {
applyToLocalCache(event);
}
} catch (Exception e) {
log.error("원격 캐시 메시지 처리 실패", e);
}
return true;
}
private void applyToLocalCache(CacheModificationEvent event) {
Cache<Object, Object> cache = cacheManager.getCache(event.getCacheName()).getNativeCache();
if (cache instanceof EventAwareLocalCache<?, ?> localCache) {
((EventAwareLocalCache<Object, Object>) localCache).evictLocally(event.getKeys());
}
}
@Override
public void customize(CaffeineCacheManager cacheManager) {
this.cacheManager = cacheManager;
}
}