Jinwoo Sung
2026.06.02unilinkDesign

ChannelFactory 설계

Config를 기반으로 concrete Channel 구현체를 생성하고 Wrapper가 transport 선택을 직접 알지 않도록 분리한 구조를 정리했다.

도입: Channel 구현체는 누가 선택하는가

Channel은 transport 차이를 흡수하기 위한 공통 통신 계약이다. Wrapper는 TCP, UDP, Serial 같은 concrete transport를 직접 알 필요 없이, Channel 계약에만 의존할 수 있다.

하지만 설계 관점에서 보면 여전히 중요한 질문이 하나 남는다.

Wrapper가 Channel 계약만 안다면, 실제 TCP나 Serial Channel 구현체는 누가, 언제, 어떻게 선택해서 만들어 줄 것인가?

TCP client 설정이면 TCP client transport가 필요하고, Serial 설정이면 Serial transport가 필요하다. UDP 설정이면 UDP channel이 필요하며, UDS 설정이면 Unix Domain Socket용 channel이 필요하다.

이 선택 로직을 wrapper 내부에 모두 넣을 수도 있다. 하지만 그렇게 하면 wrapper는 다시 transport 종류를 알아야 하고, 생성 조건도 직접 관리해야 한다. 결국 wrapper가 transport 차이를 숨기는 계층이 아니라, transport 생성 로직까지 떠안는 계층이 된다.

unilink에서는 이 역할을 ChannelFactory로 분리한다.

flowchart TD A[Wrapper] --> B[ChannelFactory] B --> C[Channel Contract] C --> D[TCP Client Channel] C --> E[TCP Server Channel] C --> F[Serial Channel] C --> G[UDP Channel] C --> H[UDS Channel]
Mermaid source
flowchart TD
    A[Wrapper] --> B[ChannelFactory]
    B --> C[Channel Contract]

    C --> D[TCP Client Channel]
    C --> E[TCP Server Channel]
    C --> F[Serial Channel]
    C --> G[UDP Channel]
    C --> H[UDS Channel]

ChannelFactory는 config를 보고 적절한 Channel 구현체를 생성한다. Wrapper는 어떤 concrete transport가 생성되는지 세부적으로 알 필요 없이, 반환된 Channel 계약에만 의존하면 된다.

Factory가 필요한 이유

Factory가 없다면 wrapper 내부에는 transport 선택 로직이 들어가야 한다.

if (type == TransportType::TcpClient) {
    channel = TcpClientTransport::create(...);
} else if (type == TransportType::Serial) {
    channel = SerialTransport::create(...);
} else if (type == TransportType::Udp) {
    channel = UdpChannel::create(...);
}

처음에는 단순해 보이지만, transport가 늘어나고 설정이 복잡해질수록 wrapper가 점점 많은 책임을 갖게 된다.

mindmap root((Wrapper Without Factory)) Runtime API start stop send Callback Handling on_data on_error on_message Transport Selection TCP UDP Serial UDS Construction Details config mapping io_context socket / acceptor serial port
Mermaid source
mindmap
  root((Wrapper Without Factory))
    Runtime API
      start
      stop
      send
    Callback Handling
      on_data
      on_error
      on_message
    Transport Selection
      TCP
      UDP
      Serial
      UDS
    Construction Details
      config mapping
      io_context
      socket / acceptor
      serial port

Wrapper는 사용자가 실제로 다루는 실행 객체다. 따라서 wrapper의 책임은 lifecycle, send, callback, stats 같은 public runtime API에 집중하는 편이 좋다.

Transport 선택과 생성은 별도의 계층으로 분리하는 것이 더 자연스럽다.

flowchart TD A[Wrapper Responsibility] --> B[Runtime API] A --> C[Callback / Policy] A --> D[Stats / State] E[Factory Responsibility] --> F[Config Dispatch] E --> G[Transport Selection] E --> H[Channel Construction]
Mermaid source
flowchart TD
    A[Wrapper Responsibility] --> B[Runtime API]
    A --> C[Callback / Policy]
    A --> D[Stats / State]

    E[Factory Responsibility] --> F[Config Dispatch]
    E --> G[Transport Selection]
    E --> H[Channel Construction]

이렇게 나누면 wrapper는 “어떻게 동작할 것인가”에 집중하고, factory는 “어떤 구현체를 만들 것인가”에 집중할 수 있다.

