HJW's IT Blog

SOLID 와 TradeOff 본문

개발 개념

SOLID 와 TradeOff

kiki1875 2025. 1. 9. 16:30

들어가며..

디스코드 시스템을 설계하고 구현하는 도중, 각 서버의 Channel 설계에 대해 고민을 하게 되었다.

처음 “Channel”을 설계할 때, 이 엔티티(혹은 객체) 하나에 음성 채널과 채팅 채널을 모두 담을 것인지가 가장 큰 고민이었다.

  • 음성 채널: 음성 통화를 위한 기능이 필요하고, 채팅 메시지는 필요 없다.
  • 채팅 채널: 채팅 메시지가 필수이고, 음성 통화는 필요 없다.

만약 둘을 하나의 부모 클래스로 묶는다면, 잘못하면 음성 채널에 채팅 기능이 들어가야 하고 채팅 채널에서 음성 채팅을 지원해야 하는 상황이 발생할 수도 있다. “서버의 채널”이라는 점에서는 같지만, 내부 동작과 필요한 기능이 전혀 다르기 때문이다.

이러한 고민 속에서 나온 아이디어가 ChannelBehavior라는 인터페이스를 둬서, 채팅 기능이 필요한 채널은 ChatBehavior를, 음성 기능이 필요한 채널은 VoiceBehavior를 주입해 쓰도록 하는 것이다.

ChannelBehavior Interface

public interface ChannelBehavior {
    void setChannelPrivate(Channel channel);
    void setChannelPublic(Channel channel);
}

그렇다면, 각 채널의 공통적인 부분은 무엇이 있을까?

필자는 위와 같이 Channel 의 공개 여부를 조작하는 부모 interface 를 만들었다. 여기서는 모든 채널이 공통적으로 가져야 할 “핵심 행동” 을 정의하는것이 관건이다. 이렇게 함으로써, 어느 채널이든 공통적으로 수행해야 하는 로직은 동일한 계약(Contract)을 유지하면서, 실제 동작은 각 구현체에서 자유롭게 정의할 수 있다.

채팅 기능을 어떻게 구현할 것인가?

ChatBehavior 구현채

public class ChatBehavior implements ChannelBehavior {

    private final List<Message> chatHistory = new ArrayList<>();
    private final UserService userService;
    private final MessageService messageService;

    public ChatBehavior(UserService userService, MessageService messageService) {
        this.userService = userService;
        this.messageService = messageService;
    }

    @Override
    public void setChannelPrivate(Channel channel) {
        channel.updatePrivate(false, channel);
    }

    @Override
    public void setChannelPublic(Channel channel) {
        channel.updatePrivate(true, channel);
    }

    // ----- 아래는 채팅 메시지와 관련된 메서드 -----

    public void addMessage(Message message){
        if(!checkIfUserExists(message.getUserUUID())){
            throw new IllegalArgumentException("존재하지 않는 user 입니다.");
        }
        chatHistory.add(message);
    }

    public Message getMessageById(String id){
        for(Message m : chatHistory){
            if(m.getUUID().equals(id)){
                return m;
            }
        }
        return null;
    }

    public void deleteMessage(String id){
        for(Message m : chatHistory){
            if(m.getUUID().equals(id)){
                chatHistory.remove(m);
                break;
            }
        }
    }

    public void editMessage(String messageId, String messageContent){
        Optional<Message> message = chatHistory.stream()
                                              .filter(msg -> msg.getUUID().equals(messageId))
                                              .findFirst();
        message.ifPresent(m -> m.setContent(messageContent));
    }

    public List<Message> getChatHistory(){
        return Collections.unmodifiableList(chatHistory);
    }

    private boolean checkIfUserExists(String userId){
        return userService.readUserById(userId).isPresent();
    }
}

 

처음 구현할 때, MessageService 에서 채팅 기록을 저장하려 했지만, 각 Channel 별로 독립된 채팅 기록이 있어야 하며, 각 channel 은 채널 종류에 따라 채팅 기록을 가질지 말지 결정이 되니, ChatBehavior 에 구현하는 것이 합리적이라는 생각이 들었다. 즉 다음과 같은 것이다.

채팅 기능만을 담당하는 ChatBehavior는 각 채널에서 독립적인 채팅 기록(chatHistory)을 가질 수 있게 한다.

  • 이 설계 덕분에 “채팅 채널”이 아닌 다른 채널(음성 채널)에서는 chatHistory가 존재하지 않는다.
  • 반면 채팅 채널은 본인의 채팅 기록을 별도로 관리하므로, Channel 간에 채팅 내역이 섞이지 않는다.

메시지는 어디에 저장하지?

