Go 인터페이스 완전 정복: 기초부터 실전 활용까지

sangjinsu·2025년 3월 22일

Go 개발자들이 왜 인터페이스에 그렇게 열광하는지 궁금해본 적 있나요? 전통적인 객체지향 언어들과는 달리, Go는 인터페이스에 대해 독특하면서도 강력하고 유연한 접근 방식을 취합니다. 지금부터 Go 인터페이스가 특별한 이유와, 이를 활용해 더 나은 코드를 작성하는 방법을 살펴보겠습니다.

Go 인터페이스의 특별함은 무엇일까?

Go에서 인터페이스는 암시적(implicit) 입니다. 즉, 어떤 타입이 인터페이스를 구현한다고 명시적으로 선언할 필요가 없고, 그저 인터페이스에 정의된 메서드들을 갖고 있기만 하면 됩니다. 이 단순한 개념이 놀라울 만큼 유연하고 유지보수가 쉬운 코드를 가능하게 만듭니다.

type User struct {
    Name string
    Age  int
}

// String() method here satisfies fmt.Stringer implicitly
func (u User) String() string {
    return fmt.Sprintf("%s (%d years old)", u.Name, u.Age)
}

func main() {
    user := User{Name: "Alice", Age: 28}
    fmt.Println(user) // Uses fmt.Stringer's String() method
}

작은 인터페이스의 힘

Go의 표준 라이브러리는 작고 명확한 목적을 가진 인터페이스들로 구성되어 있습니다. 왜 이것이 강력한지 살펴볼까요?

// function works with any type that has a Read() method (io.Reader)
func processData(r io.Reader) error {
    data := make([]byte, 1024)
    _, err := r.Read(data)
    return err
}

func main() {
    // below types have a Read method, so they implement io.Reader    

    // files
    file, _ := os.Open("data.txt")
    processData(file)

    // network connections
    conn, _ := net.Dial("tcp", "example.com:80")
    processData(conn)

    // in-memory buffers
    buf := bytes.NewBuffer([]byte("Hello"))
    processData(buf)
}

조합 가능한 인터페이스 만들기

Go의 강점 중 하나는 인터페이스 조합(interface composition) 입니다. 여러 개의 작은 인터페이스를 결합해 더 복잡한 동작을 표현할 수 있죠. 직접 살펴보겠습니다:

type DataProcessor interface {
    Process(data []byte) error
}

type DataValidator interface {
    Validate(data []byte) error
}

type DataHandler interface {
    DataProcessor
    DataValidator
}

type JSONHandler struct{}

func (j JSONHandler) Process(data []byte) error {
    fmt.Println("Processing JSON data...")
    return nil
}

func (j JSONHandler) Validate(data []byte) error {
    fmt.Println("Validating JSON format...")
    return nil
}

인터페이스 활용 베스트 프랙티스: 실전 예제

인터페이스를 활용해 유연한 로깅 시스템을 만들어 봅시다. 실무에서 어떻게 인터페이스가 강력하게 사용될 수 있는지 확인해볼 수 있는 좋은 예제입니다:

type Logger interface {
    Log(level string, message string)
}

type FileLogger struct {
    file *os.File
}

func (f FileLogger) Log(level, message string) {
    fmt.Fprintf(f.file, "[%s] %s: %s\n", 
        time.Now().Format(time.RFC3339),
        level,
        message)
}

type ConsoleLogger struct {
    prefix string
}

func (c ConsoleLogger) Log(level, message string) {
    fmt.Printf("%s [%s] %s\n", 
        c.prefix,
        level,
        message)
}

// service using the logger
type UserService struct {
    logger Logger
}

func (s UserService) CreateUser(name string) error {
    s.logger.Log("INFO", fmt.Sprintf("Creating user: %s", name))
    return nil
}

func main() {
    fileLogger := FileLogger{file: createLogFile()}
    userService := UserService{logger: fileLogger}
    userService.CreateUser("Alice")
    
    consoleLogger := ConsoleLogger{prefix: "USER-SERVICE"}
    userService.logger = consoleLogger
    userService.CreateUser("Bob")
}