ChannelFactory의 역할

ChannelFactory의 역할은 명확하다.

입력으로 config를 받고, 그 config에 맞는 concrete Channel을 생성한다.

flowchart TD A[ChannelOptions] --> B[ChannelFactory] B --> C{Config Type} C --> D[TcpClientConfig] C --> E[TcpServerConfig] C --> F[SerialConfig] C --> G[UdpConfig] C --> H[UdsClientConfig] C --> I[UdsServerConfig] D --> J[TcpClient Channel] E --> K[TcpServer Channel] F --> L[Serial Channel] G --> M[Udp Channel] H --> N[UdsClient Channel] I --> O[UdsServer Channel]
Mermaid source
flowchart TD
    A[ChannelOptions] --> B[ChannelFactory]

    B --> C{Config Type}

    C --> D[TcpClientConfig]
    C --> E[TcpServerConfig]
    C --> F[SerialConfig]
    C --> G[UdpConfig]
    C --> H[UdsClientConfig]
    C --> I[UdsServerConfig]

    D --> J[TcpClient Channel]
    E --> K[TcpServer Channel]
    F --> L[Serial Channel]
    G --> M[Udp Channel]
    H --> N[UdsClient Channel]
    I --> O[UdsServer Channel]

개념적으로 보면 다음과 같다.

class ChannelFactory {
public:
    using ChannelOptions = std::variant<
        TcpClientConfig,
        TcpServerConfig,
        SerialConfig,
        UdpConfig,
        UdsClientConfig,
        UdsServerConfig
    >;

    static std::shared_ptr<Channel> create(
        const ChannelOptions& options,
        std::shared_ptr<boost::asio::io_context> external_ioc = nullptr);
};

여기서 핵심은 ChannelOptions다. 하나의 factory function이 여러 config 타입을 받을 수 있도록 std::variant를 사용한다.

즉, factory는 문자열이나 enum만으로 transport 종류를 선택하지 않는다. config 타입 자체가 어떤 transport를 생성해야 하는지 표현한다.

Config가 생성 의도를 표현한다

Factory에서 중요한 설계 포인트는 생성 조건을 config 타입으로 표현한다는 점이다.

TCP client를 만들려면 TcpClientConfig가 필요하다. TCP server를 만들려면 TcpServerConfig가 필요하다. Serial을 만들려면 SerialConfig가 필요하다. UDP를 만들려면 UdpConfig가 필요하다.

mindmap root((Config as Creation Intent)) TcpClientConfig host port retry policy socket options TcpServerConfig bind port acceptor client sessions SerialConfig device path baud rate port options UdpConfig local port remote endpoint datagram options UdsConfig socket path local IPC
Mermaid source
mindmap
  root((Config as Creation Intent))
    TcpClientConfig
      host
      port
      retry policy
      socket options
    TcpServerConfig
      bind port
      acceptor
      client sessions
    SerialConfig
      device path
      baud rate
      port options
    UdpConfig
      local port
      remote endpoint
      datagram options
    UdsConfig
      socket path
      local IPC

이 방식은 생성 의도를 명시적으로 만든다.

config::TcpClientConfig cfg;
cfg.host = "127.0.0.1";
cfg.port = 9000;

auto channel = ChannelFactory::create(cfg);

이 코드를 읽으면 어떤 Channel을 만들려는지 분명하다. 별도의 TransportType::TcpClient 같은 enum을 추가로 전달하지 않아도 된다.

Config 타입이 곧 생성 의도를 나타낸다.

std::variant와 std::visit

ChannelFactory는 여러 config 타입을 std::variant로 묶고, std::visit을 통해 실제 타입별 생성 함수로 분기한다.

개념적으로는 다음과 같은 구조다.

std::shared_ptr<Channel> ChannelFactory::create(
    const ChannelOptions& options,
    std::shared_ptr<boost::asio::io_context> external_ioc) {
    return std::visit(
        [&external_ioc](const auto& config) -> std::shared_ptr<Channel> {
            using T = std::decay_t<decltype(config)>;

            if constexpr (std::is_same_v<T, TcpClientConfig>) {
                return create_tcp_client(config, external_ioc);
            } else if constexpr (std::is_same_v<T, SerialConfig>) {
                return create_serial(config, external_ioc);
            } else if constexpr (std::is_same_v<T, UdpConfig>) {
                return create_udp(config, external_ioc);
            }
        },
        options);
}

