Composite 패턴의 미학

suky·2024년 3월 7일
0
post-thumbnail

안녕하세요~ 이상과 혁신을 꿈꾸는 개발자 suky입니다~ 😀
이번 포스팅은 그동안 개발을 하면서 정말 요긴하게 사용했던 Composite 패턴에 대해서 소개를 드릴려고 합니다~

Composite 패턴
출처: 위키피디아 (https://ko.wikipedia.org/wiki/%EC%BB%B4%ED%8F%AC%EC%A7%80%ED%8A%B8_%ED%8C%A8%ED%84%B4)

Composite 패턴은 클래스 다이어그램 처럼 Component 혹은 인터페이스를 Composite 객체로 감싸서 단일 객체처럼 활용할 수 있도록 하는 패턴을 의미합니다.
해당 패턴은 응집도를 높이고 결합도를 낮추는데 매우 효과적인 패턴인데요. 어떻게 응집도를 높이고 결합도를 낮추는지 보이면서 Composite 패턴의 매력에 푹 빠지시고, 코드가 더욱 아름다워졌으면 좋겠습니다~ 😁

이번 포스팅에서는 장애 상황 대처를 위한 이중화를 예시로 코드를 설명드릴 예정입니다~ 그리고 편의상 가중치를 매기는 것이 아닌 트래픽은 동일한 비율로 전송하는 것을 가정합니다.

문제 - SMS 발송 이중화

현재 알림 서비스 개발자인 suky는 기존 SMS 발송을 책임지던 ACorp의 잦은 장애로 새로운 SMS 발송 업체를 선정해서 이중화를 작업하려고 합니다. 기존의 코드는 다음과 같이 되어 있었습니다.

// MessasgeSender.java
public interface MessageSender {
    void send(List<String> to, String content);
}

// MessageSenderImpl.java
public class MessageSenderImpl implements MessageSender {

    private final ACorpAdapter aCorpAdapter;

    public MessageSenderImpl(ACorpAdapter aCorpAdapter) {
        this.aCorpAdapter = aCorpAdapter;
    }

    @Override
    public void send(List<String> to, String content) {
        aCorpAdapter.send(to, content);
    }
}

처음에 구현할 때는 무조건 ACorp의 서비스만 이용할 줄 알았습니다. 때문에 MessageSender 인터페이스를 작성하고, 이를 구현한 MessageSenderImpl에서 ACorpAdapter를 주입받아서 사용하는 구조로 작성했습니다.
저는 신규로 입찰한 BCorp의 서비스를 이용하기 위해 BCorpAdapter를 작성하고 이를 이용하도록 MessasgeSenderImpl을 수정했습니다.

public class MessageSenderImpl implements MessageSender {

    private final ACorpAdapter aCorpAdapter;
    private final BCorpAdapter bCorpAdapter;

    public MessageSenderImpl(ACorpAdapter aCorpAdapter, BCorpAdapter bCorpAdapter) {
        this.aCorpAdapter = aCorpAdapter;
        this.bCorpAdapter = bCorpAdapter;
    }

    @Override
    public void send(List<String> to, String content) {
        if (Math.random() < 0.5) {
            aCorpAdapter.send(to, content);
        } else {
            bCorpAdapter.send(to, content);
        }
    }
}

오! 이정도면 아주 만족스럽군요. 그렇지 않나요? 😎

Code Smell - 수많은 IF 문

여러분들은 여러 IF문을 사용해서 분기를 처리한 경험이 있을 것입니다.
IF문이 하나, 둘 생겨가면서 여러분들은 생각해본적이 있으실겁니다. '어? 이렇게 해도 되나? IF문이 더 늘면 안될텐데?'와 같이 말이죠.

앞선 예시를 작성했을 때도 '두 개가 마지막이겠지~'라고 생각하면서 작성했지만, 여러분의 서비스들은 고가용성을 제공해야하므로 회사의 결정에 따라 CCorp의 서비스도 지원하기로 했습니다.
킹갓개발자 suky는 '이정도는 정말 쉽네~'하면서 CCorpAdapter를 작성한 후 MessageSenderImpl의 코드를 다음과 같이 작성했습니다.

public class MessageSenderImpl implements MessageSender {

    private final ACorpAdapter aCorpAdapter;
    private final BCorpAdapter bCorpAdapter;
    private final CCorpAdapter cCorpAdapter;

    public MessageSenderImpl(ACorpAdapter aCorpAdapter, BCorpAdapter bCorpAdapter, CCorpAdapter cCorpAdapter) {
        this.aCorpAdapter = aCorpAdapter;
        this.bCorpAdapter = bCorpAdapter;
        this.cCorpAdapter = cCorpAdapter;
    }

    @Override
    public void send(List<String> to, String content) {
        double random = Math.random();
        if (random < 0.33) {
            aCorpAdapter.send(to, content);
        } else if (random < 0.66) {
            bCorpAdapter.send(to, content);
        } else {
            cCorpAdapter.send(to, content);
        }
    }
}

코드에서 악취가 조금씩 느껴지지 않나요? 여러가지 가정을 해보죠. 이 가정 뒤에 해당 코드 베이스로 어떻게 코드를 수정할지 생각해보면 머리가 지끈 아파올 겁니다.

  1. MessageSender 인터페이스에 신규 메소드인 sendMMS 추가
  2. 로직상 묶어서 사용하기 위하여 각 CorpAdapter들이 MessageAdapter 마커 인터페이스를 구현하도록 변경
  3. MessageSender에서 메세지를 전송할 수 있을지 판단할 수 있도록 checkSMSEnabled와 checkMMSEnabled 추가

아,,, 현재 코드 베이스로 간다면 이제 IF문 지옥에 빠질 것입니다. 지옥은 다음과 같은 형태이겠네요 😥

// MessageSender.java
public interface MessageSender {
    void send(List<String> to, String content);
    void sendMMS(List<String> to, String content);
    boolean checkSMSEnabled();
    boolean checkMMSEnabled();
}

// MessageAdapter.java
public interface MessageAdapter {
}


// MessageSenderImpl.java
public class MessageSenderImpl implements MessageSender {

    private final MessageAdapter aCorpAdapter;
    private final MessageAdapter bCorpAdapter;
    private final MessageAdapter cCorpAdapter;

    public MessageSenderImpl(MessageAdapter aCorpAdapter, MessageAdapter bCorpAdapter,
                             MessageAdapter cCorpAdapter) {
        this.aCorpAdapter = aCorpAdapter;
        this.bCorpAdapter = bCorpAdapter;
        this.cCorpAdapter = cCorpAdapter;
    }

    @Override
    public void send(List<String> to, String content) {
        MessageAdapter randomAdapter = pickRandomMessageAdapter();
        if (randomAdapter.checkSMSEnabled()) {
            randomAdapter.send(to, content);
            return;
        }
        MessageAdapter enabledAdapter = pickSMSEnabledMessageAdapter();
        if (!enabledAdapter.checkSMSEnabled()) {
            throw new IllegalStateException("이용 가능한 MessageAdapter가 없습니다.");
        }
        enabledAdapter.send(to, content);
    }

    @Override
    public void sendMMS(List<String> to, String content) {
        MessageAdapter randomAdapter = pickRandomMessageAdapter();
        if (randomAdapter.checkMMSEnabled()) {
            randomAdapter.sendMMS(to, content);
            return;
        }
        MessageAdapter enabledAdapter = pickMMSEnabledMessageAdapter();
        if (!enabledAdapter.checkMMSEnabled()) {
            throw new IllegalStateException("이용 가능한 MessageAdapter가 없습니다.");
        }
        enabledAdapter.sendMMS(to, content);
    }

    private MessageAdapter pickRandomMessageAdapter() {
        double random = Math.random();
        if (random < 0.33) {
            return aCorpAdapter;
        }
        if (random < 0.66) {
            return bCorpAdapter;
        }
        return cCorpAdapter;
    }

    private MessageAdapter pickSMSEnabledMessageAdapter() {
        if (aCorpAdapter.isSMSEnable()) {
            return aCorpAdapter;
        }
        if (bCorpAdapter.availableSMS()) {
            return bCorpAdapter;
        }
        return cCorpAdapter;
    }

    private MessageAdapter pickMMSEnabledMessageAdapter() {
        if (aCorpAdapter.isMMSEnable()) {
            return aCorpAdapter;
        }
        if (bCorpAdapter.availableMMS()) {
            return bCorpAdapter;
        }
        return cCorpAdapter;
    }
}

우리는 지금 IF문 지옥에 빠져있습니다. 비슷한 패턴의 코드가 중복되는 것으로 보입니다. 또한 각 adapter 별로 이용가능한지 제공해주는 메서드가 달라 어떻게 중복을 줄일 수 있을 지 난감합니다.

private MessageAdapter pickRandomMessageAdapter() {
    double random = Math.random();
    if (random < 0.33) {
        return aCorpAdapter;
    }
    if (random < 0.66) {
        return bCorpAdapter;
    }
    return cCorpAdapter;
}

private MessageAdapter pickSMSEnabledMessageAdapter() {
    if (aCorpAdapter.isSMSEnable()) {
        return aCorpAdapter;
    }
    if (bCorpAdapter.availableSMS()) {
        return bCorpAdapter;
    }
    return cCorpAdapter;
}

private MessageAdapter pickMMSEnabledMessageAdapter() {
    if (aCorpAdapter.isMMSEnable()) {
        return aCorpAdapter;
    }
    if (bCorpAdapter.availableMMS()) {
        return bCorpAdapter;
    }
    return cCorpAdapter;
}

해당 코드의 무서운 점은 만약 DCorpAdapter가 추가되거나, 새로운 메서드가 필요한 경우 다시 여러 개의 if문을 작성해서 해결을 해야할 수도 있다는 것입니다. 이는 매우 비효율적입니다.

이젠 악취가 너무 고약해서 변경하지 않고는 못 배길 것 같습니다. 😥

Refactoring - Composite 패턴

이렇듯 여러 개의 비슷한 역할을 하는 객체들을 따로따로 접근을 하려면 비효율적인 중복 코드가 넘치게 됩니다. 이 때 문제를 해결할 수 있는 패턴이 Composite 패턴입니다. 이를 활용하면 매우 효과적으로 중복되는 코드를 줄이면서 응집도 있고, 결합도를 줄이면서 문제를 해결할 수 있습니다.
CompositeMessageSender를 만들어보면서 문제를 해결해보도록 하겠습니다.

// CompositeMessageSender.java
public class CompositeMessageSender implements MessageSender {

    private final List<MessageSender> messageSenders;

    public CompositeMessageSender(List<MessageSender> messageSenders) {
        this.messageSenders = messageSenders;
    }

    @Override
    public void send(List<String> to, String content) {
        MessageSender randomSender = pickRandomMessageSender();
        if (randomSender.checkSMSEnabled()) {
            randomSender.send(to, content);
            return;
        }
        MessageSender enabledSender = pickSMSEnabledMessageSender();
        if (enabledSender == null) {
            throw new IllegalStateException("이용 가능한 MessageSender가 없습니다.");
        }
        enabledSender.send(to, content);
    }

    @Override
    public void sendMMS(List<String> to, String content) {
        MessageSender randomSender = pickRandomMessageSender();
        if (randomSender.checkMMSEnabled()) {
            randomSender.send(to, content);
            return;
        }
        MessageSender enabledSender = pickMMSEnabledMessageSender();
        if (enabledSender == null) {
            throw new IllegalStateException("이용 가능한 MessageSender가 없습니다.");
        }
        enabledSender.send(to, content);
    }

    @Override
    public boolean checkSMSEnabled() {
        if (pickSMSEnabledMessageSender() != null) {
            return true;
        }
        return false;
    }

    @Override
    public boolean checkMMSEnabled() {
        if (pickMMSEnabledMessageSender() != null) {
            return true;
        }
        return false;
    }

    private MessageSender pickRandomMessageSender() {
        if (messageSenders == null || messageSenders.isEmpty()) {
            throw new IllegalStateException("MessageSenders가 비어있습니다.");
        }
        return messageSenders.get((int) Math.random() * messageSenders.size());
    }

    private MessageSender pickSMSEnabledMessageSender() {
        for (MessageSender messageSender : messageSenders) {
            if (messageSender.checkSMSEnabled()) {
                return messageSender;
            }
        }
        return null;
    }

    private MessageSender pickMMSEnabledMessageSender() {
        for (MessageSender messageSender : messageSenders) {
            if (messageSender.checkMMSEnabled()) {
                return messageSender;
            }
        }
        return null;
    }
}

그리고 ACorpMessageSender 예시를 봤을 때 어떻게 ACorp에서 제공해주는 기능에 대해서 응집도를 높였는지 확인할 수 있습니다. 또한 MessageSender를 구현하는 것을 통해 마커 인터페이스로 사용한 MessageAdapter를 제거할 수 있는 것을 확인할 수 있습니다.

public class ACorpMessageSender implements MessageSender {

    private final ACorpAdapter adapter;

    public ACorpMessageSender(ACorpAdapter adpater) {
        this.adapter = adpater;
    }

    @Override
    public void send(List<String> to, String content) {
        return adapter.send(to, content);
    }

    @Override
    public void sendMMS(List<String> to, String content) {
        return adapter.sendMMS(to, content);
    }

    @Override
    public boolean checkSMSEnabled() {
        return adapter.isSMSEnable();
    }

    @Override
    public boolean checkMMSEnabled() {
        return adapter.isMMSEnable();
    }
}

ACorp에 SMS나 MMS를 발송하기 위해서는 ACorpMessageSender를 사용하고, BCorp는 BCorpMessageSender를 사용할 수 있습니다. 각 회사마다 SMS 발송, MMS 발송, SMS 이용 가능 여부, MMS 이용 가능 여부의 인터페이스가 다르더라도 MessageSender 밑으로 캡슐화를 할 수 있습니다. 정말 아름답지 않나요?

그리고 CompositeMessageSender는 다음과 같이 생성을 담당하는 곳에서 선언하면 끝입니다.

public CompositeMessageSender compositeMessageSender() {
    List<MessageSender> messageSenders = new ArrayList();
    messageSenders.add(new ACorpMessageSender(new ACorpAdapter()));
    messageSenders.add(new BCorpMessageSender(new BCorpAdapter()));
    messageSenders.add(new CCorpMessageSender(new CCorpAdapter()));
    return new CompositeMessageSender(messageSenders);
}

만약 DCorp의 서비스를 지원해야하거나, 특정 회사와의 계약이 끝나도 걱정이 없습니다. 해당 생성을 담당하는 곳에서 특정 messageSender만 add하는 부분을 제거하면 되거든요! 이를 통해 우리는 OCP(Open-Close Principle) 를 달성할 수 있습니다.

Composite 패턴을 사용하여 해결한 실무 문제

우리는 메시징 서비스를 이중화하는 방법을 통해 Composite 패턴의 매력을 알아보았습니다.
현재는 이중화를 알아보았지만, 현업에서는 다양한 문제를 해결하도록 요구받을 수 있습니다. 제가 Composite 패턴을 통해 해결했던 실무적인 문제는 다음과 같습니다.

  • Notification 서버 이중화 로직
  • OAuth2 서버에서 Grant type에 따라 Granter에 세부 로직 구현
  • 공통 로직 작성 후 IDP(IDentification Provider)마다 세부 로직 구현
  • etc ...

해당 구현을 통해 저는 주어진 문제를 비교적 세련되게 해결했다고 생각합니다.
하지만 언제나 장점만 있지는 않았습니다. 다음 장단점을 보고 각자 주어진 상황에 맞게 해당 패턴을 사용한다면 유용할 것이라 생각합니다.

Composite 패턴의 장단점

장점

  • Composite 객체에서 복잡한 로직을 공통으로 묶어서 처리할 수 있다.
  • Composite 객체에 추가 및 삭제가 자유롭고, 이를 통해 OCP를 만족할 수 있다.

단점

  • Leaf가 늘어날수록 구성이 복잡해진다.

마무리

부족한 글 읽어주셔서 감사하고, 혹시라도 잘못된 점이 있으면 가감없는 피드백 환영합니다~ 😘

profile
도전하고 성장하는 개발자입니다 :)

0개의 댓글