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)
}
}
인터페이스를 사용할 때 자주 발생하는 실수 중 하나는 불필요하게 크고 복잡한 인터페이스를 만드는 것입니다. 이를 "인터페이스 오염"이라고 부릅니다.
❌ 문제점: 하나의 인터페이스에 너무 많은 기능을 넣으면, 그 인터페이스를 구현해야 하는 타입들도 불필요한 책임을 떠안게 됩니다.
✅ 해결 방법: 작고 역할이 명확한 인터페이스로 나누고, 필요한 경우 이를 조합하여 사용하세요.
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{}
}
인터페이스는 작고 명확하게 유지하라
클라이언트가 인터페이스를 정의하게 하라 (인터페이스 분리 원칙)
조합을 통해 더 큰 인터페이스를 구성하라
테스트와 유연성을 위해 인터페이스를 활용하라
인터페이스는 인자로 받고, 반환값은 구체 타입으로 하라
단 하나의 타입만 만족하는 인터페이스는 외부로 노출하지 마라
Go의 인터페이스 시스템은 익숙한 객체지향 언어와는 다르지만, 그 차이점이 바로 Go만의 강력함입니다.
암시적 인터페이스와 조합의 개념을 잘 활용하면, 더 유연하고, 테스트 가능하며, 유지보수가 쉬운 코드를 작성할 수 있습니다.
인터페이스는 작고 명확하게
행동(behavior) 이 인터페이스 설계를 이끈다
상속보다 조합을 우선시하라
테스트가 쉬워진다
항상 가장 작은 인터페이스를 선호하라
Go의 공동 설계자인 Rob Pike는 “인터페이스는 두세 개의 메서드만 가지는 것이 가장 이상적이다”라고 말한 바 있습니다.
재사용성이 높아집니다: 작고 단순한 인터페이스일수록 다양한 타입에서 손쉽게 구현할 수 있고, 조합도 유연하게 할 수 있습니다.
테스트하기가 훨씬 쉬워집니다: io.Reader, io.Writer처럼 작은 인터페이스는 테스트 환경에서 특히 자주 활용됩니다.
Go의 인터페이스는 전통적인 객체지향 언어처럼 implements를 명시하지 않습니다.
즉, 어떤 타입이 특정 메서드들을 가지고만 있다면, 해당 인터페이스를 자동으로 만족하는 것으로 간주합니다.
이는 낮은 결합도(loose coupling) 를 가능하게 하여 유연한 코드 설계에 큰 장점이 됩니다.
🧪 3. 테스트를 더 쉽게 하는 인터페이스 활용법
Go에서는 구현체가 아닌 인터페이스에 의존하는 방식으로 코드를 구성하면 테스트가 훨씬 쉬워집니다.
예를 들어 다음과 같이 로깅 인터페이스를 만들 수 있습니다:
type Logger interface {
Log(message string)
}
이 인터페이스를 실제 구현체인 FileLogger 또는 테스트용 FakeLogger로 대체하면서 동일한 인터페이스로 유닛 테스트를 간편하게 수행하실 수 있습니다.
Go에서 interface{}는 어떤 타입이든 받을 수 있기 때문에 유용하지만,
너무 자주 사용하면 타입 안정성(type safety) 을 해치고, 복잡한 타입 단언을 유발할 수 있습니다.
정말 다양한 타입을 받아야 하는 경우(예: JSON 파싱, 일반 유틸 함수 등)가 아니라면
구체적인 인터페이스를 설계하시는 것을 권장드립니다.
예측하지 못하는 요구사항을 대비해 과도하게 많은 메서드를 넣는 것
실제론 한 번만 사용하는데 인터페이스를 만들고 외부에 노출하는 것
타입을 먼저 만들고 인터페이스를 나중에 맞추는 것
→ 인터페이스부터 먼저 설계하고, 그에 맞는 타입을 구성하는 방식이 유지보수에 더 효과적입니다.