API 통신 컴포넌트 설계와 SOLID 원칙

주싱·2023년 2월 6일
5

더 나은 코드

목록 보기
6/14

좋은 기회였던 것 같습니다. 특정 도메인에서 다양한 외부 API 서버와 통신하는 컴포넌트를 연이어 개발할 수 있었습니다. 1년 동안 7개 정도의 통신 컴포넌트를 개발했습니다. 통신 프로토콜은 모두 달랐지만 처리하는 구조는 유사했기 때문에 점진적으로 통신 컴포넌트를 개선해 나갈 수 있었습니다. 새로운 장비 API 서버를 만날 때 마다 우리의 코드가 프로그램 외부의 변화에 쉽고, 안정적이며, 빠르게 적응할 수 있는 조금 더 기민한 코드 구조가 되도록 노력했습니다. 여러 책에서 SOLID 원칙, 높은 응집성, 낮은 결합도 등의 설계 원칙들에 대해 읽었는데 내가 만든 소프트웨어에 이런 원칙들이 어떻게 적용되었는지 나의 언어로 설명해 보고 싶어졌습니다. 그래서 구현된 컴포넌트들을 되집어 보며 적용된 설계 원칙들을 정리해 봅니다.

1. 클래스 관점의 설계

1.1 비대한 최상위 클래스와 단일 책임 원칙(with Facade 패턴)

한 클래스는 단 한 가지의 변경 이유만을 가져야 한다. (클린 소프트웨어, 로버트 C. 마틴)

장비에 내장된 API 서버와 통신하여 장비를 제어하고 모니터링 할 수 있는 컴포넌트(이하, 장비 제어 컴포넌트)를 개발하였습니다. Controller 클래스는 이 장비 제어 컴포넌트의 최상위 클래스인데 개발 초기에 많은 기능이 Controller 내부에 직접 구현되어 있었습니다.

코드 이해의 어려움

이로 인해 우선 Controller 클래스의 크기가 지나치게 커졌습니다. 개발자가 클래스가 제공하는 기능을 이해하기 위해 클래스의 어느 부분을 보면 되는지 찾기 부터 쉽지 않습니다. 또한 확인 하려는 기능 코드가 다른 기능들과 얼기설기 엮여 있는 경우가 많아 필요한 코드를 구별해 내야 했고, 여기를 고치면 다른 코드가 영향을 받지 않는지 함께 확인해야 했습니다. 더 큰 문제는 이런 과정을 통해 잠시 코드에 대한 이해를 가질 수 있지만 코드가 구조화 되어 있지 않다보니 다음에 다시 코드를 들여다보면 비슷한 과정을 반복하게 된다는 것이었습니다. 적절히 코드를 나누고 모듈화하는 것이 좋겠다는 생각이 들었습니다.

코드 변경의 어려움

덩치가 큰 Controller 클래스를 살펴보면 대략 아래와 같은 기능을 수행하는 코드들이 한 클래스에 모여 있습니다. 서로 변경의 이유나 시기가 다른 코드들입니다. 예를 들면 장비의 상태를 모니터링하기 위한 메시지 처리와 특정 도메인 이벤트 발생 시 처리할 메시지가 다릅니다. 결국 이들이 함께 모여 있음으로 클래스가 하는 일이 선명하게 드러나지 않게 되었고, 서로 다른 역할의 코드가 불필요하게 결합되어 (또는 그럴 수 있는 가능성을 내포하고 있어) 이 기능 코드를 수정하면 저 기능 코드가 영향을 받는 불안정한 코드가 되었습니다.

  • 의존객체 생성 및 관계 설정
  • 장비 연결 제어
  • 수동 제어 명령 전송
  • 주기적인 장비 상태 요청
  • 도메인 이벤트에 대한 일련의 명령 전송

해결책

코드를 개선하기 위해 변경의 이유가 다른 서로 다른 목적의 코드들을 각 기능 클래스로 분리했습니다. 그리고 기존의 Controller 클래스는 오직 사용자의 요청을 받아 기능 클래스에 처리를 위임하는 Facade 역할만 수행하도록 설계를 변경해 주었습니다.

