본문 바로가기
프로그래밍/Golang

Golang Interface 최적화: 인터페이스 불필요한 추상화 피하고 테스트 용이성 높이기

by slowin 2024. 7. 27.

개요

Go 언어의 인터페이스는 다른 언어들과 달리 독특한 특성을 가지고 있습니다. Golang interface 사용시 주의할점을 알아 보겠습니다.

1. Golang 인터페이스 암묵적 구현의 개념

Go에서는 타입이 인터페이스에 정의된 모든 메서드를 구현하기만 하면, 해당 타입은 자동으로 그 인터페이스를 만족합니다. 이를 "암묵적 구현"이라고 합니다. 이는 다른 언어에서 흔히 볼 수 있는 "implements" 키워드 같은 명시적 선언이 필요 없는 이유입니다.

2. Golang Wiki 설명

링크

Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values.
The implementing package should return concrete (usually pointer or struct) types: that way, new methods can be added to implementations without requiring extensive refactoring.

Do not define interfaces on the implementor side of an API “for mocking”; instead, design the API so that it can be tested using the public API of the real implementation.

Do not define interfaces before they are used: without a realistic example of usage, it is too difficult to see whether an interface is even necessary, let alone what methods it ought to contain.

wiki정의된 내용을 바탕으로 예시와함께 자세히 설명해보겠습니다.

1. 인터페이스 위치 : 사용하는 패키지에서 정의하기

  • Go 인터페이스는 일반적으로 인터페이스 타입의 값을 사용하는 패키지에 속해야 합니다.
  • 인터페이스를 구현하는 패키지가 아닌, 사용하는 패키지에 정의해야 합니다.

1.1. 잘못된 방식 (구현하는 패키지에 인터페이스 정의)

1.1.1 구현체에서 인터페이스 정의

// storage/storage.go
package storage

// Storage 인터페이스를 구현 패키지에 정의 (이렇게 하지 마세요!)
type Storage interface {
    Save(data []byte) error
    Load() ([]byte, error)
}

type FileStorage struct {
    filename string
}

func (fs *FileStorage) Save(data []byte) error {
    // 파일에 데이터 저장 구현
    return nil
}

func (fs *FileStorage) Load() ([]byte, error) {
    // 파일에서 데이터 로드 구현
    return nil, nil
}

func NewFileStorage(filename string) Storage {
    return &FileStorage{filename: filename}
}
// user/user.go
package user

import "myapp/storage"

type UserService struct {
    storage storage.Storage
}

func NewUserService(s storage.Storage) *UserService {
    return &UserService{storage: s}
}

func (us *UserService) SaveUser(userData []byte) error {
    return us.storage.Save(userData)
}

사용

type MockStorage struct {
	SaveFunc func(data []byte) error
}

func (ms *MockStorage) Save(data []byte) error {
	return ms.SaveFunc(data)
}

func (ms *MockStorage) Load() ([]byte, error) {
	// logic ...
	return nil, nil
}

func TestSaveUser(t *testing.T) {
	mockStorage := &MockStorage{
		SaveFunc: func(data []byte) error {
			// 테스트를 위한 저장 로직
			return nil
		},
	}

	service := NewUserService(mockStorage)
	
	//... TEST
}

이 방식의 문제점:

  1. storage 패키지가 인터페이스를 정의하고 있어, 사용자가 필요한 메서드만 정의할 수 없습니다.
  2. Storage 인터페이스에 새로운 메서드를 추가하면, 모든 구현체를 수정해야 합니다.
  3. 테스트를 위해 모의 객체를 만들 때, 불필요한 메서드도 구현해야 할 수 있습니다.

테스트 코드로 알아보는 문제점

type MockStorage struct {
	SaveFunc func(data []byte) error
}

func (ms *MockStorage) Save(data []byte) error {
	return ms.SaveFunc(data)
}

//---------- 불필요하게 메서드를 정의 해주어야합니다 -------------
func (ms *MockStorage) Load() ([]byte, error) { 
	// logic ...
	return nil, nil
}
//---------- 불필요하게 메서드를 정의 해주어야합니다 -------------

func TestSaveUser(t *testing.T) {
	mockStorage := &MockStorage{
		SaveFunc: func(data []byte) error {
			// 테스트를 위한 저장 로직
			return nil
		},
	}

	service := NewUserService(mockStorage)
	
	//... TEST
}

1.1.2. 올바른 방식 (사용하는 패키지에 인터페이스 정의)

// storage/storage.go
package storage

type FileStorage struct {
    filename string
}

func (fs *FileStorage) Save(data []byte) error {
    // 파일에 데이터 저장 구현
    return nil
}

func (fs *FileStorage) Load() ([]byte, error) {
    // 파일에서 데이터 로드 구현
    return nil, nil
}

func NewFileStorage(filename string) *FileStorage {
    return &FileStorage{filename: filename}
}
// user/user.go
package user

import "myapp/storage"

// Storage 인터페이스를 사용하는 패키지에 정의
type Storage interface {
    Save(data []byte) error
}

type UserService struct {
    storage Storage
}

func NewUserService(s Storage) *UserService {
    return &UserService{storage: s}
}

func (us *UserService) SaveUser(userData []byte) error {
    return us.storage.Save(userData)
}

테스트 코드

// user/user_test.go
package user

import "testing"

type MockStorage struct {
    SaveFunc func(data []byte) error
}

func (ms *MockStorage) Save(data []byte) error {
    return ms.SaveFunc(data)
}

