먼저 응집도와 결합도가 무엇인가에 대한 이해가 필요합니다. 해당 내용을 먼저 알아보는 이유는 위의 내용을 이해하기 전에 본문을 읽을 경우 "굳이 왜 사용을 해야하나?"와 같은 의문이 생길 수 있기 때문입니다.

결합도와 응집도

 개발을 하다 보면 "코드를 작성할 때 결합도가 낮고 응집도 높아야 좋은 코드다!" 란 말을 들어보았을 것입니다. 무엇보다 동작하는 코드가 중요하지만 유지보수성을 고려했을 때 무엇보다 중요하게 생각해야 하는 결합도가 낮고 응집도가 높은 코드가 무엇인지 한번 알아보겠습니다.

결합도(Coupling)

 결합도란 말그대로 기능들끼리 결합되어 있는 정도를 말합니다.

예를 들어 매크로라는 말을 들어본 적이 있을 것입니다. 매크로란 여러 행위(Action)들을 하나의 행위로 합쳐 간편하게 호출하는 것이라 말할 수 있습니다.

가상에 한명의 캐릭터가 존재하고, 캐릭터는 동, 서, 남, 북 이렇게 4가지 방향으로 1칸씩 움직인다고 가정했을 때 동쪽으로 2칸, 서쪽으로 2칸 가고 싶을 때 우리는 커맨드로 동쪽으로 이동, 동쪽으로 이동, 서쪽으로 이동, 서쪽으로 이동 이렇게 4번의 커맨드를 보내 캐릭터를 움직 일 수 있습니다.

하지만 각 이동을 명령어로 입력하는 것은 번거롭습니다. 메크로를 이용하면 동쪽으로 2칸 이동 + 서쪽으로 2칸이동을 메크로로 만들어 동2서2 이름으로 메크로를 만들면 편하게 동2서2 명령을 이용하여 캐릭터가 우리가 원하는 행동을 취하는 것을 볼 수 있습니다.

그런데 만약 처음부터 커맨드를 동, 서, 남, 북 4방향으로 1칸씩 이동이 아닌 동2서2만을 만들었다고 가정했을 때 다른 방향 혹은 같은 방향이더라도 가고싶은 거리가 다를 경우 새로운 커맨드를 만들어야 합니다.

이처럼 기능별 동, 서, 남, 북 으로 1칸 씩 이동과 같이 기능별로 나누는 것은 결합도가 낮다 할 수 있고, 동2서2를 이미 만들어져있는 커맨드를 조합해서 사용하는 것이 아니라 새로운 커맨드를 등록하는 행위는 기능들 간의 결합도가 높다고 할 수 있습니다.

응집도(Coheison)

 응집도란 협력 가능한 기능끼리 모아놓고 서로 관계가 없는 다른 기능들은 떨어트려 놓는 것을 응집도가 낮다라고 말할 수 있습니다.

결합도의 예에서 동, 서, 남, 북으로 "1칸씩" 이동한다는 것을 커맨드로 만들었는데 이때 동, 서, 남, 북은 방향을 나타내고 몇 칸씩 움직인다는 실제적인 좌표 이동입니다. 엄밀히 말하면 방향을 변경하는 것과 이동한다는 것은 서로 다른 행위입니다.

하지만 우리의 목적이 "어디로 갈 것인가"라고 했을 때 방향을 정의 하고 얼마나 이동할지를 따로 커맨드로 생성하는것은 불필요한 작업입니다. 이때 방향 변경과 이동이란 행위를 하나로 묶어 둔다면 우리는 보다 편하게 커맨드를 정의하고 호출할 수 있습니다.이처럼 협력할 수 있는 것들을 하나로 묶어 놓은 정도를 응집도라고 합니다.

결합도가 낮고 응집도가 높으면 재사용성이 높아지고 코드 가독성 또한 좋아지는 효과를 얻을 수 있습니다.

이벤트(Event)

 우리가 흔히 들을 수 있는 이벤트에는 어떤 것들이 있을까요? 아마 카프카(Kafka)나 스프링 이벤트(Spring Event)를 한번쯤 들어 보았을 것 입니다. 그럼 도대체 이벤트가 어떤 의미를 가지고 어떻게 동작하기에 이 기술이 나온 것일까?라는 의문을 가질 수 있습니다.

간단히 정의하면 이벤트는 특정 트리거가 작동하면 시작되는 동작 혹은 사건이라 말할 수 있습니다.

이벤트가 아닌 기존 방식부터 예시를 들면 A가 B에게 전달할 메시지(Message)나 물건(Object)이 존재할 경우 A가 직접 B에게 전달하는 방법을 사용합니다. 하지만 이벤트의 경우 A의 작업이 끝나면 우체국에 물건이 아닌 작업 끝났습니다라는 메시지를 전달합니다.

이후 B가 우체국에게 "A가 우체국에 메시지를 남기면 알려주세요" 라고 미리 말해두었으면 A가 발행한 메시지를 우체국이 B에게 알려줍니다. B는 우체국에서 받는 메시지를 보고 자신이 해야 할 일을 시작합니다.