빈 인터페이스와 타입 단언

Go의 빈 인터페이스(interface{} 또는 Go 1.18부터는 any)는 어떤 값이든 담을 수 있는 유연한 타입입니다. 하지만 이를 사용할 때는 신중한 타입 단언(type assertion) 이 필요합니다:

func printAny(v any) {
    switch v := v.(type) {
    case string:
        fmt.Printf("String: %s\n", v)
    case int:
        fmt.Printf("Integer: %d\n", v)
    case bool:
        fmt.Printf("Boolean: %v\n", v)
    default:
        fmt.Printf("Unknown type: %T\n", v)
    }
    
    if str, ok := v.(string); ok {
        fmt.Printf("Got a string: %s\n", str)
    }
}

func main() {
    printAny("hello")
    printAny(42)
    printAny(true)
}

인터페이스를 활용한 테스트

인터페이스 덕분에 테스트가 훨씬 수월해집니다. 어떻게 가능한지 함께 살펴볼까요?

type User struct {
    ID   string
    Name string
}

type UserRepository interface {
    GetUser(id string) (*User, error)
    SaveUser(user *User) error
}

type UserService struct {
    repo UserRepository
}

func (s UserService) GetUserByID(id string) (*User, error) {
    return s.repo.GetUser(id)
}

// Mock implementation
type MockUserRepo struct {
    users map[string]*User
}

func (m *MockUserRepo) GetUser(id string) (*User, error) {
    user, exists := m.users[id]
    if !exists {
        return nil, fmt.Errorf("user not found")
    }
    return user, nil
}

func (m *MockUserRepo) SaveUser(user *User) error {
    m.users[user.ID] = user
    return nil
}

func TestUserService(t *testing.T) {
    mockRepo := &MockUserRepo{
        users: map[string]*User{
            "1": {ID: "1", Name: "Test User"},
        },
    }
    
    service := UserService{repo: mockRepo}
    user, err := service.GetUserByID("1")
    
    if err != nil {
        t.Fatal("Unexpected error:", err)
    }
    if user.Name != "Test User" {
        t.Errorf("Expected 'Test User', got %s", user.Name)
    }
}

흔한 인터페이스 실수와 예방법

인터페이스 오염(Interface Pollution)

인터페이스를 사용할 때 자주 발생하는 실수 중 하나는 불필요하게 크고 복잡한 인터페이스를 만드는 것입니다. 이를 "인터페이스 오염"이라고 부릅니다.

❌ 문제점: 하나의 인터페이스에 너무 많은 기능을 넣으면, 그 인터페이스를 구현해야 하는 타입들도 불필요한 책임을 떠안게 됩니다.

✅ 해결 방법: 작고 역할이 명확한 인터페이스로 나누고, 필요한 경우 이를 조합하여 사용하세요.
Go의 설계 철학은 “작은 인터페이스부터 시작하라(Small interfaces are the key)”는 점을 기억하세요.

// DON'T: Too many methods
type BigInterface interface {
    DoThis()
    DoThat()
    DoSomethingElse()
    // ... bla bla
}

// DO: Break it down
type Doer interface {
    DoThis()
    DoThat()
}

type SomethingElseDoer interface {
    DoSomethingElse()
}

인터페이스를 반환할 때의 주의점

함수에서 인터페이스를 반환하는 것은 유연한 설계를 가능하게 하지만, 몇 가지 주의할 점이 있습니다.

✅ 장점: 내부 구현을 감추고, 호출자에게는 필요한 동작만 노출할 수 있습니다. 이는 의존성을 줄이고 테스트를 쉽게 만듭니다.

⚠️ 주의사항:

  • 반환 타입이 인터페이스일 경우, 구체 타입의 기능에 의존하면 안 됩니다.

  • 값 대신 포인터를 인터페이스로 반환할 때, 값 복사나 nil 체크 등에서 예기치 않은 동작이 발생할 수 있습니다.

// DON'T: Return interfaces
func NewWriter() io.Writer {
    return &bytes.Buffer{}
}

