CodeGym /행동 /Go SELF /인터페이스의 합성

인터페이스의 합성

Go SELF
레벨 23, 레슨 2
사용 가능

1. 왜 굳이 인터페이스를 “붙여서” 쓰는가

프로그래밍을 막 시작하면 모든 것이 “한곳에” 있기를 바라게 됩니다. 하나의 타입이 모든 것을 할 수 있으면 좋겠다고 생각하죠. 하지만 프로그램이 커질수록 그것은 커피도 만들고 버그도 키우는 만능 기계로 변합니다. 그래서 Go는 보통 작은 행동 단위에서 더 큰 단위로 나아가며, 때로는 이런 조각들을 함께 묶는 것이 편합니다. 상속이 아니라 계약의 합성을 통해서 말이죠.

인터페이스를 계약이라고 생각해 보세요. “당신이 A와 B 메서드를 약속한다면, 우리는 여기서 그것을 사용할 수 있습니다.” 하지만 어떤 상황에서는 “A와 B를 함께” 만족하는 계약이 필요합니다. 바로 이때 인터페이스 합성이 등장합니다. 작은 계약들을 모아 큰 계약을 만들고, 매번 메서드 목록을 손으로 다시 쓰지 않아도 됩니다.

인터페이스의 최소 모델

인터페이스 합성을 이해하려면, 인터페이스가 메모리 내부에서 어떻게 동작하는지, 어떤 nil 함정이 있는지, 그리고 왜 interface != nil이 때때로 “보이는 것과 다른지” 같은 세부 사항까지 파고들 필요는 없습니다. 오늘은 아주 단순하고 실용적인 모델만 있으면 됩니다. 인터페이스는 메서드 시그니처의 집합으로 정의되는 타입이며, 그런 메서드를 가진 어떤 타입이든 “맞는다”는 것입니다.

독자에게 인터페이스의 가장 큰 장점은 동작을 설명한다는 점입니다. 데이터가 아니라 동작을 말하죠. 인터페이스에는 필드가 없고, “상태”를 저장하지도 않습니다. 대신 이렇게 말합니다. “나는 이걸 할 수 있다.” 코드에서 인터페이스 타입의 변수를 만나면, 거의 항상 “내부가 무엇이든 상관없고, 필요한 메서드만 할 수 있으면 된다”는 뜻입니다.

2. 인터페이스의 합성

인터페이스 embedding: 메서드의 “합”

Go의 인터페이스는 다른 인터페이스를 포함(embed)할 수 있으며, 이것은 “새 인터페이스에는 포함된 인터페이스의 모든 메서드와 내 고유 메서드가 들어간다”는 뜻입니다. 즉, 말 그대로 “메서드의 합”입니다. 시그니처를 복사하는 대신, 이미 준비된 계약 조각을 포함시키는 것입니다.

이것은 언어의 공식적이고 일반적인 문법입니다. interface { ... } 안에는 메서드뿐 아니라 다른 인터페이스의 이름도 나열할 수 있으며, 그렇게 해서 계약을 조각들로 조립할 수 있습니다.

가장 직관적으로 보이도록, 아주 작은 예제를 보겠습니다. 깊게 분석하지 않고, 문법의 사실만 확인하는 수준입니다:


package main

type Reader interface {
	Read() string
}

type Writer interface {
	Write(s string)
}

type ReadWriter interface {
	Reader
	Writer
}

func main() {}

여기서 ReadWriter는 Read()와 Write(string) 메서드를 요구합니다. 중요한 점은 이것이 “상속 마법”도 아니고 “클래스가 확장된 것”도 아니라는 것입니다. 그저 계약을 간결하게 적는 방식일 뿐입니다.

embedding 없이 같은 의미를 쓰는 방법

인터페이스 embedding은 때때로 언어의 “초능력”처럼 보이지만, 실제로는 문법적 편의에 가깝습니다. 같은 내용을 손으로 직접 쓸 수도 있습니다. 다만 글자 수가 더 많아질 뿐이죠. 머리가 불필요한 마법을 덧붙이지 않도록, 이 점을 분명히 해두는 것이 좋습니다. 머리는 이런 걸 좋아하니까요. 특히 월요일 아침에는 더 그렇습니다.

같은 예제에서 “embedding 있음”과 “embedding 없음”을 비교해 봅시다. 의미는 같고, 표기만 다릅니다.

package main

type ReadWriter interface {
	Read() string
	Write(s string)
}

func main() {}

그렇다면 왜 embedding이 필요할까요? 실제 프로젝트에서는 작은 인터페이스를 재사용하기 때문입니다. 같은 Reader가 열 군데에서 필요할 수 있습니다. 그리고 Read() string을 열 개의 인터페이스에 일일이 복사해 넣는다면, 어느 날 “계약을 조금 바꾸고 싶다”는 생각이 들었을 때 “10곳을 찾아서 실수하지 말기”라는 즐겁지 않은 퀘스트가 시작됩니다. Embedding은 줄 수만 줄이는 것이 아니라 버그가 생길 이유도 줄여줍니다.

미니 예제: 학습용 태스크 트래커의 TaskStore

인터페이스 합성의 의미를 실제 코드에서 보려면, 학습용 프로젝트를 작은 태스크 트래커라고 상상해 봅시다. 작업(Task)이 있고, 프로그램의 어떤 부분은 그것을 추가할 수 있고, 또 다른 부분은 목록을 보여줄 수 있습니다. 오늘은 계층형 아키텍처를 만들거나 “인터페이스를 어떻게 설계하는 게 맞는지”를 깊게 논의하지 않습니다. 중요한 것은 계약을 합성한다는 사실 그 자체입니다.