1.2 메시지 핸들러와 SOLID 원칙 (with 템플릿-콜백 패턴)

장비 제어 컴포넌트 구현에 Netty 프레임워크를 활용하였습니다. Netty 프레임워크를 활용하면 통신 메시지를 처리하는 일련의 과정을 각각의 핸드러로 구현하고 이들을 파이프라인으로 연결하여 메시지 처리 로직을 구현할 수 있습니다.

단일 책임 원칙(SRP)

한 클래스는 단 한 가지의 변경 이유만을 가져야 한다. (클린 소프트웨어, 로버트 C. 마틴)

각각의 메시지 핸들러 구현을 계획하며 핸들러 코드는 크게 두 가지 역할로 나뉠 수 있다고 생각했습니다. 하나는 Netty 프레임워크의 파이프라인 구조 안에서 변경되지 않는 메시지 처리(디코딩, 상태 업데이트, 로깅 등) 구조가 되는 코드이고, 나머지 하나는 외부에서 정의한 통신 스펙에 의존하는 변경될 수 있는 각각의 메시지 처리 코드였습니다. 그래서 변경되지 않는 구조를 다루는 코드는 템플릿 핸들러로 만들고, 메시지 스펙에 의존적인 구체적인 메시지 처리 코드는 템플릿에서 주입받아 콜백해 줄 수 있는 별도의 클래스로 분리했습니다. 여기서 메시지 각각은 서로 독립적으로 변경될 수 있기 때문에 통신 스펙에 정의된 메시지 하나는 메시지 클래스 하나가 되도록 했습니다. 이제 아래 다이어그램과 같이 역할이 다른 두 코드가 템플릿과 메서지 처리 콜백이라는 두 개의 클래스 그룹으로 분리되었습니다.

// 템플릿 핸들러
@RequiredArgsConstructor
public class StatusUpdater extends ChannelInboundHandlerAdapter {
    private final DeviceStatus deviceStatus; // 장비의 전체 상태

    // 장비의 부분적인 상태 메시지를 받아 전체 상태의 일부를 업데이트 합니다.
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        if (msg instanceof RunningStatus runningStatus) { // 장비 구동 상태
            runningStatus.updateTo(deviceStatus);
        } else if (...) {
						... 
				} ... 
        super.channelRead(ctx, msg);
    }
}

// 메시지 처리 콜백
@Getter
@RequiredArgsConstructor
public class RunningStatus {
    private final int angle;
    private final double velocity;

	// 장비의 부분적인 상태 업데이트
    public void updateTo(DeviceStatus totalStatus) {
        totalStatus.setAngle(angle);
        totalStatus.setVelocity(velocity);
    }
}

의존성 역전 원칙(DIP)

상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다. (클린 소프트웨어, 로버트 C. 마틴)

그러나 한 가지 문제가 보입니다. 위 구조에서는 변하지 않는 템플릿 클래스에서 변경될 수 있는 구체적인 메시지 처리 클래스를 직접 참조하고 있습니다. 즉 통신 스펙에 의존적인 메시지 처리 클래스에 변경이 생기면 템플릿 코드가 영향을 받을 수 있는 불안정한 구조입니다. 전체 구조를 구현한 상위 수준 클래스가 세부 사항을 다루는 하위 수준 클래스에 의존하고 있으며, 서로 협력하는 메시지 인터페이스의 변경 주도권이 세부 사항을 다루는 하위 수준 클래스에 있다고 말할 수 있습니다. 이제 템플릿 핸들러가 자신이 메시지 처리를 위임할 클래스의 인터페이스를 직접 정의하고 인터페이스만을 참조하여 해당 인터페이스를 구현한 메시지 처리 클래스만을 다루도록 하겠습니다. 그러면 템플릿 클래스는 구체적인 메시지 처리 클래스는 몇 개가 있고, 어떤 것이 있는지 알 필요가 없습니다. 심지어 지금 존재하지 않지만 앞으로 해당 인터페이스를 구현하기만 한다면 어떠한 메시지 처리 클래스도 다룰 수 있게 되었습니다. 반대로 메시지 처리 클래스는 템플릿에 의해 사용되기 위해 인터페이스라는 규약을 반드시 준수해야만 하는 적절한 제약이 생겼습니다. 통신 스펙에 의존적인 메시지 처리 클래스가 마음대로 템플릿과의 인터페이스를 바꾸는 것도 어렵게 되었습니다. 템플릿과 메시지 처리 클래스 사이 인터페이스 변경의 주도권이 이제 변하지 않는 템플릿에게 있고, 템플릿이 메시지 처리 클래스에 의존하던 것이 이제는 메시지 처리 클래스가 템플릿이 정한 인터페이스에 의존하도록 의존 방향이 반대가 되었습니다.

