개발일기/Spring

결합도를 낮추고 응집도를 높히자. “Spring Event”

ignuy 2024. 9. 23.

객체 지향 프로그래밍의 5가지 설계 원칙을 다시 되새겨보자. 오늘의 주제 Spring Event도 이 5원칙에서 출발한다.

1. SRP(Single Responsibility Principle) 단일 책임 원칙
2. OCP(Open-Closed Principle) 개방 폐쇄 원칙
3. ISP(Interface Segregation Principle) 인터페이스 분리 원칙
4. LSP(Liscov Substitution Principle) 리스코프 치환 원칙
5. DIP(Dependency Inversion Principle) 의존 역전 원칙

흔히 말하는 SOLID 원칙(https://dev-ignuy.tistory.com/14)에 의해 객체지향은 계속 발전해왔다. 이 중에서도 SRP는 “하나의 메서드나 클래스는 한 가지 역할만 수행해야 한다“는 원칙이다. 또한, OCP는 "확장에는 열려 있고, 변경에는 닫혀 있어야 한다"는 원칙으로, 새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장하는 방식을 지향한다. 그런 의미에서 Spring Event는 SRP, OCP 원칙을 충실히 수행하는 기능 중 하나이다. 그럼 지금부터 Spring Event를 활용해서 시스템의 결합도를 낮추고 응집도를 높히는 경험을 체험해 보자.

Event?

Event란 시스템에서 발생한 상태 변화나 사건을 의미한다. 이 Event를 다른 Spring Bean 객체에게 편리하게 넘겨주기 위한 라이브러리가 Spring Event이다. Spring 애플리케이션 내에서 Event를 하나의 메시지 또는 신호로 사용하고, 이를 감지하고 처리하는 리스너(Listener)가 해당 상황에 맞는 로직을 실행하게 된다.

왜 이런 짓을 하는가?

MSA를 구현해 본 경험이 있는 사람이라면 시니어의 손길을 거친 제대로 된 설계가 없다면 시스템의 사이즈가 커질수록 시스템의 복잡도는 기하급수적으로 늘어나는 것을 경험해 본 적 있을 것이다. Spring Event를 사용하는 가장 큰 이유는 서 서비스 간의 강한 의존성을 줄여 시스템의 복잡도를 낮추는데에 있다.

어떻게 복잡도를 낮추는가?

Event는 주로 다음과 같은 역할을 수행한다.

  1. 상태 변화 전달 : 애플리케이션에서 중요한 상태 변화(예 : 사용자 등록, 주문 생성, 파일 업로드 성공 등)를 다른 객체에게 알리기 위해 사용된다.
  2. 비동기 처리 : 이벤트를 발생시키고 리스너가 처리하는 구조이기 때문에, 이벤트 처리 로직을 비동기적으로 처리할 수 있어 성능 최적화에 도움을 줄 수 있다.
  3. 모듈 간 결합도 감소 : 이벤트 발행자(Event Publisher)와 이벤트 리스너(Event Listener)가 서로 명시적으로 알 필요가 없기 때문에, 모듈 간 결합도를 낮출 수 있다.

이 몇 줄의 텍스트로 개발자를 한 번에 이해시키기 어렵다는 것을 누구보다 잘 알고 있다. 그럼 지금부터 케이리버스(https://k-l1verse.site)의 코드와 함께 설명해 보겠다. 잠깐만. 아직도 K-L1VERSE를 모른다고? 당장 블로그를 구독하고 K-L1VERSE 운영소식(https://dev-ignuy.tistory.com/category/K-L1VERSE)을 팔로우하자!

로그인 로직이 부차적인 기능과 함께 수행된다.

현재 K-L1VERSE의 운영 방침은 아래와 같다.

1. 사용자가 로그인한다.
2. 당일 최초 로그인이라면 로그인 보상으로 시스템 상 포인트인 “Goal”을 100만큼 추가한다.
3. 로그인 보상 사실을 알림으로 사용자에게 보여준다.

따라서 로그인 로직을 간단하게 표현하자면 아래와 같다.

public void signIn(User user) {
  user.setGoal(user.getGoal() + 100);    // 100골 지급
  notificationService.sendNotification(user.getId());    // 사용자에게 알림 전송
}

1. SRP 원칙 위반

현재 signIn 메서드는 두 가지 일을 수행하고 있다.

  1. 사용자의 Goal(포인트)를 100 증가시키는 로직
  2. 알림(notification) 전송 로직

SRP 원칙에 따르면, 메서드나 클래스는 한 가지 일만 수행해야 한다. 현재 위 코드에서는 포인트 증가와 알림 전송이라는 두 가지 작업을 처리하고 있어, 책임이 분리되지 않은 상태이다. 만약 알림 서비스가 변경되거나, 알림을 전송하는 방식이 달라진다면 signIn 메서드도 수정되어야 할 가능성이 크므로 이 구조는 유지보수성을 저해한다.

2. 다른 도메인과 높은 결합도

signIn 메서드가 직접적으로 notificationService를 코드 상에서 호출하며 의존하고 있습니다. 현재의 구조에서는 사용자에게 알림을 보내는 방식이 바뀌면, signIn 메서드도 같이 변경되어야 한다. 예를 들어, 이메일 대신 푸시 알림이나 SMS를 전송하려면 signIn 메서드를 수정해야 하는 일이 벌어진다.

3. 열려 있는 수정, 닫혀 있는 확장

현재 코드 구조에서는 signIn 시 발생하는 로직이 제한적이다. 예를 들어, 추후 signIn 로직에서 추가적으로 다른 비즈니스 로직(로그 기록, 보상 지급 등)이 필요할 경우, signIn 메서드에 새로운 코드가 추가되어야 한다. 따라서 메서드가 점점 더 복잡해지고, 여러 책임을 지게 되어 유지보수가 어려워질 수 있다. OCP와 정확히 반대되는 설계인 것이다.

4. 테스트의 어려움

signIn 메서드는 notificationService를 직접 호출하고 있기 때문에, 이 부분을 테스트하려면 알림 서비스의 동작을 함께 테스트한다. 이렇게 되면 단일 기능에 대한 테스트가 어려워지고, 여러 로직이 얽히면서 테스트 범위가 불명확해질 것이다.

Spring Event 도입

Spring 4.2(Spring Boot로는 1.3) 이전 버전까지는 반드시 이벤트 클래스가 ApplicationEvent 클래스를 상속받아야 했지만 이제는 해당 클래스를 상속받지 않고도 이벤트를 발행 및 구독할 수 있다.

먼저, 사용자가 로그인에 성공했다는 이벤트를 정의하자. 실제론 User Entity의 모든 정보가 필요하진 않지만 편의상 User Entity로 작성했다.

public class UserSignedInEvent {
    private final User user;

    public UserSignedInEvent(User user) {
        this.user = user;
    }

    public User getUser() {
        return user;
    }
}

이제 기존 signIn 메서드를 수정하자.

@Service
@RequiredArgsConstructo
public class UserService {

    private final ApplicationEventPublisher publisher;

    public void signIn(User user) {
        user.setGoal(user.getGoal() + 100);    // 100 골 지급
        publisher.publishEvent(new UserSignedInEvent(user));    // 사용자 로그인 이벤트 발행
    }
}

이로써 기존에 NotificationService를 직접 호출하며 엮여있던 의존관계를 제거할 수 있었다.

이제 이벤트를 처리할 리스너를 정의하자. 여기서는 사용자에게 알림을 보내는 역할을 수행할 것이다.

@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationListener {

    private final NotificationService notificationService;

    @EventListener
    public void sendSignedInNotification(UserSignedInEvent event) {
        // 이벤트에서 사용자 정보를 가져와서 알림 전송
        log.info("[NotificationListener - sendSignedInNotification]")
        User user = event.getUser();
        notificationService.sendNotification(event.getUser().getId());
    }
}

@EventListener를 사용하여 이벤트 리스너를 등록하고, 매개변수에 이벤트 클래스를 정의하면 해당 이벤트가 발생했을 때 알아서 이벤트를 처리할 수 있다.

정리

이렇게 Spring Event를 활용하게 되면 SRP, OCP 등 객체 지향 원칙을 준수하며 확장성 있는 서비스를 구축할 수 있다. 이벤트를 손쉽게 추가하거나 수정하여 새로운 기능을 도입하기도 쉽고 코드가 더 명확해진 것을 볼 수 있다. 다만 Spring Event의 도입이 무조건 장점만 있는 것은 아니다.

바로 다음 포스팅으로 알아볼 사항이지만 Spring Event는 기본적으로 동기적으로 동작하지만, 비동기적으로 이벤트를 처리할 수도 있다. 비동기 처리 시 발생할 수 있는 예외 처리, 타이밍 문제, 이벤트 리스너가 너무 느리게 작동할 때의 성능 문제 등을 특별히 관리해야 한다.

또한 지나치게 많은 이벤트를 발행하면 오히려 코드의 복잡도가 증가할 수 있다. 이벤트가 너무 많은 곳에서 발생하거나, 남발되면 오히려 유지보수가 어려워질 수 있으니 필요한 경우에만 사용해야 한다.

뿐만 아니라, 이벤트 리스너에서 발생한 예외는 기본적으로 발행자 쪽에 전파되지 않는다. 예외를 적절히 처리하지 않으면 이벤트 처리 로직의 문제를 감지하지 못할 수 있다. 필요한 경우 적절한 예외 처리나 로깅 메커니즘을 설계해야 한다.

댓글