위와 같이 기존 업무가

A : 업무를 처리 한다 -> B에게 메시지를 전달한다.
B : B는 작업을 처리한다.

에서

A : 업무를 처리한다. -> 업무가 끝나면 메시지를 발행한다.
이벤트 전달 : 메시지를 담아두고 해당 메시지를 받고 싶은 곳이 있으면 전달한다.
B : 메시지에 따라 필요한 업무를 시작한다.

로 각자 자신의 업무 외 다른 업무와의 관계성을 코드에서 분리합니다. 이처럼 이벤트는 각 모듈간의 결합도를 낮추고 응집도를 높여주는 효과를 가질 수 있게 도와주는 장점이 있습니다. 다만 이벤트를 무분별하게 주고 받는 것은 큰 이슈를 야기할 수 있어 목적에 맞게 설계하여 사용하는 것이 바람직합니다.


스프링 이벤트 사용방법

 스프링 이벤트는 스프링 프레임워크를 사용할 때 빈(Bean) 간 데이터를 주고받는 방식 중 하나로 이벤트를 발행(Publish)하고 이벤트를 수신 또는 구독하여 소비(Listen/Subscribe)하는 기능을 제공합니다. 이는 코드의 관심사를 분리하여 낮은 결합도를 가진 코드라 할 수 있습니다.

1. 발행할 이벤트 객체 생성

ApplicationContext의 publishEvent 메소드를 이용하여 Object 또는 ApplicationEvent타입을 가진 인자를 전달할 수 있습니다. 따라서 먼저 이벤트로 사용할 클래스를 정의해야 합니다.

ApplicationEventPublisher 를 사용하여 이벤트를 발행해야 하지만  ApplicationContext
ApplicationEventPublisher 인터페이스를 구현하므로 예제는 ApplicationContext를 사용합니다.

순수 자바(POJO)로 이벤트 선언

@Getter
@Setter
public class EntityCreated {
    //
    private String entityId;

    public EntityCreated(String entityId) {
        //
        this.entityId = entityId;
    }
}

ApplicationEvent로 이벤트 선언

@Getter
@Setter
public class EntityCreated extends ApplicationEvent {
    //
    private final String entityId;

    public EntityCreated(Object source, String entityId) {
        //
        super(source);
        this.entityId = entityId;
    }
}

2. 애플리케이션 서비스에서 이벤트 발행

 이벤트를 발행하기 위해 ApplicationContext를 주입받아 publishEvent 메서드를 사용하여 이벤트를 발행합니다. 아래 예제처럼 비즈니스 로직 이전 또는 이후에 이벤트를 발행합니다.

@Service
public class ApplicationService {
    //
    private final ApplicationContext applicationContext;
    
    public ApplicationService(ApplicationContext applicationContext) {
        //
        this.applicationContext = applicationContext;
    }
    
    public void createEntity() {
        // Entity 생성
        
        // Entity 생성 후 이벤트 발행
        this.applicationContext.publishEvent(new EntityCreated("domain생성 Id"));
    }
}

3. 이벤트 수신/소비

 이벤트를 소비하려면 @EventListener 어노테이션을 사용하고 파라미터로 받아야 하는 이벤트 타입을 선언하면 해당 ApplicationContext가 이벤트를 발행했을 때 메서드를 실행합니다.

@Slf4j
@Component
public class EntityEventHandler {
    //
    @EventListener
    public void on(EntityCreated event) {
        //
        log.info(String.format("Event Data : %s", event.getEntityId()));
    }
}

Spring Event 사용 예시

 프로젝트에서 스프링 이벤트를 사용하면서 코드간 연관성을 줄이면서 간결하게 유지할 수 있습니다. 스프링을 사용하면서 한번쯤은 스프링 이벤트를 접해 봤을 수 있는데 스프링 이벤트를 어떤 상황에 사용하면 좋을 지에 대하여 알아보겠습니다.

유튜브에 영상을 업로드 할 때 해당 유튜브 채널을 구독하고 있는 구독자에게 알림과 메일을 발송해주고 싶다는 요구사항이 있다고 가정합니다. 업무 프로세스는 크리에이터가 Video를 등록하면 Email을 발송하고 Notie를 등록합니다.

 VideoService는 EmailService와 NotieService를 주입받고 Video를 등록하면 sendMail과 registerNotie 메소드를 호출하여 이메일과 알림을 발송합니다.

public class VideoService {
    //
    private final VideoStore videoStore;
    private final EmailService emailService;
    private final NotieService notieService;

    public VideoService(VideoStore videoStore, EmailService emailService, NotieService notieService) {
        //
        this.videoStore = videoStore;
        this.emailService = emailService;
        this.notieService = notieService;
    }