이 구조에는 몇 가지 장점이 있다.

첫째, 지원 가능한 config 타입이 ChannelOptions에 명시된다. 어떤 transport를 factory가 생성할 수 있는지 타입 수준에서 확인할 수 있다.

둘째, 각 config 타입에 대한 생성 경로가 컴파일 타임 분기로 정리된다. if constexpr를 사용하면 타입별 생성 로직을 명확히 나눌 수 있다.

셋째, 잘못된 타입이 들어오는 경로를 줄일 수 있다. std::variant에 포함되지 않은 config 타입은 애초에 ChannelOptions로 전달될 수 없다.

flowchart TD A[ChannelOptions variant] --> B[std::visit] B --> C{Actual Config Type} C --> D[TcpClientConfig] D --> D1[create_tcp_client] C --> E[SerialConfig] E --> E1[create_serial] C --> F[UdpConfig] F --> F1[create_udp] C --> G[UdsClientConfig] G --> G1[create_uds_client] D1 --> H[shared_ptr Channel] E1 --> H F1 --> H G1 --> H
Mermaid source
flowchart TD
    A[ChannelOptions variant] --> B[std::visit]

    B --> C{Actual Config Type}

    C --> D[TcpClientConfig]
    D --> D1[create_tcp_client]

    C --> E[SerialConfig]
    E --> E1[create_serial]

    C --> F[UdpConfig]
    F --> F1[create_udp]

    C --> G[UdsClientConfig]
    G --> G1[create_uds_client]

    D1 --> H[shared_ptr Channel]
    E1 --> H
    F1 --> H
    G1 --> H

여기서 중요한 점은 std::variant가 지원 가능한 config 집합을 닫힌 타입 집합으로 만든다는 것이다.

전통적인 enum + switch 방식이나 문자열 기반 factory에서는 transport type과 config payload가 따로 움직일 수 있다. 예를 들어 type은 TCP인데 config는 Serial용 구조체인 잘못된 조합을 만들 여지가 생긴다. 이런 오류는 런타임에서야 드러날 가능성이 높다.

반면 std::variant 기반 구조에서는 factory가 받을 수 있는 config 타입의 목록이 타입 시스템 안에 들어간다. 새로운 transport를 추가하려면 ChannelOptions에 config 타입을 추가해야 하고, std::visit 분기에서 해당 타입을 처리해야 한다. 이 과정에서 누락된 분기는 컴파일 단계에서 드러날 수 있다.

또한 dynamic_cast나 런타임 타입 검사에 의존하지 않고, config의 실제 타입에 따라 생성 경로를 정할 수 있다. 즉, Factory는 런타임 문자열 비교나 불안정한 downcast보다, 타입으로 생성 경로를 정리하는 방식을 선택했다.

이 설계의 핵심은 단순히 “여러 타입을 받을 수 있다”가 아니다. 지원 가능한 config 집합을 명시적으로 닫아두고, 타입 기반 dispatch로 생성 책임을 안전하게 관리하는 데 있다.

외부 io_context 지원

통신 라이브러리에서 io_context 관리는 중요한 설계 포인트다.

라이브러리가 내부 io_context를 직접 관리할 수도 있고, 사용자가 외부에서 관리하는 io_context를 주입할 수도 있다.

unilink의 ChannelFactory는 optional external io_context를 받는다.

static std::shared_ptr<Channel> create(
    const ChannelOptions& options,
    std::shared_ptr<boost::asio::io_context> external_ioc = nullptr);

이 인자는 생성되는 transport가 어떤 runtime context에서 동작할지 결정한다.

flowchart TD A[ChannelFactory create] --> B{external io_context?} B -- Yes --> C[Create channel with provided io_context] B -- No --> D[Create channel with internal/default context] C --> E[Caller manages runtime context] D --> F[Library manages runtime context]
Mermaid source
flowchart TD
    A[ChannelFactory create] --> B{external io_context?}

    B -- Yes --> C[Create channel with provided io_context]
    B -- No --> D[Create channel with internal/default context]

    C --> E[Caller manages runtime context]
    D --> F[Library manages runtime context]

외부 io_context를 지원하면 사용자는 여러 channel을 하나의 event loop에 묶거나, 이미 존재하는 application-level runtime에 unilink channel을 통합할 수 있다.