// 템플릿 핸들러
@RequiredArgsConstructor
public class StatusUpdater extends SimpleChannelInboundHandler<StatusUpdatable> {
    private final DeviceStatus totalStatus; // 장비의 전체 상태

    // 장비의 부분적인 상태 메시지를 받아 전체 상태의 일부를 업데이트 합니다.
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, StatusUpdatable msg) throws Exception {
				msg.updateTo(totalStatus);
        ctx.fireChannelRead(msg);
    }
}

// 인터페이스
public interface StatusUpdatable {
	// 상태 업데이트가 가능한 인터페이스 
    void updateTo(DeviceStatus totalStatus);
}

// 메시지 처리 콜백
@Getter
@RequiredArgsConstructor
public class RunningStatus implements StatusUpdatable {
    private final int angle;
    private final double velocity;

	// 장비의 부분적인 상태 업데이트
    @Override
    public void updateTo(DeviceStatus totalStatus) {
        totalStatus.setAngle(angle);
        totalStatus.setVelocity(velocity);
    }
}

리스코프 치환 원칙(LSP)

서브타입은 그것의 기반 타입으로 치환 가능해야 한다. (클린 소프트웨어, 로버트 C. 마틴)

사실 앞서 적용한 의존성 역전 원칙이 제대로 동작하기 위해서는 한 가지 조건을 더 만족해야 합니다. 바로 “서브타입은 그것의 기반 타입으로 치환 가능해야 한다”는 리스코프 치환 원칙이 인터페이스와 인터페이스를 구현한 각각의 메시지 처리 클래스 사이에 적용될 수 있어야 합니다. 만약 메시지 처리 클래스에서 인터페이스에 정의하지 않은 메서드를 구현하고, 템플릿에서 해당 메서드를 참조하는 조금 억지스러운 위반 예를 생각해 볼 수 있습니다. 또는 리스코프 치환 원칙 위반 예시로 유명한 상속 관계에 있는 사각형과 정사각형 예제 코드 역시 생각해 볼 수 있습니다. (Java 8 이후로 인터페이스는 디폴트 메서드를 구현할 수 있고, 디폴트 메서드에서 final static 멤버의 내부 상태를 변경할 수 있기 때문에 사각형과 정사각형 예제는 인터페이스를 통해서도 구현될 수 있습니다) 그러나 현업에서 아직 이런 예제를 접해 보지 못했고, 현재 도메인에서 일어날 수 있는 상황은 잘 생각나지 않는 것이 사실입니다. 어쨋든 모든 메시지 처리 클래스를 인터페이스로 치환했을 때 프로그램의 정확성에 문제가 없다면 템플릿은 인터페이스에만 의존하는 문제 없는 코드를 작성할 수 있습니다.

개방 폐쇄 원칙(OCP) - 그래서 결국 하려는게 뭐야?

소프트웨어 개체(클래스, 모듈, 함수)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다. (클린 소프트웨어, 로버트 C. 마틴)