	public void upload(CreateVideo command) {
        //
        Video video = new Video(command);

        this.videoStore.create(video);

        this.emailService.sendMail(video.getChannelId());
        this.notieService.registerNotie(video.getChannelId());
    }

}

 VideoService의 주요 기능은 Video를 등록하는 것으로 직접적으로 관계가 없는 Email과 Notie로 구독자에게 알리는 기능을 사용합니다. 즉 자신이 해야 할 기능 외에 추가 기능을 포함하여 결합도가 높고 응집도가 낮습니다.

스프링 이벤트를 사용하면 Video를 등록한 후 이벤트를 발행하여 Email, Notie와 관련있는 기능을 분리할 수 있습니다.

 VideoService는 EmailService와 NotieService를 주입받지 않으므로 두 개의 서비스와 더 이상 결합하지 않습니다. VideoService의 역할은 Video를 등록한다는 본연의 기능만 가지며 등록되었다는 사실만을 알리는 VideoCreated 이벤트를 발행합니다.

public class VideoService {
    //
    private final VideoStore videoStore;
    private final ApplicationEventPublisher publisher;

    public VideoService(VideoStore videoStore, ApplicationEventPublisher publisher) {
        //
        this.videoStore = videoStore;
        this.publisher = publisher;
    }

    public void upload(CreateVideo command) {
        //
        Video video = new Video(command);

        this.videoStore.create(video);

        this.publisher.publishEvent(new VideoCreated(video));
    }
}

publishEvent 메소드로 이벤트를 발행하면 빈에서 @EventListener로 선언한 메소드들 중에서 해당 클래스를 파라미터로 가지는 메소드를 실행시킵니다. 아래 예제는 Email과 Notie에서 Video가 등록된 이벤트를 구독하여 각 모듈이 다른 모듈에 직접 의존하지 않고 자신의 기능만을 실행하므로 결합도가 낮고 응집도가 높은 코드에 한 걸음 더 다가갈 수 있습니다.

@Component
public class EmailEventHandler {
    //
    private final EmailService emailService;

    public NotieHandler(EmailService emailService) {
        //
        this.emailService = emailService;
    }

    @EventListener
    public void on(VideoCreated event) {
        //
        this.emailService.sendMail(event.getVideo().getChannelId());
    }
}
@Component
public class NotieEventHandler {
    //
    private final NotieService notieService;

    public NotieHandler(NotieService notieService) {
        //
        this.notieService = notieService;
    }

    @EventListener
    public void on(VideoCreated event) {
        //
        this.notieService.registerNotie(event.getVideo().getChannelId());
    }
}

비동기 처리

 스프링 이벤트는 이벤트라는 단어로 인해 비동기로 오해할 수 있습니다. 이벤트를 발행하는 스레드(Thread)와 이벤트를 소비하는 스레드 아이디를 확인하면 스레드 아이디가 같아 동기 방식으로 작동함을 알 수 있습니다.

이처럼 동기로 작동하는 스프링 이벤트를 비동기로 사용하기 위해서는 스프링의 비동기 메커니즘을 사용할 수 있습니다.

1. Application 클래스에 @EnableAsync 어노테이션을 선언합니다.

@EnableAsync
@SpringBootApplication
public class SpringEventApplication {

    public static void main(String[] args) {
        //
        SpringApplication.run(SpringEventApplication.class, args);
    }
}

2. 비동기로 동작해야 하는 EventListener에 @Async어노테이션을 선언합니다.

@EventListener
@Async
public void on(EntityCreated event) {
    //
    log.info(String.format("Listener thread id: %s", Thread.currentThread().getId()));
}

서비스를 실행하고 테스트하면 할당된 스레드 아이디가 서로 다른 것을 확인할 수 있습니다.

비동기 순서 정하기

 이벤트를 사용하면서 EventListener의 실행 순서를 조정해야 하기도 합니다. 예를 들어 EntityCreated 이벤트를 발행하면 A를 생성(2)하고 B를 생성(2)한 후 C는 직전에 생성한 B를 조회하여 일부 값을 복사하여 생성(3)한다고 가정했을 때 C의 EventListener가 B의 EventListener보다 먼저 작동하면 오류가 발생합니다.

EventListener 실행 순서는 @Order 어노테이션으로 조정할 수 있고 Order에 할당한 숫자가 낮을수록 먼저 실행됩니다.

@EventListener
@Order(1)
public void on(EntityCreated event) {
    //
    log.info(String.format("Event Data : %s", event.getEntityId()));
}

스프링 이벤트를 사용한 후

 최근 프로젝트에서 스프링 이벤트를 적용하여 서비스간의 결합도를 낮출 수 있었고 코드를 구현할 때 해당 관심사만을 생각하며 코딩을 할 수 있어 편리함과 동시에 오류를 쉽게 찾을 수 있었습니다. 그러나 사용할 때 무분별한 이벤트 발행은 오히려 혼란을 초래할 수 있어 발행할 객체의 사용 목적에 맞춰 네이밍하여 명확하게 하는 것이 무엇보다 중요하다고 생각했습니다.

krong

참조