<-home

인터페이스 기반 설계의 중요성과 go언어를 이용한 구현 방법


왜 우리는 인터페이스 기반으로 설계해야 할까?

우리가 사용하고 있는 여러 서비스들을 포함한 일반적인 프로그램은 다양한 모듈로 구성되어 있다. 그리고 이러한 모듈간의 유연한 연결을 위하여 인터페이스 기반의 설계를 권장한다.

넷플릭스를 예로 들어보자. 넷플릭스라는 하나의 서비스안에는 비디오 스트리밍 뿐만 아니라 회원관리, 구독권 결재, 추천, 광고 등 수많은 세부 서비스들이 존재한다.

그리고 당연히, 이 수많은 서비스들을 개발하기 위한 수많은 팀들이 존재한다. 심지어 소규모의 스타트업에서도 (일반적으로는) 각 기능(모듈)을 개발하기 위한 팀, 아니 적어도 사람이 별도로 구성된다.

비록 아닌 곳들 또한 많이 있지만, 오늘 하고싶은 이야기의 주제와는 조금 벗어나기 때문에 넘어간다.

다시 돌아와서 결국 수많은 기능들이 개발됨으로써 넷플릭스라는 하나의 서비스가 사용자에게 제공되는 것인데, 이 과정에서 수많은 팀들이 각자의 계획과 일정에 맞게 개발을 진행하기 때문에 의사소통과 협업에 문제가 발생하기 마련이다.

이를 해결하기 위한 가장 좋은 방법이 인터페이스 기반으로 설계하고 개발하는 것이다. 간단하게 예시로 표현해보면 아래와 같다.

  1. 회원관리를 개발하는 A팀과 결제시스템을 개발하는 B팀이 있다. A팀에서 개발하는 기능 중 회원권 구매 기능이 있고, 이 기능은 B팀에서 개발중인 PG 연동 기능을 포함해야 한다.
  2. B팀에서 개발중인 다른 기능들이 있기 때문에, PG 연동 기능을 바로 개발하기 어렵다. 그렇다고 A팀은 B팀의 업무가 끝날때까지 기다리고만 있을수는 없다.
  3. B팀은 A팀에게 인터페이스 형태의 PG 연동 기능을 제공한다. 실제 동작하는 부분은 개발하지 않았지만 어떤 형태로 만들어질지에 대한 추상적인 형태는 제공할 수 있다.
  4. A팀은 B팀으로부터 제공받은 인터페이스를 기반으로 Mock 기반의 구현체를 만들고, 이를 이용하여 회원권 구매 기능 개발을 진행한다.
  5. 추후 B팀에서 PG 연동 기능 개발이 완료되면, 기존의 Mock 기반 구현체를 실제 동작이 포함된 구현체로 교체한다.

Mock : 실제 구현체와 통신하지 않고 얻을 수 있는 가짜 또는 샘플데이터

이렇게 인터페이스 기반으로 설계하고 개발하면 각 모듈간 의존성을 최소화시키고 독립적인 개발이 가능하다는 장점이 있다.


인터페이스 기반 설계의 어려움

하지만 인터페이스 기반의 설계는 쉽지 않다. 가장 큰 이유로 인터페이스는 결국 명확한 업무 설계를 기반으로 만들어지기 때문이다. 대부분의 개발자의 경우 머리보다 손이 먼저나가는 경우가 많은데, 이는 업무에 대한 명확한 분석이 먼저가 아니라 개발 과정에서 업무의 분석이 이루어지기 마련이다.

이렇게 되면, 위에서 설명한 A팀과 B팀의 예시로 볼 때, A팀이 B팀에서 제공받은 인터페이스를 기반으로 개발한 내용들이 향후에 변경될 요지가 존재한다는 것이고 이는 인터페이스 기반의 설계와 개발로부터 얻을 수 있는 장점을 완전히 벗어나게 된다. 그러므로 실제 개발을 시작하는 시간은 좀 늦어질지라도, 업무에 대한 많은 고민과 분석을 통해 인터페이스를 먼저 설계하고 이를 기반으로 구현체를 개발하는 것이 무엇보다 중요하다.


Go언어를 이용한 인터페이스 설계

Go언어는 다른 언어와 비교하여 적극적으로 인터페이스 형태의 설계를 권장하고 있고, 실제로 인터페이스 방식으로 설계하기가 매우 편리하다. 별도의 인터페이스를 위한 파일을 만들 필요도 없으며, 해당 인터페이스를 사용하겠다는 명시적인 선언도 필요없다. 단지 인터페이스의 모든 기능을 모듈 내에 포함시키면 된다.

Go언어에서 인터페이스를 설계할 경우 권장하는 사항이 하나 있는데, 인터페이스 이름을 동작을 수행하는 객체 형태로 짓는 것이다. 예를 들어 어떠한 결과를 출력하기 위한 인터페이스의 경우 Printer, 결과를 파일로 저장하는 경우 Writer와 같이 뒤에 -er 접미사를 붙여서 작성하는 것을 권장한다.

예시

서명된 이더리움 트랜잭션을 네트워크에 전송하는 인터페이스를 만들어보자.

type Sender interface {
    SendTransaction(ctx context.Context, from common.Address, to *common.Address, value *big.Int, data []byte) error
}

이더리움으로 트랜잭션을 전송하기 위한 두가지 방법이 있다.

  • 이더리움 네트워크로 전송된 트랜잭션이 블록에 마이닝될때까지 기다렸다가 결과를 반환해주는 동기 처리방식
  • 이더리움 네트워크로 트랜잭션을 전송하고 결과는 반환받지 않는 비동기 처리방식

이더리움 네트워크로 트랜잭션을 전송하는 것은 동일하기 때문에, 위에서 선언한 Sender 인터페이스를 사용할 수 있다.

type TxManager struct {
    // TxManager라는 구현체가 Sender 인터페이스를 사용할 것이라고 선언했다. 
    sender Sender 
    ...
}

// Sender 인터페이스를 가진 구현체를 만든다.
func NewTxManager(sender Sender) *TxManager {
    &TxManager{
        sender: sender,
    }
}

동기 처리 방식을 제공하는 모듈과 비동기 처리 방식을 제공하는 모듈은 각각 아래와 같다. 둘다 Sender 인터페이스에 포함된 기능을 포함하고 있다.

// SyncSender
func (t *SyncSender) NewSyncSender() *SyncSender {}
func (t *SyncSender) SendTransaction(ctx context.Context, from common.Address, to *common.Address, value *big.Int, data []byte) error

// AsyncSender
func (t *AsyncSender) NewAsyncSender() *AsyncSender {}
func (t *AsyncSender) SendTransaction(ctx context.Context, from common.Address, to *common.Address, value *big.Int, data []byte) error
...

인터페이스를 실제 구현체에 주입할 때는 아래와 같이 진행하면 된다.

func main() {
    
    syncSender := NewSyncSender()
    asyncSender := NewAsyncSender()
    
    // 동기 방식
    txm := NewTxManager(syncSender) 
    // 비동기 방식
    txm := NewTxManager(asyncSender)
}



참고