여기까지 진행하면 제 스스로 “그런데 이런 걸 왜 하는데?” 라는 질문을 하게 됩니다. 관리할 클래스 파일은 늘어났고, 연관된 두 클래스 사이에 인터페이스가 있음으로 코드만 보고 따라가서는 런타임에 실행될 클래스를 확인하기 어려워졌습니다. 또한 코드를 읽기 위해 처음에는 구조에 대한 이해가 어느 정도 필요합니다. 어떤 동료에게는 왜 도대체 이런 단점을 감수하고 여러 설계 원칙을 적용하는지 질문을 받게 될 수 있습니다. 이런 질문에 답을 생각해 봅시다. 지금까지 변하는 코드와 변하지 않는 코드를 분리하고(SRP), 두 클래스가 인터페이스에 의존하도록 의존성의 방향과 변경의 주도권을 역전시켰고(DIP), 또 모든 하위 클래스가 인터페이스로 치환될 수 있도록 (LSP)한 이유는 바로 개방 폐쇄 원칙(OCP)이란 것을 실현하기 위함이 아닐까 생각합니다. 한 가지 실제 상황을 생각해 보겠습니다. 고객의 요청으로 장비를 제어하기 위한 외부 API 서버와의 통신 메시지 Z가 추가되었습니다. 기존 통신 컴포넌트에서 어디를 수정하면 될까요? 템플릿이 되는 핸들러는 물론이고 기존의 A, B, C 메시지 클래스는 전혀 수정할 필요가 없습니다. 변경에 대해 닫혀 있는 폐쇄성이 효과를 발휘합니다. 대신 우리는 Z라는 메시지를 새로운 클래스로 정의하고 Z 메시지를 생성해서 템플릿에 주입해 주는 코드만 추가하면 됩니다. 기존 코드를 재활용하며 새로운 메시지 처리 코드를 쉽게 확장해 갈 수 있습니다. 확장에 대해 열려 있는 효과가 발휘됩니다. 이처럼 개방 폐쇄 원칙이 적용되면 코드를 수정해야 할 때 변경이 관련있는 국지적인 영역으로 최소화(가능하면 전혀 주지 않으면서) 됩니다. 기존 코드 입장에서는 변경에 영향을 받지 않도록 닫혀 있고, 새로 추가하는 코드는 기존 코드를 그대로 재활용하면서 쉽게 확장해 나갈 수 있어서 좋습니다. 그래서 코드를 유지하고 확장해 나가는 비용은 줄고 코드 수정의 안정성은 증가하며 이로 인해 비지니스 관점에서도 고객에게 가치 있는 기능을 쉽게 추가하고 유지해 나갈 수 있게 됩니다.

불필요한 복잡성의 악취

그러나 주의할 점이 있습니다. 클린 소프트웨어(로버트 C. 마틴)에서 언급된 ‘불필요한 복잡성의 악취’에 대한 것입니다. 우리는 설계 시점에 이러이러한 변경이 발생할 것으로 예상하면서 변경이 발생할 취약한 부분들로부터 다른 영역들을 안전하게 보호하기 위해 이와 같은 설계 원칙들을 적용합니다. 그러나 우리 예측이 합리적이지 않고, 실제로 절대 일어나지 않는 변경에 대해 이런 설계 장치를 추가했다고 생각해 봅시다. 클래스가 쪼개지면서 클래스의 개수가 증가했고, 구조적인 연결이 생기며 관계에 대해 우리가 이해 해야할 정보들이 추가되었습니다. 만약 코드가 상대적으로 간단하다면 구조화하여 관리하는 비용이 그 효과 보다 적을 수도 있습니다. 바로 불필요한 복잡성을 야기하는 겁니다. 그래서 클린 소프트웨어에서 로버트 C. 마틴은 변경이 명백한 것이 아니라면 분명한 악취를 맡을 때까지 처리를 연기하라고 말하기도 합니다.

1.3 파이프라인을 통과하는 메시지와 인터페이스 분리 원칙

클라이언트가 자신이 사용하지 않는 메서드에 의존하도록 강제되어서는 안 된다. (클린 소프트웨어, 로버트 C. 마틴)

메시지 클래스 응집성 높이기