반대로 external context를 제공하지 않으면 transport가 내부 context를 사용해 동작할 수 있다.

이 선택지를 factory에서 처리하면 wrapper는 context 생성 방식에 대해 덜 알게 된다. Wrapper는 반환된 Channel을 사용하면 되고, context 사용 방식은 factory와 transport 생성 로직이 담당한다.

Transport별 생성 함수

ChannelFactory는 내부적으로 transport별 생성 함수를 가진다.

static std::shared_ptr<Channel> create_tcp_client(
    const TcpClientConfig& cfg,
    std::shared_ptr<boost::asio::io_context> external_ioc);

static std::shared_ptr<Channel> create_serial(
    const SerialConfig& cfg,
    std::shared_ptr<boost::asio::io_context> external_ioc);

static std::shared_ptr<Channel> create_udp(
    const UdpConfig& cfg,
    std::shared_ptr<boost::asio::io_context> external_ioc);

이 방식은 create() 함수 하나에 모든 transport 생성 세부사항을 몰아넣지 않기 위한 선택이다.

mindmap root((Factory Internal Structure)) Public Entry create ChannelOptions Dispatch std::visit if constexpr Creation Functions create_tcp_client create_tcp_server create_serial create_udp create_uds_client create_uds_server Output shared_ptr Channel
Mermaid source
mindmap
  root((Factory Internal Structure))
    Public Entry
      create ChannelOptions
    Dispatch
      std::visit
      if constexpr
    Creation Functions
      create_tcp_client
      create_tcp_server
      create_serial
      create_udp
      create_uds_client
      create_uds_server
    Output
      shared_ptr Channel

외부에서 보면 factory는 단일 진입점을 제공한다. 내부에서는 transport별 생성 함수로 분리되어 있어 각 transport의 생성 조건을 독립적으로 다룰 수 있다.

예를 들어 Serial은 serial port 객체가 필요할 수 있고, TCP server는 acceptor 객체가 필요할 수 있다. UDP는 local port와 endpoint 설정이 중요하고, UDS는 socket path가 중요하다.

Factory 내부에서 이 차이를 감싸면 wrapper는 이런 세부 차이를 몰라도 된다.

Wrapper와 Factory의 관계

Wrapper는 ChannelFactory를 통해 Channel을 얻는다.

Wrapper가 직접 TCP transport를 생성하지 않고, config를 만든 뒤 factory에 생성을 위임하면 구조가 단순해진다.

flowchart TD A[Wrapper] --> B[Build Config] B --> C[ChannelFactory] C --> D[Concrete Channel] D --> E[Wrapper uses Channel Contract]
Mermaid source
flowchart TD
    A[Wrapper] --> B[Build Config]
    B --> C[ChannelFactory]
    C --> D[Concrete Channel]
    D --> E[Wrapper uses Channel Contract]

이 구조에서 wrapper의 역할은 config를 구성하고, 생성된 Channel을 사용해 runtime API를 제공하는 것이다.

Factory의 역할은 config에 맞는 concrete channel을 선택하고 생성하는 것이다.

Transport의 역할은 실제 protocol-specific I/O를 수행하는 것이다.

mindmap root((Responsibility Split)) Wrapper user-facing API callback mapping lifecycle mediation Factory config dispatch transport selection channel construction Transport protocol-specific I/O Boost.Asio integration OS resource handling
Mermaid source
mindmap
  root((Responsibility Split))
    Wrapper
      user-facing API
      callback mapping
      lifecycle mediation
    Factory
      config dispatch
      transport selection
      channel construction
    Transport
      protocol-specific I/O
      Boost.Asio integration
      OS resource handling

이 책임 분리는 unilink 구조에서 중요하다. Wrapper가 transport 선택까지 담당하면 wrapper는 점점 무거워지고, transport가 늘어날수록 변경 영향을 받게 된다.

Factory를 분리하면 transport 추가나 생성 방식 변경이 wrapper에 직접 퍼지는 것을 줄일 수 있다.

Factory와 Dependency Injection

Factory는 dependency injection과도 연결된다.

Wrapper는 concrete transport를 직접 생성하지 않고 Channel 계약을 받거나, factory를 통해 생성된 Channel을 사용한다. 이 구조는 wrapper가 구체 transport 타입에 강하게 묶이는 것을 줄인다.

