Go에서의 인터페이스 컴포지션 및 모범 사례
Grace Collins
Solutions Engineer · Leapcell

Go의 인터페이스 접근 방식은 디자인 철학의 초석으로, 개발자가 애플리케이션을 유연성, 모듈성 및 테스트 용이성을 위해 구조화하는 방식에 지대한 영향을 미칩니다. 명시적인 인터페이스 구현에 의존하는 많은 객체 지향 언어와 달리, Go는 암시적 인터페이스와 인터페이스 컴포지션을 위한 강력한 메커니즘을 활용합니다. 이 글에서는 이러한 개념을 심도 있게 탐구하고, 모범 사례와 실제 코드 예제를 통해 효과를 보여줍니다.
암시적 인터페이스의 힘
Go에서 타입은 해당 인터페이스에 선언된 모든 메서드를 제공하는 경우 인터페이스를 구현합니다. implements
키워드는 없으며, 관계는 순전히 구조적입니다. 이러한 암시적 특성은 매우 강력합니다.
- 디커플링: 구체적인 타입은 자신이 만족하는 인터페이스를 알 필요가 없습니다. 이는 컴포넌트 간의 훌륭한 디커플링을 가능하게 합니다.
- Ad-hoc 다형성: 기존 타입 정의를 수정하지 않고 기존 타입에 대해 새 인터페이스를 도입할 수 있습니다. 이는 서드파티 라이브러리와 작업하거나 핵심 구조를 변경하지 않고 새 행위를 추가할 때 매우 중요합니다.
- 유연성: "이것이 무엇인지 묻지 말고, 이것이 무엇을 하는지 물으라"는 원칙을 촉진합니다. 함수는 인터페이스에 대해 작동하며, 기본 구체 타입을 넘어 필요한 동작에만 신경 씁니다.
간단한 Writer
인터페이스를 고려해 보세요.
type Writer interface { Write(p []byte) (n int, err error) }
Write([]byte) (int, error)
메서드를 가진 모든 타입은 자동으로 Writer
인터페이스를 만족합니다. Go의 표준 라이브러리 os.File
, bytes.Buffer
, compress/gzip.Writer
는 모두 우리의 Writer
와 기본적으로 동일한 io.Writer
를 만족합니다.
인터페이스 컴포지션: 더 풍부한 추상화 구축
Go의 가장 우아한 기능 중 하나는 인터페이스를 구성하는 기능입니다. 구조체 내의 구조체를 포함할 수 있듯이, 인터페이스 내의 인터페이스를 포함할 수 있습니다. 이를 통해 더 간단하고 일반적인 추상화로부터 더 복잡하고 구체적인 추상화를 구축할 수 있습니다.
구문은 간단합니다. 새 인터페이스 정의 내에서 해당 이름을 나열하여 간단한 인터페이스를 포함합니다.
// Reader는 기본 Read 메서드를 래핑하는 인터페이스입니다. type Reader interface { Read(p []byte) (n int, err error) } // Closer는 기본 Close 메서드를 래핑하는 인터페이스입니다. type Closer interface { Close() error } // ReadWriter는 기초 Read 및 Write 메서드를 그룹화하는 인터페이스입니다. type ReadWriter interface { Reader Writer // Writer가 위와 같이 정의되었다고 가정 } // ReadCloser는 기초 Read 및 Close 메서드를 그룹화하는 인터페이스입니다. type ReadCloser interface { Reader Closer } // ReadWriteCloser는 기초 Read, Write 및 Close 메서드를 그룹화하는 인터페이스입니다. type ReadWriteCloser interface { Reader Writer Closer }
이 구성은 상속이 아니라 "ReadWriteCloser
는 Reader
가 제공하는 모든 메서드, Writer
가 제공하는 모든 메서드, Closer
가 제공하는 모든 메서드를 제공해야 한다"고 말하는 방식입니다. 구체적인 타입은 Read
, Write
, Close
메서드를 구현하는 경우 ReadWriteCloser
를 만족합니다.
인터페이스 컴포지션의 이점
- 재사용성: 더 작고 집중된 인터페이스(
Reader
,Writer
,Closer
)는 다양한 조합으로 재사용될 수 있습니다. - 명확성: 구성된 인터페이스는 메서드 시그니처를 반복하지 않고 필요한 기능을 명확하게 명시합니다.
- 모듈식 설계: 복잡한 동작을 더 작고 관리하기 쉬운 단위로 분해하도록 장려합니다.
- 테스트 용이성: 특정 동작을 쉽게 모의(mock)할 수 있습니다. 함수가
Reader
를 필요로 한다면,bytes.Buffer
또는Read
만 구현하는 사용자 정의 모의 객체를 전달할 수 있습니다. - 확장성: 동작에 대한 새 조합을 쉽게 형성할 수 있습니다.
인터페이스 디자인 모범 사례
인터페이스는 강력하지만, 그 효과는 디자인 및 사용 방식에 크게 좌우됩니다.
1. 작은 인터페이스가 더 좋습니다. (단일 책임 원칙)
이것은 아마도 가장 중요한 원칙일 것입니다. 인터페이스는 단일하고 응집력 있는 책임을 나타내야 합니다. 인터페이스에 메서드가 너무 많으면 단일 책임 원칙을 위반할 가능성이 높으며, 구현하기 어렵고 구성에 덜 유용합니다.
// 나쁨: 너무 많은 관련 없는 책임 type DataStore interface { Get(id string) ([]byte, error) Save(data []byte) error Connect(addr string) error Close() error Log(msg string) } // 좋음: 더 작은, 집중된 인터페이스로 분할 type DataGetter interface { Get(id string) ([]byte, error) } type DataSaver interface { Save(data []byte) error } type Connector interface { Connect(addr string) error } type Logger interface { Log(msg string) } type Closes interface {} // 참고: 'Closer'는 이미 io 패키지에 있습니다. Close() error } // 이제 필요한 대로 구성합니다: type PersistentStore interface { DataGetter DataSaver Closes }
2. 인터페이스는 소비자에 속합니다.
흔한 함정은 구현하는 타입과 동일한 패키지에 인터페이스를 정의하는 것입니다. 대신, 인터페이스는 일반적으로 소비하는 패키지에 정의되어야 합니다. 이는 디커플링을 강화합니다.
foo
패키지가 User
타입을 제공하고, bar
패키지가 사용자를 저장해야 한다면, bar
는 foo.User
(또는 foo.User
에 대한 어댑터)가 구현할 수 있는 UserStorer
인터페이스를 정의해야 합니다.
// Package user: 구체적인 타입을 정의합니다. package user type User struct { ID string Name string } func NewUser(id, name string) *User { return &User{ID: id, Name: name} } // Package storage: 소비자가 필요로 하는 인터페이스를 정의합니다. package storage import "example.com/myapp/user" type UserStorer interface { // storage가 필요로 하는 것을 정의합니다. StoreUser(u *user.User) error GetUser(id string) (*user.User, error) } // 데이터베이스 패키지에서의 잠재적 구현 package db import ( "database/sql" "example.com/myapp/storage" "example.com/myapp/user" ) type UserDB struct { db *sql.DB } func NewUserDB(database *sql.DB) *UserDB { return &UserDB{db: database} } // 이 타입은 암시적으로 storage.UserStorer를 구현합니다. func (udb *UserDB) StoreUser(u *user.User) error { _, err := udb.db.Exec("INSERT INTO users (id, name) VALUES (?, ?)", u.ID, u.Name) if err != nil { return err } return nil } func (udb *UserDB) GetUser(id string) (*user.User, error) { row := udb.db.QueryRow("SELECT id, name FROM users WHERE id = ?", id) u := &user.User{} err := row.Scan(&u.ID, &u.Name) if err != nil { return nil, err } return u, nil }
이는 user
패키지가 storage
에 대해 아무것도 알 필요가 없도록 보장하며, 독립적인 진화를 촉진합니다.
3. 해당되는 경우 io.Reader
, io.Writer
, io.Closer
를 포함합니다.
Go의 표준 라이브러리 io
패키지는 널리 채택된 기본 인터페이스를 제공합니다. 타입이 I/O 작업을 수행하는 경우, 이러한 인터페이스와 일치시키거나 구성하도록 노력해야 합니다.
import "io" // MyStorage는 읽기 및 쓰기 기능을 모두 제공합니다. type MyStorage struct { // ... } func (ms *MyStorage) Read(p []byte) (n int, err error) { // ... 구현 ... } func (ms *MyStorage) Write(p []byte) (n int, err error) { // ... 구현 ... } func (ms *MyStorage) Close() error { // ... 구현 ... } // 이제 MyStorage는 암시적으로 io.ReadWriteCloser를 구현합니다. func ProcessData(rwc io.ReadWriteCloser) { // io.ReadWriteCloser를 구현하는 모든 타입에 대해 작동할 수 있습니다. // 예: os.File, bytes.Buffer, 네트워크 연결, 사용자 정의 타입 data := make([]byte, 1024) n, err := rwc.Read(data) if err != nil { // 오류 처리 } _, err = rwc.Write(data[:n]) if err != nil { // 오류 처리 } rwc.Close() }
이 상호 운용성은 Go가 컴포넌트 가능한 시스템 구축에 성공한 엄청난 이유입니다.
4. 테스트를 위해 구체적인 구현을 제공합니다.
인터페이스는 테스트를 크게 촉진합니다. 함수가 인터페이스를 받아들이면 테스트 중에 모의(mock) 또는 스텁(stub) 구현을 전달할 수 있습니다.
// datafetcher.go package businesslogic import "fmt" type DataFetcher interface { Fetch(query string) (string, error) } type Service struct { Fetcher DataFetcher // Service는 인터페이스에 의존합니다. } func NewService(fetcher DataFetcher) *Service { return &Service{Fetcher: fetcher} } func (s *Service) ProcessQuery(query string) (string, error) { data, err := s.Fetcher.Fetch(query) if err != nil { return "", fmt.Errorf("failed to fetch data: %w", err) } return "Processed: " + data, nil } // datafetcher_test.go package businesslogic_test import ( "errors" "testing" "example.com/myapp/businesslogic" ) // MockDataFetcher는 테스트를 위해 businesslogic.DataFetcher를 구현합니다. type MockDataFetcher struct { FetchFunc func(query string) (string, error) } func (m *MockDataFetcher) Fetch(query string) (string, error) { if m.FetchFunc != nil { return m.FetchFunc(query) } return "", errors.New("FetchFunc not set") } func TestService_ProcessQuery(t *testing.T) { tests := []struct { name string query string mockFetchFunc func(query string) (string, error) expected string expectErr bool }{ { name: "Successful fetch", query: "test1", mockFetchFunc: func(query string) (string, error) { return "mock_data_" + query, nil }, expected: "Processed: mock_data_test1", expectErr: false, }, { name: "Fetch error", query: "test2", mockFetchFunc: func(query string) (string, error) { return "", errors.New("network error") }, expected: "", expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockFetcher := &MockDataFetcher{FetchFunc: tt.mockFetchFunc} service := businesslogic.NewService(mockFetcher) result, err := service.ProcessQuery(tt.query) if tt.expectErr { if err == nil { t.Errorf("expected an error, but got none") } } else { if err != nil { t.Fatalf("did not expect an error, but got: %v", err) } if result != tt.expected { t.Errorf("expected %q, got %q", tt.expected, result) } } }) } }
이것은 의존성 주입을 인터페이스를 통해 보여주며, Service
를 실제 데이터 소스 없이 단위 테스트 가능하게 만듭니다.
5. 제로 메서드 인터페이스(마커 인터페이스)는 신중하게 고려하십시오.
Go는 메서드가 없는 인터페이스, 종종 "마커 인터페이스"라고 불리는 것을 허용하지만, 일반적으로 권장되지 않습니다. 이는 동작을 강제하지 않으며 주로 타입 어설션 또는 리플렉션에 사용됩니다.
// 비권장: 제로 메서드 인터페이스 type Printable interface{} // 어떤 타입이든 이것을 구현할 수 있습니다. func PrintAnything(item Printable) { // 일반적으로 타입 어설션 또는 리플렉션을 사용합니다. // 예: if v, ok := item.(fmt.Stringer); ok { ... } }
마커 인터페이스가 필요하다고 생각되면 디자인을 다시 고려하십시오. 원하는 동작을 메서드를 통해 인코딩할 수 있습니까? 아니면 리플렉션을 위한 구조체 태그와 같이 목표를 달성하기 위한 더 관용적인 방법이 있습니까?
결론
Go의 인터페이스 시스템은 암시적 구현과 강력한 구성 기능을 통해 유연하고 유지 관리 가능하며 테스트 가능한 애플리케이션 구축의 초석입니다. 모범 사례를 준수하면 — 인터페이스를 작고 집중적으로 유지하고, 소비자의 관점에서 정의하고, 표준 라이브러리 인터페이스를 활용하고, 의존성 주입에 사용 — 개발자는 Go의 타입 시스템의 전체 잠재력을 활용하여 강력하고 확장 가능한 소프트웨어를 만들 수 있습니다. 이러한 원칙을 이해하고 효과적으로 적용하는 것이 관용적인 Go를 마스터하는 열쇠입니다.