지금까지 메시지를 처리하는 하나의 핸들러를 설계해 보았습니다. 이제는 시선을 조금 더 넓혀 핸들러들이 연결되어 구성되는 파이프라인을 관점에서 컴포넌트를 살펴보겠습니다. 통신 프로토콜에 정의된 하나의 메시지는 코드 상에서 디코딩, 상태 업데이트, 로깅 핸들러 등에 의해 처리됩니다. 그런데 앞선 설계를 통해서 우리는 템플릿 핸들러를 두고 메시지 별로 변경되는 콜백 클래스를 외부에 별도로 구현했습니다. 그렇다면 메시지 A의 디코딩, 상태 업데이트, 로깅 핸들러 기능 역시 각각의 클래스로 분리되어야 할까요? 아닙니다. 저는 대신에 위의 코드들은 하나의 클래스에 캡슐화하는 것이 좋겠다고 생각했습니다. 왜냐하면 A 메시지에 어떤 정보들이 어떤 구조로 포함되는지에 대한 프로토콜 스펙이 변경되면 디코딩, 상태 업데이트, 로깅 핸들러 코드가 다 같이 함께 변경되기 때문입니다. 따라서 메시지 A 프로토콜 스펙과 연관된 처리는 하나의 클래스 안에 구현하여 메시지 A 클래스의 응집성을 높이도록 했습니다. 결과적으로 메시지 스펙이 변경되면 관련된 변경이 A 클래스 안에서 국지적으로 일어납니다.

// 로그 생성 인터페이스
public interface LogFactory {
    String toLogMessage();
}

// 상태 업데이트 인터페이스
public interface StatusUpdatable {
    void updateTo(DeviceStatus totalStatus);
}

// 메시지 처리 클래스
@Getter
@RequiredArgsConstructor
public class RunningStatus implements StatusUpdatable, LogFactory {
    private final int angle;
    private final double velocity;

	// 장비의 부분적인 상태 업데이트
    @Override
    public void updateTo(DeviceStatus totalStatus) {
        totalStatus.setAngle(angle);
        totalStatus.setVelocity(velocity);
    }

	// 로그 메시지 생성
    @Override
    public String toLogMessage() {
        return String.format("angle : %d, velocity : %f", angle, velocity);
    }
}

핸들러가 바라보는 인터페이스 달리하기

앞에서 하나의 메시지 처리 클래스 안에 디코딩, 상태 업데이트, 로깅 기능 등을 구현할 수 있다고 했지만 각 기능을 사용하는 핸들러(템플릿)는 분리되어 있습니다. 만약 각 핸들러에서 메시지 처리 클래스 타입의 객체 참조를 받는다면 각 핸들러가 사용하지 않는 불필요한 인터페이스에 의존하게 됩니다. 예를 들면 로깅 핸들러에서는 상태 업데이트 기능을 사용하지 않는 것이 확실합니다. 그래서 각각의 기능 핸들러가 자신이 필요로하는 메시지 인터페이스에만 의존하도록 메시지의 인터페이스를 분리합니다. 아래에서 보는 것과 같이 실제로 메시지 A의 객체 인스턴스가 파이프라인을 통과하지만 로깅 핸들러에서는 메시지를 로그메시지 팩토리 인터페이스로 바라보고, 상태 업데이트 핸들러에서는 메시지를 업데이트하는 인터페이스로 바라봅니다. 자신이 사용하는 메서드 이외의 메서드 존재 자체를 알지 못하는 안전한 형태가 되었습니다.

// 로깅 핸들러
@Slf4j
public class Logger extends SimpleChannelInboundHandler<LogFactory> {

		// 수신한 메시지에 대한 로그를 남깁니다.
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, LogFactory msg) throws Exception {
        log.info("Received content : {}", msg.toLogMessage());
				ctx.fireChannelRead(msg);
    }
}

// 상태 업데이트 핸들러
@RequiredArgsConstructor
public class StatusUpdater extends SimpleChannelInboundHandler<StatusUpdatable> {
    private final DeviceStatus totalStatus; // 장비의 전체 상태

    // 장비의 부분적인 상태 메시지를 받아 전체 상태의 일부를 업데이트 합니다.
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, StatusUpdatable msg) throws Exception {
        msg.updateTo(totalStatus);
        ctx.fireChannelRead(msg);
    }
}

2. 아키텍처 관점의 설계

2.1 추상화 계층 두고, 느슨한 결합 만들기

