2025-12-21
Go 언어의 인터페이스(Interface)는 다른 언어(Java, C#)의 인터페이스와 근본적으로 다릅니다. Java는 implements 키워드로 명시적으로 인터페이스를 선언해야 하지만, Go는 암묵적으로(Implicitly) 인터페이스를 만족합니다. 이를 덕 타이핑(Duck Typing)이라고 합니다.
“오리처럼 걷고, 오리처럼 운다면, 그것은 오리다.” — Duck Typing의 핵심 철학
이 글에서는 Go 언어 인터페이스의 기본 개념부터 내부 메모리 구조, 타입 단언(Type Assertion)과 타입 스위치(Type Switch)의 실무 활용, 인터페이스 합성(Composition), 그리고 nil 인터페이스의 함정까지 실무에서 반드시 알아야 할 모든 패턴을 심층적으로 다룹니다.
🛠️ 실무에서 인터페이스의 중요성
여러 도형(Rectangle, Circle)에 대해 “넓이를 구한다”는 동작의 의미는 같지만 구현 코드는 도형별로 다릅니다. 인터페이스를 사용하면 이 공통된 “계약(Contract)“을 정의하고, 각 타입이 독립적으로 구현할 수 있습니다. 결과적으로 코드 재사용성이 높아지고, 테스트가 쉬워지며, 팀원과의 협업 품질이 올라갑니다.
인터페이스는 메서드 시그니처(이름, 매개변수, 반환 타입)의 집합입니다. 구체적인 구현 코드는 포함하지 않습니다.
type Shape interface {
Area() float64
Perimeter() float64
}
이제 Rectangle과 Circle이 이 인터페이스를 암묵적으로 구현합니다. implements Shape라는 선언이 전혀 없습니다.
type Rectangle struct {
Width, Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return 3.14159 * c.Radius * c.Radius
}
func (c Circle) Perimeter() float64 {
return 2 * 3.14159 * c.Radius
}
Area()와 Perimeter() 메서드를 모두 구현했기 때문에, Rectangle과 Circle은 자동으로 Shape 인터페이스를 만족합니다.
func PrintShapeInfo(s Shape) {
fmt.Printf("넓이: %.2f, 둘레: %.2f\n", s.Area(), s.Perimeter())
}
func main() {
var s Shape
s = Rectangle{Width: 10, Height: 5}
PrintShapeInfo(s) // 출력: 넓이: 50.00, 둘레: 30.00
s = Circle{Radius: 7}
PrintShapeInfo(s) // 출력: 넓이: 153.94, 둘레: 43.98
}
Go 언어 덕 타이핑의 핵심은 타입이 인터페이스를 알 필요가 없다는 점입니다. 기존 코드를 전혀 수정하지 않고도 새로운 인터페이스를 만족시킬 수 있습니다.
// 나중에 추가된 새 인터페이스
type Describer interface {
Describe() string
}
// Rectangle에 메서드 추가 (Shape 인터페이스와 무관하게)
func (r Rectangle) Describe() string {
return fmt.Sprintf("Rectangle %.1f x %.1f", r.Width, r.Height)
}
이제 Rectangle은 Shape와 Describer 두 인터페이스를 동시에 만족합니다. Rectangle 코드는 두 인터페이스의 존재를 전혀 모르지만, Go 컴파일러가 자동으로 판단합니다.
💡 Java와의 비교
Java에서 사후에 인터페이스를 추가하려면 기존 클래스에
implements NewInterface를 추가해야 합니다. 하지만 Go에서는 외부 라이브러리의 타입도, 기본 타입도 여러분이 정의한 인터페이스를 만족할 수 있습니다.
Go 언어의 인터페이스 변수는 내부적으로 두 가지 정보를 저장합니다:
var s Shape = Rectangle{Width: 10, Height: 5}
// s의 내부: (type=Rectangle, value={10, 5})
s = Circle{Radius: 7}
// s의 내부: (type=Circle, value={7})
이 구조를 이해하면 이후에 다룰 nil 인터페이스의 함정을 예방할 수 있습니다.
여러 인터페이스를 조합하여 새로운 인터페이스를 만들 수 있습니다. Go 표준 라이브러리에서 광범위하게 사용되는 패턴입니다.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// Reader와 Writer를 합성
type ReadWriter interface {
Reader
Writer
}
io.ReadWriter는 실제 Go 표준 라이브러리에 존재하는 합성 인터페이스입니다.
type Logger interface {
Info(msg string)
Error(msg string)
}
type MetricsRecorder interface {
Record(key string, value float64)
}
// 두 인터페이스를 합성한 서버 인터페이스
type ObservableServer interface {
Logger
MetricsRecorder
Start() error
Stop() error
}
any빈 인터페이스 interface{}는 메서드가 하나도 없어, 모든 타입이 자동으로 만족합니다. Go 1.18부터는 any라는 타입 별칭을 사용합니다.
func PrintAnything(i interface{}) {
fmt.Println(i)
}
// Go 1.18+: any 사용
func PrintAnythingNew(i any) {
fmt.Println(i)
}
func main() {
PrintAnything(42) // 출력: 42
PrintAnything("Hello Go") // 출력: Hello Go
PrintAnything(3.14) // 출력: 3.14
PrintAnything([]int{1,2,3}) // 출력: [1 2 3]
}
// JSON 디코딩 시 구조가 불명확할 때
var result map[string]interface{}
json.Unmarshal(jsonData, &result)
// 가변 타입의 컨테이너
type Cache struct {
data map[string]any
}
func (c *Cache) Set(key string, value any) {
c.data[key] = value
}
func (c *Cache) Get(key string) (any, bool) {
v, ok := c.data[key]
return v, ok
}
🛠️ 실무 주의사항
빈 인터페이스는 강력하지만, 남용하면 타입 안전성을 잃습니다. 가능하면 구체적인 인터페이스를 사용하고,
any는 정말 어떤 타입이든 받아야 할 때만 사용하세요. Go 1.18 이후에는 Generic을 사용하면 타입 안전성을 유지하면서 동일한 유연함을 얻을 수 있습니다.
인터페이스 변수에서 실제 타입의 값을 꺼낼 때 타입 단언을 사용합니다.
// 형태 1: 단순 단언 (실패 시 패닉)
var i interface{} = "Hello"
s := i.(string) // s = "Hello"
// 형태 2: 쉼표-ok 패턴 (실패 시 zero value + false)
s, ok := i.(string)
if ok {
fmt.Printf("string 타입: %s\n", s)
} else {
fmt.Println("string 타입이 아님")
}
// 타입 단언 실패 처리 예제
func main() {
var i interface{} = "Hello"
s, ok := i.(string)
if ok {
fmt.Printf("i는 string: %s\n", s)
}
n, ok := i.(int)
if !ok {
fmt.Printf("i는 int가 아님: %v\n", n) // n = 0 (int의 Zero Value)
}
}
🛠️ 실무 Best Practice
형태 1(단순 단언)은 타입이 확실할 때만 사용하세요. 런타임 패닉이 발생할 수 있습니다. 불확실하다면 항상 형태 2(쉼표-ok 패턴)를 사용하세요.
// 마케팅 API에서 요청 데이터 타입 검증
func validateRequestField(key string, value interface{}) error {
switch key {
case "email":
_, ok := value.(string)
if !ok {
return fmt.Errorf("email 필드는 string이어야 합니다")
}
case "age":
_, ok := value.(float64) // JSON 숫자는 float64로 파싱됨
if !ok {
return fmt.Errorf("age 필드는 숫자여야 합니다")
}
}
return nil
}
예상치 못한 타입의 값이 반복적으로 들어올 때 해당 IP를 차단하는 등의 어뷰징 방어 로직에 활용할 수 있습니다.
여러 타입을 분기 처리해야 할 때, if-else 체인 대신 타입 스위치를 사용합니다.
func Describe(i interface{}) string {
switch v := i.(type) {
case int:
return fmt.Sprintf("정수: %d", v)
case string:
return fmt.Sprintf("문자열: %q (길이: %d)", v, len(v))
case bool:
return fmt.Sprintf("불리언: %t", v)
case []int:
return fmt.Sprintf("int 슬라이스: %v (길이: %d)", v, len(v))
case nil:
return "nil 값"
default:
return fmt.Sprintf("알 수 없는 타입: %T", v)
}
}
func main() {
fmt.Println(Describe(42)) // 출력: 정수: 42
fmt.Println(Describe("Hello")) // 출력: 문자열: "Hello" (길이: 5)
fmt.Println(Describe(true)) // 출력: 불리언: true
fmt.Println(Describe([]int{1,2,3})) // 출력: int 슬라이스: [1 2 3] (길이: 3)
fmt.Println(Describe(nil)) // 출력: nil 값
fmt.Println(Describe(3.14)) // 출력: 알 수 없는 타입: float64
}
인터페이스를 통해 서로 다른 타입을 동일한 방식으로 처리하는 것이 다형성입니다.
type Animal interface {
Speak() string
Name() string
}
type Dog struct{ name string }
type Cat struct{ name string }
type Parrot struct{ name string }
func (d Dog) Speak() string { return "Woof!" }
func (d Dog) Name() string { return d.name }
func (c Cat) Speak() string { return "Meow!" }
func (c Cat) Name() string { return c.name }
func (p Parrot) Speak() string { return "Squawk!" }
func (p Parrot) Name() string { return p.name }
func MakeAllSpeak(animals []Animal) {
for _, animal := range animals {
fmt.Printf("%s says: %s\n", animal.Name(), animal.Speak())
}
}
func main() {
animals := []Animal{
Dog{name: "바둑이"},
Cat{name: "나비"},
Parrot{name: "폴리"},
}
MakeAllSpeak(animals)
// 출력:
// 바둑이 says: Woof!
// 나비 says: Meow!
// 폴리 says: Squawk!
}
새로운 동물 타입을 추가할 때 MakeAllSpeak 함수는 전혀 수정할 필요가 없습니다. 이것이 인터페이스 기반 설계의 가장 큰 장점입니다.
Go 언어에서 가장 혼란스러운 개념 중 하나입니다. 인터페이스의 nil과 인터페이스가 담은 포인터의 nil은 다릅니다.
type MyError struct {
msg string
}
func (e *MyError) Error() string { return e.msg }
// ❌ 함정: 인터페이스가 nil이 아닌 것처럼 동작
func getError(fail bool) error {
var err *MyError = nil // (*MyError)(nil)
if fail {
err = &MyError{msg: "에러 발생"}
}
return err // error 인터페이스에 (*MyError)(nil)을 담아 반환
}
func main() {
err := getError(false)
if err != nil {
// ⚠️ 이 분기가 실행됨! err는 nil이 아님
fmt.Println("에러 발생:", err)
}
}
왜냐하면 err의 내부 구조가 (type=*MyError, value=nil)이기 때문입니다. 타입 정보가 있으므로 nil != nil입니다.
// ✅ 올바른 패턴
func getError(fail bool) error {
if fail {
return &MyError{msg: "에러 발생"}
}
return nil // 진짜 nil: (type=nil, value=nil)
}
Go 표준 라이브러리는 작고 명확한 인터페이스를 통해 엄청난 유연성을 제공합니다.
| 인터페이스 | 패키지 | 메서드 | 설명 |
|---|---|---|---|
io.Reader | io | Read(p []byte) (n int, err error) | 데이터 읽기 |
io.Writer | io | Write(p []byte) (n int, err error) | 데이터 쓰기 |
fmt.Stringer | fmt | String() string | 문자열 표현 |
error | 내장 | Error() string | 에러 표현 |
sort.Interface | sort | Len/Less/Swap | 정렬 |
// fmt.Stringer 구현으로 println 출력 커스터마이징
type Point struct {
X, Y float64
}
func (p Point) String() string {
return fmt.Sprintf("(%.1f, %.1f)", p.X, p.Y)
}
func main() {
p := Point{X: 1.5, Y: 2.7}
fmt.Println(p) // 출력: (1.5, 2.7) ← String() 자동 호출
}
// 인터페이스 정의
type UserRepository interface {
FindByID(id int) (*User, error)
Save(user *User) error
}
// 서비스는 인터페이스에만 의존
type UserService struct {
repo UserRepository
}
func (s *UserService) GetUser(id int) (*User, error) {
return s.repo.FindByID(id)
}
// 실제 구현체
type PostgresUserRepo struct {
db *sql.DB
}
func (r *PostgresUserRepo) FindByID(id int) (*User, error) {
// DB 쿼리 실행
}
// 테스트용 Mock 구현체
type MockUserRepo struct {
users map[int]*User
}
func (m *MockUserRepo) FindByID(id int) (*User, error) {
if user, ok := m.users[id]; ok {
return user, nil
}
return nil, fmt.Errorf("user %d not found", id)
}
func (m *MockUserRepo) Save(user *User) error {
m.users[user.ID] = user
return nil
}
// 테스트 코드
func TestGetUser(t *testing.T) {
mock := &MockUserRepo{
users: map[int]*User{
1: {ID: 1, Name: "Alice"},
},
}
svc := &UserService{repo: mock}
user, err := svc.GetUser(1)
// DB 없이도 테스트 가능!
}
| 원칙 | 설명 |
|---|---|
| 작게 유지 | 메서드 1~3개가 이상적 |
| 구현자 관점 | 구현하기 쉬운 인터페이스 설계 |
| 호출자 관점 | 필요한 메서드만 포함 |
| 명사보다 동사 | Saver, Reader 등 동작 중심 이름 |
Go 언어 인터페이스는 단순한 문법 요소를 넘어, 타입 간의 계약을 정의하는 핵심 설계 도구입니다. 덕 타이핑 덕분에 코드 결합도를 낮추고, 테스트 가능성을 높이며, 변경에 유연한 시스템을 만들 수 있습니다.
오늘 배운 핵심을 요약하면:
(type, value) 쌍이며, nil 함정의 원인이다.io.ReadWriter처럼 작은 인터페이스를 조합한다.다음 글에서는 Go 언어의 패키지(Package)와 모듈(Module) 시스템을 심층적으로 다루며, 대규모 프로젝트에서 코드를 논리적으로 구성하는 방법, internal 패키지와 접근 제어, init() 함수의 실행 순서, 그리고 순환 의존성을 방지하는 실무 설계 전략을 알아보겠습니다.