여기까지 오며 두가지 시나리오에 대해 고민을 해 보았다.

  1. 시나리오 A : MessageService 를 통한 중앙 관리
    1. 모든 채팅 메시지가 한곳에 모이므로, 유지보수 관점에서 우선 유리하다.
    2. DB 설계의 단순화 : join 연산을 최소화 할 수 있고, 원하는 정보를 한가지 테이블에서 바로 가져올 수 있다.
    3. 모든 메시지가 하나의 거대한 컨테이너에 쌓이므로, 채널별로 메시지를 구분하기 위해 다양한 조건(channelUUID)을 자주 사용해야 할 수 있다.
    4. 채널이 늘어날 때마다(혹은 새로운 특수한 채널이 추가될 때마다) MessageService 쪽 결합도가 커질 수 있다.
  2. 시나리오 B: ChatBehavior 에서 채널별 독립 저장
    1. 채널마다 독립적으로 메시지를 관리하니, 해당 채널(또는 해당 Behavior) 안에서만 유지보수가 가능하다.
    2. “채팅 메시지”가 필요한 채널만 메시지를 가질 수 있으므로, 구조가 깔끔해 보인다.
    3. 채널별로 메시지를 관리하므로, DB 단에서는 각 채널이 메시지를 별도 컬렉션/테이블로 관리하게 되어, 운영 비용이 올라갈 수 있다.

MessageService

public class JCFMessageService implements MessageService {

    private static volatile JCFMessageService messageRepository;
    // private final Map<String, Message> data;

    private JCFMessageService(){
        // data = new HashMap<>();
    }
    public static JCFMessageService getInstance(){
        if(messageRepository == null){
            synchronized (JCFMessageService.class){
                if(messageRepository == null){
                    messageRepository = new JCFMessageService();
                }
            }
        }
        return messageRepository;
    }

    @Override
    public Message createMessage(Message message, ChatBehavior chatBehavior) {
       // data.put(message.getUUID(), message);
        chatBehavior.addMessage(message);
        return message;
    }

    @Override
    public Optional<Message> getMessageById(String messageUUID, ChatBehavior chatBehavior) {
        return Optional.ofNullable(chatBehavior.getMessageById(messageUUID));
    }

    @Override
    public List<Message> getMessagesByChannel(String channelUUID) {
        return null; // 아직 미구현
    }

    @Override
    public List<Message> getMessagesByThread(String threadUUID) {
        return null; // 아직 미구현
    }

    @Override
    public Message updateMessage(String messageUUID, String newContent, String newContentImageUrl, ChatBehavior chatBehavior) {
        Message message = chatBehavior.getMessageById(messageUUID);
        message.setContent(newContent);
        message.setContentImage(newContentImageUrl);
        message.setIsEdited();
        return null;
    }

    @Override
    public boolean deleteMessage(String messageUUID, ChatBehavior chatBehavior) {
        chatBehavior.deleteMessage(messageUUID);
        return true;
    }

    @Override
    public List<Message> getChatHistory(ChatBehavior chatBehavior){
        return Collections.unmodifiableList(new ArrayList<>(chatBehavior.getChatHistory()));
    }

    // TODO : ChatBehavior 연결
    @Override
    public void addReactionToMessage(String messageUUID, String userUUID, String reactionType) {
    }

    // TODO : ChatBehavior 연결
    @Override
    public void removeReactionFromMessage(String messageUUID, String userUUID) {
    }

    public void modifyMessage(String messageId, String content, ChatBehavior chatBehavior){
        chatBehavior.editMessage(messageId, content);
    }

}

실제로는 chatBehavior.addMessage(message) 등을 통해 ChatBehavior 내부의 chatHistory에 메시지를 추가하도록 했다.

채널별 메시지 관리는 여전히 ChatBehavior가 담당하지만, MessageService라는 계층을 하나 더 둠으로써, 외부에서 메시지 생성/수정을 요청할 때 일관된 방법(서비스)을 통해 처리하도록 만들었다.

트레이드 오프

코드를 리뷰받는 과정에서, “현업이라면 어떻게 할까?”라는 질문을 했다. 결론은 총 3가지 관점에서 이 문제를 바라 볼 수 있었다.

  1. 유지보수 관점 : MessageService 를 통해 한곳에 모여있다면, 편리하다
  2. DB 비용 : 테이블을 정규화시키고 많이 쪼갤수록 DB 운영 비용이 든다. Join 은 비용이다.
  3. SOLID 관점 : 기능을 정말 잘개 쪼개고 관심사의 분리 관점

즉 두 방법 모두 장단점이 있으며, 설계 과정에서 트레이드오프를 고려한 설계를 해야 한다는 결론이 나왔다.

결론

실무에선 결국 대규모 트래픽, 운영 비용, 유지보수의 편의성 등 종합적인 측면을 보고 판단해야 한다. 이러한 원칙은 결코 법칙이 아니기 때문에 SOLID 원칙을 모두 따르며 설계하는것은 이상에 가까우며 꼭 그래야 하는 것도 아닌 것 같다. 원칙을 따르는것 보단, 어떤 설계를 통해, 어떤 트레이드오프가 발생하는 지를 이해하며 팀 내에서 공유와 합의가 이루어져야 하는것 같다.