class TcpClient {
public:
    explicit TcpClient(std::shared_ptr<Channel> channel)
        : channel_(std::move(channel)) {}

private:
    std::shared_ptr<Channel> channel_;
};

이런 구조에서는 실제 TCP channel 대신 test double이나 mock channel을 넣을 수 있다. 또한 production code에서는 factory가 concrete transport를 생성하고, test code에서는 직접 fake channel을 주입할 수 있다.

flowchart TD A[Wrapper] --> B[Channel Interface] B --> C[Factory-created Real Channel] B --> D[Test Fake Channel]
Mermaid source
flowchart TD
    A[Wrapper] --> B[Channel Interface]

    B --> C[Factory-created Real Channel]
    B --> D[Test Fake Channel]

Factory는 production path에서 concrete channel을 생성하고, DI 구조는 wrapper가 concrete transport에 직접 의존하지 않게 만든다.

이 관점에서 ChannelFactory는 단순한 객체 생성 유틸리티가 아니라, 의존성 방향을 정리하는 아키텍처 요소다.

Factory 설계의 Trade-off

Factory 계층을 두는 것도 비용이 있다.

mindmap root((Factory Trade-offs)) Benefits centralized creation wrapper decoupling explicit config dispatch external context handling easier test injection Costs additional layer variant maintenance config type coupling update needed for new transport
Mermaid source
mindmap
  root((Factory Trade-offs))
    Benefits
      centralized creation
      wrapper decoupling
      explicit config dispatch
      external context handling
      easier test injection
    Costs
      additional layer
      variant maintenance
      config type coupling
      update needed for new transport

첫 번째 비용은 계층이 하나 더 늘어난다는 점이다. Wrapper가 직접 transport를 생성하면 코드 흐름은 짧아질 수 있다. Factory를 두면 생성 경로가 한 단계 더 생긴다.

두 번째 비용은 ChannelOptions 유지 비용이다. 새로운 transport config가 추가되면 variant에 타입을 추가하고, std::visit 분기와 생성 함수를 함께 추가해야 한다.

세 번째 비용은 factory가 config 타입을 알게 된다는 점이다. Factory는 transport 선택을 담당하는 만큼, 지원하는 config 타입과 transport 생성 방식에 대한 지식을 가진다.

하지만 이 비용은 wrapper와 transport 구현을 분리하기 위해 감수할 만하다. 생성 책임을 factory에 모으면 wrapper는 실행 객체로서의 역할에 집중할 수 있고, transport 추가와 생성 방식 변경도 한 곳에서 관리할 수 있다.

정리

ChannelFactory는 config를 기반으로 concrete Channel 구현체를 생성하는 계층이다.

mindmap root((ChannelFactory Role)) Creation Boundary config to channel transport selection construction policy Type-driven Dispatch variant visit if constexpr closed config set Runtime Context external io_context internal context fallback Decoupling wrapper from transport public API from construction detail Extensibility add transport add config centralize creation path
Mermaid source
mindmap
  root((ChannelFactory Role))
    Creation Boundary
      config to channel
      transport selection
      construction policy
    Type-driven Dispatch
      variant
      visit
      if constexpr
      closed config set
    Runtime Context
      external io_context
      internal context fallback
    Decoupling
      wrapper from transport
      public API from construction detail
    Extensibility
      add transport
      add config
      centralize creation path

정리하면 다음과 같다.

  • ChannelFactory는 Wrapper와 concrete transport 생성 로직을 분리한다.
  • Config 타입이 생성 의도를 표현한다.
  • std::variant는 factory가 지원하는 config 집합을 닫힌 타입 집합으로 만든다.
  • std::visitif constexpr는 config 타입별 생성 경로를 타입 기반으로 정리한다.
  • 외부 io_context를 지원해 runtime context 주입 경로를 제공한다.
  • Factory는 production path에서 concrete Channel을 만들고, DI 관점에서는 wrapper가 Channel 계약에만 의존하게 돕는다.
  • 새로운 transport가 추가되면 factory의 variant와 생성 함수가 명확한 확장 지점이 된다.

Channel 계층이 “통신 행위의 공통 계약”이라면, ChannelFactory는 그 계약을 만족하는 concrete 구현체를 선택하고 생성하는 경계다. 이 구조 덕분에 unilink는 wrapper의 public API를 안정적으로 유지하면서도, 내부 transport 구현과 생성 방식을 독립적으로 확장할 수 있다.