Skip to content

멀티 벤더 IoT 연동 설계하기 (2편) - Event-Driven Architecture로 확장하기

멀티 벤더 IoT 연동 설계하기 (2편) - Event-Driven Architecture로 확장하기




TL;DR

  • 문제: 기기 제어 성공 시 푸시 알림, 로그 저장, 통계, 슬랙 알림 등이 한 메서드에 몰려서 결합도 급증
  • 원인: Service 클래스가 너무 많은 책임을 가짐 (단일 책임 원칙 위반)
  • 해결: Event-Driven Architecture - 기기 제어 성공 시 이벤트 발행, 각 기능은 리스너로 독립 처리
  • 효과: 결합도 낮아짐, 새 기능 추가 쉬움 (리스너만 추가), 테스트 독립적
  • 한계: 디버깅 어려움 (이벤트 흐름 추적), 트랜잭션 분리, 이벤트 순서 보장 안 됨, 러닝 커브




글 머리말

이전 편에서는 Adapter 패턴으로 여러 제조사 API를 통합하는 방법을 다뤘습니다.

기능은 잘 동작했지만, 서비스가 커지면서 예상치 못한 문제가 생기더군요.


기획팀에서 요청이 쏟아지기 시작했습니다.

“기기 제어 성공하면 사용자한테 푸시 알림 보내주세요.”

“IoT 기기 제어 이력을 남겨서 통계 내야 해요.”

“제어 실패 시 슬랙으로 알림 보내주세요.”

“기기 사용 패턴 분석을 위해 데이터를 수집해야 합니다.”


처음엔 단순하게 접근했습니다. SmartHomeService에 코드를 계속 추가했죠.

public void turnOnLight(Long deviceId, int brightness) {
    // 기기 제어
    adapter.executeCommand(...);

    // 알림 발송
    pushNotificationService.send(...);

    // 로그 저장
    deviceLogRepository.save(...);

    // 통계 업데이트
    statisticsService.update(...);

    // 슬랙 알림 (실패 시)
    if (failed) {
        slackService.send(...);
    }
}

한두 개는 괜찮았습니다. 근데 요구사항이 10개가 넘어가니까 문제가 보이기 시작하더군요.

핵심 로직보다 부가 기능이 더 많아졌습니다. 기기 제어는 한 줄인데 알림, 로그, 통계가 열 줄이었습니다.

이번 글에서는 Event-Driven Architecture(EDA)를 도입해서 핵심 로직과 부가 기능을 분리한 경험을 공유합니다.





문제 상황 - 계속 커지는 서비스 클래스

부가 기능이 추가될 때마다 서비스 클래스가 비대해졌습니다.


Before - 모든 걸 다 하는 서비스

@Service
@RequiredArgsConstructor
public class SmartHomeService {

    private final IoTAdapterFactory adapterFactory;
    private final DeviceRepository deviceRepository;
    private final PushNotificationService pushService;
    private final DeviceLogRepository logRepository;
    private final StatisticsService statisticsService;
    private final SlackService slackService;
    private final DataCollectionService dataCollectionService;
    // ... 의존성이 계속 늘어남

    @Transactional
    public void turnOnLight(Long deviceId, int brightness) {
        try {
            // 1. 기기 정보 조회
            Device device = deviceRepository.findById(deviceId)
                .orElseThrow();

            // 2. 기기 제어 (핵심 로직)
            IoTVendorAdapter adapter = adapterFactory.getAdapter(
                device.getVendorId()
            );
            adapter.executeCommand(device.getExternalId(), command);

            // 3. 상태 업데이트
            device.updateStatus(true, brightness);
            deviceRepository.save(device);

            // 4. 푸시 알림 발송
            pushService.send(
                device.getUserId(),
                "조명이 켜졌습니다."
            );

            // 5. 로그 저장
            DeviceLog log = DeviceLog.builder()
                .deviceId(deviceId)
                .action("TURN_ON")
                .brightness(brightness)
                .timestamp(LocalDateTime.now())
                .build();
            logRepository.save(log);

            // 6. 통계 업데이트
            statisticsService.incrementUsageCount(deviceId);
            statisticsService.updateBrightnessAverage(deviceId, brightness);

            // 7. 데이터 수집 (분석용)
            dataCollectionService.collect(
                device.getVendorId(),
                "TURN_ON",
                brightness
            );

        } catch (Exception e) {
            // 8. 실패 시 슬랙 알림
            slackService.sendError(
                "기기 제어 실패: " + deviceId,
                e.getMessage()
            );
            throw e;
        }
    }
}

