스프링 애플리케이션 이벤트 메커니즘 활용 및 분산 캐시 동기화 구현

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;
    }
}

태그: Spring SpringEvent Caffeine ApacheIgnite DistributedCache

6월 12일 20:49에 게시됨