작은 인터페이스 두 개를 만들고, 세 번째를 “조합”해 봅시다:

package main

type Task struct {
	Title string
	Done  bool
}

type TaskAdder interface {
	Add(t Task) error
}

type TaskLister interface {
	List() []Task
}

type TaskStore interface {
	TaskAdder
	TaskLister
}

func main() {}

여기서 아이디어는 단순합니다. TaskAdder는 “작업을 추가할 수 있다”는 계약이고, TaskLister는 “작업 목록을 나열할 수 있다”는 계약이며, TaskStore는 “둘 다 할 수 있다”는 의미입니다.

실제 애플리케이션에서는 책임을 분리하는 데 도움이 됩니다. 어떤 함수는 TaskLister만 받으면 됩니다. 그 함수는 추가를 할 필요가 없으니까요. 다른 함수는 TaskAdder만 있으면 됩니다. 그리고 모든 것이 필요한 곳에서는 TaskStore를 사용하면 됩니다.

머릿속에 “레고”처럼 잘 들어가도록 작은 도식도 보겠습니다. “추상적인 추상화”가 아니라요:

flowchart TD
  A["TaskAdder: Add(Task) error"] --> C[TaskStore]
  B["TaskLister: List() []Task"] --> C[TaskStore]
  C --> D[코드, which needs to add and read]

제약: 모순되는 메서드를 “붙일” 수는 없다

인터페이스 합성은 “모든 것을 다 합치자”처럼 들리지만, 언어는 상식이 있습니다. 서로 모순되는 계약을 만들려고 하면 컴파일러가 멈춰 세웁니다. 이것은 괴롭히려는 것이 아니라 보호하기 위한 것입니다. 그렇지 않으면 아무도 구현할 수 없는 인터페이스를 만들거나, 구현 가능 여부가 모호한 상태가 될 수 있습니다.

가장 흔한 원인은 같은 이름의 메서드가 서로 다른 시그니처를 갖는 경우입니다. 초보자에게는 “왜 안 되지? 그냥 두 인터페이스를 합쳤을 뿐인데”처럼 보이지만, 계약의 관점에서는 “Do 메서드가 동시에 int도 받고 string도 받아야 한다”는 요구가 됩니다. 불가능하죠.

package main

type A interface {
	Do(x int) int
}

type B interface {
	Do(x string) string
}

// type Bad interface { A; B } // cannot: conflict of Do name with different signatures

func main() {}

이런 경우 컴파일러는 여러분을 위해 좋은 일을 합니다. 구현할 수 없는 계약을 쓰지 못하게 막아 주는 것이죠.

4. 흔한 실수

실수 1: 인터페이스 embedding을 “상속”으로 받아들이는 것.
type X interface { A; B }를 보면, OOP에서 익숙한 그림이 떠오를 수 있습니다. 마치 X가 A와 B를 “상속”한 것처럼 말이죠. 하지만 여기에는 객체도 없고, 필드도 없고, 기본 클래스도 없습니다. 이것은 단지 “X는 A와 B의 모든 메서드를 요구한다”는 뜻입니다. 머릿속에 “계약”이라는 단어를 두면 혼동이 훨씬 줄어듭니다.

실수 2: 합성 대신 메서드를 손으로 복사해 두고, 나중에 변경에 고생하는 것.
초보자들은 종종 “메서드가 두 개뿐이니 그냥 다시 쓰자”고 생각합니다. 그러다 세 번째, 네 번째가 생기고… 한 달 뒤에는 ReadWriter 하나가 ReadWriter2와 매개변수 하나 차이로 달라져 있습니다. Embedding은 “똑똑한 사람용”이 아니라, 불필요한 수동 리팩터링을 피하고 싶은 사람을 위한 것입니다.

실수 3: 충돌하는 시그니처를 가진 인터페이스를 “붙일” 수 있다고 생각하고 컴파일 에러에 놀라는 것.
두 인터페이스가 같은 이름의 메서드를 요구하지만 매개변수나 반환값이 다르면, 그것은 Go의 이상한 점이 아니라 요구 사항의 논리적 비호환성입니다. 이럴 때는 단순한 습관이 도움이 됩니다. 계약을 합칠 때 마음속으로 “나는 이런 메서드들을 요구한다”라고 소리 내어 말해 보고, 서로 양립 불가능한 요구가 없는지 확인하세요.

실수 4: 혹시 몰라서 거대한 인터페이스를 만드는 것.
인터페이스 합성은 편리하지만, 괴물로 만들 수도 있습니다. 하나의 인터페이스가 다른 다섯 개를 포함하고, 그 다섯 개가 또 다섯 개씩 포함하면, 결국 30개 메서드짜리 계약이 되어 구현하기도 어렵고 이해하기도 더 어렵습니다. 문법은 허용하더라도, 읽기 쉬움을 위해서는 보통 작은 계약을 유지하고 정말 필요한 곳에서만 “이것도, 저것도”를 조합하는 편이 더 낫습니다.

1
과제
Go SELF, 레벨 23, 레슨 2
잠금
정중한 봇
정중한 봇
1
과제
Go SELF, 레벨 23, 레슨 2
잠금
학생 출석증
학생 출석증
1
과제
Go SELF, 레벨 23, 레슨 2
잠금
메모 저장소
메모 저장소
1
과제
Go SELF, 레벨 23, 레슨 2
잠금
관리자 패널
관리자 패널
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION