Backend

Go 인터페이스 설계 원칙 - Accept Interfaces, Return Structs

2026-01-026 min read

Go 인터페이스 설계 원칙

개요

Go의 "Accept interfaces, return structs" 원칙은 유연하고 테스트 가능한 코드를 작성하는 핵심 가이드라인입니다. 이 글에서는 이 원칙의 철학과 실전 적용법을 다룹니다.

Accept Interfaces, Return Structs

원칙 설명

// ✅ 좋은 예: 인터페이스를 받고, 구체 타입을 반환
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// ❌ 나쁜 예: 구체 타입을 받음
func NewUserService(repo *PostgresUserRepo) *UserService {
    return &UserService{repo: repo}
}

왜 이렇게 해야 할까?

관점인터페이스 수용구체 타입 반환
유연성어떤 구현체든 주입 가능호출자가 구체 메서드 접근 가능
테스트Mock 쉽게 주입타입 단언 없이 사용
결합도낮음 (구현에 독립적)API 명확성

실전 예시

// 인터페이스 정의 (소비자 측에서 정의)
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
    Save(ctx context.Context, user *User) error
}

// 구체 구현
type PostgresUserRepo struct {
    db *sql.DB
}

func NewPostgresUserRepo(db *sql.DB) *PostgresUserRepo {
    return &PostgresUserRepo{db: db}
}

func (r *PostgresUserRepo) FindByID(ctx context.Context, id string) (*User, error) {
    // 구현
}

func (r *PostgresUserRepo) Save(ctx context.Context, user *User) error {
    // 구현
}

// 추가 메서드 (인터페이스에 없음)
func (r *PostgresUserRepo) BulkInsert(ctx context.Context, users []*User) error {
    // PostgreSQL 전용 최적화
}

// 서비스 - 인터페이스를 받음
type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

인터페이스 정의 위치

소비자 측에서 정의 (권장)

Go Wiki에서는 다음과 같이 권장합니다:

"Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values."

// ❌ 구현자가 인터페이스 정의 (Java 스타일)
// repository/interfaces.go
type UserRepository interface { ... }

// repository/postgres.go
type PostgresUserRepo struct { ... }

// ✅ 소비자가 인터페이스 정의 (Go 스타일)
// service/user.go
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
}

type UserService struct {
    repo UserRepository
}

[!IMPORTANT] Go의 암묵적 인터페이스 구현 덕분에, 소비자가 필요한 메서드만 정의할 수 있습니다.

작은 인터페이스 선호

Rob Pike의 Go Proverbs에서:

"The bigger the interface, the weaker the abstraction."

// ❌ 너무 큰 인터페이스
type UserRepository interface {
    FindByID(id string) (*User, error)
    FindByEmail(email string) (*User, error)
    FindAll() ([]*User, error)
    Save(user *User) error
    Delete(id string) error
    UpdateProfile(id string, profile *Profile) error
    // ... 10개 더
}

// ✅ 작은 인터페이스 (인터페이스 분리 원칙)
type UserFinder interface {
    FindByID(id string) (*User, error)
}

type UserSaver interface {
    Save(user *User) error
}

// 필요하면 조합
type UserRepository interface {
    UserFinder
    UserSaver
}

포인터 vs 값 수신자

기본 가이드라인

상황선택이유
상태 변경 필요*T 포인터원본 수정 가능
큰 구조체*T 포인터복사 비용 절감
작은 불변 값T안전하고 간단
일관성 유지하나로 통일혼란 방지

예시

// 값 수신자 - 작고 불변
type Point struct {
    X, Y int
}

func (p Point) Distance() float64 {
    return math.Sqrt(float64(p.X*p.X + p.Y*p.Y))
}

// 포인터 수신자 - 상태 변경
type Counter struct {
    value int
}

func (c *Counter) Increment() {
    c.value++
}

func (c *Counter) Value() int {
    return c.value
}

인터페이스와 포인터/값

type Stringer interface {
    String() string
}

type MyType struct {
    Name string
}

// 값 수신자로 정의
func (m MyType) String() string {
    return m.Name
}

var s Stringer

s = MyType{Name: "hello"}  // ✅ 값 할당 가능
s = &MyType{Name: "world"} // ✅ 포인터도 할당 가능
// 포인터 수신자로 정의
func (m *MyType) String() string {
    return m.Name
}

s = MyType{Name: "hello"}  // ❌ 컴파일 에러!
s = &MyType{Name: "world"} // ✅ 포인터만 할당 가능

