Go의 Interface: 작고, 암묵적이며, 올바르게 사용하는 방법
1. 개요
Go의 Interface는 Java나 C#과 한 가지 핵심 점에서 다릅니다: implements 키워드가 없습니다. 어떤 타입이 필요한 method를 모두 가지고 있으면 자동으로 interface를 만족합니다. 이 특징은 독특한 디자인 방식으로 이어집니다: 작은 interface, 이를 구현하는 쪽이 아니라 사용하는 쪽에서 선언합니다.
이 글을 읽으면 Go 방식으로 interface를 작성하는 방법, mock 라이브러리 없이 테스트하는 방법, 그리고 ‘기술적으로 nil이지만 nil이 아닌’ 같은 흔한 오류를 피하는 방법을 알게 될 것입니다.
2. Interface는 암묵적으로 만족됩니다
고객에게 알림을 보내는 interface부터 시작해봅시다:
package main
import "fmt"
type Notifier interface {
Notify(customer, message string) error
}
type EmailNotifier struct {
From string
}
func (e EmailNotifier) Notify(customer, message string) error {
fmt.Printf("[%s으로부터의 이메일] %s에게: %s\n", e.From, customer, message)
return nil
}
type SMSNotifier struct{}
func (SMSNotifier) Notify(customer, message string) error {
fmt.Printf("[sms] %s에게: %s\n", customer, message)
return nil
}
func main() {
notifiers := []Notifier{
EmailNotifier{From: "shop@nonla.dev"},
SMSNotifier{},
}
for _, n := range notifiers {
_ = n.Notify("An", "주문 #42가 확인되었습니다")
}
}
결과:
[shop@nonla.dev으로부터의 이메일] An에게: 주문 #42가 확인되었습니다
[sms] An에게: 주문 #42가 확인되었습니다
EmailNotifier나 SMSNotifier 어디에도 Notifier를 언급하는 곳이 없습니다. Compiler가 자동으로 확인합니다: Notify(string, string) error method를 가진 어떤 타입이든 Notifier 타입의 변수에 할당할 수 있습니다.
중요한 결과는 구현하는 쪽이 interface의 존재를 알 필요가 없다는 것입니다. 표준 라이브러리나 제3자 라이브러리의 기존 타입에 대해 그들의 코드를 수정하지 않고도 새로운 interface를 선언할 수 있습니다.
3. Interface를 작게 유지하세요
Go의 표준 라이브러리가 최고의 예입니다. io.Reader와 io.Writer는 각각 정확히 하나의 method만 가집니다:
type Writer interface {
Write(p []byte) (n int, err error)
}
이렇게 작기 때문에 많은 타입이 이를 만족합니다: *os.File, *bytes.Buffer, *strings.Builder, http.ResponseWriter, *gzip.Writer… io.Writer를 받는 함수는 이 모든 타입과 작동합니다:
package main
import (
"fmt"
"io"
"os"
"strings"
)
type Order struct {
ID int
Customer string
Total int64
}
func WriteReceipt(w io.Writer, o Order) error {
_, err := fmt.Fprintf(w, "주문 #%d - %s - %d VND\n", o.ID, o.Customer, o.Total)
return err
}
func main() {
order := Order{ID: 42, Customer: "An", Total: 350000}
_ = WriteReceipt(os.Stdout, order) // 터미널에 출력
var sb strings.Builder
_ = WriteReceipt(&sb, order) // 메모리에 기록
fmt.Printf("영수증 길이: %d byte\n", sb.Len())
}
주문 #42 - An - 350000 VND
영수증 길이: 26 byte
WriteReceipt는 “바이트를 쓰는” 능력만 필요하므로 정확히 그것만 요구합니다. 파일, HTTP 응답 또는 압축 파일에 쓰려고 해도 함수를 수정할 필요가 없습니다.
여러 동작이 필요할 때는 처음부터 큰 interface를 쓰는 대신 작은 interface들을 조합하세요:
type OrderFinder interface {
FindOrder(id int) (Order, error)
}
type OrderSaver interface {
SaveOrder(o Order) error
}
type OrderStore interface {
OrderFinder
OrderSaver
}
주문을 표시하는 함수는 OrderFinder만 받습니다. 상태를 업데이트하는 함수는 OrderStore를 받습니다. 각 함수는 필요한 것을 명확히 선언하고, 테스트할 fake를 작성하는 것도 훨씬 가벼워집니다.
4. 사용하는 쪽에서 interface를 선언하세요
Java에서는 보통 PaymentGateway를 작성하고 StripePaymentGateway implements PaymentGateway를 함께 작성합니다. Go에서는 일반적인 방식이 반대입니다: 구현 패키지는 구체적인 struct를 반환하고, 사용하는 패키지가 필요한 interface를 자체적으로 선언합니다.
package payment가 많은 method를 가진 client를 제공한다고 가정하겠습니다:
// package payment
type Client struct {
apiKey string
}
func NewClient(apiKey string) *Client { return &Client{apiKey: apiKey} }
func (c *Client) Charge(customer string, amount int64) (string, error) {
return fmt.Sprintf("txn-%s-%d", customer, amount), nil
}
func (c *Client) Refund(txnID string) error { return nil }
func (c *Client) ListTransactions() ([]string, error) { return nil, nil }
package checkout는 돈을 차감하기만 하면 되므로 한 method를 가진 interface를 선언합니다:
// package checkout
var ErrInvalidTotal = errors.New("총액은 0보다 커야 합니다")
type PaymentGateway interface {
Charge(customer string, amount int64) (string, error)
}
type Service struct {
gateway PaymentGateway
}
func NewService(gw PaymentGateway) *Service {
return &Service{gateway: gw}
}
func (s *Service) Checkout(o Order) (string, error) {
if o.Total <= 0 {
return "", ErrInvalidTotal
}
txnID, err := s.gateway.Charge(o.Customer, o.Total)
if err != nil {
return "", fmt.Errorf("주문 #%d 결제: %w", o.ID, err)
}
return txnID, nil
}
main에서 초기화할 때:
svc := checkout.NewService(payment.NewClient("sk_test_123"))
txn, err := svc.Checkout(Order{ID: 42, Customer: "An", Total: 350000})
fmt.Println(txn, err)
txn-An-350000 <nil>
*payment.Client는 checkout.PaymentGateway를 암묵적으로 만족합니다. package payment는 checkout을 import하지 않으므로 그것이 존재함을 알 필요가 없습니다.
이 원칙은 보통 **“accept interfaces, return structs”**로 요약됩니다: 함수는 유연성을 위해 interface를 받지만, 호출자가 모든 method와 field를 사용할 수 있도록 구체적인 타입을 반환합니다.
5. Fake로 테스트하기, mock 라이브러리는 필요 없습니다
사용하는 쪽에서의 작은 interface는 테스트를 매우 간결하게 만듭니다. 정확히 하나의 method를 가진 struct를 작성하면 됩니다:
package checkout
import (
"errors"
"testing"
)
type fakeGateway struct {
charged []int64
err error
}
func (f *fakeGateway) Charge(customer string, amount int64) (string, error) {
if f.err != nil {
return "", f.err
}
f.charged = append(f.charged, amount)
return "txn-test", nil
}
func TestCheckout_ChargesOrderTotal(t *testing.T) {
gw := &fakeGateway{}
svc := NewService(gw)
txn, err := svc.Checkout(Order{ID: 1, Customer: "An", Total: 200000})
if err != nil {
t.Fatalf("오류를 예상하지 않았습니다: %v", err)
}
if txn != "txn-test" {
t.Errorf("txn = %q, 원함 %q", txn, "txn-test")
}
if len(gw.charged) != 1 || gw.charged[0] != 200000 {
t.Errorf("charged = %v, 원함 [200000]", gw.charged)
}
}
func TestCheckout_RejectsZeroTotal(t *testing.T) {
gw := &fakeGateway{}
svc := NewService(gw)
_, err := svc.Checkout(Order{ID: 2, Customer: "Bình", Total: 0})
if !errors.Is(err, ErrInvalidTotal) {
t.Fatalf("err = %v, 원함 ErrInvalidTotal", err)
}
if len(gw.charged) != 0 {
t.Errorf("Charge를 호출하면 안 되는데, charged = %v", gw.charged)
}
}
func TestCheckout_WrapsGatewayError(t *testing.T) {
gwErr := errors.New("카드가 거부되었습니다")
svc := NewService(&fakeGateway{err: gwErr})
_, err := svc.Checkout(Order{ID: 3, Customer: "Chi", Total: 100000})
if !errors.Is(err, gwErr) {
t.Fatalf("err = %v, 감싸기를 원함 %v", err, gwErr)
}
}
테스트 실행:
go test ./checkout -v
=== RUN TestCheckout_ChargesOrderTotal
--- PASS: TestCheckout_ChargesOrderTotal (0.00s)
=== RUN TestCheckout_RejectsZeroTotal
--- PASS: TestCheckout_RejectsZeroTotal (0.00s)
=== RUN TestCheckout_WrapsGatewayError
--- PASS: TestCheckout_WrapsGatewayError (0.00s)
PASS
PaymentGateway가 10개의 method를 가지면 fake도 10개 모두를 구현해야 합니다. 이는 interface가 너무 크다는 가장 명확한 신호입니다.
6. Type assertion, type switch, 컴파일 타임 검사
6.1. 선택적 interface와 type assertion
때때로 특정 타입이 지원할 때 추가 기능을 사용하고 싶을 수 있습니다. Go는 두 값 형태의 type assertion을 사용합니다:
type Refunder interface {
Refund(txnID string) error
}
func (s *Service) Cancel(txnID string) error {
r, ok := s.gateway.(Refunder)
if !ok {
return errors.New("결제 게이트웨이가 환불을 지원하지 않습니다")
}
return r.Refund(txnID)
}
*payment.Client의 경우 Refund method가 있으므로 ok는 true입니다. fakeGateway의 경우 ok는 false이고 함수는 panic하는 대신 오류를 반환합니다. 표준 라이브러리는 이 기법을 많이 사용합니다. 예를 들어 io.Copy는 더 빠른 경로를 선택하기 위해 매개변수가 io.WriterTo인지 확인합니다.
주의: 한 값 형태인
r := s.gateway.(Refunder)는 타입이 일치하지 않으면 panic합니다. 절대 확실하지 않다면 항상r, ok := …형태를 사용하세요.
6.2. Type switch
여러 타입을 처리해야 할 때는 type switch를 사용합니다:
func describe(n Notifier) string {
switch v := n.(type) {
case EmailNotifier:
return "이메일: " + v.From
case SMSNotifier:
return "sms"
default:
return fmt.Sprintf("알 수 없음: %T", v)
}
}
여러 곳에서 비즈니스 타입에 대해 type switch를 작성하는 것을 발견하면, interface에 method가 빠진 것일 수 있습니다.
6.3. 컴파일 타임에 interface 만족 확인
만족이 암묵적이기 때문에 누군가 method 서명을 변경하면 오류는 할당 지점에서만 나타납니다. 구현 파일에서 오류를 조기에 포착하려면 다음 줄을 추가하세요:
var _ checkout.PaymentGateway = (*payment.Client)(nil)
이 줄은 실행 시 어떤 값도 생성하지 않습니다. 이것은 단순히 compiler가 *payment.Client가 PaymentGateway를 만족하는지 확인하도록 강제합니다. 구현 패키지가 실제로 interface에 대해 약속할 때만 사용하세요. 그렇지 않으면 불필요한 순환 import이 생길 것입니다.
7. 흔한 실수들
7.1. nil 포인터를 포함한 interface는 nil이 아닙니다
이것은 Go의 가장 고전적인 오류입니다:
type ValidationError struct {
Field string
}
func (e *ValidationError) Error() string {
return "필드가 유효하지 않습니다: " + e.Field
}
func validate(o Order) error {
var verr *ValidationError
if o.Total <= 0 {
verr = &ValidationError{Field: "Total"}
}
return verr // 잘못됨
}
func main() {
err := validate(Order{Total: 100})
fmt.Println(err == nil)
}
false
interface 값은 두 부분으로 구성됩니다: 타입과 값입니다. interface는 둘 다 비어있을 때만 nil과 같습니다. 여기서 타입은 *ValidationError이고 값은 nil 포인터이므로 err != nil입니다. 수정 방법은 명시적으로 nil을 반환하는 것입니다:
func validate(o Order) error {
if o.Total <= 0 {
return &ValidationError{Field: "Total"}
}
return nil
}
7.2. Pointer receiver와 method set
type AuditNotifier struct {
count int
}
func (a *AuditNotifier) Notify(customer, message string) error {
a.count++
return nil
}
var n Notifier = AuditNotifier{} // 컴파일 오류
cannot use AuditNotifier{} (value of struct type AuditNotifier) as Notifier value
in variable declaration: AuditNotifier does not implement Notifier
(method Notify has pointer receiver)
pointer receiver를 가진 method는 *AuditNotifier의 method set에만 속합니다. &AuditNotifier{}를 할당해야 합니다. 반대로 value receiver를 가진 method는 값과 포인터 모두에 사용할 수 있습니다.
7.3. 필요하기 전에 interface 작성하기
Java 습관에서 비롯된 코드는 보통 다음과 같습니다:
type OrderService interface {
Create(o Order) error
Cancel(id int) error
Find(id int) (Order, error)
// … 10개 method 더 추가
}
type orderServiceImpl struct{ /* … */ }
func NewOrderService() OrderService {
return &orderServiceImpl{}
}
이 방식에는 세 가지 문제가 있습니다. interface는 하나의 구현만 가집니다. 사용하는 쪽이 아니라 구현하는 쪽에 있습니다. 생성자가 interface를 반환하므로 호출자가 구체적인 타입에 접근할 수 없습니다. *OrderService struct를 반환하고, 각 사용 패키지가 실제로 필요할 때 (예: 테스트) 작은 interface를 자체 선언하도록 하세요.
7.4. any 오용하기
any (interface{}의 별칭)는 모든 타입을 수용하므로 compiler가 더 이상 도움이 되지 않습니다. Go 1.18 이후로 여러 타입에서 작동하는 함수가 필요하면 먼저 generics를 고려하세요:
func SumTotals[T interface{ GetTotal() int64 }](items []T) int64 {
var sum int64
for _, it := range items {
sum += it.GetTotal()
}
return sum
}
any는 데이터의 타입을 실제로 미리 알 수 없을 때만 사용하세요. 예를 들어 임의의 JSON을 디코딩할 때입니다.
8. 결론
Go의 interface는 단순하기 때문에 강력합니다. 기억해야 할 점들:
- 타입은 암묵적으로 interface를 만족하며,
implements가 필요하지 않습니다. - interface는 작아야 하며, 이상적으로는 하나 또는 두 개의 method입니다. 더 많이 필요할 때는 작은 interface들을 조합하세요.
- 사용하는 쪽에서 interface를 선언하세요. 함수는 interface를 받고, 생성자는 struct를 반환합니다.
- 작은 interface는 mock 라이브러리 없이 손으로 작성한 fake로 테스트할 수 있게 합니다.
- nil 포인터를 포함한 interface와 pointer receiver의 method set에 주의하세요.
다음에 interface를 작성하려고 할 때 자문해보세요: “누가 이것을 사용하고, 그들은 실제로 어떤 method가 필요할까요?” 그 답변은 보통 당신이 생각하는 것보다 적습니다.