뭐가 문제일까요?


명백한 문제들

1. 단일 책임 원칙(SRP) 위반

“기기 제어” 외에 알림, 로그, 통계, 데이터 수집까지 전부 다 합니다.


2. 의존성 폭발

서비스 하나가 7-8개의 다른 서비스에 의존합니다. 테스트할 때 mock 만들기도 힘듭니다.


3. 변경의 연쇄 작용

푸시 알림 로직을 수정하려면 SmartHomeService를 열어야 합니다. 통계 로직을 추가하려면 또 열어야 합니다.


4. 성능 문제

모든 부가 기능이 동기로 실행됩니다. 푸시 알림 보내느라 API 응답이 느려집니다.


5. 트랜잭션 범위 혼란

핵심 로직(기기 제어)과 부가 기능(로그, 통계)이 같은 트랜잭션에 묶여있습니다. 통계 업데이트 실패하면 기기 제어도 롤백됩니다.


가장 큰 문제는 새 요구사항이 들어올 때마다 이 클래스를 수정해야 한다는 점이었습니다.

“기기 제어 성공하면 이메일도 보내주세요.” → SmartHomeService 수정

“제어 실패 로그를 별도로 수집해주세요.” → SmartHomeService 수정


Open-Closed Principle(개방-폐쇄 원칙) 완전 위반입니다.





해결 방법 - Event-Driven Architecture

핵심은 “기기 제어가 성공했다”는 사실을 알리기만 하고, 그 이후는 관심 없는 것입니다.


이벤트 기반 설계 원칙

기존 방식과 이벤트 기반 방식의 차이를 도식화하면 다음과 같습니다.

graph TB
    subgraph Before["기존 (동기, 강결합)"]
        A1[기기 제어] --> B1[알림 발송]
        B1 --> C1[로그 저장]
        C1 --> D1[통계 업데이트]
        D1 --> E1[데이터 수집]
    end

    subgraph After["이벤트 기반 (비동기, 느슨한 결합)"]
        A2[기기 제어] --> Event[이벤트 발행]
        Event -.->|비동기| B2[알림 리스너]
        Event -.->|비동기| C2[로그 리스너]
        Event -.->|비동기| D2[통계 리스너]
        Event -.->|비동기| E2[데이터 리스너]
    end

    style Event fill:#4CAF50,color:#fff
    style A1 fill:#f44336,color:#fff
    style A2 fill:#2196F3,color:#fff

핵심 로직은 이벤트만 발행하고 끝입니다. 누가 듣는지, 무엇을 하는지 전혀 몰라도 됩니다.


이벤트 정의

// 기기 제어 성공 이벤트
@Getter
@AllArgsConstructor
public class DeviceControlSuccessEvent {
    private final Long deviceId;
    private final String vendorId;
    private final CommandType commandType;
    private final Map<String, Object> parameters;
    private final LocalDateTime timestamp;

    public static DeviceControlSuccessEvent of(
        Device device,
        DeviceCommand command
    ) {
        return new DeviceControlSuccessEvent(
            device.getId(),
            device.getVendorId(),
            command.getType(),
            command.getParameters(),
            LocalDateTime.now()
        );
    }
}

// 기기 제어 실패 이벤트
@Getter
@AllArgsConstructor
public class DeviceControlFailureEvent {
    private final Long deviceId;
    private final String vendorId;
    private final CommandType commandType;
    private final String errorMessage;
    private final Exception exception;
    private final LocalDateTime timestamp;
}

이벤트는 불변(immutable) 객체입니다. 발생한 사실을 담고 있을 뿐, 누가 처리하든 내용이 바뀌면 안 됩니다.


서비스 - 이벤트만 발행

@Service
@RequiredArgsConstructor
public class SmartHomeService {

    private final IoTAdapterFactory adapterFactory;
    private final DeviceRepository deviceRepository;
    private final ApplicationEventPublisher eventPublisher; // Spring 제공

    @Transactional
    public void turnOnLight(Long deviceId, int brightness) {
        try {
            // 1. 기기 정보 조회
            Device device = deviceRepository.findById(deviceId)
                .orElseThrow();

            // 2. 기기 제어 (핵심 로직)
            IoTVendorAdapter adapter = adapterFactory.getAdapter(
                device.getVendorId()
            );

            DeviceCommand command = DeviceCommand.builder()
                .type(CommandType.TURN_ON)
                .parameter("brightness", brightness)
                .build();

            adapter.executeCommand(device.getExternalId(), command);

            // 3. 상태 업데이트
            device.updateStatus(true, brightness);
            deviceRepository.save(device);

            // 4. 성공 이벤트 발행
            eventPublisher.publishEvent(
                DeviceControlSuccessEvent.of(device, command)
            );

        } catch (Exception e) {
            // 실패 이벤트 발행
            eventPublisher.publishEvent(
                new DeviceControlFailureEvent(
                    deviceId,
                    device.getVendorId(),
                    CommandType.TURN_ON,
                    e.getMessage(),
                    e,
                    LocalDateTime.now()
                )
            );
            throw e;
        }
    }
}

드라마틱하게 간단해졌습니다.

의존성도 3개로 줄었고, 코드도 절반 이하로 줄었습니다. 핵심 로직만 남았습니다.


이벤트 리스너 - 부가 기능 처리

각 부가 기능은 독립적인 리스너로 분리됩니다.


1. 푸시 알림 리스너

@Component
@RequiredArgsConstructor
@Slf4j
public class DeviceNotificationListener {

    private final PushNotificationService pushService;
    private final DeviceRepository deviceRepository;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleDeviceControlSuccess(DeviceControlSuccessEvent event) {
        try {
            Device device = deviceRepository.findById(event.getDeviceId())
                .orElseThrow();

            String message = createNotificationMessage(
                event.getCommandType(),
                event.getParameters()
            );

            pushService.send(device.getUserId(), message);

            log.info("Push notification sent: deviceId={}", event.getDeviceId());

        } catch (Exception e) {
            log.error("Failed to send push notification", e);
            // 알림 실패는 전체 프로세스에 영향 없음
        }
    }

    private String createNotificationMessage(
        CommandType commandType,
        Map<String, Object> parameters
    ) {
        return switch (commandType) {
            case TURN_ON -> "조명이 켜졌습니다.";
            case TURN_OFF -> "조명이 꺼졌습니다.";
            case SET_VALUE -> {
                int brightness = (int) parameters.get("brightness");
                yield "밝기가 " + brightness + "%로 설정되었습니다.";
            }
        };
    }
}

2. 로그 저장 리스너

@Component
@RequiredArgsConstructor
@Slf4j
public class DeviceLogListener {

    private final DeviceLogRepository logRepository;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleDeviceControlSuccess(DeviceControlSuccessEvent event) {
        try {
            DeviceLog deviceLog = DeviceLog.builder()
                .deviceId(event.getDeviceId())
                .vendorId(event.getVendorId())
                .commandType(event.getCommandType())
                .parameters(event.getParameters())
                .success(true)
                .timestamp(event.getTimestamp())
                .build();

            logRepository.save(deviceLog);

            log.info("Device log saved: deviceId={}", event.getDeviceId());

        } catch (Exception e) {
            log.error("Failed to save device log", e);
        }
    }

    @Async
    @EventListener
    public void handleDeviceControlFailure(DeviceControlFailureEvent event) {
        try {
            DeviceLog deviceLog = DeviceLog.builder()
                .deviceId(event.getDeviceId())
                .vendorId(event.getVendorId())
                .commandType(event.getCommandType())
                .success(false)
                .errorMessage(event.getErrorMessage())
                .timestamp(event.getTimestamp())
                .build();

            logRepository.save(deviceLog);

        } catch (Exception e) {
            log.error("Failed to save failure log", e);
        }
    }
}

3. 통계 업데이트 리스너