[!WARNING] 포인터 수신자로 인터페이스를 구현하면, 값은 해당 인터페이스를 만족하지 않습니다.

일관성 규칙

// ❌ 혼합 사용 - 혼란스러움
func (u User) Name() string { ... }
func (u *User) SetName(name string) { ... }
func (u User) Age() int { ... }
func (u *User) SetAge(age int) { ... }

// ✅ 통일 - 명확함
func (u *User) Name() string { ... }
func (u *User) SetName(name string) { ... }
func (u *User) Age() int { ... }
func (u *User) SetAge(age int) { ... }

인터페이스 반환이 적합한 경우

일반적으로 구체 타입을 반환하지만, 예외 상황도 있습니다.

1. 표준 라이브러리 인터페이스

// io.Reader, io.Writer 등 표준 인터페이스
func NewReader(data []byte) io.Reader {
    return bytes.NewReader(data)
}

2. 팩토리 패턴

type Database interface {
    Query(query string) ([]Row, error)
    Close() error
}

// 설정에 따라 다른 구현 반환
func NewDatabase(config Config) (Database, error) {
    switch config.Driver {
    case "postgres":
        return newPostgresDB(config)
    case "mysql":
        return newMySQLDB(config)
    default:
        return nil, errors.New("unknown driver")
    }
}

3. 내부 구현 숨기기

// unexported 구현
type client struct {
    httpClient *http.Client
}

// exported 인터페이스
type Client interface {
    Get(url string) (*Response, error)
}

func NewClient() Client {
    return &client{httpClient: &http.Client{}}
}

테스트를 위한 인터페이스

Mock 구현

// 테스트용 Mock
type MockUserRepository struct {
    FindByIDFunc func(ctx context.Context, id string) (*User, error)
    SaveFunc     func(ctx context.Context, user *User) error
}

func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
    return m.FindByIDFunc(ctx, id)
}

func (m *MockUserRepository) Save(ctx context.Context, user *User) error {
    return m.SaveFunc(ctx, user)
}

// 테스트
func TestUserService_GetUser(t *testing.T) {
    mockRepo := &MockUserRepository{
        FindByIDFunc: func(ctx context.Context, id string) (*User, error) {
            return &User{ID: id, Name: "Test User"}, nil
        },
    }
    
    service := NewUserService(mockRepo)
    
    user, err := service.GetUser(context.Background(), "123")
    assert.NoError(t, err)
    assert.Equal(t, "Test User", user.Name)
}

gomock 사용 (권장)

go.uber.org/mock을 활용한 코드 생성 방식:

# mockgen 설치
go install go.uber.org/mock/mockgen@latest

# 인터페이스에서 Mock 생성
mockgen -source=repository.go -destination=mocks/repository_mock.go -package=mocks
// 생성된 mocks/repository_mock.go 사용
import (
    "testing"
    "go.uber.org/mock/gomock"
    "myapp/mocks"
)

func TestUserService_GetUser(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    
    mockRepo := mocks.NewMockUserRepository(ctrl)
    
    // 기대 동작 설정
    mockRepo.EXPECT().
        FindByID(gomock.Any(), "123").
        Return(&User{ID: "123", Name: "Test User"}, nil)
    
    service := NewUserService(mockRepo)
    user, err := service.GetUser(context.Background(), "123")
    
    assert.NoError(t, err)
    assert.Equal(t, "Test User", user.Name)
}

[!TIP] gomock은 컴파일 타임에 타입 안전성을 보장하며, IDE 자동완성도 지원됩니다.

체크리스트

인터페이스 설계

  • 소비자 측에서 인터페이스 정의
  • 가능한 작은 인터페이스 (1-3 메서드)
  • 함수 파라미터로 인터페이스 수용
  • 구체 타입 반환 (특별한 이유 없으면)

수신자 선택

  • 상태 변경 필요하면 포인터 수신자
  • struct 크기 > 64 bytes면 포인터 수신자
  • 동일 타입에서 수신자 통일
  • 인터페이스 구현 시 포인터/값 주의

참고 자료

Share

Related Articles

Comments

이 블로그는 제가 알고 있는 것들을 잊지 않기 위해 기록하는 공간입니다.
직접 작성한 글도 있고, AI의 도움을 받아 정리한 글도 있습니다.
정확하지 않은 내용이 있을 수 있으니 참고용으로 봐주세요.

© 2026 Seogyu Kim