func TestSaveUser(t *testing.T) {
    mockStorage := &MockStorage{
        SaveFunc: func(data []byte) error {
            // 테스트를 위한 저장 로직
            return nil
        },
    }

    service := NewUserService(mockStorage)
    err := service.SaveUser([]byte("test data"))

    // 테스트 검증 로직
}

이 방식의 장점:

  1. user 패키지는 자신이 필요로 하는 메서드만 포함하는 Storage 인터페이스를 정의합니다.
  2. storage 패키지는 구체적인 구현만 제공하므로, 새로운 메서드를 자유롭게 추가할 수 있습니다.
  3. 테스트 시 필요한 메서드만 구현한 모의 객체를 쉽게 만들 수 있습니다.
  4. 다른 패키지에서 FileStorage를 사용할 때, 자신들의 요구사항에 맞는 인터페이스를 별도로 정의할 수 있습니다.

2. 구체적 타입 반환

  • 구현하는 패키지는 구체적인 타입(주로 포인터나 구조체)을 반환해야 합니다.
  • 이렇게 하면 광범위한 리팩토링 없이 구현에 새로운 메서드를 추가할 수 있습니다.

2.1. 인터페이스를 반환하는 방식 (피해야 할 방식)

// logger/logger.go
package logger

type Logger interface {
    Log(message string)
}

type fileLogger struct {
    filename string
}

func (fl *fileLogger) Log(message string) {
    // 파일에 로그 메시지 작성 구현
}

// NewFileLogger는 Logger 인터페이스를 반환합니다 (좋은 방법이 아님)
func NewFileLogger(filename string) Logger {
    return &fileLogger{filename: filename}
}
// app/app.go
package app

import "myapp/logger"

type App struct {
    logger logger.Logger
}

func NewApp(l logger.Logger) *App {
    return &App{logger: l}
}

func (a *App) Run() {
    a.logger.Log("Application started")
    // 애플리케이션 로직...
}

이 방식의 문제점:

  1. fileLogger에 새로운 메서드(예: SetLogLevel)를 추가하려면, Logger 인터페이스도 수정해야 합니다.
  2. Logger 인터페이스 수정 시, 이를 사용하는 모든 코드를 수정해야 할 수 있습니다.
  3. fileLogger의 구체적인 기능을 사용하고 싶을 때 타입 단언(type assertion)이 필요합니다.

2.2. 구체적 타입을 반환하는 방식 (권장 방식)

// logger/logger.go
package logger

type FileLogger struct {
    filename string
}

func (fl *FileLogger) Log(message string) {
    // 파일에 로그 메시지 작성 구현
}

// NewFileLogger는 구체적인 타입 *FileLogger를 반환합니다
func NewFileLogger(filename string) *FileLogger {
    return &FileLogger{filename: filename}
}

// 나중에 새로운 메서드를 쉽게 추가할 수 있습니다
func (fl *FileLogger) SetLogLevel(level string) {
    // 로그 레벨 설정 구현
}
// app/app.go
package app

import "myapp/logger"

type App struct {
    logger *logger.FileLogger
}

func NewApp(l *logger.FileLogger) *App {
    return &App{logger: l}
}

func (a *App) Run() {
    a.logger.Log("Application started")
    a.logger.SetLogLevel("DEBUG")  // 새로 추가된 메서드 사용 가능
    // 애플리케이션 로직...
}

이 방식의 장점:

  1. FileLogger에 새로운 메서드를 자유롭게 추가할 수 있습니다.
  2. 새 메서드 추가 시 기존 코드를 수정할 필요가 없습니다.
  3. 구체적인 타입의 모든 기능을 직접 사용할 수 있습니다.
  4. 필요한 경우, 사용하는 쪽에서 인터페이스를 정의할 수 있습니다.

3. 나머지 주의사항

  1. 모킹을 위한 인터페이스 정의 지양
    • API의 구현자 측에서 "모킹을 위해" 인터페이스를 정의하지 마세요.
    • 대신, 실제 구현의 공개 API를 사용하여 테스트할 수 있도록 API를 설계하세요.
  2. 사용 전 인터페이스 정의 지양
    • 인터페이스를 사용하기 전에 미리 정의하지 마세요.
    • 실제 사용 예시 없이는 인터페이스의 필요성이나 포함해야 할 메서드를 판단하기 어렵습니다.

정리

Go 언어에서 인터페이스를 효과적으로 사용하기 위한 핵심 원칙들을 살펴보았습니다.

  • 암묵적 구현: Go의 인터페이스는 명시적 선언 없이 메서드 구현만으로 만족됩니다.
  • 인터페이스 위치: 인터페이스는 사용하는 패키지에서 정의해야 합니다. 이를 통해 필요한 메서드만 포함하는 좁은 인터페이스를 만들 수 있습니다.
  • 구체적 타입 반환: 구현 패키지는 인터페이스가 아닌 구체적 타입을 반환해야 합니다. 이는 코드의 유연성을 높이고 향후 확장성을 보장합니다.

참고

관련

Golang Interface - 포인터와 값 타입의 개념 및 활용

Golang Interface - 소프트웨어 인터페이스란?

Golang Interface - OOP에서의 인터페이스 개념, 예시 및 장점

Golang Interface - 암시적 인터페이스: 코드 유연성과 재사용성

Golang Interface - 빈 인터페이스 (interface{})

Golang Interface 최적화: 인터페이스 불필요한 추상화 피하고 테스트 용이성 높이기