@Component
@RequiredArgsConstructor
@Slf4j
public class DeviceStatisticsListener {

    private final StatisticsService statisticsService;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleDeviceControlSuccess(DeviceControlSuccessEvent event) {
        try {
            statisticsService.incrementUsageCount(
                event.getDeviceId(),
                event.getCommandType()
            );

            if (event.getParameters().containsKey("brightness")) {
                int brightness = (int) event.getParameters().get("brightness");
                statisticsService.updateBrightnessAverage(
                    event.getDeviceId(),
                    brightness
                );
            }

            log.info("Statistics updated: deviceId={}", event.getDeviceId());

        } catch (Exception e) {
            log.error("Failed to update statistics", e);
        }
    }
}

4. 슬랙 알림 리스너 (실패 시)

@Component
@RequiredArgsConstructor
@Slf4j
public class DeviceErrorAlertListener {

    private final SlackService slackService;

    @Async
    @EventListener
    public void handleDeviceControlFailure(DeviceControlFailureEvent event) {
        try {
            String message = String.format(
                "기기 제어 실패\n" +
                "- 기기 ID: %d\n" +
                "- 제조사: %s\n" +
                "- 명령: %s\n" +
                "- 에러: %s",
                event.getDeviceId(),
                event.getVendorId(),
                event.getCommandType(),
                event.getErrorMessage()
            );

            slackService.sendToChannel("iot-alerts", message);

            log.info("Slack alert sent: deviceId={}", event.getDeviceId());

        } catch (Exception e) {
            log.error("Failed to send Slack alert", e);
        }
    }
}

각 리스너는 완전히 독립적입니다. 서로를 전혀 모릅니다.





핵심 포인트 - @EventListener 활용법

Spring의 @EventListener에는 몇 가지 중요한 옵션이 있습니다.


1. @Async - 비동기 처리

@Async
@EventListener
public void handleEvent(DeviceControlSuccessEvent event) {
    // 별도 스레드에서 실행
}

@Async 붙이면 이벤트 처리가 비동기로 실행됩니다.

API 응답 시간에 영향을 주지 않습니다. 알림 발송이 느려도 기기 제어 API는 빠르게 응답합니다.


주의: @EnableAsync 설정 필요

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("event-async-");
        executor.initialize();
        return executor;
    }
}

2. @TransactionalEventListener - 트랜잭션 연동

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleEvent(DeviceControlSuccessEvent event) {
    // 트랜잭션 커밋 후 실행
}

4가지 Phase가 있습니다:

  • AFTER_COMMIT (기본): 트랜잭션 커밋 후 실행 (가장 많이 사용)
  • AFTER_ROLLBACK: 트랜잭션 롤백 후 실행
  • AFTER_COMPLETION: 커밋이든 롤백이든 트랜잭션 완료 후 실행
  • BEFORE_COMMIT: 트랜잭션 커밋 전 실행

왜 중요한가?

트랜잭션과 이벤트의 실행 순서를 도식화하면 이렇습니다.

sequenceDiagram
    participant S as Service
    participant DB as Database
    participant E as EventPublisher
    participant L as Listener

    S->>DB: 1. 기기 제어
    S->>DB: 2. DB 저장 (device.save)
    S->>E: 3. 이벤트 발행
    Note over E: 이벤트 대기열에 등록
    S->>DB: 4. 트랜잭션 커밋
    DB-->>S: 커밋 완료
    E->>L: 5. 리스너 실행
    Note over L: @Async면 별도 스레드

이벤트는 3번에서 발행되지만, 실제 리스너는 4번 커밋 이후에 실행됩니다.

만약 DB 저장이 실패하면? 이벤트 리스너는 실행되지 않습니다. 알림도 안 보내고, 로그도 안 남습니다.

데이터 정합성이 보장됩니다.


3. 이벤트 필터링

@EventListener(condition = "#event.commandType.name() == 'TURN_ON'")
public void handleTurnOnOnly(DeviceControlSuccessEvent event) {
    // TURN_ON 명령만 처리
}

@EventListener(condition = "#event.vendorId == 'VENDOR_A'")
public void handleVendorAOnly(DeviceControlSuccessEvent event) {
    // A사 기기만 처리
}

SpEL(Spring Expression Language)로 조건을 걸 수 있습니다.

특정 제조사, 특정 명령에만 반응하는 리스너를 만들 수 있습니다.


4. 이벤트 순서 제어

@Order(1)
@EventListener
public void firstListener(DeviceControlSuccessEvent event) {
    // 먼저 실행
}

@Order(2)
@EventListener
public void secondListener(DeviceControlSuccessEvent event) {
    // 나중에 실행
}

기본적으로 리스너 순서는 보장되지 않습니다. @Order로 순서를 지정할 수 있습니다.

다만 @Async와 함께 쓰면 순서 보장이 안 됩니다. (당연히, 비동기니까요)





실전 효과 - Before vs After

숫자로 보는 변화입니다. 실제 프로덕션 환경에서 측정한 결과입니다.


코드 메트릭

항목BeforeAfter개선
SmartHomeService 라인 수약 250줄약 80줄-68%
의존성 개수8개3개-62%
단위 테스트 Mock 개수8개3개-62%
새 기능 추가 시 수정 파일1개 (Service)1개 (새 Listener)기존 코드 수정 없음

성능

JMeter로 동시 사용자 50명 기준 부하 테스트한 결과입니다.

항목BeforeAfter개선
기기 제어 API 응답 시간평균 800ms 정도평균 200ms 정도약 75% 감소
P95 응답 시간약 1.5초약 350ms약 77% 감소

알림, 로그, 통계가 모두 비동기로 처리되면서 API 응답이 극적으로 빨라졌습니다. 사용자 입장에서는 조명 버튼을 누르면 바로 반응하는 느낌을 받게 됐습니다.


개발 생산성

새 요구사항: “기기 제어 성공 시 이메일 발송”


Before (동기, 강결합)

  1. SmartHomeService 열기
  2. EmailService 의존성 추가
  3. turnOnLight 메서드에 이메일 발송 코드 추가
  4. 전체 서비스 테스트 다시 돌리기 (8개 mock 설정)
  5. 소요 시간: 2-3시간

After (이벤트 기반)

  1. DeviceEmailListener 새로 생성
  2. @EventListener 메서드 구현
  3. 해당 리스너만 단위 테스트
  4. 소요 시간: 30분

기존 코드는 한 글자도 건드리지 않습니다.





이 접근의 아쉬운 점

완벽한 패턴은 없습니다. EDA도 마찬가지입니다.


1. 디버깅 어려움

이벤트 기반은 흐름을 따라가기 어렵습니다.

기기 제어 → 이벤트 발행 → ??? → 알림이 안 감

어떤 리스너가 실행됐는지, 왜 실패했는지 파악하기 어렵습니다.


해결책: 로깅 강화

@Aspect
@Component
@Slf4j
public class EventLoggingAspect {

    @AfterReturning("@annotation(org.springframework.context.event.EventListener)")
    public void logEventListenerExecution(JoinPoint joinPoint) {
        Object[] args = joinPoint.getArgs();
        if (args.length > 0 && args[0] != null) {
            log.info("Event listener executed: {} for event: {}",
                joinPoint.getSignature().getName(),
                args[0].getClass().getSimpleName()
            );
        }
    }

    @AfterThrowing(
        pointcut = "@annotation(org.springframework.context.event.EventListener)",
        throwing = "ex"
    )
    public void logEventListenerError(JoinPoint joinPoint, Exception ex) {
        log.error("Event listener failed: {} - {}",
            joinPoint.getSignature().getName(),
            ex.getMessage(),
            ex
        );
    }
}

모든 이벤트 리스너 실행/실패를 자동으로 로깅합니다.


2. 이벤트 순서 보장 어려움

비동기 이벤트는 순서가 보장되지 않습니다.

이벤트 발행 순서: A → B → C
리스너 실행 순서: C → A → B (?)

순서가 중요하다면? 이벤트 기반이 적합하지 않을 수 있습니다.

순서가 중요한 로직은 동기로 처리하거나, 메시지 큐(Kafka, RabbitMQ)를 사용해야 합니다.


3. 트랜잭션 범위 혼란

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleEvent(DeviceControlSuccessEvent event) {
    // 여기는 별도 트랜잭션? 아니면 원래 트랜잭션?
}

AFTER_COMMIT이면 원래 트랜잭션은 이미 끝났습니다. 새 트랜잭션이 필요하면 명시해야 합니다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleEvent(DeviceControlSuccessEvent event) {
    // 새 트랜잭션에서 실행
}

4. 이벤트 남발 주의

“모든 걸 이벤트로 만들자!”는 위험합니다.


이벤트가 적합한 경우:

  • 핵심 로직과 부가 기능의 분리
  • 여러 컴포넌트가 같은 사실에 반응해야 할 때
  • 비동기 처리가 필요할 때

이벤트가 부적합한 경우:

  • 직접 호출이 더 명확할 때
  • 순서가 중요한 로직
  • 트랜잭션 안에서 동기로 처리해야 할 때

저는 이 기준을 씁니다:

“이 로직이 실패해도 핵심 기능은 성공해야 하는가?”

답이 “Yes”면 이벤트로, “No”면 직접 호출로 구현합니다.





내 프로젝트에 바로 적용하기

EDA를 도입하기 전에 아래 체크리스트를 확인하세요.


도입 전 체크리스트

  • 부가 기능이 3개 이상인가? (그 이하면 직접 호출이 더 간단)
  • 부가 기능이 실패해도 핵심 기능은 성공해야 하는가?
  • API 응답 시간에 민감한 서비스인가?
  • 향후 부가 기능이 더 늘어날 예정인가?

4개 중 3개 이상 해당하면 EDA 도입을 고려하세요.


구현 시 주의사항

절대 하지 말 것:

  • @Async 없이 @TransactionalEventListener 사용 (메인 스레드에서 실행됨)
  • 이벤트 리스너에서 예외를 상위로 던지기 (다른 리스너까지 중단됨)
  • 순서가 중요한 로직을 비동기 이벤트로 처리

추천 설정:

  • Thread Pool: 코어 스레드 5개, 최대 10개 (부하에 따라 조정)
  • Queue Capacity: 100개 (넘으면 RejectedExecutionException 발생)
  • 모든 리스너에 try-catch 필수 (다른 리스너에 영향 주지 않음)

트러블슈팅

Q. “이벤트가 발행됐는데 리스너가 실행 안 돼요”

  • @EnableAsync 설정 확인
  • @TransactionalEventListener 사용 시 트랜잭션 커밋 여부 확인
  • 리스너 메서드가 public인지 확인

Q. “리스너에서 DB 저장이 안 돼요”

  • AFTER_COMMIT Phase에서는 원래 트랜잭션이 끝난 상태
  • @Transactional(propagation = Propagation.REQUIRES_NEW) 추가

Q. “어떤 리스너가 실패했는지 모르겠어요”

  • AOP 기반 로깅 추가 (위 EventLoggingAspect 참고)
  • 각 리스너에 개별 로그 추가




시스템 점검 체크리스트

저도 Event-Driven Architecture를 적용할 때 이 항목들을 확인합니다. 이벤트 기반 설계를 고려한다면 참고하시면 좋을 것 같습니다.

  • 이벤트 순서 보장: 순서가 중요한 이벤트는 @Order 또는 @Priority로 순서를 명시했는가?
  • 이벤트 실패 처리: 리스너에서 예외 발생 시 어떻게 처리할지 정의했는가? (재시도, 로깅, 무시 등)
  • 트랜잭션 분리: 각 리스너가 독립적인 트랜잭션(@TransactionalEventListener)을 사용하는가?
  • 디버깅 준비: 이벤트 흐름을 추적할 로깅이 충분한가? (어떤 이벤트가 어떤 리스너를 호출했는지)
  • 성능 모니터링: 이벤트 처리 시간이 너무 길지 않은가? (비동기 고려 필요한지)




결론

이번 프로젝트에서 세 가지를 배웠습니다.


첫째, 관심사의 분리가 얼마나 중요한지 깨달았습니다.

기기 제어라는 핵심 로직과 알림, 로그, 통계 같은 부가 기능을 분리하니까 코드가 극적으로 간단해졌습니다.