Web UI에 사용자가 장비를 제어하기 위한 메뉴들이 있습니다. 이때 UI에서 서버를 거쳐 장비까지 메시지가 전송되어야 하는데 UI, 서버, 장비 사이에 어떤 형태의 메시지를 주고 받아야 할까요? 초기에는 간단하게 장비에서 정의한 메시지 타입을 UI에서도 그대로 사용했습니다. 그랬더니 장비가 정의한 메시지 스펙이 변경되면 서버 뿐 아니라 UI까지 연쇄적으로 변경이 일어나는 문제가 생겼습니다. 장비 통신과는 직접적인 관련 없는 UI 까지 변경이 전파되는 불필요한 결합을 가진 아키텍처 구조라고 할 수 있습니다. 그래서 UI와 서버가 주고 받는 메시지는 별도의 변하지 않는 양식을 정의하고 서버의 메시지 처리 시작부분에 장비와의 메시지 양식으로 변환하는 추상화 계층을 두도록 했습니다. 그러면 장비의 메시지 형식이 변경되어도 UI는 항상 일관되게 메시지를 주고 받을 수 있습니다.

UI - Server

{
  "id":"some_id",
  "value":100.0
	... 
}

Server - Device

"set some_id 100.0, ..."

2.2 함께 변경되는 클래스 모으고, 응집도 높이기

개발했던 서비스는 N대의 장비를 동시에 통합 제어합니다. 따라서 위에서 살펴본 장비 제어 컴포넌트가 N개 존재하고, 관련된 클래스들도 공통 사용되는 것이 아니라면 N개씩 존재합니다. 예를 들어 장비의 상태를 표현한 값 객체는 정확히 N개가 존재합니다. (공유할 수 없는 장비 고유의 상태를 담기 때문입니다) 그런데 개발 초기에 장비별로 존재하는 N개의 클래스들을 기능 특성 별로 모아두었습니다. 예를 들면 장비 상태를 나타내는 N개의 서로 다른 장비 상태 클래스들은 status 라는 패키지 아래에 모여 있었습니다. 또 장비를 제어하는 최상위 클래스인 N개의 Controller 클래스도 controller 라는 패키지 아래 모여 있었습니다. 그런데 서비스를 운영하다 보니 불편합니다. 특정 장비를 제어하는 컴포넌트에 변경이 생기면 controller 패키지 따로 status 패키지 따로 패키지 구조를 찾아가야 합니다. 장비 단위로 패키지를 두고 그 안에 장비와 관련된 모든 클래스가 모여 있으면 좋겠단 생각이 듭니다. 생각해 보면 함께 변경되는 클래스들을 같은 패키지 아래 두는 것이 훨씬 좋습니다. 이전 구성은 클래스가 사용되는 측면이 아니라 클래스의 기술적인 관점에서 공통점을 찾아 패키지를 분류한 것이었습니다. 기술 구현의 공통점은 있지만 실제로 함께 다루는 클래스는 전혀 없습니다. 그래서 실제로 사용 측면에서 함께 사용되고 함께 변경되는 클래스들을 하나의 패키지 아래 모아두어 패키지(컴포넌트)의 응집성을 높여 보았습니다. 이렇게 하면 추후 해당 패키지를 독립적인 서비스로 분리하거나 또는 라이브러리화 하기도 훨씬 유리하다는 것을 뒤늦게 깨닫게 되었습니다.

마치며

담당했던 소프트웨어를 마음껏 개발하고 개선할 수 있어 좋았고 감사했습니다. 프로젝트 말미에 동료들에게 위 내용을 코드와 함께 설명해 주었는데 팀장님께서 “하고 싶은거 다 해봤네”라며 웃으셨습니다. 저에게 전적인 오너쉽을 주신 팀장님께 문득 감사한 마음이 듭니다. 그리고 이 프로젝트를 시작하며 Netty 라는 기술을 선택하며 썻던 글(→ 우주지상국 구축 프로젝트에 Netty 프레임워크를 선택한 이유 5가지)이 생각납니다. 기술을 선택하며 프로젝트를 시작했던 순간과 아직 완전한 끝은 아니지만 어느 정도의 틀이 잡힌 서비스를 바라보며 묘한 기분이 듭니다. 앞으로 더 많은 경험을 통해 이 글에 설명한 것들을 더 확실히 알아가고 싶고 사람들에게 더 유용한 서비스를 만들어 가고 싶습니다.

profile
소프트웨어 엔지니어, 일상

0개의 댓글