// DO: Return concrete type
func NewWriter() *bytes.Buffer {
    return &bytes.Buffer{}
}

베스트 프랙티스 요약

  1. 인터페이스는 작고 명확하게 유지하라

  2. 클라이언트가 인터페이스를 정의하게 하라 (인터페이스 분리 원칙)

  3. 조합을 통해 더 큰 인터페이스를 구성하라

  4. 테스트와 유연성을 위해 인터페이스를 활용하라

  5. 인터페이스는 인자로 받고, 반환값은 구체 타입으로 하라

  6. 단 하나의 타입만 만족하는 인터페이스는 외부로 노출하지 마라

결론

Go의 인터페이스 시스템은 익숙한 객체지향 언어와는 다르지만, 그 차이점이 바로 Go만의 강력함입니다.
암시적 인터페이스와 조합의 개념을 잘 활용하면, 더 유연하고, 테스트 가능하며, 유지보수가 쉬운 코드를 작성할 수 있습니다.

기억하세요

  • 인터페이스는 작고 명확하게

  • 행동(behavior) 이 인터페이스 설계를 이끈다

  • 상속보다 조합을 우선시하라

  • 테스트가 쉬워진다

  • 항상 가장 작은 인터페이스를 선호하라



🔍 1. 작은 인터페이스 철학의 배경

Go의 공동 설계자인 Rob Pike는 “인터페이스는 두세 개의 메서드만 가지는 것이 가장 이상적이다”라고 말한 바 있습니다.

  • 재사용성이 높아집니다: 작고 단순한 인터페이스일수록 다양한 타입에서 손쉽게 구현할 수 있고, 조합도 유연하게 할 수 있습니다.

  • 테스트하기가 훨씬 쉬워집니다: io.Reader, io.Writer처럼 작은 인터페이스는 테스트 환경에서 특히 자주 활용됩니다.

🧩 2. Go의 인터페이스는 Duck Typing 기반입니다

Go의 인터페이스는 전통적인 객체지향 언어처럼 implements를 명시하지 않습니다.
즉, 어떤 타입이 특정 메서드들을 가지고만 있다면, 해당 인터페이스를 자동으로 만족하는 것으로 간주합니다.
이는 낮은 결합도(loose coupling) 를 가능하게 하여 유연한 코드 설계에 큰 장점이 됩니다.

🧪 3. 테스트를 더 쉽게 하는 인터페이스 활용법
Go에서는 구현체가 아닌 인터페이스에 의존하는 방식으로 코드를 구성하면 테스트가 훨씬 쉬워집니다.

예를 들어 다음과 같이 로깅 인터페이스를 만들 수 있습니다:

type Logger interface {
    Log(message string)
}

이 인터페이스를 실제 구현체인 FileLogger 또는 테스트용 FakeLogger로 대체하면서 동일한 인터페이스로 유닛 테스트를 간편하게 수행하실 수 있습니다.

⚠️ 4. 빈 인터페이스(interface{} 또는 any)는 신중하게 사용하세요

Go에서 interface{}는 어떤 타입이든 받을 수 있기 때문에 유용하지만,
너무 자주 사용하면 타입 안정성(type safety) 을 해치고, 복잡한 타입 단언을 유발할 수 있습니다.

정말 다양한 타입을 받아야 하는 경우(예: JSON 파싱, 일반 유틸 함수 등)가 아니라면
구체적인 인터페이스를 설계하시는 것을 권장드립니다.

📚 5. 인터페이스 설계 시 자주 하는 실수

예측하지 못하는 요구사항을 대비해 과도하게 많은 메서드를 넣는 것
실제론 한 번만 사용하는데 인터페이스를 만들고 외부에 노출하는 것
타입을 먼저 만들고 인터페이스를 나중에 맞추는 것

→ 인터페이스부터 먼저 설계하고, 그에 맞는 타입을 구성하는 방식이 유지보수에 더 효과적입니다.

profile
개발에 집중할 수 있는, 이슈를 줄이는 환경을 만들고 싶습니다.

0개의 댓글