각 리스너는 자기 역할만 합니다. SmartHomeService는 다른 게 뭘 하는지 전혀 몰라도 됩니다.

처음 이벤트 기반으로 리팩토링하고 나서 팀원들한테 보여줬을 때 반응이 인상적이었습니다. “아, 이제 새 기능 추가할 때 기존 코드 안 봐도 되겠네요.” 정확히 제가 노린 효과였습니다.

이게 진짜 느슨한 결합입니다.


둘째, 이벤트는 ‘사실’을 전달하는 것이라는 점입니다.

“이거 해줘”가 아니라 “이런 일이 일어났어”입니다.

DeviceControlSuccessEvent는 “기기 제어에 성공했다”는 사실만 담고 있습니다. 누가 이걸 듣고 무엇을 할지는 전혀 관여하지 않습니다.

이 차이가 크더군요. 명령(Command)이 아니라 사실(Fact)을 전달하면, 새로운 리스너를 추가하는 게 자연스러워집니다. 기존 코드는 전혀 손대지 않아도 되니까요.

실제로 2개월 뒤에 “이메일 알림” 기능이 추가됐는데, 리스너 하나만 추가하면 끝이었습니다. 30분 걸렸습니다.


셋째, 패턴은 상황에 맞게 써야 한다는 것입니다.

처음에 팀원이 물었습니다.

“그냥 별도 메서드로 분리하면 안 되나요? 왜 이벤트까지 써야 하죠?”

좋은 질문이었습니다. 사실 부가 기능이 2-3개면 굳이 이벤트까지 쓸 필요 없습니다.

하지만 저희는 부가 기능이 10개가 넘었고, 기획 문서를 보니 계속 늘어날 예정이더군요. 그때 이벤트 기반이 필요하다고 판단했습니다.

그리고 3개월 후, 정말로 부가 기능이 15개로 늘었을 때 이 선택이 옳았다는 걸 확인했습니다. 그 팀원도 인정하더군요. “처음엔 과하다고 생각했는데, 지금 보니 이게 답이었네요.”


“지금 필요해서”가 아니라 “미래에 확장될 것 같아서” 도입했습니다.


사실 이 결정이 쉽지는 않았습니다. 당장은 오버엔지니어링처럼 보일 수 있거든요. “그냥 메서드 호출하면 되는데 왜 이렇게 복잡하게?” 하는 시선도 있었습니다.

근데 저희 경험상, 서비스가 커지면서 “나중에 리팩토링하자”고 미뤘던 것들이 결국 기술 부채가 되더군요. 그래서 저는 확장 가능성이 높은 부분은 초기에 투자하는 편입니다. 물론 이건 팀 상황과 서비스 특성에 따라 다르겠지만요.


이전 편의 Adapter 패턴과 이번 편의 Event-Driven Architecture.

두 패턴 모두 “변경에 열려있고, 수정에 닫혀있는” 구조를 만들기 위한 것입니다.

새로운 제조사가 추가되어도 괜찮고, 새로운 부가 기능이 추가되어도 괜찮습니다.

기존 코드는 건드리지 않습니다.


솔직히 처음엔 이게 과연 작동할까 싶었습니다. 이론상으론 좋은데 실제론 어떨지 불안했거든요. 근데 6개월 돌려보니까 확신이 생기더군요. 이게 답입니다.

물론 모든 프로젝트에 이렇게 할 필요는 없습니다. 작은 서비스에 이벤트 기반 도입하면 오히려 복잡도만 올라갑니다. 하지만 확장 계획이 있고, 부가 기능이 계속 추가될 서비스라면? 초기에 투자할 가치가 충분합니다.

그게 좋은 아키텍처의 조건 아닐까요? 지금 당장은 조금 복잡하지만, 나중에 자신에게 감사하게 되는 구조 말입니다.





참고 :

https://docs.spring.io/spring-framework/reference/core/beans/context-introduction.html#context-functionality-events
https://spring.io/blog/2015/02/11/better-application-events-in-spring-framework-4-2
https://www.baeldung.com/spring-events
https://martinfowler.com/articles/201701-event-driven.html
Implementing Domain-Driven Design (Vaughn Vernon, Addison-Wesley)




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site